Desenvolvimento de Miniaplicativo WeChat: Um Cliente de Música

A criação de um miniaplicativo (miniprogram) WeChat que replique as funcionalidades de um serviço de música requer acesso a uma API de dados confiável. Embora APIs públicas completas sejam raras para muitos serviços, uma API não oficial para o Netease Cloud Music está disponível e pode ser utilizada como backend para este projeto.

Configuração do Servidor da API

O projeto NeteaseCloudMusicApi no GitHub fornece uma implementação de API para os dados do Netease Cloud Music. Para configurá-lo:

  1. Clone o repositório ou baixe o projeto.
  2. Siga as instruções de instalação do repositório para configurar as dependências.
  3. Após a instalação, navegue até o diretório raiz do projeto e inicie o serviço com:
cd NeteaseCloudMusicApi
node app.js

Com isso, a API estará acessível, tipicamente em http://localhost:3000.

Estrutura Fundamental do Miniaplicativo: Configuração Global

O arquivo app.json é o coração da configuração de um miniaplicativo WeChat. Ele define a estrutura das páginas, os estilos visuais globais da janela e a barra de navegação inferior (tabBar), além de gerenciar as rotas principais.

{
  "pages": [
    "pages/home/home",
    "pages/trending/trending",
    "pages/explore/explore",
    "pages/playlist-detail/playlist-detail",
    "pages/audio-player/audio-player"
  ],
  "window": {
    "backgroundColor": "#F6F6F6",
    "backgroundTextStyle": "light",
    "navigationBarBackgroundColor": "#d43c33",
    "navigationBarTitleText": "Meu Reprodutor de Música",
    "navigationBarTextStyle": "white"
  },
  "tabBar": {
    "backgroundColor": "#212121",
    "color": "#8F8F8F",
    "selectedColor": "#FFF",
    "list": [{
      "pagePath": "pages/home/home",
      "text": "Explorar",
      "iconPath": "/images/icon_discovery_inactive.png",
      "selectedIconPath": "/images/icon_discovery_active.png"
    },
    {
      "pagePath": "pages/trending/trending",
      "text": "Populares",
      "iconPath": "/images/icon_radio_inactive.png",
      "selectedIconPath": "/images/icon_radio_active.png"
    },
    {
      "pagePath": "pages/explore/explore",
      "text": "Buscar",
      "iconPath": "/images/icon_music_inactive.png",
      "selectedIconPath": "/images/icon_music_active.png"
    }]
  },
  "requiredBackgroundModes": ["audio"],
  "sitemapLocation": "sitemap.json"
}

Para garantir que o áudio continue sendo reproduzido mesmo quando o miniaplicativo estiver em segundo plano, é fundamental incluir "audio" na propriedade requiredBackgroundModes dentro de app.json.

Módulo de Requisições HTTP para a API

Para facilitar a comunicação com o servidor da API, é uma boa prática criar um módulo de utilidade que encapsule as funções de requisição HTTP. Este exemplo demonstra a abordagem para requisições diretas, sem o uso de funções na nuvem do WeChat.

Crie o arquivo utils/apiClient.js:

const BASE_URL_API = 'http://localhost:3000';

/**
 * Retorna a URL base da API.
 * @returns {string} URL base da API.
 */
function getApiBaseUrl() {
  return BASE_URL_API;
}

/**
 * Realiza uma requisição GET para a API.
 * @param {string} endpoint - O caminho da API (ex: '/personalized').
 * @param {object} [data={}] - Parâmetros de consulta.
 * @returns {Promise<any>} Uma Promise que resolve com a resposta da requisição.
 */
function fetchGet(endpoint, data = {}) {
  return new Promise((resolve, reject) => {
    wx.request({
      method: 'GET',
      url: BASE_URL_API + endpoint,
      data: data,
      success: resolve,
      fail: reject
    });
  });
}

/**
 * Realiza uma requisição POST para a API.
 * @param {string} endpoint - O caminho da API.
 * @param {object} [data={}] - O corpo da requisição.
 * @returns {Promise<any>} Uma Promise que resolve com a resposta da requisição.
 */
function fetchPost(endpoint, data = {}) {
  return new Promise((resolve, reject) => {
    wx.request({
      method: 'POST',
      url: BASE_URL_API + endpoint,
      data: data,
      success: resolve,
      fail: reject
    });
  });
}

module.exports = {
  fetchGet,
  fetchPost,
  getApiBaseUrl
};
</any></any>

Página Inicial: Exibição de Playlists

A página inicial (pages/home/home.js) é responsável por carregar e exibir uma lista de playlists recomendadas. A lógica de busca de dados é implementada no ciclo de vida onLoad.

// pages/home/home.js
const apiClient = require('../../utils/apiClient.js');

Page({
  data: {
    recommendedPlaylists: [],
    // Adicione outras propriedades de dados conforme necessário
  },

  /**
   * Ciclo de vida: a página é carregada.
   */
  async onLoad() {
    try {
      // Busca as 6 primeiras playlists personalizadas
      const response = await apiClient.fetchGet('/personalized', { limit: 6 });
      this.setData({ recommendedPlaylists: response.data.result });
      console.log('Playlists recomendadas carregadas:', response.data.result);
    } catch (error) {
      console.error('Falha ao obter playlists recomendadas:', error);
    }
  },

  // Outros métodos e ganchos de ciclo de vida do miniaplicativo
});

Para a renderização da interface (pages/home/home.wxml), um componente WXS é utilizado para formatar os números grandes (como contagens de reprodução) em um formato mais amigável, como "1.23M" ou "1.23万".


<wxs module="numberFormatter" src="../../utils/formatUtils.wxs"></wxs>

<view class="home-section">
  <h2 class="section-title">Playlists Sugeridas</h2>
  <view class="playlist-grid">
    <navigator class="playlist-item" url="../playlist-detail/playlist-detail?id={{item.id}}" wx:for="{{recommendedPlaylists}}" wx:key="id">
      <view class="playlist-cover">
        <image class="cover-image" mode="aspectFill" src="{{item.picUrl}}"></image>
        <span class="playback-count-overlay">{{numberFormatter.formatBigNumber(item.playCount)}}</span>
      </view>
      <text class="playlist-name">{{item.name}}</text>
    </navigator>
  </view>
</view>

O módulo WXS (utils/formatUtils.wxs) para formatar números grandes:

/**
 * Formata um número grande para exibição mais legível.
 * Ex: 12345678 -> 1.23亿
 * @param {number} numVal O número a ser formatado.
 * @returns {string} O número formatado com sufixo.
 */
function formatBigNumber(numVal) {
  var divisor = 10000; // Unidade base para '万' (dez mil)
  var suffixes = ['', '万', '亿', '兆']; // Sufixos em Português-Chinês

  if (numVal < divisor) {
    return numVal + ''; // Retorna o valor original como string
  } else {
    var index = Math.floor(Math.log(numVal) / Math.log(divisor));
    var formattedValue = (numVal / Math.pow(divisor, index)).toFixed(1);
    var unit = suffixes[index];
    return formattedValue + unit;
  }
}

module.exports = {
  formatBigNumber: formatBigNumber
};

Estilos CSS para a página inicial (pages/home/home.wxss):

.home-section {
  padding-top: 20px;
}
.home-section .section-title {
  position: relative;
  padding-left: 9px;
  margin-bottom: 14px;
  font-size: 17px;
  height: 20px;
  line-height: 20px;
}
.home-section .section-title::before {
  content: " ";
  position: absolute;
  left: 0;
  top: 50%;
  margin-top: -9px;
  width: 2px;
  height: 16px;
  background-color: #d33a31; /* Cor de destaque */
}

.home-section .playlist-grid { 
  display: flex;
  flex-wrap: wrap;
}
.home-section .playlist-item {
  box-sizing: border-box;
  flex: 0 1 33.3%; /* 3 itens por linha */
  padding: 0 5px 20px; /* Espaçamento entre os itens */
  text-align: center;
}
.home-section .playlist-cover > .cover-image { 
  width: 100px;
  height: 100px;
  border-radius: 4px;
  object-fit: cover;
}
.home-section .playlist-name {
  display: -webkit-box;
  -webkit-line-clamp: 2; /* Limita o texto a duas linhas */
  -webkit-box-orient: vertical;
  overflow: hidden;
  padding: 6px 2px 0 6px;
  min-height: 30px;
  line-height: 1.2;
  font-size: 13px;
  color: #333;
  text-align: left; /* Alinhar texto à esquerda */
}
.home-section .playlist-cover {
  position: relative;
  display: inline-block; /* Para alinhar o texto dentro */
}
.home-section .playback-count-overlay {
  position: absolute;
  right: 5px;
  top: 2px;
  z-index: 3;
  padding-left: 13px;
  color: #fff;
  font-size: 12px;
  background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAQAAABKfvVzAAABfUlEQVR4AZXSA0y/YRAH8PvbdhqyrbnmMDPXFIYwZmt2bo5jDRlDtu1mZHc9z737/Z7t4n833ffu8+p5Af+zePAZQ7AaB3AF57Ebq0X35X7wBhNxEXdZLWKSmNwB/mATW1XVjH84+IdD2nC/tyclLuC9u5l3QmBXyn6vlorpPwXkw7TK+HqjJ+WVG7iqeuXWlXK9QaQV3ygQQ+ubFVFiyRp+gRjBc3gLP0XnWh11vUkkRgc+45wMetPBBX7xzyiQS28agTmxSSBYtof9X9zg051f/vMXt8N+IqEErqpl05/Dr64KfvVnyx2xKcHFsGyKA+DZveBZob/cuRgigNui2YE/D/4Qf2hrm4D2peHdg+CdtoUKoC+OifLhq/pcgWt6JBylYIADfa4eabH0amW5jGJRHOjypfKrlaUyAuAif4LHgParaMAYnMHkMUBbxgh8wAHPFTin/+l0ggOWK9AYdzZzPl8bwwDPFQBHei17DlTOwVdwEMMvHPAc8D/rBs0Gmx5Y/w/0AAAAAElFTkSuQmCC'); /* Ícone de fone de ouvido */
  background-position: 0;
  background-repeat: no-repeat;
  background-size: 11px 10px;
  text-shadow: 1px 0 0 rgba(0,0,0,.15);
}

Página de Detalhes da Playlist

Ao selecionar uma playlist na página inicial, o miniaplicativo navega para uma página de detalhes, passando o ID da playlist como parâmetro de consulta. Este ID é então usado para buscar informações detalhadas, incluindo a lista completa de faixas.

// pages/playlist-detail/playlist-detail.js
import { fetchGet } from "../../utils/apiClient.js";

Page({
  data: {
    playlistDetails: null // Objeto para armazenar todos os detalhes da playlist
  },

  /**
   * Ciclo de vida: a página é carregada.
   * Recebe os parâmetros de consulta da navegação.
   */
  async onLoad(options) {
    const playlistId = options.id; // Obtém o ID da playlist dos parâmetros
    if (playlistId) {
      try {
        const response = await fetchGet(`/playlist/detail`, { id: playlistId });
        this.setData({ playlistDetails: response.data.playlist });
        console.log('Detalhes da playlist carregados:', response.data.playlist);
      } catch (error) {
        console.error('Erro ao carregar os detalhes da playlist:', error);
      }
    }
  },
  // Outros métodos e ganchos de ciclo de vida
});

A renderização da página de detalhes (pages/playlist-detail/playlist-detail.wxml) inclui a capa, título, informações do criador e a lista de faixas. O módulo WXS para formatação de números é reutilizado.


<wxs module="numberFormatter" src="../../utils/formatUtils.wxs"></wxs>

<view class="playlist-header">
    <view class="header-background" style="background-image:url({{playlistDetails.coverImgUrl}})"></view>
    <view class="header-content">
        <view class="cover-area">
            <image class="cover-image" mode="aspectFill" src="{{playlistDetails.coverImgUrl}}" wx:if="{{playlistDetails.coverImgUrl}}"></image>
            <span class="playlist-tag">Playlist</span>
            <i class="playback-stats">{{numberFormatter.formatBigNumber(playlistDetails.playCount)}}</i>
        </view>
        <view class="info-area">
            <h2 class="playlist-title">{{playlistDetails.name}}</h2>
            <view class="creator-info">
                <navigator class="creator-link">
                    <view class="avatar-container">
                        <image class="avatar-image" mode="aspectFill" src="{{playlistDetails.creator.avatarUrl}}"></image>
                        <span class="avatar-badge"></span>
                    </view>
                    {{playlistDetails.creator.nickname}}
                </navigator>
            </view>
        </view>
    </view>
</view>

<h3 class="tracklist-title">Lista de Músicas</h3>
<view class="track-list">
    <block wx:for="{{playlistDetails.tracks}}" wx:for-item="track" wx:key="id">
        <navigator class="track-item" url="../audio-player/audio-player?id={{track.id}}&title={{track.name}}">
            <view class="track-index">{{index+1}}</view>
            <view class="track-details">
                <view class="track-name">{{track.name}}
                    <span class="track-alias" wx:if="{{track.alia.length}}"> ({{track.alia[0]}})</span>
                </view>
                <view class="track-meta">{{track.ar[0].name}} - {{track.al.name}}</view>
            </view>
            <view class="track-actions">
                <span class="action-play-icon"></span>
            </view>
        </navigator> 
    </block>
</view>

Estilos CSS para a página de detalhes da playlist (pages/playlist-detail/playlist-detail.wxss):

.playlist-header {
    position: relative;
    padding: 30px 10px 30px 15px;
    overflow: hidden;
}
.playlist-header .header-background {
    background-repeat: no-repeat;
    background-size: cover;
    background-position: 50%;
    filter: blur(20px);
    transform: scale(1.5);
}
.playlist-header .header-background, .playlist-header .header-background::after {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
    z-index: 1;
}
.playlist-header .header-background::after {
    content: " ";
    background-color: rgba(0,0,0,.25);
}
.playlist-header .header-content {
    display: flex;
    position: relative;
    z-index: 2;
}
.playlist-header .playlist-title {
    padding-top: 1px;
    font-size: 17px;
    line-height: 1.3;
    color: #fefefe;
    height: 44px;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
    word-break: break-word; /* Para garantir quebra de linha em palavras longas */
}
.cover-area {
    position: relative;
    width: 114px;
    height: 114px;
    background-color: #e2e2e3;
    border-radius: 4px;
    overflow: hidden;
    flex-shrink: 0; /* Impede que a capa encolha */
}
.cover-area .cover-image {
    width: 100%;
    height: 100%;
    object-fit: cover;
}
.playlist-header .playlist-tag {
    position: absolute;
    z-index: 3;
    top: 10px;
    left: 0;
    padding: 0 8px;
    height: 17px;
    color: #fff;
    font-size: 9px;
    text-align: center;
    line-height: 17px;
    background-color: rgba(217,48,48,.8);
    border-top-right-radius: 17px;
    border-bottom-right-radius: 17px;
}
.playlist-header .info-area {
    flex: 1 1 auto;
    width: 1%;
    margin-left: 16px;
    display: flex;
    flex-direction: column;
    justify-content: center;
}
.playlist-header .avatar-container {
    display: inline-block;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    vertical-align: middle;
    margin-right: 5px;
    position: relative;
    overflow: hidden;
}
.avatar-container .avatar-image {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    object-fit: cover;
}
.avatar-container .avatar-badge { /* Ícone de distintivo do usuário */
    position: absolute;
    right: -5px;
    bottom: 0;
    width: 12px;
    height: 12px;
    background-image: url('//s3.music.126.net/mobile-new/img/usericn_2x.png'); /* Exemplo de imagem de ícone */
    background-repeat: no-repeat;
    background-size: 75px auto;
    background-position: -40px 0;
}
.playlist-header .creator-info {
    display: block;
    position: relative;
    margin-top: 10px; /* Ajuste para espaçamento */
}
.creator-link {
    display: inline-block;
    color: hsla(0,0%,100%,.7);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    word-break: normal;
    max-width: 100%; /* Garante que o link não ultrapasse o contêiner */
}
.playlist-header .playback-stats {
    position: absolute;
    right: 2px;
    top: 0;
    z-index: 3;
    padding-left: 15px;
    color: #fff;
    font-size: 12px;
    background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAQAAABKfvVzAAABfUlEQVR4AZXSA0y/YRAH8PvbdhqyrbnmMDPXFIYwZmt2bo5jDRlDtu1mZHc9z737/Z7t4n833ffu8+p5Af+zePAZQ7AaB3AF57Ebq0X35X7wBhNxEXdZLWKSmNwB/mATW1XVjH84+IdD2nC/tyclLuC9u5l3QmBXyn6vlorpPwXkw7TK+HqjJ+WVG7iqeuXWlXK9QaQV3ygQQ+ubFVFiyRp+gRjBc3gLP0XnWh11vUkkRgc+45wMetPBBX7xzyiQS28agTmxSSBYtof9X9zg051f/vMXt8N+IqEErqpl05/Dr64KfvVnyx2xKcHFsGyKA+DZveBZob/cuRgigNui2YE/D/4Qf2hrm4D2peHdg+CdtoUKoC+OifLhq/pcgWt6JBylYIADfa4eabH0amW5jGJRHOjypfKrlaUyAuAif4LHgParaMAYnMHkMUBbxgh8wAHPFTin/+l0ggOWK9AYdzZzPl8bwwDPFQBHei17DlTOwVdwEMMvHPAc8D/rBs0Gmx5Y/w/0AAAAAElFTkSuQmCC'); /* Ícone de fone de ouvido */
    background-position: 0;
    background-repeat: no-repeat;
    background-size: 11px 10px;
    text-shadow: 1px 0 0 rgba(0,0,0,.15);
}
.cover-area::after {
    content: " ";
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 18px;
    z-index: 2;
    background-image: linear-gradient(90deg,rgba(0,0,0,0),rgba(0,0,0,.2));
}

.tracklist-title {
    height: 23px;
    line-height: 23px;
    padding: 0 10px;
    font-size: 12px;
    color: #666;
    background-color: #eeeff0;
    margin-top: 15px; /* Adiciona espaçamento superior */
}
.track-item {
    display: flex;
    align-items: center;
    padding-left: 10px;
    padding-right: 10px; /* Adiciona padding à direita para o ícone */
}
.track-item .track-index {
    width: 40px;
    font-size: 17px;
    color: #999;
    flex-shrink: 0;
    text-align: center;
}
.track-item .track-details {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    padding: 6px 0;
    border-bottom: 1px solid #eee;
    min-width: 0; /* Permite que o conteúdo encolha */
}
.track-item .track-name {
    font-size: 17px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: #333;
}
.track-item .track-meta {
    font-size: 12px;
    color: #888;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    margin-top: 2px;
}
.track-item .action-play-icon {
    display: inline-block;
    width: 22px;
    height: 22px;
    background: url('//s3.music.126.net/mobile-new/img/index_icon_2x.png') no-repeat; /* Ícone de play/vídeo */
    background-size: 166px 97px;
    background-position: -24px 0; /* Posição específica do ícone de play */
}
.track-item .track-actions {
    display: flex;
    align-items: center;
    padding: 0 10px;
    flex-shrink: 0;
}
.track-item .track-alias {
    color: #888;
    margin-left: 4px;
}

Tags: WeChat Mini Program Miniprogram Development javascript WXS Frontend Development

Publicado em 7-20 11:28