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:
- Clone o repositório ou baixe o projeto.
- Siga as instruções de instalação do repositório para configurar as dependências.
- 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;
}