Skip to content

小程序-项目:mr-music

[TOC]

项目搭建

1、创建项目

image-20230220163456163

2、Git init

sh
git init

3、删除无用文件,创建目录结构

image-20230220170221089

tabbar

image-20230221110015050

1、在 app.json 中配置 tabbar

js
    {
      "pages":[
+        "pages/main-music/main-music",
+        "pages/main-video/main-video"
      ],
+      "tabBar": {
+        "list": [
          {
+            "pagePath": "pages/main-music/main-music",
+            "text": "音乐",
+            "iconPath": "assets/images/tabbar/music_normal.png",
+            "selectedIconPath": "assets/images/tabbar/music_active.png"
          },
          {
+            "pagePath": "pages/main-video/main-video",
+            "text": "视频",
+            "iconPath": "assets/images/tabbar/video_normal.png",
+            "selectedIconPath": "assets/images/tabbar/video_active.png"
          }
        ]
      },
    }

2、导航栏标题自定义

app.json 中配置 navigationBarTitleText

js
  "window":{
    "backgroundTextStyle":"light",
    "navigationBarBackgroundColor": "#fff",
+    "navigationBarTitleText": "音乐小程序",
    "navigationBarTextStyle":"black"
  },

在各个页面分别配置 .json 文件

js
    {
+	   "navigationBarTitleText": "音乐",
      "usingComponents": {}
    }
js
    {
+      "navigationBarTitleText": "视频",
      "usingComponents": {}
    }

video 页

封装request

1、baseURL

baseURL:https://coderwhy-music.vercel.app/

baseURL:http://codercba.com:9002/ (该地址可能会被网易云限制)

2、封装的请求类 MrRequest

js
// services/index.js

/* 封装成类 */
class MrRequest {
  constructor(baseUrl) {
    this.baseUrl = baseUrl
  }
    
  request(options) {
    return new Promise((resolve, reject) => {
      wx.request({
        ...options,
        url: this.baseUrl + options.url,
        success: (res) => {
          resolve(res.data)
        },
        fail: reject
      })
    })
  }
    
  get(options) {
    return this.request({ ...options, method: 'GET' })
  }
  post(options) {
    return this.request({ ...options, method: 'POST' })
  }
  put(options) {
    return this.request({ ...options, method: 'PUT' })
  }
  delete(options) {
    return this.request({ ...options, method: 'DELETE' })
  }
  patch(options) {
    return this.request({ ...options, method: 'PATCH' })
  }
}
export const mrRequest = new MrRequest('http://codercba.com:9002')

请求视频列表

1、后台配置

由于请求的是一个跨域的 URL:http://codercba.com:9002 ,要在【详情 - 本地设置】中设置如下:

image-20230221141450895

为了方便调试,可以在【编译 - 添加编译模式】中设置一个视频页的编译模式

image-20230221141632546

2、发送网络请求

services 下,新建 video.js 模块,负责视频页面的网络请求

js
// services/video.js
import { mrRequest } from './index'

    /* 获取视频页的MV列表 */
    export function getTopMV(offset = 0, limit = 20) {
+      return mrRequest.get({
+        url: '/top/mv',
+        data: {
+          limit,
+          offset
+        }
      })
    }

pages\main-video\main-video.js 下调用 getTopMV 方法

js
// pages/main-video/main-video.js
    import { getTopMV } from '../../services/video'
    Page({
      data: {
        videoList: [],
        offset: 0
      },
      onLoad() {
        /* 发送网络请求 */
+        this.fetchTopMV()
      },
+      async fetchTopMV() {
+        const res = await getTopMV(this.data.offset, 20)
        // this.setData中赋值时,只能一次性赋值,不能 += 这种赋值
+        const newVideoList = [...this.data.videoList, ...res.data]
        this.setData({ videoList: newVideoList })
        this.data.offset = this.data.videoList.length
      }
    })

3、展示视频列表-基础

html
<view class="main-video">
  <block wx:for="{{videoList}}" wx:key="id">
    <view class="video-item">
      <image class="img" mode="widthFix" src="{{itemData.cover}}"></image>
      <view class="title">{{ itemData.name }} - {{ itemData.artistName }}</view>
    </view>
  </block>
</view>

封装video-item

image-20240711162011501

1、基础使用

/components/ 目录下新建组件 video-item

main-video.js 中设置

js
  "usingComponents": {
+    "video-item": "/components/video-item/video-item"
  }

main-video.wxml 中使用组件,并传递数据到 item-data

html
<view class="main-video">
  <block wx:for="{{videoList}}" wx:key="id"> + <video-item class="item" item-data="{{ item }}"></video-item> </block>
</view>

在组件的 properties 中接收传递的数据itemData

js
    Component({
      properties: {
+        itemData: {
+          type: Object,
+          value: {}
        }
      }
    })

组件模板,并在组件模板中使用 wxs 模块,使用fmt.formatCount()fmt.formatTime() 格式化数据

html
+ <wxs src="/utils/format.wxs" module="fmt"></wxs>
<view class="video-item">
  <view class="item-inner">
    <view class="album">
      <image class="img" mode="widthFix" src="{{itemData.cover}}"></image>
      <view class="tags">
        <view class="tag play-count">
          <text class="icon"></text>
          + <text class="text">{{ fmt.formatCount(itemData.playCount) }}</text>
        </view>
        + <view class="tag duration">{{ fmt.formatTime(itemData.mv.videos[0].duration, 'ms') }}</view>
      </view>
    </view>
    <view class="info">
      <view class="title">{{ itemData.name }} - {{ itemData.artistName }}</view>
    </view>
  </view>
</view>

注意:

  • 1、在小程序中,背景图不可以使用本地图片,此处使用 base64 图片替代

    image-20230221143434000

  • 2、小程序中组件的根元素并不是直接放在.video-list 的子元素上 ,所以在给 .video-list 设置的 flex 其实设置在了 .video-list的子元素 <video-item> 上的

    image-20230220175517368

    所以需要给 <video-item> 添加一个 class,并在它上面设置 width

    image-20230220175932506

    image-20230220180104402

2、格式化播放量

/utils/format.wxs中,定义格式化函数,并导出

注意:

  • 此处需要使用 ComonJS 语法 module.exports
  • wxs 文件中只能使用 ES5 的语法,不能有 ES6+的语法
js
    /* 格式化数量单位 */
    function formatCount(count) {
      count = Number(count)
      if(count >= 100000000) {
        return (count / 100000000).toFixed(1) + '亿'
      }else if(count >= 10000) {
        return (count / 10000).toFixed(1) + '万'
      } else {
        return count
      }
    }

    /* 格式化时间单位 */
    function formatTime(time, type='s') {
      time = Number(time)
      if(type === 'ms') {
        time = Math.floor(time / 1000)
      }
      var minute = Math.floor(time / 60)
      var second = Math.floor(time % 60)
+      return PadLeft(minute) + ':' + PadLeft(second)
    }

    function PadLeft(n) {
      n = n + ''
      return ('00' + n).slice(n.length)
    }

+    module.exports = {
+      formatCount: formatCount,
+      formatTime: formatTime
    }

使用格式化函数

html
+ <wxs src="/utils/format.wxs" module="fmt"></wxs>
<view class="video-item">
  <view class="item-inner">
    <view class="album">
      <image class="img" mode="widthFix" src="{{itemData.cover}}"></image>
      <view class="tags">
        <view class="tag play-count">
          <text class="icon"></text>
          + <text class="text">{{ fmt.formatCount(itemData.playCount) }}</text>
        </view>
        + <view class="tag duration">{{ fmt.formatTime(itemData.mv.videos[0].duration, 'ms') }}</view>
      </view>
    </view>
    <view class="info">
      <view class="title">{{ itemData.name }} - {{ itemData.artistName }}</view>
    </view>
  </view>
</view>

上拉加载更多

onReachBottom 选项中监听页面滚动到底部,调用网络请求函数

js
+  onReachBottom() {
    console.log('到达底部~');
+    this.fetchTopMV()
  }

网络请求函数

js
  async fetchTopMV() {
+    const res = await getTopMV(this.data.offset, 20)
    const newVideoList = [...this.data.videoList, ...res.data]
    this.setData({ videoList: newVideoList })
    this.data.offset = this.data.videoList.length
  }

解决最多加载 50 条数据的问题

image-20230220182014488

在调用函数前判断是否有更多的数据 hasMore

js
  onReachBottom() {
    console.log('到达底部~');
     // 1、判断是否有更多的数据
+    if(!this.data.hasMore) return
    this.fetchTopMV()
  },

  async fetchTopMV() {
    const res = await getTopMV(this.data.offset, 20)
    const newVideoList = [...this.data.videoList, ...res.data]
    this.setData({ videoList: newVideoList })
    this.data.offset = this.data.videoList.length
    // 2、更新hasMore的值
+    this.data.hasMore = res.hasMore
  }

下拉刷新

开启下拉刷新

json
// pages\main-video\main-video.json
{
  "usingComponents": {
  },
  "navigationBarTitleText": "视频",
  "backgroundTextStyle": "dark",
+  "enablePullDownRefresh": true
}

看到加载中的...

json
// pages\main-video\main-video.json
{
  "usingComponents": {
    "video-item": "/components/video-item/video-item"
  },
  "navigationBarTitleText": "视频",
+  "backgroundTextStyle": "light",
  "enablePullDownRefresh": true
}

监听下拉刷新

js
// pages\main-video\main-video.js
+  async onPullDownRefresh() {
    // 重置请求参数
    this.setData({
      videos: [],
      offset: 0,
      hasMore: true
    })
    // 发送请求完,停止刷新动画
+    await this.fetchTopMV()
  },

停止下拉刷新

js
// pages\main-video\main-video.js 
async onPullDownRefresh() {
    // 重置请求参数
    this.setData({
      videos: [],
      offset: 0,
      hasMore: true
    })
+    // 发送请求完,停止刷新动画
+    await this.fetchTopMV()
+    wx.stopPullDownRefresh()
  },

video 详情页

跳转到详情页

方法一:在组件上监听跳转

创建 detail 页

image-20230221150424069

绑定监听事件

image-20230221150707517

定义监听事件,跳转到详情页,并传递参数 id

image-20230221151010692

在详情页接收 id 参数

image-20230221152252084

方法二:在组件内部监听跳转

绑定监听事件

html
<!--components/video-item/video-item.wxml-->
<wxs src="/utils/format.wxs" module="fmt"></wxs>
+ <view class="video-item" bindtap="hdlItemClick">	
	<view class="cover">

定义监听事件,无需使用 data-* 属性传递参数,可以直接在 properties 中获取

js
// components/video-item/video-item.js
  methods: {
    hdlItemClick() {
+      const id = this.properties.itemData.id
      wx.navigateTo({
        url: `/pages/detail-video/detail-video?id=${id}`,
      })
    }
  },

请求视频地址

封装函数 getMVUrl

js
// service\video.js
/** 请求MV视频播放url */
export function getMVUrl(id) {
	return mrRequest.get({
		url: '/mv/url',
		data: { id }
	})
}

调用函数,发送网络请求,封装函数 fetchMVUrl

js
// pages/detail-video/detail-video.js
import { getMVUrl } from "../../service/video"

  onLoad(options) {
    this.data.id = options.id
    // 请求数据
+    this.fetchMVUrl()
  },
      
  // 请求MV视频播放url
+  async fetchMVUrl() {
    const res = await getMVUrl(this.data.id)
    this.setData({mvUrl: res.data.url})
  },

展示视频

html
<!--pages/detail-video/detail-video.wxml-->
<view class="detail-video">
	<video class="video" 
           src="{{mvUrl}}" 
    </video>
</view>

样式修改

<video> 组件属性-弹幕

html
<!--pages/detail-video/detail-video.wxml-->
<view class="detail-video">
	<video class="video" 
           src="{{mvUrl}}" 
           danmu-list="{{danmuList}}" 
    </video>
</view>
js
// pages/detail-video/detail-video.js
  data: {
    mvUrl: '',
+    danmuList: [
+      { text: '我是沙发', color: '#f00', time: 2 },
+      { text: '我是板凳', color: '#0f0', time: 5 },
+      { text: '我是地板', color: '#ff0', time: 10 }
+    ]
  },

视频信息 getMVInfo

js
// service\video.js
/** 请求MV视频信息 */
export function getMVDetail(mvid) {
	return mrRequest.get({
		url: '/mv/detail',
		data: { mvid }
	})
}

image-20230221165633506

相关视频 getMVRelated

js
// service\video.js
/** 请求推荐视频 */
export function getSimiMV(mvid) {
	return mrRequest.get({
		url: '/simi/mv',
		data: { mvid }
	})
}

注意: 原先的接口/related/allvideo已经失效,现用/simi/mv接口替代

image-20230221154215439

image-20230221165806204

image-20230221170130637

页面局部滚动

方法一:设置 video 为固定定位

css
/* pages/detail-video/detail-video.wxss */
.video {
	width: 100%;
+	position: fixed;
+	top: 0;
+	left: 0;
+	z-index: 10;
}

.content {
+	margin-top: 225px;
	height: calc(100% - 225px);
}

方法二:要滚动的区域用 scroll-view 包裹

html
<!--pages/detail-video/detail-video.wxml-->
+	<scroll-view class="content" scroll-y>
		<!-- 视频信息 -->
		<view class="detail">
            ...
		</view>
		<!-- 推荐视频 -->
		<view class="simi">
            ...
		</view>
	</scroll-view>

计算 scroll-view 的高度

js
/* pages/detail-video/detail-video.wxss */
page {
	height: 100vh;
}
css
/* pages/detail-video/detail-video.wxss */
.content 
	height: calc(100% - 225px);
}

问题:

在播放视频时暂停后拖动时会报以下错误:

image-20230221160054303

分析:这是由于服务器端对 referrer 进行了检测

image-20230221160228000

解决:

html
<!--pages/detail-video/detail-video.wxml-->
<view class="detail-video">
	<video class="video" 
           src="{{mvUrl}}" 
           danmu-list="{{danmuList}}" 
           referrer-policy="origin">
    </video>
</view>

问题:

image-20230221163902375

分析:这是由于微信小程序内部渲染出的错,只能等官方修复

music 页

Vant安装和使用

地址:https://vant-contrib.gitee.io/vant-weapp/#/home

通过 pnpm 安装

sh
pnpm i @vant/weapp

修改 app.json

将 app.json 中的 "style": "v2" 去除,小程序的新版基础组件强行加上了许多样式,难以覆盖,不关闭将造成部分组件样式混乱。

构建 npm

在小程序编辑器中,选择【工具 - 构建 npm】构建 npm

构建 npm 做的事情:将 node_modules 中的库 放到了 miniprogram_npm 中去,小程序只会在 miniprogram_npm 中查找包

每次安装完包都需要构建一次

image-20230221171711977

注册组件

json
  "usingComponents": {
+    "van-button": "@vant/weapp/button/index"
  }

使用组件

html
<view class="main-music">
+ <van-button type="primary">提示按钮</van-button>
</view>

搜索框

image-20230221180159523

基础展示

html
<!-- 搜索框 -->
<view class="search-box">
  + <van-search value="{{ searchValue }}" shape="round" background="#fafafa" placeholder="请输入搜索关键词" />
</view>
js
  data: {
+    searchValue: ''
  }

修改搜索框背景为白色

css
    page {
        /* 整个页面背景色为 #fafafa */
+      background-color: #fafafa;
        /* 搜索框内部背景色为 #fff */
+      --search-background-color: #fff !important;
    }
json
{
  "navigationBarTitleText": "音乐",
	// 导航栏背景色为 #fafafa
+  "navigationBarBackgroundColor": "#fafafa",
}

修改 vant 样式的方法

方法一:修改 search 组件的 class 样式

css
    .van-search__content {
+      background-color: #fff !important;
    }

方法二:修改统一的自定义 CSS 变量样式

js
    .main-music {
+      --search-background-color: #fff;
    }

问题:

这个是 vant 官方的问题,只能等对方修改,或者自己修改源码

image-20230221173723288

这是由于小程序基础库版本变化(2.18.1 开始出错),vant 没有跟随修改才导致的问题

解决(暂时):

降低小程序基础库版本到 2.18.0

json
// project.config.json
	{
      "compileType": "miniprogram",
+      "libVersion": "2.18.0",
    }

跟进:目前(3.5.1版本)已经修复该问题

点击跳转

点击搜索框跳转到搜索页

创建搜索页

json
// app.json
    {
      "pages":[
        "pages/main-music/main-music",
        "pages/main-video/main-video",
        "pages/detail-video/detail-video",
+        "pages/detail-search/detail-search"
      ],
    }

绑定事件,跳转到搜索详情页

  • click-input:是vant-search组件自身的事件
html
<van-search
  value="{{ searchValue }}"
  shape="round"
  background="#fafafa"
  placeholder="请输入搜索关键词"
+  bind:click-input="onJumpSearch"
/>
js
  /* 跳转到detail-search页 */
  onJumpSearch() {
    wx.navigateTo({
      url: '/pages/detail-search/detail-search',
    })
  }

轮播图

image-20230223113840318

请求数据

services/music.js 页面定义封装函数 getMusicBanner

js
import { mrRequest } from './index'

/* 获取轮播图数据 */
export function getMusicBanner(type = 0) {
  return mrRequest.get({
    url: '/banner',
    data: {
      type
    }
  })
}

main-music 页面,使用函数,发送网络请求

js
+    import { getMusicBanner } from '../../services/music'
    Page({
      onLoad() {
        // 发送网络请求
+        getMusicBanner(1).then(res => {
+          console.log(res);
+        })
      }
    })

封装网络请求的方法

js
      onLoad() {
        // 发送网络请求
+        this.fetchMusicBanner() // 请求轮播图数据
      },
      /* 请求轮播图数据 */
+      async fetchMusicBanner() {
        const res = await getMusicBanner(1)
        this.setData({ banners: res.banners })
      },

展示轮播图

  • 无限轮播: swiper[circular]
  • 指示器:swiper[indicator-dots]
html
<!-- 轮播图 -->
<view class="banner">
+ <swiper circular indicator-dots>
   <block wx:for="{{ banners }}" wx:key="bannerId">
+     <swiper-item>
       <image class="banner-image" src="{{ item.pic }}" mode="widthFix"></image>  
      </swiper-item>
    </block>
  </swiper>
</view>

设置样式

设置内边距

css
    page {
      background-color: #fafafa;
      --search-background-color: #fff;
+	  --search-padding: 20rpx 0;

+      padding: 0 20rpx;
+      box-sizing: border-box;
    }

给 .banner 设置圆角

css
    .main-music .banner {
+      border-radius: 12rpx;
+      overflow: hidden;
    }

问题:指示器靠下

问题: 指示器靠下

image-20230221180130972

**原因:**轮播图和图片的高度不一致导致的,轮播图有着自己的默认高度(150px),而图片在设置了mode="widthFix" 之后,宽度全部显示,高度自适应显示,此时轮播图图片的高度小于轮播图盒子的高度,就会显得指示器向下了

**解决:**修改轮播图的默认高度

1、由于需要适配不同尺寸的屏幕,所以高度不能写死

image-20230223105333899

此处 bannerHeight 需要和图片的高度相等

2、计算图片的高度

html
<swiper circular indicator-dots style="height: {{bannerHeight}}px;"></swiper>
js
  data: {
    searchValue: '',
    banners: [],
+    bannerHeight: 150 // 默认150px
  },
html
<!-- 轮播图 -->
<swiper class="banner" indicator-dots autoplay circular interval="{{3000}}" style="height: {{bannerHeight}}px;">
  <block wx:for="{{banners}}" wx:key="targetId">
    <swiper-item class="item">
      <image class="img" 
             mode="widthFix" 
             src="{{item.imageUrl}}" 
 +            bindload="getBannerHeight" />
    </swiper-item>
  </block>
</swiper>
js
  /** 计算轮播图高度 */
  async getBannerHeight() {
    // 获取图片的宽高
+    const res = await querySelector('.img')
    this.setData({ bannerHeight: res[0].height })
  },

3、封装获取元素节点信息的函数querySelector

注意: 由于 res 是在回调函数中,最好使用 Promise 的 resolve 返回结果

  • wx.createSelectorQuery(),返回一个 SelectorQuery 对象实例。在自定义组件或包含自定义组件的页面中,应使用 this.createSelectorQuery() 来代替
    • 返回值
    • SelectorQuery:``,查询节点信息的对象
  • SelectorQuery.select(selector),在当前页面下选择第一个匹配选择器 selector 的节点。返回一个 NodesRef 对象实例,可以用于获取节点信息
    • 参数
    • selectorstring,CSS 选择器
    • 返回值
    • NodesRef:``,用于获取 WXML 节点信息的对象
  • NodesRef.boundingClientRect(cb),添加节点的布局位置的查询请求。相对于显示区域,以像素为单位。其功能类似于 DOM 的 getBoundingClientRect。返回 NodesRef 对应的 SelectorQuery
    • 参数
    • cbfunction,回调函数,在执行 SelectorQuery.exec 方法后,节点信息会在 callback 中返回
  • SelectorQuery.exec(cb),执行所有的请求。请求结果按请求次序构成数组,在 callback 的第一个参数中返回
    • 参数
    • cbfunction,回调函数,在执行 SelectorQuery.exec 方法后,节点信息会在 callback 中返回
    • 返回值
    • NodesRef:``,用于获取 WXML 节点信息的对象
js
    // utils\query-selector.js
    export function querySelector(selector) {
      return new Promise((resolve, reject) => {
+        const query = wx.createSelectorQuery()
+        query.select(selector).boundingClientRect().exec(res => {
+          resolve(res)
        })
      })
    }

问题: querySelector()函数如果在component而非page中使用时,会无法获取到 WXML 节点的信息

解决:待解决

4、使用节流函数,限制 onBannerImageLoad 执行次数

  • 使用自己手写的节流函数

    image-20230223112649618

    image-20230223112625564

  • 使用 underscore 中的节流函数

    1 安装underscore

    sh
    npm i underscore

    2 构建 npm

    3 使用 throttle,对函数节流

    js
    +    import { throttle } from 'underscore'
    
    +    export const querySelector = throttle(selector =>{
          console.log('querySelector');
          return new Promise((resolve, reject) => {
            const query = wx.createSelectorQuery()
            query.select(selector).boundingClientRect().exec(res => {
              resolve(res)
            })
          })
    +    }, 100, { trailing: false })

    4 使用函数 querySelector

    js
        import { querySelector } from '../../utils/query-selector'
        /* 获取图片的高度 */
        async onBannerImageLoad(e) {
    +      const img = await querySelector('.banner-image')
          this.setData({ bannerHeight: img[0].height })
        }

头部区域

image-20230223144637038

基础布局

创建全局组件 area-header

html
<view class="area-header">
  <view class="title">{{ title }}</view>
+  <view class="more" bind:tap="onMoreTap">
    <text class="text">更多</text>
+     <van-icon name="arrow" />
  </view>
</view>

接收传递过来的参数title

js
  properties: {
    title: {
      type: String,
      value: '默认标题'
    }
  },

使用组件

html
<!-- 推荐歌曲 -->
<view class="recommend-song">
 + <area-header title="推荐歌曲"></area-header> 
</view>

点击更多

将组件内部的点击事件发送出去

js
  methods: {
    /* 点击更多,发送点击事件moreClick到组件外部 */
    onMoreTap() {
+     this.triggerEvent('moreClick')
    }
  },

在组件外部接收发送过来的事件 moreclick

html
<!-- 推荐歌曲 -->
<view class="recommend-song">
  <area-header title="推荐歌曲"
 +                bind:moreClick="onJumpDetailSong">
  </area-header>
</view>

定义事件 onJumpDetailSong

js
  /* 跳转到推荐歌曲详情页 */
+  onJumpDetailSong() {
    wx.navigateTo({ url: '/pages/detail-song/detail-song' })
  }

是否展示右侧更多

组件内部接收参数 isMore

js
  properties: {
+    isMore: {
      type: Boolean,
      value: true
    }
  },

组件内部通过wx:if控制是否显示更多

html
<view class="area-header">
  <view class="title">{{ title }}</view>
+  <view wx:if="{{ isMore }}" class="more" bind:tap="onMoreTap">
    <text class="text">更多</text>
    <van-icon name="arrow" />
  </view>
</view>

组件外部传递参数 isMore注意: 此处 isMore 使用的是驼峰写法,和 vue 不同,不是 is-more 的写法)

html
<view class="recommend-song">
  <area-header title="推荐歌曲" isMore="{{ true }}" bind:moreClick="onJumpDetailSong"></area-header>
</view>

image-20230223145352325

推荐歌曲

image-20240712152734465

请求数据

1、service

js
// services/music.js

/* 请求推荐歌曲数据 */
+ export function getRecommendSong(id) {
  return mrRequest.get({
    url: '/playlist/detail',
    data: {
      id
    }
  })
}

2、组件中发起请求,由于返回数据过多,这里截取前6条数据

js
// pages\main-music\main-music.js

  /* 请求推荐歌曲数据 */
  async fetchRecommendSong() {
    const res = await getRecommendSong(3778678)
    console.log(res);
+    this.setData({ recommendSongs: res.playlist.tracks.slice(0, 6) })
  },

3、遍历渲染数据

html
// pages\main-music\main-music.wxml

<!-- 推荐歌曲 -->
<view class="recommend-song">
  <area-header title="推荐歌曲" isMore="{{ true }}" bind:moreClick="onJumpDetailSong"></area-header>
  <view class="list">
 +   <block wx:for="{{ recommendSongs }}" wx:key="id">
      <song-item itemData="{{item}}"></song-item>
    </block>
  </view>
</view>

封装song-item-v1

image-20240722230927866

组件内部

html
<view class="song-item">
  <image class="image" src="{{ itemData.al.picUrl }}"></image>
  <view class="content">
    <view class="name">{{ itemData.name }}</view>
    <view class="singer">{{ itemData.ar[0].name }}</view>
  </view>
  <van-icon class="more" name="arrow" />
</view>
js
  properties: {
    itemData: {
      type: Object,
      value: {}
    }
  }

组件外部使用组件

html
<view class="list">
  <block wx:for="{{ recommendSongs }}" wx:key="id">
      + <song-item itemData="{{item}}"></song-item>
  </block>
</view>

store共享数据

在 store 中发送网络请求

js
// stores\recommendStore.js

import { HYEventStore } from 'hy-event-store'
import { getRecommendSong } from '../services/music'

export const recommendStore = new HYEventStore({
  state: {
    recommendSongs: []
  },
  actions: {
    async fetchRecommendSong(ctx) {
      const res = await getRecommendSong(3778678)
      // console.log(res);
      ctx.recommendSongs = res.playlist.tracks
    }
  }
})

在页面中使用 store

js
// pages\main-music\main-music.js

import { recommendStore } from '../../stores/recommendStore'

  onLoad() {
    // 发送网络请求
    this.fetchMusicBanner() // 请求轮播图数据

    // 监听store中数据的变化
+    recommendStore.onState('recommendSongs', value => {
      console.log(value);
      this.setData({ recommendSongs: value.slice(0, 6) })
    })
+    recommendStore.dispatch('fetchRecommendSong')
  },

由于请求网易服务器的地址需要经常变化,防止被限制访问

js
// services\config.js
export const baseUrl = 'http://codercba.com:9002'
// export const baseUrl = 'https://coderwhy-music.vercel.app/'
js
// services\index.js

import { baseUrl } from "./config"
...
export const mrRequest = new MrRequest(baseUrl)

离开页面时,不再监听 store 的变化

js
// pages\main-music\main-music.js

      onLoad() {
        /* 监听store中数据的变化 */
        recommendStore.onState('recommendSongs', this.handleRecommendSongs)
        recommendStore.dispatch('fetchRecommendSong')
      },
      /* 离开页面时,取消监听store中的数据 */
+      onUnload() {
+        recommendStore.offState('recommendSongs', this.handleRecommendSongs)
+      },

      /* 监听store数据的处理函数 */
      handleRecommendSongs(value) {
        this.setData({ recommendSongs: value.slice(0, 6) })
      },

热门歌单

image-20230223180207794

html
<view class="hot-menu">
 + <area-header title="热门歌单" isMore="{{ true }}"></area-header> 
</view>

请求数据

js
// services/music.js

/* 请求热门歌单数据 */
export function getHotMenu(cat = '全部', limit = 6, offset = 0) {
  return mrRequest.get({
    url: '/top/playlist',
    data: {
      cat,
      limit,
      offset
    }
  })
}
js
// pages\main-music\main-music.js
  onLoad() {
    /* 请求热门歌单 */
+    this.fetchHotMenu()
  },

  /* 请求热门歌单数据 */
+  async fetchHotMenu() {
+    const res = await getHotMenu()
+    this.setData({ hotMenus: res.playlist })
  },

渲染热门歌单

html
// pages\main-music\main-music.wxml

<!-- 热门歌单 -->
<view class="hot-menu">
  <area-header title="热门歌单" isMore="{{ true }}"></area-header>
+   <view class="list">
+     <block wx:for="{{ hotMenus }}" wx:key="id">
+    	 <menu-item class="item" itemData="{{ item }}"></menu-item>
+     </block>
+   </view>
</view>

封装menu-item

image-20240722231526434

接收数据itemData

js
// components\menu-item\menu-item.js

Component({
  properties: {
    itemData: {
      type: Object,
      value: {}
    }
  }
})

渲染组件,并且格式化播放量

html
// components\menu-item\menu-item.wxml
+ <wxs src="/utils/format.wxs" module="fmt"></wxs>
<view class="menu-item">
  <view class="cover">
+    <cover-image class="img" src="{{ itemData.coverImgUrl }}" mode="widthFix" />
+      <view class="play-count">{{ fmt.formatCount(itemData.playCount) }}</view>
  </view>
  <view class="info">
    <view class="title">{{ itemData.name }}</view>
  </view>
</view>

注意: 使用 <cover-image> 没有了 <image> 盒子多出来一部分空白的问题

横向滚动

修改热门歌单样式为滚动

  • [scroll-x]:横向滚动
  • [enable-flex]:开启 flex 布局

在 flex 布局时,需要在.item 上设置 flex 子容器样式

html
+ <scroll-view class="list" scroll-x enable-flex>
    <block wx:for="{{ hotMenus }}" wx:key="id">
+      <menu-item class="item" itemData="{{ item }}"></menu-item>
    </block>
+ </scroll-view>

让滚动时在屏幕左边缘消失

html
<scroll-view class="list" scroll-x enable-flex + style="width: {{ screenWidth }}px;"></scroll-view>

1、在 app.js 全局文件中获取屏幕的高度和宽度,可以共享到所有的页面

js
// app.js
App({
+      globalData: {
+        screenWidth: 375,
+        screenHeight: 667
+      },
      onLaunch() {
        /* 获取屏幕宽高 */
+        wx.getSystemInfo({
+          success: res => {
+            this.globalData.screenWidth = res.screenWidth
+            this.globalData.screenHeight = res.screenHeight
          }
        })
      }
    })

更新: 微信小程序2.20.1版本开始wx.getSystemInfo()方法已经废弃,由wx.getSystemSetting、wx.getAppAuthorizeSetting、wx.getDeviceInfo、wx.getWindowInfo、wx.getAppBaseInfo方法代替

js
  /** 获取屏幕宽高 */
  async getScreenSize() {
+    const {screenWidth, screenHeight} = await wx.getWindowInfo()
    this.globalData.screenWith = screenWidth
    this.globalData.screenHeight  = screenHeight
  }

2、在页面中使用全局定义的数据

js
  onLoad() {
    /* 获取屏幕宽度 */
+    const app = getApp()
+    this.setData({ screenWidth: app.globalData.screenWidth })
  }

3、让一开始的时候,左侧可以有一个缩进

css
    /* 热门歌单 */
    .hot-menu .list {
      /* display: flex; */
+      margin-left: -24rpx;
+      white-space: nowrap;
    }
    .hot-menu .list .item {
+      display: inline-block;
+      margin-left: 24rpx;
    }
    .hot-menu .list .item:last-of-type {
+      margin-right: 24rpx;
    }

4、menu-item 组件内部

css
    .menu-item .info {
+      white-space: normal;
    }

推荐歌单

image-20240722232748550

基本使用

和热门歌单的区别只是数据不同

修改 services/music.js 中的请求函数为 getMenu

js
/* 请求歌单(热门,推荐)数据 */
export function getMenu(cat = '全部', limit = 6, offset = 0) {
  return mrRequest.get({
    url: '/top/playlist',
    data: {
      cat,
      limit,
      offset
    }
  })
}

在 music.js 中,请求推荐歌单数据

js
  /* 请求推荐歌单数据 */
  async fetchRecMenu() {
    const res = await getMenu('华语')
    this.setData({ recMenus: res.playlists })
  },

在 music.wxml 中,使用 menu-area 组件

html
<!-- 热门歌单 -->
<menu-area class="hot-menu" title="热门歌单" menus="{{ hotMenus }}"></menu-area>

<!-- 推荐歌单 -->
<menu-area class="rec-menu" title="推荐歌单" menus="{{ recMenus }}"></menu-area>

封装menu-area

image-20240722232748550

组件内部接收参数

js
    Component({
      properties: {
+        title: {
          type: String,
          value: '默认标题'
        },
+        menus: {
          type: Object,
          value: {}
        }
      }
    })

组件实现

html
<view class="menu">
  <area-header title="{{ title }}" isMore="{{ true }}"></area-header>
+ <scroll-view class="list" scroll-x enable-flex style="width: {{ screenWidth }}px;">
     <block wx:for="{{ menus }}" wx:key="id">
+       <menu-item class="item" itemData="{{ item }}"></menu-item> 
     </block>
  </scroll-view>
</view>
json
  "usingComponents": {
+    "area-header": "/components/area-header/area-header",
+    "menu-item": "/components/menu-item/menu-item"
  }

设置滚动视图的宽度为屏幕宽度

js
+    const app = getApp()
    Component({
      data: {
        screenWidth: 375
      },
+      lifetimes: {
+        attached() {
+          this.setData({ screenWidth: app.globalData.screenWidth })
        }
      }
    }

注意: 此处 setData 不能写在 created 生命周期中

巅峰榜

image-20230224174040336

header

html
<!-- 巅峰榜 -->
<view class="ranking">
+  <area-header title="巅峰榜" isMore="{{ false }}"></area-header> 
</view>

请求数据

在 store 中请求数据

js
    import { HYEventStore } from 'hy-event-store'
    import { getRanking } from '../services/music'

    const rankingMap = {
+      newRanking: 3779629,
+      originalRanking: 2884035,
+      upRanking: 19723756
    }
    export const rankingStore = new HYEventStore({
      state: {
        newRanking: {},
        originalRanking: {},
        upRanking: {}
      },
      actions: {
        /* 请求榜单数据 */
        async fetchRanking(ctx) {
          console.log(rankingMap);
           // 遍历rankingMap,分别请求榜单数据
+          for(let key in rankingMap) {
+            const res = await getRanking(rankingMap[key])
+            ctx[key] = res.playlist
+          }
        }
      }
    })

在页面获取数据

image-20230225140410100

js
    import { rankingStore } from '../../stores/ranking'

    Page({
      data: {
        ranking: {}
      },

      onLoad() {
        /* 监听store数据,请求巅峰榜 */
        rankingStore.onState('newRanking', this.handleNewRanking)
        rankingStore.dispatch('fetchRanking')
        rankingStore.onState('originalRanking', this.handleOriginalRanking)
        rankingStore.dispatch('fetchRanking')
        rankingStore.onState('upRanking', this.handleUpRanking)
        rankingStore.dispatch('fetchRanking')
      }

      /* 离开页面时,取消监听store中的数据 */
      onUnload() {
        rankingStore.offState('newRanking', this.handleNewRanking)
        rankingStore.offState('originalRanking', this.handleOriginalRanking)
        rankingStore.offState('upRanking', this.handleUpRanking)
      },

      /* 监听store数据的处理函数 */
      handleNewRanking(value) {
        const newRanking = {...this.data.ranking, newRanking: value}
        this.setData({ ranking: newRanking })
      },
      handleOriginalRanking(value) {
        const newRanking = {...this.data.ranking, originalRanking: value}
        this.setData({ ranking: newRanking })
      },
      handleUpRanking(value) {
        const newRanking = {...this.data.ranking, upRanking: value}
        this.setData({ ranking: newRanking })
      },

渲染巅峰榜

html
<!-- 巅峰榜 -->
<view class="ranking">
  <area-header title="巅峰榜" isMore="{{ false }}"></area-header>
  <view class="list">
+    <block wx:for="{{ranking}}" wx:key="index">
+      <ranking-item itemData="{{item}}">index</ranking-item>   
+    </block>
  </view>
</view>

封装 raking-item

image-20240722233409363

组件内部

js
  properties: {
    itemData: {
      type: Object,
      value: {}
    }
  }

组件渲染,并格式化播放量

html
+ <wxs src="/utils/format.wxs" module="fmt"></wxs>
<view class="ranking-item">
  <view class="content">
    <view class="title">{{itemData.name}}</view>
    <view class="list">
      <block wx:for="{{3}}" wx:key="*this">
        <view>
          <text class="num">{{item + 1}}. </text>
          <text class="name">{{itemData.tracks[item].name}}</text>
+          <text class="singer"> - {{itemData.tracks[item].ar[0].name}}</text>
        </view>
      </block>
    </view>
  </view>
  <view class="album">
+    <image class="img" src="{{itemData.coverImgUrl}}" mode="widthFix"></image>
+    <view class="play-count">{{fmt.formatCount(itemData.playCount)}}</view>
  </view>
</view>

显示多个歌手

html
<text wx:if="{{itemData.tracks[item].ar}}" class="singer">
 - {{fmt.joinSingers(itemData.tracks[item].ar)}}
</text>
js
/* 合并歌手 */
function joinSingers(singers) {
  var names = []
  singers.forEach(function (item) {
    names.push(item.name)
  })
  return names.join('/')
}

flex:1 自适应宽度的盒子中设置overflow:hidden可以解决子元素中 overflow 失效的问题,显示省略号

js
    .ranking-item .content {
 +     flex: 1;
 +     overflow: hidden;
    }

    .ranking-item .content .list .list-item {
+      text-overflow: ellipsis;
+      overflow: hidden;
+      white-space: nowrap;
    }

优化:onState 处理函数

js
  onLoad() {
    /* 请求巅峰榜 */
    rankingStore.onState('newRanking', this.handleRanking('newRanking'))
    rankingStore.onState('originalRanking', this.handleRanking('originalRanking'))
    rankingStore.onState('upRanking', this.handleRanking('upRanking'))
  },

  /* 离开页面时,取消监听store中的数据 */
  onUnload() {
    rankingStore.offState('newRanking', this.handleRanking('newRanking'))
    rankingStore.offState('originalRanking', this.handleRanking('originalRanking'))
    rankingStore.offState('upRanking', this.handleRanking('upRanking'))
  },

  handleRanking(type) {
+    return value => {
+      const newRanking = {...this.data.ranking, [type]: value}
+      this.setData({ ranking: newRanking })
+    }
  },

image-20230225165934861

跳转到歌单页

html
<view class="menu">
+  <area-header title="{{ title }}" isMore="{{ true }}" bind:moreClick="onJumpDetailMenu" />
</view>
js
  methods: {
    /* 跳转到detail-menu页 */
    onJumpDetailMenu() {
+      wx.navigateTo({ url: '/pages/detail-menu/detail-menu' })
    }
  }

创建歌单详情页

pages\detail-menu\detail-menu.wxml

修改导航栏标题

js
/* 修改详情页导航栏标题 */
wx.setNavigationBarTitle({
  title: '歌单详情'
})

请求歌单页数据

services

js
/* 请求歌单分类数据,获取cat */
export function getMenuCat() {
  return mrRequest.get({
    url: '/playlist/hot'
  })
}

/* 请求歌单(热门,推荐)数据 */
export function getMenu(cat = '全部', limit = 6, offset = 0) {
  return mrRequest.get({
    url: '/top/playlist',
    data: {
      cat,
      limit,
      offset
    }
  })
}

detail-menu.js

注意: 此处发送了 10 次 getSongMenuList 请求,由于这些请求都是异步请求,所以想等 10 次请求完毕之后再调用 setData,就需要用到Promise.all()

js
    import { getMenu, getMenuCat } from "../../services/music"

    Page({
      data: {
        menuCats: [],
        menus: []
      },
      onLoad() {
        /* 请求所有歌单数据 */
+        this.fetchMenus()
      },

      /* 请求所有歌单数据 */
 +     async fetchMenus() {
        // 1. 请求歌单分类
        const res = await getMenuCat()
        this.setData({ menuCats: res.tags })

        // 2. 根据歌单分类,请求所有歌单数据
+        const allPromise = []
+        for (const cat of this.data.menuCats) {
+          allPromise.push(getMenu(cat.name))
+        }

        // 3. 等到所有Promise都执行完毕,重新渲染页面
+        Promise.all(allPromise).then(res => {
+          this.setData({ menus: res })
+        })
      }
    })

渲染歌单页

使用 menu-item 组件

html
<view class="detail-menu">
  + <block wx:for="{{menus}}" wx:key="cat">
    <area-header title="{{item.cat}}" isMore="{{ false }}"></area-header>
    <view class="list">
      + <block wx:for="{{item.playlists}}" wx:key="id" wx:for-item="value">
        + <menu-item class="item" itemData="{{value}}"></menu-item>
      </block>
    </view>
  </block>
</view>

song 详情页

榜单

image-20230225172530112

跳转到 song 详情页

在 ranking-item 组件中绑定 onJumpDetailSong 事件

html
<view class="ranking-item" bind:tap="onJumpDetailSong"></view>

跳转的同时传递参数 typekey

  • typerecommend | ranking 推荐歌曲 | 排行榜
  • keynewRanking | originRanking | upRanking 新歌榜 | 原创榜 | 飙升榜
js
  properties: {
+    key: {
      type: String,
      value: 'newRanking'
    }
  },
  methods: {
    /* 跳转到推荐歌曲详情页 */
+    onJumpDetailSong() {
      wx.navigateTo({
+        url: `/pages/detail-song/detail-song?from=ranking&key=${this.properties.key}`
      })
    }
  }

修改导航栏标题

js
/* 修改导航栏标题 */
wx.setNavigationBarTitle({ title: '歌曲详情' })

根据跳转时的参数展示不同的内容

js
    import { rankingStore } from "../../stores/ranking";

    Page({
      data: {
        songs: {}
      },
      onLoad(query) {
        // 根据进入的场景展示不同的页面数据
+        if(query.from === 'ranking') {
+          rankingStore.onState(query.key, this.handleRanking)
        }
      }
    })
js
+      onUnload() {
        // 根据进入的场景卸载不同的页面数据
        if(query.from === 'ranking') {
+          rankingStore.offState(query.key, this.handleRanking)
        }
      }

保存数据到 data 中

js
      // 监听Store
+      handleRanking(value) {
        this.setData({ songs: value })
      }

渲染页面

html
<view class="detail-song">
  <area-header title="{{songs.name}}" isMore="{{false}}"></area-header>
  <view class="list">
    +
    <block wx:for="{{songs.tracks}}" wx:key="id">
      + <song-item-v2 itemData="{{item}}" index="{{index}}"></song-item-v2>
    </block>
  </view>
</view>

组件 song-item-v2

内部接收数据

js
  properties: {
+    itemData: {
      type: Object,
      value: {}
    },
+    index: {
      type: Number,
      value: 0
    }
  }

渲染组件

html
+ <wxs src="/utils/format.wxs" module="fmt"></wxs>
<view class="item">
  + <view class="num">{{index+1}}</view>
  <view class="content">
    <view class="name">{{itemData.name}}</view>
    <view class="singer">
      <image class="icon" src="/assets/images/icons/sq_icon.png" mode="widthFix"></image>
      + <text class="text">{{fmt.joinSingers(itemData.ar)}}</text>
    </view>
  </view>
</view>

推荐歌曲

从 store 获取推荐歌曲数据

修改 recommendStore 中的 recommendSongs

js
// main-music.js

onLoad() {
    /* 请求推荐歌曲 */
    recommendStore.onState('recommendSongs', this.handleRecommendSongs)
    recommendStore.dispatch('fetchRecommendSong')
  },
  onUnload() {
    recommendStore.offState('recommendSongs', this.handleRecommendSongs)
  },
  /* 监听store数据的处理函数 */
  handleRecommendSongs(value) {
+    this.setData({ recommendSongs: value.tracks?.slice(0, 6) })
  },

监听和取消监听 store

js
// detail-song.js

  onLoad(query) {
    // 根据进入的场景展示不同的页面数据
    if(query.from === 'ranking') {
      rankingStore.onState(query.key, this.handleRanking)
+    } else if (query.from === 'recommend') {
+      recommendStore.onState('recommendSongs', this.handleRanking)
+    }
  },
  onUnload() {
    // 根据进入的场景卸载不同的页面数据
    if(query.from === 'ranking') {
      rankingStore.offState(query.key, this.handleRanking)
+    } else if (query.from === 'recommend') {
+      recommendStore.offState('recommendSongs', this.handleRanking)
+    }
  },
  // 监听Store
+  handleRanking(value) {
+    this.setData({ songs: value })
+  }

歌单歌曲

image-20230225180637373

点击歌单 item 跳转到 song 详情页

html
// components\menu-item\menu-item.wxml <view class="menu-item" bind:tap="onJumpDetailSong"></view>
js
Component({
  properties: {
+    itemData: {
      type: Object,
      value: {}
    }
  },
  methods: {
    // 跳转
    /* 跳转到detail-song页 */
    onJumpDetailSong() {
+      wx.navigateTo({ url: `/pages/detail-song/detail-song?from=menu&id=${this.properties.itemData.id}` })
    }
  }
})

song 详情页

js
  onLoad(query) {
+    } else if (this.data.from === 'menu'){
+      this.fetchMenuSong(query.id)
    }
  },

请求歌单歌曲数据

js
  /* 请求歌单歌曲 */
  async fetchMenuSong(id) {
+    const res = await getRanking(id)
    this.setData({ songs: res.playlist })
  }

image-20230225175710382

渲染歌单歌曲

html
<view class="detail-song">
  + <menu-header wx:if="{{from === 'menu'}}" menuData="{{songs}}"></menu-header>
  <area-header wx:else title="{{songs.name}}" isMore="{{false}}"></area-header>
  <view class="list">
    <block wx:for="{{songs.tracks}}" wx:key="id">
      <song-item-v2 itemData="{{item}}" index="{{index}}"></song-item-v2>
    </block>
  </view>
</view>

封装组件 menu-header

接收数据,格式化订阅数

js
  properties: {
    menuData: {
      type: Object,
      value: {}
    }
  },
html
+ <wxs src="/utils/format.wxs" module="fmt"></wxs>
<view
  class="menu-header"
  style="width: {{screenWidth}}px; background: url({{menuData.backgroundCoverUrl}}) no-repeat; background-size: 100% 100%; backdrop-filter: blur(20rpx)"
>
  <view class="mask">
    <view class="content">
      <view class="album">
        <image class="img" src="{{menuData.coverImgUrl}}" mode="widthFix"></image>
      </view>
      <view class="info">
        <view class="title">{{menuData.name}}</view>
        <view class="creator">
          <view class="avatar">
            <image class="img" src="{{menuData.creator.avatarUrl}}" mode="widthFix"></image>
          </view>
          <text class="nickname">{{menuData.creator.nickname}}</text>
        </view>
        <view class="desc">简介:{{menuData.description}}</view>
      </view>
    </view>

    <view class="actions">
      <view class="favor item">
        <image class="icon" src="/assets/images/icons/favor_icon.png" mode="widthFix"></image>
        + <text class="count">{{fmt.formatCount(menuData.subscribedCount)}}</text>
      </view>
      <view class="share item">
        <image class="icon" src="/assets/images/icons/share_icon.png" mode="widthFix"></image>
        <text class="count">分享</text>
      </view>
    </view>
  </view>
</view>

播放页

image-20230301104409796

跳转播放页

创建music-player页面

pages\music-player\music-player.wxml

song-itemsong-item-v2 组件中绑定点击事件,跳转到播放页

点击时需要获取的数据

  • 1、获取点击的歌曲 id
  • 2、歌曲播放列表数据
html
<!-- song-item.wxml -->

<view class="song-item" bind:tap="onJumpMusicPlayer"></view>
js
// song-item.js

	/* 跳转到music-player页面,并携带参数id */
    onJumpMusicPlayer() {
      wx.navigateTo({
+        url: `/pages/music-player/music-player?id=${this.properties.itemData.id}`,
      })
    }

在播放页中接收数据

js
// music-player.js
Page({
  data: {
    id: 0
  },
  onLoad(query) {
    this.setData({ id: query.id })
  }
})
html
<view class="music-player"> {{ id }} </view>

请求数据

请求歌曲详情

image-20230301155311706

image-20230301160228452

创建 player.js 请求数据

service/player.js

js
/* 请求歌曲详情数据 */
export function getSongDetail(ids) {
  return mrRequest.get({
    url: '/song/detail',
    data: {
      ids
    }
  })
}

music-player.js

js
import { getSongDetail } from '../../services/player'

Page({
  data: {
    id: 0,
    song: {},
  onLoad(query) {
    this.setData({ id: query.id })
    // 网络请求
+    this.fetchSongDetail()
  // 网络请求
  /* 请求歌曲详情 */
+  async fetchSongDetail() {
+    const res = await getSongDetail(this.data.id)
+    this.setData({ song: res.songs[0] })
+  },
})

渲染播放歌曲

html
<!--pages/music-player/music-player.wxml-->
<view class="music-player">
  <view class="name">{{ song.name }}</view>
  <view class="back-img">
    <image src="{{ song.al.picUrl }}"></image>
  </view>
</view>

请求歌词信息

image-20230301155736608

service

js
/* 请求歌词信息 */
export function getLyric(id) {
  return mrRequest.get({
    url: '/lyric',
    data: {
      id
    }
  })
}

music-player.js

js
  onLoad(query) {
    this.fetchLyric()
  },
  /* 请求歌词信息 */
  async fetchLyric() {
    const res = await getLyric(this.data.id)
    this.setData({ lyric: res.lrc })
  }

自定义样式

背景展示:毛玻璃效果

html
<!-- 毛玻璃背景 -->
<image class="bg-img" src="{{ song.al.picUrl }}"></image>
<view class="bg-cover"></view>
css
    .bg-img,
    .bg-cover {
      position: fixed;
      top: 0;
      bottom: 0;
      left: 0;
      right: 0;
      width: 100%;
      height: 100%;
+      z-index: -1;
    }
    .bg-cover {
+      background-color: rgba(0, 0, 0, .4);
+      backdrop-filter: blur(20rpx);
    }

image-20230301112206588

计算状态栏高度

状态栏高度默认情况下为20px

导航栏所有机型高度都是44px

music-player.wxml

html
<!-- 自定义导航栏、状态栏 -->
<view class="nav-bar">
 + <view class="status" style="height: {{statusHeight}}px;"></view> 
</view>

app.js

js
App({
  globalData: {
    statusHeight: 20
  },
  onLaunch() {
    wx.getSystemInfo({
      success: res => {
+        this.globalData.statusHeight = res.statusBarHeight
      }
    })
  }
})

music-player.js

js
+    const app = getApp()
    Page({
      data: {
+        statusHeight: '20px',
      },
      onLoad(query) {
        // 设置statusHeight
+        this.setData({ statusHeight: app.globalData.statusHeight })
      },
    })

自定义导航栏

修改导航栏配置

json
{
+  "navigationBarTextStyle": "white",
+  "navigationStyle": "custom",
  "usingComponents": {}
}

music-player.wxml

html
<!-- 自定义导航栏、状态栏 -->
<view class="nav-bar">
  <view class="status" style="height: {{statusHeight}}px;"></view>
  + <nav-bar> 
  +   <text slot="center">歌曲播放</text> 
  + </nav-bar>
</view>

封装自定义导航栏 nav-bar

开启多插槽配置

js
  options: {
+    multipleSlots: true
  }

渲染导航栏组件

html
<view class="nav-bar">
  <view class="left">
    + <view class="slot">
      + <slot name="left"></slot>
    + </view>
    + <view class="default">
      + <image class="img" src="/assets/images/icons/arrow-left.png"></image>
    + </view>
  </view>
  <view class="center">
    + <view class="slot"> 
      + <slot name="center"></slot> 
    + </view> 
    + <view class="default">标题</view>
  </view>
  <view class="right"></view>
</view>

设置样式:是否显示默认插槽

css
.default {
  display: none;
}
.slot:empty + .default {
+  display: flex;
}

解决 image 组件包裹后出现的高度多了几个像素的问题

方法:设置 image 包裹盒子为 display: flex

歌曲

歌曲、歌词切换

image-20230301165339411

可以利用轮播图 swiper 实现 2 个页面的滚动切换

监听轮播图 change 事件,获取当前页的 current

html
+ <swiper bind:change="onSwiperChange">
  <swiper-item>歌曲</swiper-item>
  <swiper-item>歌词</swiper-item>
</swiper>
js
  /* 监听轮播图change,获取currentPage */
  onSwiperChange(e) {
+    this.setData({ currentPage: e.detail.current })
  },

image-20230301170635276

根据 current 为 tabs 添加 active 样式

html
<view class="tabs">
  <block wx:for="{{navTabs}}" wx:key="*this">
+     <text class="tab {{ currentPage === index ? 'active': '' }}">{{item}}</text>  
+      <text class="split" wx:if="{{ index !== navTabs.length - 1 }}"> | </text>
  </block>
</view>
css
/* 导航栏 */
.nav-bar .tabs {
  color: #aaa;
}
.nav-bar .tabs .active {
  color: #fff;
}

计算轮播图高度占据屏幕剩下的全部高度

html
+ <swiper bind:change="onSwiperChange" style="height: {{ contentHeight }}px;">
  <swiper-item>歌曲</swiper-item>
  <swiper-item>歌词</swiper-item>
</swiper>
js
// app.js

App({
  globalData: {
    contentHeight: 300
  },
  onLaunch() {
    /* 获取屏幕宽高 */
    wx.getSystemInfo({
      success: res => {
+        this.globalData.contentHeight = res.screenHeight - res.statusBarHeight - 44
      }
    })
  }
})
js
// music-player.js

const app = getApp()
Page({
  data: {
+    contentHeight: 300
  },
  onLoad(query) {
    // 设置contentHeight
+    this.setData({ contentHeight: app.globalData.contentHeight })
  },
})

点击 tab,切换歌曲、歌词页

绑定事件

html
// music-player.wxml

<view class="tabs">
  <block wx:for="{{navTabs}}" wx:key="*this">
    <text class="tab {{ currentPage === index ? 'active': '' }}" 
          + data-index="{{ index }}" 
          + bind:tap="onNavTabTap">
      {{item}}
    </text>
    <text class="split" wx:if="{{ index !== navTabs.length - 1 }}"> | </text>
  </block>
</view>

事件处理函数

js
// music-player.js

	/* 监听点击tab, 实现歌曲、歌词页面滚动切换*/
  onNavTabTap(e) {
    this.setData({ currentPage: e.currentTarget.dataset.index })
  }

将 currentPage 绑定到 swiper 的 current 属性上

html
<swiper bind:change="onSwiperChange" 
+        current="{{ currentPage }}" 
        style="height: {{ contentHeight }}px;">
  <swiper-item>歌曲</swiper-item>
  <swiper-item>歌词</swiper-item>
</swiper>

点击 tab 时,就会改变 currentPage,而 currentPage 改变后又会改变 swiper 的 current 属性,从而实现页面之间的切换

优化:使用 for 遍历 tabs

增强扩展性

js
  data: {
    navTabs: ['歌曲', '歌词'],
  },
html
<block wx:for="{{navTabs}}" wx:key="*this">
  <text class="tab {{ currentPage === index ? 'active': '' }}" 
+        data-index="{{ index }}" 
        bind:tap="onNavTabTap">
     {{item}}
  </text>
  <!-- 去除最后的分隔符 -->
  <text class="split" wx:if="{{ index !== navTabs.length - 1 }}"> | </text>
</block>

歌曲分页

image-20230301172058500

封面图片

html
<!-- 封面图片 -->
<view class="album">
  <image src="{{ song.al.picUrl }}"></image>
</view>

歌曲信息

html
<!-- 歌曲信息 -->
<view class="info">
  <view class="name">{{ song.name }}</view>
  <view class="singer">{{ song.ar[0].name }}</view>
</view>

一句歌词

html
<!-- 歌词 -->
<view class="lyric-oneline">{{ '一句的歌词' }}</view>

播放进度

html
<!-- 播放进度 -->
<slider block-size="12"></slider>
<view class="time">
  <text class="current">{{ '00:33' }}</text>
  <text class="duration">{{ fmt.formatTime(song.dt, 'ms') }}</text>
</view>

控制栏

html
<!-- 控制器 -->
<view class="control">
  <image class="btn repeat" src="/assets/images/player/play_repeat.png"></image>
  <image class="btn prev" src="/assets/images/player/play_prev.png"></image>
  <image class="btn pause" src="/assets/images/player/play_pause.png"></image>
  <image class="btn next" src="/assets/images/player/play_next.png"></image>
  <image class="btn list" src="/assets/images/player/play_music.png"></image>
</view>

歌曲播放

创建播放上下文,并自动播放歌曲

js
    // 创建 webAudio 上下文
+    const innerAudioContext = wx.createInnerAudioContext()
    Page({
      onLoad(query) {
        // 播放歌曲
+        innerAudioContext.src = `https://music.163.com/song/media/outer/url?id=${this.data.id}.mp3`
+        innerAudioContext.autoplay = true
      },
    })

监听播放进度

js
  // 监听播放进度,设置currentTime, sliderValue
  innerAudioContext.onTimeUpdate(() => {
+    const currentTime = innerAudioContext.currentTime * 1000 // ms
+    this.setData({ currentTime })
  })

获取总时长

image-20240723222500509

展示播放时间和总时长

html
<!-- 播放进度 -->
<slider class="slider" block-size="12" value="{{sliderValue}}"></slider>
<view class="time">
+  <view class="current">{{ fmt.formatTime(currentTime, 'ms') }}</view>
+  <view class="duration">{{ fmt.formatTime(duration, 'ms') }}</view>
</view>

播放滑块随着播放进度变化

html
<!-- 播放进度 -->
+ <slider class="slider" block-size="12" value="{{sliderValue}}"></slider>
js
  // 监听播放进度,设置currentTime, sliderValue
  innerAudioContext.onTimeUpdate(() => {
    const currentTime = innerAudioContext.currentTime * 1000
+    const sliderValue = currentTime / this.data.duration * 100
+    this.setData({ currentTime, sliderValue })
  })

点击滑块,改变播放进度

html
<!-- 播放进度 -->
<slider class="slider" block-size="12" value="{{sliderValue}}" + bind:change="onSliderChange"> </slider>
js
  /* 监听滑块点击,改变播放进度 */
  onSliderChange(e) {
    const sliderValue = e.detail.value
    const currentTime = sliderValue / 100 * this.data.duration
    this.setData({
      sliderValue,
      currentTime
    })
    innerAudioContext.seek(currentTime / 1000)
  },

解决跳转后不再监听播放(onTimeUpdate)的问题:必须明确调用下 play()方法

image-20240723224711996

js
  onLoad(query) {
    // BUG:跳转之后不能再监听onTimeUpdate,当前播放时间不再变化
    innerAudioContext.onWaiting(() => {
      innerAudioContext.pause()
    })
    innerAudioContext.onCanplay(() => {
      innerAudioContext.play()
    })
  },

拖动滑块,改变播放进度

html
<!-- 播放进度 -->
<slider
  class="slider"
  block-size="12"
  value="{{sliderValue}}"
  bind:change="onSliderChange"
+  bind:changing="onSliderChanging"
>
</slider>
js
  /* 监听滑块拖动,改变播放进度 */
  onSliderChanging(e) {
    const sliderValue = e.detail.value
    const currentTime = sliderValue / 100 * this.data.duration
    this.setData({
      currentTime,
      isSliderChanging: true
    })
  },

滑动时滑块出现跳跃

设置一个变量 isSliderChanging 判断是否处于滑动中

滑动时设 isSliderChanging 为 true

js
  /* 监听滑块拖动,改变播放进度 */
  onSliderChanging(e) {
    const sliderValue = e.detail.value
    const currentTime = sliderValue / 100 * this.data.duration
    this.setData({
      currentTime,
+      isSliderChanging: true
    })
  },

滑动停止时设 isSliderChanging 为 false

js
  /* 监听滑块点击,改变播放进度 */
  onSliderChange(e) {
    const sliderValue = e.detail.value
    const currentTime = sliderValue / 100 * this.data.duration
    this.setData({
      sliderValue,
      currentTime,
+      isSliderChanging: false
    })
    innerAudioContext.seek(currentTime / 1000)
  },

只有在 isSliderChanging 为 false 的时候才监听播放 onTimeUpdate

js
    // 监听播放进度,设置currentTime, sliderValue
    innerAudioContext.onTimeUpdate(() => {
+      if(this.data.isSliderChanging === true) return
      handleProcessthrottle()
      // 匹配歌词
      this.matchLyric()
    })

BUG:点击滑块时出现跳动

方法一:点击滑块后重新给 sliderValue 赋值(依然还有跳动,但频率降低了很多)

js
  /* 监听滑块点击,改变播放进度 */
  onSliderChange(e) {
    const sliderValue = e.detail.value
    const currentTime = sliderValue / 100 * this.data.duration
    this.setData({
+      sliderValue,
      currentTime,
      isSliderChanging: false
    })
    innerAudioContext.seek(currentTime / 1000)
  },

方法二:使用节流函数 throttle,降低 onTimeUpdate 中修改 currentTime 和 SliderValue 的频率(依然有些问题)

js
import { throttle } from 'underscore'

Page({
  onLoad(query) {
    // 添加节流
+    const handleProcessthrottle = throttle(this.handleProcess, 500, { leading: false ,trailing: false })

    // 监听播放进度,设置currentTime, sliderValue
    innerAudioContext.onTimeUpdate(() => {
      if(this.data.isSliderChanging === true) return
+      handleProcessthrottle()
      // 匹配歌词
      this.matchLyric()
    })
  },
  // 处理函数
+  handleProcess() {
    const currentTime = innerAudioContext.currentTime * 1000
    const sliderValue = (currentTime / this.data.duration * 100)
    this.setData({ currentTime, sliderValue })
  },
})

方法三:使用 setTimeout()

image-20240724100137425

image-20240724100317155

image-20240724100353663

方法四:从2.26.2版本开始,currentTime已经变成可写可读属性。基于此特性尝试通过innerAudioContext.currentTime = currentTime来实现跳转播放。

暂停、播放切换

暂停、播放切换并更换按钮图标

html
<image
  class="btn play"
+   src="/assets/images/player/play_{{ isPlaying ? 'pause' : 'resume' }}.png"
+   bind:tap="onPlayOrPause"
>
</image>
js
  /* 监听播放/暂停键点击,改变播放状态 */
  onPlayOrPause(e) {
    if(innerAudioContext.paused) {
+      innerAudioContext.play()
      this.setData({ isPlaying: true })
    } else {
+      innerAudioContext.pause()
      this.setData({ isPlaying: false })
    }
  }

歌词

解析歌词

封装解析歌词的方法 parseLyric()

js
// utils/parse.js
export function parseLyric(lrcStr) {
  const lrcLines = []
  const lines = lrcStr.split('\n')

  const reg = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    if (!line) continue

    const results = reg.exec(line)
    const minute = results[1] * 60 * 1000
    const second = results[2] * 1000
    const mSecond = results[3].length === 2 ? results[3] * 10 : results[3] * 1
    const time = minute + second + mSecond
    lrcLines.push({ time: time, text: line.replace(reg, '') })
  }
  return lrcLines
}

使用 parseLyric()

js
import { parseLyric } from '../../utils/parse'
Page({
  data: {
    // 歌词
    lrcLines: [],
  }
  onLoad(query) {
    // 网络请求
    this.fetchLyric()
  },
  /* 请求歌词信息 */
  async fetchLyric() {
    const res = await getLyric(this.data.id)
    this.setData({
      lyric: res.lrc,
+      lrcLines: parseLyric(res.lrc.lyric) // 解析歌词
    })
  },
})

歌词的精准匹配和记录展示

js
  data: {
    // 歌词
    lrcLines: [],
+    currLrcIndex: this.data.lrcLines.length - 1, // 匹配最后一句歌词
    currLrcText: ''
  },
  onLoad(query) {
    // 监听播放进度,设置currentTime, sliderValue
    innerAudioContext.onTimeUpdate(() => {
      if(this.data.isSliderChanging === true) return
      handleProcessthrottle()
      // 匹配歌词
+      this.matchLyric()
    })
  },
      
+  /** 匹配歌词 */
+  matchLyric() {
+    if(!this.data.lrcLines.length) return
+    // 核心代码
+    let currentTime = innerAudioContext.currentTime * 1000 //  最新的时间
+    let currLrcIndex = this.data.lrcLines.length - 1
+    for(let i = 0 ; i < this.data.lrcLines.length; i++) {
+      const line = this.data.lrcLines[i]
+      if(line.time > currentTime) {
+        currLrcIndex = i - 1
+        break
+      }
+    }
+    // 优化:如果取到当前index,则不再重复取当前index
+	if(currLrcIndex === this.data.currLrcIndex || currIndex === -1) return
+    this.setData({
+      currLrcIndex,
+      currLrcText: this.data.lrcLines[currLrcIndex].text
+    })
+  },

展示歌词

html
<!-- 歌词 -->
<view class="lyric-oneline">{{ currLrcText }}</view>

歌词分页展示

html
<swiper-item class="lyric">
+  <scroll-view class="scroll-box" scroll-y scroll-top="{{ lrcScrollTop }}" scroll-with-animation>
+    <view class="lines" style="padding-top: 200px; padding-bottom: 200px; ">
+      <block wx:for="{{ lrcLines }}" wx:key="time">
+         <view class="line {{currLrcIndex === index ? 'active' : ''}}">{{ item.text }}</view> +
+      </block>
+    </view>
+  </scroll-view>
</swiper-item>

实现滚动效果

条件:<scroll-view> 要有高度,设置了 scroll-y

css
.lyric .scroll-box {
  height: 100%;
}

隐藏滚动条

css
/** ::之前有一个空格 */
.lyric .scroll-box ::-webkit-scrollbar {
  display: none;
}

歌词首尾留下空间

html
<view class="lines" style="padding-top: {{ contentHeight/2 - 66 }}px; padding-bottom: {{ contentHeight/2 + 66 }}px; ">
  <block wx:for="{{ lrcLines }}" wx:key="time">
    <view class="line {{currLrcIndex === index ? 'active' : ''}}">{{ item.text }}</view>
  </block>
</view>

歌词自动滚动

属性:scroll-view 中的scroll-top

html
<scroll-view class="scroll-box" scroll-y + scroll-top="{{ lrcScrollTop }}"></scroll-view>
js
  /* 匹配歌词 */
  matchLyric() {
    if(!this.data.lrcLines.length) return
    let currentTime = this.data.currentTime
    let currLrcIndex = this.data.lrcLines.length - 1
    for(let i = 0 ; i < this.data.lrcLines.length; i++) {
      const line = this.data.lrcLines[i]
      if(line.time > currentTime) {
        currLrcIndex = i - 1
        break
      }
    }
    if(currLrcIndex === this.data.currLrcIndex || currLrcIndex === -1) return

    this.setData({
      currLrcIndex,
      currLrcText: this.data.lrcLines[currLrcIndex].text,
      // 歌词自动滚动
+      lrcScrollTop: currLrcIndex * 35
    })
    //
  },

设置滚动时的动画

属性:scroll-with-animation

html
<scroll-view class="scroll-box" 
             scroll-y 
             scroll-top="{{ lrcScrollTop }}" 
            + scroll-with-animation></scroll-view>

高亮当前歌词

html
<block wx:for="{{ lrcLines }}" wx:key="time">
  + <view class="line {{currLrcIndex === index ? 'active' : ''}}">{{ i++tem.text }}</view>
</block>
css
.lyric .scroll-box .line.active {
  color: #fff;
  font-size: 30rpx;
}

播放列表

播放列表

image-20230302174704664

点击歌曲时,绑定事件,添加到播放列表中

html
<song-item itemData="{{item}}" bind:tap="onAddPlaylist"></song-item>
js
  /* 添加到播放列表 */
  onAddPlaylist() {
    playerStore.setState('playlists', this.data.recommendSongs)
  }

detail-song.wxml

html
<song-item-v2 itemData="{{item}}" index="{{index}}" + bind:tap="onAddPlaylist"> </song-item-v2>
js
  /* 添加到播放列表 */
  onAddPlaylist() {
    playerStore.setState('playlists', this.data.songs.tracks)
  }

添加播放列表索引

js
// player.js

import { HYEventStore } from 'hy-event-store'
export const playerStore = new HYEventStore({
  state: {
    playlists: [],
+    playIndex: 0
  },
  actions: {}
})
html
// detail-song.wxml 或 main-music.wxml

<song-item-v2 itemData="{{item}}" index="{{index}}" + data-index="{{index}}" bind:tap="onAddPlaylist"> </song-item-v2>
js
// detail-song.js 或 main-music.js

/* 添加到播放列表 */
  onAddPlaylist(e) {
+    const index = e.currentTarget.dataset.index
    playerStore.setState('playlists', this.data.songs.tracks)
+    playerStore.setState('playIndex', index)
  }

实现播放列表数据的多页面共享

播放页获取 playSongList

监听 playerStore

js
// music-player.js

import { playerStore } from '../../stores/player'
Page({
  data: {
    playlists: [],
    playIndex: 0,
  },
  onLoad(query) {
    // 监听playerStore
    playerStore.onStates(['playlists', 'playIndex'], this.handlePlayer)
  },
  onUnload() {
    playerStore.offStates(['playlists', 'playIndex'], this.handlePlayer)
  },

响应式设置 store 中的值

js
// music-player.js

  handlePlayer({ playlists, playIndex }) {
    if(playlists !== undefined) this.setData({ playlists })
    if(playIndex !== undefined) this.setData({ playIndex })
  },

上一首、下一首

点击上一首、下一首获取它们的 id

html
<image class="btn prev" src="/assets/images/player/play_prev.png" + bind:tap="onPrev"> </image>
<image class="btn next" src="/assets/images/player/play_next.png" + bind:tap="onNext"> </image>

处理函数

js
// music-player.js
  /* 上一首 */
  onPrev(e) {
    this.changeNewSong(false)
  },
  /* 下一首 */
  onNext(e) {
    this.changeNewSong()
  },
  /* 封装onNext, onPrev逻辑 */
  changeNewSong(isNext = true) {
    let index = this.data.playIndex
    const length = this.data.playlists.length

    // 判断上一首、下一首,计算新歌index
    index = isNext? index + 1 : index - 1
    if(index === length) index = 0
    if(index === -1) index = length - 1

    const newSong = this.data.playlists[index]
    playerStore.setState({ playIndex: index, newSong })
+    this.playSong(newSong.id)
  },

封装playSong

js
// music-player.js

  playSong(id) {
    // 网络请求
    this.fetchSongDetail()
    this.fetchLyric()

    // 播放歌曲
    innerAudioContext.src = `https://music.163.com/song/media/outer/url?id=${id}.mp3`
    innerAudioContext.autoplay = true

    // 添加节流
    const handleProcessthrottle = throttle(this.handleProcess, 500, { leading: false, trailing: false })

    // 监听播放进度,设置currentTime, sliderValue
    innerAudioContext.onTimeUpdate(() => {
      if(this.data.isSliderChanging === true) return
      handleProcessthrottle()
      // 匹配歌词
      this.matchLyric()
    })

    // BUG:跳转之后不能再监听onTimeUpdate,当前播放时间不再变化
    innerAudioContext.onWaiting(() => {
      innerAudioContext.pause()
    })
    innerAudioContext.onCanplay(() => {
      innerAudioContext.play()
    })
  },

根据新的 id 播放歌曲

js
  onLoad(query) {
    // 播放歌曲
+    this.playSong(this.data.id)
  },

播放下首歌前先清空之前歌曲的数据(不推荐

image-20230303155923918

onTimeUpdate onWating onCanplay 只需要在第一次播放的时候添加监听,不需要多次添加

在播放多个歌曲的时候,使用的播放实例都是innerAudioContext实例,监听的也都是innerAudioContext

js
  data: {
    isFirstPlay: true,
 },
  playSong(id) {
    // onTimeUpdate,onWaiting,onCanplay只在第一次播放时监听
+    if(!this.data.isFirstPlay) return
+    this.data.isFirstPlay: false

    innerAudioContext.onTimeUpdate(() => {...}
    innerAudioContext.onWaiting(() => {...}
    innerAudioContext.onCanplay(() => {...}
  },

歌曲结束自动播放下一首

js
  playSong(id) {
    // 歌曲结束自动播放下一首
+    innerAudioContext.onEnded(() => {
+      this.onNext()
    })
  },

播放模式

切换播放模式

js
// music-player.js

  data: {
    playMode: 0, // 0:顺序播放,1:单曲循环,2:随机播放
    playModeInfos: ['order', 'repeat', 'random'],
  },
  /* 切换播放模式 */
  onChangePlayMode() {
    let playMode = this.data.playMode
    playMode ++
    if(playMode === 3) playMode = 0
    this.setData({ playMode })
  },

同时修改图片

html
<image
  class="btn repeat"
+  src="/assets/images/player/play_{{ playModeInfos[playMode] }}.png"
  bind:tap="onChangePlayMode"
>
</image>

点击不同的模式,播放歌曲

image-20230303163218811

image-20230303163329927

单曲循环时点击下一首,播放下首歌

只有自然播放完毕时,才单曲循环

  1. 在播放切换函数changeNewSong中判断播放模式
js
  /* 封装onNext, onPrev逻辑 */
  changeNewSong(isNext = true) {
    let index = this.data.playIndex
    const length = this.data.playlists.length

    // 判断播放模式
+    switch(this.data.playMode) {
+      case 0:
+      case 1:
        // 判断上一首、下一首,计算新歌index
        index = isNext? index + 1 : index - 1
        if(index === length) index = 0
        if(index === -1) index = length - 1
        break
+      case 2:
        index = Math.floor(Math.random() * length)
        break
    }

    const newSong = this.data.playlists[index]
    this.setData({ playIndex: index, newSong, id: newSong.id })
    this.playSong(newSong.id)
  },
  1. 在模式切换函数onChangePlayMode中设置单曲循环情况
js
  /* 切换播放模式 */
  onChangePlayMode() {
    let playMode = this.data.playMode
    playMode ++
    if(playMode === 3) playMode = 0
    this.setData({ playMode })

 +   // 设置单曲循环
 +   if(playMode === 1) {
 +     innerAudioContext.loop = true
 +   } else {
 +     innerAudioContext.loop = false
 +   }
  },
  1. innerAudioContext.onEnded 方法中判断播放模式为单曲循环的情况下不调用 changeNewSong
js
  playSong(id) {
    // 歌曲结束自动播放下一首
    innerAudioContext.onEnded(() => {
      // 如果是单曲循环模式就不要再切歌
+      if(innerAudioContext.loop) return
      // 播放下一首
      this.onNext()
    })
  },

优化滑块拖动

滑块拖动优化

解决:对onSliderChanging方法使用节流

js
  /* 监听滑块拖动,改变播放进度 */
+  onSliderChanging: throttle(function(e) {
    const sliderValue = e.detail.value
    const currentTime = sliderValue / 100 * this.data.duration
    this.setData({
      currentTime,
      isSliderChanging: true
    })
+  }, 100),

代码重构

抽取 playSong

js
// music-player.js

  onLoad(query) {
    // 播放歌曲
+    playerStore.dispatch('playSong', this.data.id)
  },

player.js 中

js
// player.js

// 创建 webAudio 上下文
export const innerAudioContext = wx.createInnerAudioContext()

export const playerStore = new HYEventStore({
  state: {
  actions: {
    // 播放音乐
+    playSong(ctx, id) {
      ctx.id = id

      // 网络请求
      /* 请求歌曲详情 */
      getSongDetail(id).then(res => {
        ctx.song = res.songs[0]
        ctx.duration = res.songs[0].dt
      })
      getLyric(id).then(res => {
        ctx.lyric = res.lrc
        ctx.lrcLines = parseLyric(res.lrc.lyric) // 解析歌词
      })

      // 播放歌曲
      innerAudioContext.src = `https://music.163.com/song/media/outer/url?id=${id}.mp3`
      innerAudioContext.autoplay = true

      // onTimeUpdate,onWaiting,onCanplay只在第一次播放时监听
      if (!ctx.isFirstPlay) return
      ctx.isFirstPlay = false

      // 监听播放进度,设置currentTime, sliderValue
      innerAudioContext.onTimeUpdate(() => {
        // 获取播放currentTime
        const currentTime = innerAudioContext.currentTime * 1000
        ctx.currentTime = currentTime

        // 匹配歌词
        if (!ctx.lrcLines.length) return
        let currLrcIndex = ctx.lrcLines.length - 1
        for (let i = 0; i < ctx.lrcLines.length; i++) {
          const line = ctx.lrcLines[i]
          if (line.time > currentTime) {
            currLrcIndex = i - 1
            break
          }
        }
        if (currLrcIndex === ctx.currLrcIndex || currLrcIndex === -1) return
        ctx.currLrcIndex = currLrcIndex
        ctx.currLrcText = ctx.lrcLines[currLrcIndex].text
      })

      // BUG:跳转之后不能再监听onTimeUpdate,当前播放时间不再变化
      innerAudioContext.onWaiting(() => {
        innerAudioContext.pause()
      })
      innerAudioContext.onCanplay(() => {
        innerAudioContext.play()
      })

      // 歌曲结束自动播放下一首
      innerAudioContext.onEnded(() => {
        // 如果是单曲循环模式就不要再切歌
        if (innerAudioContext.loop) return
        // 播放下一首
        this.dispatch('changeNewSong')
      })
    },

获取 Store 中播放数据,在 music-player 页面展示

js
// music-player.js

import { playerStore, innerAudioContext } from '../../stores/player'
Page({
  data: {
    playerStates: ['id','song','lyric','currentTime','duration','isPlaying','playlists','playIndex','isFirstPlay','playMode','playModeInfos','lrcLines','currLrcIndex','currLrcText'],
  },
  /* 监听Store数据 */
  handlePlayer({ id, song, lyric, currentTime, duration, isPlaying, playlists, playIndex,
   isFirstPlay, playMode, playModeInfos,lrcLines, currLrcIndex, currLrcText }) {
    if(id !== undefined) this.setData({ id })
    if(song !== undefined) this.setData({ song })
    if(lyric !== undefined) this.setData({ lyric })
+    if(currentTime !== undefined) this.onUpdateProgress(currentTime)
    if(duration !== undefined) this.setData({ duration })
    if(isPlaying !== undefined) this.setData({ isPlaying })
    if(playlists !== undefined) this.setData({ playlists })
    if(playIndex !== undefined) this.setData({ playIndex })
    if(isFirstPlay !== undefined) this.setData({ isFirstPlay })
    if(playMode !== undefined) this.setData({ playMode })
    if(playModeInfos !== undefined) this.setData({ playModeInfos })
    if(lrcLines !== undefined) this.setData({ lrcLines })
      // 实现歌词滚动
+    if(currLrcIndex !== undefined) this.setData({ currLrcIndex, lrcScrollTop: currLrcIndex * 35 })
    if(currLrcText !== undefined) this.setData({ currLrcText })

  },
  onUnload() {
    playerStore.offStates(this.data.playerStates, this.handlePlayer)
  }

实现歌词滚动

js
// 实现歌词滚动
if(currLrcIndex !== undefined) this.setData({
    currLrcIndex,
+    lrcScrollTop: currLrcIndex * 35
})

修改滑块进度

js
    handlePlayer({ id, song, lyric, currentTime, duration, isPlaying, playlists, playIndex,
       isFirstPlay, playMode, playModeInfos,lrcLines, currLrcIndex, currLrcText }) {
    +    if(currentTime !== undefined) this.onUpdateProgress(currentTime)
    }

    /* 监听滑块进度 */
    onUpdateProgress: throttle(function(currentTime) {
        // 拖动时不要监听播放进度
        if(this.data.isSliderChanging) return

        const sliderValue = currentTime / this.data.duration * 100
        this.setData({ sliderValue, currentTime })
    },500, { leading: false, trailing: false }),

抽取 changePlayStatus

播放、暂停

js
// player.js

  state: {
    isPlaying: true,
  },
  actions: {
    // 播放、暂停
    changePlayStatus(ctx) {
      if(innerAudioContext.paused) {
        innerAudioContext.play()
        ctx.isPlaying = true
      } else {
        innerAudioContext.pause()
        ctx.isPlaying = false
      }
    },
  }
js
// music-player.js

  /* 监听播放/暂停键点击,改变播放状态 */
  onPlayOrPause(e) {
+    playerStore.dispatch('changePlayStatus')
  },

抽取 changePlayMode

播放模式

js
// player.js
    // 改变播放模式
    changePlayMode(ctx) {
       // 改变播放模式
      let playMode = ctx.playMode
      playMode ++
      if(playMode === 3) playMode = 0
        
      // 设置单曲循环
      if(playMode === 1) {
        innerAudioContext.loop = true
      } else {
        innerAudioContext.loop = false
      }
        
      ctx.playMode = playMode
    },
js
// music-player.js

  /* 切换播放模式 */
  onChangePlayMode() {
    playerStore.dispatch('changePlayMode')
  },

抽取 changeNewSong

js
// player.js
    // 封装onNext, onPrev逻辑
    changeNewSong(ctx, isNext = true) {
      let index = ctx.playIndex
      const length = ctx.playlists.length

      // 判断播放模式
      switch(ctx.playMode) {
        case 0:
        case 1:
          // 判断上一首、下一首,计算新歌index
          index = isNext? index + 1 : index - 1
          if(index === length) index = 0
          if(index === -1) index = length - 1
          break
        case 2:
          index = Math.floor(Math.random() * length)
          break
      }
        
      this.dispatch('playSong', ctx.playlists[index].id)}

      ctx.id = ctx.playlists[index].id
      ctx.playIndex = index
    }
js
// music-player.js
  /* 上一首 */
  onPrev(e) {
    playerStore.dispatch('changeNewSong', false)
  },
  /* 下一首 */
  onNext(e) {
    playerStore.dispatch('changeNewSong')
  },

当自然播放完毕后也调用changeNewSong

js
// 歌曲结束自动播放下一首
innerAudioContext.onEnded(() => {
  // 如果是单曲循环模式就不要再切歌
  if (innerAudioContext.loop) return
  // 播放下一首
+  this.dispatch('changeNewSong')
})

播放工具栏

image-20240725102620961

html
<!-- 播放工具栏 -->
+ <view class="player" wx:if="{{song.name}}">
  <view class="left">
    <image class="album" src="{{song.al.picUrl}}"></image>
    <view class="name">{{song.name}}</view>
  </view>
  <view class="right">
+    <image class="icon" bind:tap="onPlayOrPause" src="/assets/images/playbar/{{isPlaying ? 'pause' : 'play'}}.png" />
+    <image class="icon" bind:tap="onPlaylistShow" src="/assets/images/playbar/playlist.png" />
  </view>
</view>

占位元素

html
+ <view class="player-placeholder" wx:if="{{song.name || isPlaying}}"></view>
css
.player-placeholder {
  height: 50px;
}

暂停、播放

html
<view class="right">
  <image class="icon" 
         + bind:tap="onPlayOrPause" 
         + src="/assets/images/playbar/{{isPlaying ? 'pause' : 'play'}}.png" />
  <image class="icon" bind:tap="onPlaylistShow" src="/assets/images/playbar/playlist.png" />
</view>
js
  /* 播放、暂停 */
  onPlayOrPause(e) {
    playerStore.dispatch('changePlayStatus')
  }
js
  data: {
    playerStates: ['song', 'playlists', 'isPlaying'],
    isPlaying: true
  },
  onLoad() {
    /* 监听播放工具栏数据 */
    playerStore.onStates(this.data.playerStates, this.handlePlayerStates)
  },
  handlePlayerStates(value) {
    if(value.song !== undefined) this.setData({ song: value.song })
    if(value.isPlaying !== undefined) this.setData({ isPlaying: value.isPlaying })
  },

播放时封面旋转动画

方便测试,进入首页就自动播放一首歌

js
  onLoad() {
    // 临时测试用
    playerStore.dispatch('playSong', 1403318151)
  },

动画

css
/* 旋转动画 */
@keyframes album-rotate {
  0% {
    transform: rotate(0);
  }
  100% {
    transform: rotate(360deg);
  }
}
.album-ani {
  animation: album-rotate 10s linear infinite;
}

动画暂停的时候会复位图片

image-20230307153903325

动画暂停优化:

  • animation-play-staterunning | paused,(实验样式),定义一个动画是否运行或者暂停。它的值可以被设置为暂停和恢复的动画的重放。恢复一个已暂停的动画,将从它开始暂停的时候,而不是从动画序列的起点开始在动画
html
<image
  class="album album-ani"
  src="{{song.al.picUrl}}"
+  style="animation-play-state: {{ isPlaying ? 'running' : 'paused' }};"
/>

点击进入播放页

html
<image
  class="album album-ani"
  src="{{song.al.picUrl}}"
  style="animation-play-state: {{ isPlaying ? 'running' : 'paused' }};"
+  bind:tap="onJumpPlayer"
/>
js
  /* 点击播放栏封面,跳转到播放页 */
  onJumpPlayer(e) {
    wx.navigateTo({
      url: '/pages/music-player/music-player'
    })
  }

修改 playSong:id 有值时才执行 palySong

js
// music-player.js
// 播放歌曲
if (query.id) {
  playerStore.dispatch('playSong', query.id)
}

分包处理

某些情况下,开发者需要将小程序划分成不同的子包,在构建时打包成不同的分包,用户在使用时按需进行加载。

在构建小程序分包项目时,构建会输出一个或多个分包。每个使用分包小程序必定含有一个主包。所谓的主包,即放置默认启动页面/TabBar 页面,以及一些所有分包都需用到公共资源/JS 脚本;而分包则是根据开发者的配置进行划分。

目前小程序分包大小有以下限制:

  • 整个小程序所有分包大小不超过 20M
  • 单个分包/主包大小不能超过 2M

优点:

  • 可以优化小程序首次启动的下载时间
  • 在多团队共同开发时可以更好的解耦协作

使用分包

假设支持分包的小程序目录结构如下:

image-20230307160309183

配置方法:

app.json

json
{
  "pages":[
    "pages/index",
    "pages/logs"
  ],
+  "subpackages": [
    {
+      "root": "packageA",
+      "pages": [
        "pages/cat",
        "pages/dog"
      ]
    }, {
+      "root": "packageB",
+      "name": "pack2",
+      "pages": [
        "pages/apple",
        "pages/banana"
      ]
    }
  ]
}

subpackages 中,每个分包的配置有以下几项:

  • rootString,分包根目录
  • nameString,分包别名,分包预下载时可以使用
  • pagesArray,分包页面路径,相对于分包根目录
  • independentBoolean,分包是否是独立分包

打包原则

  • 声明 subpackages 后,将按 subpackages 配置路径进行打包,subpackages 配置路径外的目录将被打包到主包中
  • 主包也可以有自己的 pages,即最外层的 pages 字段。
  • subpackage 的根目录不能是另外一个 subpackage 内的子目录
  • tabBar 页面必须在主包内

引用原则

  • packageA 无法 require packageB JS 文件,但可以 require 主包、packageA 内的 JS 文件;使用 分包异步化 时不受此条限制
  • packageA 无法 import packageB 的 template,但可以 require 主包、packageA 内的 template
  • packageA 无法使用 packageB 的资源,但可以使用主包、packageA 内的资源

独立分包

独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。

配置方法

开发者通过在app.jsonsubpackages字段中对应的分包配置项中定义independent字段声明对应分包为独立分包。

image-20230307161927396

限制

  • 独立分包属于分包的一种。普通分包的所有限制都对独立分包有效。

  • 独立分包中插件、自定义组件的处理方式同普通分包。

  • 独立分包中不能依赖主包和其他分包中的内容

  • 主包中的 app.wxss 对独立分包无效

  • App 只能在主包内定义,独立分包中不能定义 App,会造成无法预期的行为;

  • 独立分包中暂时不支持使用插件

注意事项

  • 关于 getApp():独立分包运行时,App 并不一定被注册,因此 getApp() 也不一定可以获得 App 对象
  • 关于 App 生命周期:由于独立分包中无法定义 App,小程序生命周期的监听可以使用 wx.onAppShowwx.onAppHide 完成

分包预下载

开发者可以通过配置,在进入小程序某个页面时,由框架自动预下载可能需要的分包,提升进入后续分包页面时的启动速度。对于独立分包,也可以预下载主包。

分包预下载目前只支持通过配置方式使用,暂不支持通过调用 API 完成

配置方法

预下载分包行为在进入某个页面时触发,通过在 app.json 增加 preloadRule 配置来控制。

json
{
  "pages": ["pages/index"],
  "subpackages": [
    {
      "root": "important",
      "pages": ["index"]
    },
    {
      "root": "sub1",
      "pages": ["index"]
    },
    {
      "name": "hello",
      "root": "path/to",
      "pages": ["index"]
    },
    {
      "root": "sub3",
      "pages": ["index"]
    },
    {
      "root": "indep",
      "pages": ["index"],
      "independent": true
    }
  ],
  // 分包预下载
  "preloadRule": {
    "pages/index": {
      "network": "all",
      "packages": ["important"]
    },
    "sub1/index": {
      "packages": ["hello", "sub3"]
    },
    "sub3/index": {
      "packages": ["path/to"]
    },
    "indep/index": {
      "packages": ["__APP__"]
    }
  }
}

preloadRule 中,key 是页面路径,value 是进入此页面的预下载配置,每个配置有以下几项:

  • packagesArray,进入页面后预下载分包的 rootname__APP__ 表示主包。
  • networkString默认:wifi,在指定网络下预下载,可选值为:all | wifi
    • all:``,不限网络
    • wifi:``,仅 wifi 下预下载

限制

同一个分包中的页面享有共同的预下载大小限额 2M,限额会在工具中打包时校验。

分包异步化

在小程序中,不同的分包对应不同的下载单元;因此,除了非独立分包可以依赖主包外,分包之间不能互相使用自定义组件或进行 require

分包异步化特性将允许通过一些配置和新的接口,使部分跨分包的内容可以等待下载后异步使用,从而一定程度上解决这个限制。

跨分包自定义组件引用

一个分包使用其他分包的自定义组件时,由于其他分包还未下载或注入,其他分包的组件处于不可用的状态。通过为其他分包的自定义组件设置 占位组件,我们可以先渲染占位组件作为替代,在分包下载完成后再进行替换。

json
{
  "usingComponents": {
    "button": "../../commonPackage/components/button",
    "list": "../../subPackageB/components/full-list",
    "simple-list": "../components/simple-list"
  },
  // 占位组件
+  "componentPlaceholder": {
+    "button": "view",
+    "list": "simple-list"
+  }
}

跨分包 JS 代码引用

一个分包中的代码引用其它分包的代码时,为了不让下载阻塞代码运行,我们需要异步获取引用的结果

js
// subPackageA/index.js
// 使用回调函数风格的调用
+ require('../subPackageB/utils.js', (utils) => {
  console.log(utils.whoami) // Wechat MiniProgram
}, ({ mod, errMsg }) => {
  console.error(`path: ${mod}, ${errMsg}`)
})

// 或者使用 Promise 风格的调用
+ require
+  .async('../commonPackage/index.js')
  .then((pkg) => {
    pkg.getPackageName() // 'common'
  })
  .catch(({ mod, errMsg }) => {
    console.error(`path: ${mod}, ${errMsg}`)
  })

在其它分包中的插件也可以通过类似的方法调用:

js
// 使用回调函数风格的调用
requirePlugin(
  'live-player-plugin',
  (livePlayer) => {
    console.log(livePlayer.getPluginVersion())
  },
  ({ mod, errMsg }) => {
    console.error(`path: ${mod}, ${errMsg}`)
  }
)

// 或者使用 Promise 风格的调用
requirePlugin
  .async('live-player-plugin')
  .then((livePlayer) => {
    console.log(livePlayer.getPluginVersion())
  })
  .catch(({ mod, errMsg }) => {
    console.error(`path: ${mod}, ${errMsg}`)
  })

项目分包

js
// app.json

  "pages": [
    "pages/main-music/main-music",
    "pages/main-video/main-video",
    "pages/detail-search/detail-search",
    "pages/detail-song/detail-song",
    "pages/detail-menu/detail-menu"
  ],
   // 分包配置
+  "subPackages": [
+    {
+      "root": "packageVideo",
+      "pages": [
+        "pages/detail-video/detail-video"
+      ]
+    },
+    {
+      "root": "packagePlayer",
+      "pages": [
+        "pages/music-player/music-player"
+      ]
+    }
+  ],

修改文件路径

js
// music-player.js
import { playerStore, innerAudioContext } from '../../../stores/player'
js
// detail-video.js
import { getMVUrl, getMVInfo, getMVRelated } from '../../../services/video'
js
// song-item.js
    /* 跳转到music-player页面 */
    onJumpMusicPlayer() {
      wx.navigateTo({
+        url: `/packagePlayer/pages/music-player/music-player?id=${this.properties.itemData.id}`,
      })
    }
js
// song-item-v2.js
    /* 跳转到music-player页面 */
    onJumpMusicPlayer() {
        wx.navigateTo({
+            url: `/packagePlayer/pages/music-player/music-player?id=${this.properties.itemData.id}`,
        })
    }

将图片等文件放入各自的分包中

image-20230309114911620

分包预下载

js
// app.json
  "subPackages": [
    {
      "root": "packageVideo",
      "pages": [
        "pages/detail-video/detail-video"
      ]
    },
    {
      "root": "packagePlayer",
      "pages": [
        "pages/music-player/music-player"
      ]
    }
  ],
  // 分包预加载
+  "preloadRule": {
+    "pages/main-video/main-video": {
+      "packages": ["packageVideo"]
+    },
+    "pages/main-music/main-music": {
+      "packages": ["packagePlayer"]
+    }
  },

优化 Vant

手动删除 miniprogram_npm 下的组件

common、用到的组件、组件依赖的组件不能删

image-20230309120145724

image-20230309120203626

上架、发布小程序

image-20230307171152809

image-20230307171247657

集成云开发

我的-登录

image-20230310160402214

我的收藏,喜欢的歌,最近播放

image-20230310160530626

添加到歌单

image-20230310161500594

我的页

API

  • wx.getUserProfile(options)参数:options: Object返回:,获取用户信息。
    • 参数
    • descstring,声明获取用户个人信息后的用途。
    • 返回值(res) => void,Promise或Callback
      • userInfo{nickName, avatarUrl, gender...}默认:,用户信息对象
        • nickName:``,默认:,用户昵称
        • avatarUrl:``,默认:,用户头像图片的 URL
        • gender:``,默认:,用户性别

app.json 配置添加我的 tabbar

js
  {
    "pagePath": "pages/main-profile/main-profile",
    "text": "我的",
    "iconPath": "assets/images/tabbar/profile_normal.png",
    "selectedIconPath": "assets/images/tabbar/profile_active.png"
  }

头像、昵称

image-20230310165543377

image-20230310162353959

登录功能

1、获取用户的头像和昵称

html
<!-- 用户 -->
+ <view class="card user" bind:tap="onLogin">
  <image
    class="avatar"
    src="{{ userInfo.nickName ? userInfo.avatarUrl : '/assets/images/profile/login.png' }}"
    mode="widthFix"
  />
  <view class="nickname">{{ userInfo.nickName ? userInfo.nickName : '未登录' }}</view>
</view>
js
  data: {
    userInfo: {}
  },
  // 登录
  async onLogin() {
+    const res = await wx.getUserProfile({ desc: '获取用户信息' })
    console.log(res);
    this.setData({ userInfo: res.userInfo })
  },

image-20230310163006577

2、获取用户的 openid

方法:通过云函数获取 openid

2.1、创建云函数

json
// project.config.json

{
  "cloudfunctionRoot": "cloudfunctions/"
}

2.2、云开发初始化

js
// app.js

wx.cloud.init({
  env: 'cloud1-fahdf'
})

2.3、创建music-login云函数,并获取 openid

js
// 云函数入口文件
const cloud = require('wx-server-sdk')

cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) // 使用当前云环境

// 云函数入口函数
exports.main = async (event, context) => {
+  const wxContext = cloud.getWXContext()

  return {
+    openid: wxContext.OPENID,
+    unionid: wxContext.UNIONID
  }
}

2.4、在本地获取 openid

js
  // 1. 登录
  async onLogin() {
    // 获取用户信息
    const res = await wx.getUserProfile({ desc: '获取用户信息' })
    console.log(res);
    this.setData({ userInfo: res.userInfo })

    // 获取openid
+    const openidRes = await wx.cloud.callFunction({ name: 'getOpenid' })
+    console.log(openidRes);
+    const openid = openidRes.result.openid
  },

2.5、保存数据到本地

js
// 保存数据
wx.setStorageSync('openid', openid)
wx.setStorageSync('userInfo', res.userInfo)
this.setData({ userInfo: res.userInfo, isLogin: !!openid })

3、判断用户是否登录

js
  onLoad() {
    // 2. 判断是否登录,显示不同user界面
    const openid = wx.getStorageSync('openid')
    const userInfo = wx.getStorageSync('userInfo')

+    this.setData({ isLogin: !!openid })
    if(this.data.isLogin){
      this.setData({ userInfo })
    }
  },
html
<!-- 用户 -->
<view class="card user" bind:tap="onLogin">
  <image class="avatar" 
         mode="widthFix"
 +        src="{{ isLogin ? userInfo.avatarUrl : '/assets/images/profile/login.png' }}" />
  <view class="nickname">
 + 	{{ isLogin ? userInfo.nickName : '未登录' }}  
  </view>
</view>

我的收藏、喜欢的歌

image-20230310165517174

展示 tabs

data 中的数据

js
// main-profile.js
  data: {
    tabs: [
      {name: '我的收藏', type: 'favor'},
      {name: '我的喜欢', type: 'like'},
      {name: '最近播放', type: 'history'}
    ]
  },
html
<!-- 我的歌单 -->
<view class="card collection">
  <block wx:for="{{tabs}}" wx:key="type">
    <view class="item" data-tab="{{item.type}}" bind:tap="onCltTapJump">
      <image class="icon" src="/assets/images/profile/{{item.type}}.png" mode="widthFix" />
      <view class="text">{{item.name}}</view>
    </view>
  </block>
</view>

添加歌曲到歌单

1、展示添加按钮

song-item-v2

html
<view class="item" bind:tap="onJumpMusicPlayer">
  ...
  <view class="opr" catch:tap="onAddToast">
    <van-icon name="more-o" />
  </view>
</view>

image-20230310170327968

2、点击添加按钮,弹出列表

注意: 使用catch:tap捕获模式事件不会向上冒泡

html
+ <view class="opr" catch:tap="onAddToast">
  <van-icon name="more-o" />
</view>
js
    // 点击添加到我的集合中
    async onAddToast() {
+        try {
+            const { tapIndex } = await wx.showActionSheet({ itemList: ['收藏', '喜欢'] })
+            this.addToCollection(tapIndex)
+        } catch (error) {
            wx.showToast({ title: '取消添加', icon: 'error' })
        }
    },

3、保存歌曲到数据库

创建数据库 c_music_favorc_music_like

本地

js
      // 添加到数据库
      async addToCollection(tapIndex) {
        let cltName = ''
        if(tapIndex === 0) { // 收藏
          cltName = 'favor'
        } else if (tapIndex === 1) { // 喜欢
          cltName = 'like'
        }
        const res = await wx.cloud.callFunction({
          name: 'musicAddCollection',
          data: {
            cltName,
            itemData: this.properties.itemData
          }
        })
        console.log(res);
        if(res.result._id) wx.showToast({title: '添加成功',})
      }

云函数

js
// 云函数入口文件
const cloud = require('wx-server-sdk')

cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) // 使用当前云环境

// 云函数入口函数
const db = cloud.database()
exports.main = async (event, context) => {
  const { cltName, itemData } = event
  const clt = db.collection('music_' + cltName)
  const res = await clt.add({ data: itemData })
  return res
}

4、优化:收藏、喜欢之前先查询数据库是否已经存在这首歌

真实开发中其实在歌曲的 item 中存在一个字段,显示是否已经收藏、喜欢

封装:数据库操作

db/index.js

js
const db = wx.cloud.database()

class MrCollection {
  constructor(cltName) {
    this.clt = db.collection(cltName)
  }
  // 增加
  add(data) {
    return this.clt.add({ data })
  }
  // 删除
  remove(condition, isDoc = true) {
    if (isDoc) {
      return this.clt.doc(condition).remove()
    } else {
      return this.clt.where(condition).remove()
    }
  }
  // 修改
  update(data, condition, isDoc = true) {
    if (isDoc) {
      return this.clt.doc(condition).update({ data })
    } else {
      return this.clt.where(condition).update({ data })
    }
  }
  // 查询
  query(offset = 0, size = 20, condition = {}, isDoc = false) {
    if (isDoc) {
      return this.clt.id(condition).get()
    } else {
      return this.clt.where(condition).skip(offset).limit(size).get()
    }
  }
}

export const favorClt = new MrCollection('favor')

使用

image-20230310173801455

image-20230310173834207

我的收藏、喜欢

html
<!-- 我的歌单 -->
<block wx:for="{{tabs}}" wx:key="type">
+  <view class="item" data-tab="{{item.type}}" bind:tap="onCltTapJump">
    <image class="icon" src="/assets/images/profile/{{item.type}}.png" mode="widthFix" />
    <view class="text">{{item.name}}</view>
  </view>
</block>

跳转到 detail-song,并传递不同的类型 from,tab

js
  // 3. 点击collections,跳转到
  onCltTapJump(e) {
+    const tab = e.currentTarget.dataset.tab
    wx.navigateTo({
+      url: '/pages/detail-song/detail-song?from=profile&key='+tab,
    })
  }

detail-song

js
  onLoad(query) {
    } else if (this.data.from === 'profile'){
      this.setData({ key: query.key })
      this.queryDB()
    }
  },
  // 查询数据库
  async queryDB() {
    const key = this.data.key
    const res = await wx.cloud.callFunction({
      name: 'musicGetCollection',
      data: { cltName: key }
    })
    const type = (key === 'favor') ? '收藏' : '喜欢'
    const songs = {
      name: '我的' + type,
      tracks: res.result.data
    }
    this.setData({ songs })
  }

查询时,区分不同的用户收藏的歌曲

方式一:查询的时候传入_openid 条件

image-20230311172157653

方式二:设置数据权限为:仅创建者可读写

image-20230311172303292

历史记录

store/player.js

image-20230311173019677

image-20230311173046229

我的歌单

image-20230311173327955

创建歌单

弹窗

image-20230311174807664

image-20230311174710635

使用 vant 前将 style: "v2"去除

image-20230311174638154

点击+,显示创建歌单弹窗

image-20230311174853180

image-20230311175022646

监听 dialog 的 confirm 事件

image-20230311175010674

image-20230311175857165

双向绑定 input

注意: 使用双向绑定时,需要通过监听 input 事件,否则会报警告

image-20230311175239684

image-20230311175740374

展示歌单

共享歌单列表到 Store 中

menuStore.js

image-20230311180538953

在menuStore中发送action

image-20240730164517314

在 main-profile 中获取 Store 中的数组

image-20230311180804574

image-20230311180911772

展示歌单,封装组件menu-item-v2

image-20230311181413388

image-20230311181425986

image-20230311181638094

添加完歌单后,重新调用下fetchMenuListAction

image-20230311181838211

删除歌单

image-20230315104940576

image-20230315105140898

image-20230315105215479

image-20230315105425633

设置 menu 表的联合索引

image-20230315105329915

添加歌曲

song-item-v2

image-20230315105651311

获取歌单列表 detail-song

image-20230315105927837

image-20230315105942515

image-20230315110006270

传递歌单数据到 item 组件中

image-20230315110129788

image-20230315110305386

image-20230315111246497

image-20230315111324271

点击歌单,显示歌单歌曲列表

数据共享

新建页面 detail-song

点击更多跳转到 detail-song

image-20230223153708894

在 music 页中请求了 200 条歌曲数据,这个数据在 detail-song 页中也会用到,这里就需要数据共享

此前使用 app.globalData 共享数据,并不能做到响应式(一个页面修改了数据,另一个页面并不能做到跟随变化)

hy-event-store

安装

sh
npm i hy-event-store

基础用法

将需要响应式的数据放在 state 中

image-20230223155556564

监听 state 中数据的变化

image-20230223155636773

修改数据

image-20230223155609167

请求网络数据

image-20230223160229319

image-20230223160252588

image-20230223160319119

同时监听多个数据的变化

image-20230223160406731

image-20230223160434383

错误日常

1、Git 提交时提示 Permission denied

git commit 提交时错误:

sh
$ git commit -m "mr-music - 播放 - 背景、导航栏(完成)"
fatal: could not open '.git/COMMIT_EDITMSG': Permission denied

原因:可能某些时候修改了隐藏文件而不再具有对隐藏文件的写入权限

解决:进入.git 文件(隐藏文件)删除“COMMIT_EDITMSG”文件即可