Skip to content

Uni-App 开发本地音乐播放器 Android 软件

前言

  • 使用 Uni-App 开发本地音乐播放器 Android 软件,以下以 Tune 软件为示例。

页面静态开发

  • 技术栈:Vue3.x + Vite + TypeScript + Uni-App + Less

扫描音频文件

  • Android 10+ 以上版本支持扫描音频文件
ts
/**
 * 扫描 Android 目录
 * latest
 */
export const useScanAndroidDirs = async (): Promise<SongItemType[]> => {
  try {
    const mediaStore = plus.android.importClass('android.provider.MediaStore') as unknown as MediaStoreType
    const mainActivity = plus.android.runtimeMainActivity() as unknown as {
      getContentResolver: () => PlusAndroidInstanceObject
    }
    const contentResolver = mainActivity.getContentResolver() // 这返回 Java 对象代理
    // MediaStore URI
    const audioUri = mediaStore.Audio.Media.EXTERNAL_CONTENT_URI
    // 投影列(要查询的字段)
    const projection = [
      mediaStore.Audio.Media._ID, // 音频 ID
      mediaStore.Audio.Media.DISPLAY_NAME, // 显示名称(文件名)
      mediaStore.Audio.Media.TITLE, // 标题(可空)
      mediaStore.Audio.Media.ARTIST, // 艺术家(可空)
      mediaStore.Audio.Media.ALBUM, // 专辑(可空)
      mediaStore.Audio.Media.ALBUM_ID, // 专辑 ID(可空)
      mediaStore.Audio.Media.DATA, // 音频文件路径
      mediaStore.Audio.Media.DURATION, // 持续时间(毫秒)
      mediaStore.Audio.Media.SIZE // 文件大小(字节)
    ]
    // 可选:过滤只查询音乐(非铃声/通知音等),IS_MUSIC=1
    const selection = `${mediaStore.Audio.Media.IS_MUSIC} = 1`
    // 执行查询:用 plus.android.invoke 调用 Java 方法
    const cursor = (
      plus.android.invoke as (
        obj: string | PlusAndroidClassObject | PlusAndroidInstanceObject,
        name: string,
        ...args: unknown[]
      ) => any
    )(contentResolver, 'query', audioUri, projection, selection, null, null)
    const audioList: SongItemType[] = []
    if (!cursor) return []

    // 移动游标并读取(同样用 invoke 调用 Cursor 方法)
    while (plus.android.invoke(cursor, 'moveToNext')) {
      const id = plus.android.invoke(
        cursor,
        'getLong',
        plus.android.invoke(cursor, 'getColumnIndexOrThrow', mediaStore.Audio.Media._ID)
      )
      const title = plus.android.invoke(
        cursor,
        'getString',
        plus.android.invoke(cursor, 'getColumnIndexOrThrow', mediaStore.Audio.Media.TITLE)
      )
      const name = plus.android.invoke(
        cursor,
        'getString',
        plus.android.invoke(cursor, 'getColumnIndexOrThrow', mediaStore.Audio.Media.DISPLAY_NAME)
      )
      const artist = plus.android.invoke(
        cursor,
        'getString',
        plus.android.invoke(cursor, 'getColumnIndexOrThrow', mediaStore.Audio.Media.ARTIST)
      )
      const path = plus.android.invoke(
        cursor,
        'getString',
        plus.android.invoke(cursor, 'getColumnIndexOrThrow', mediaStore.Audio.Media.DATA)
      )
      const duration = plus.android.invoke(
        cursor,
        'getLong',
        plus.android.invoke(cursor, 'getColumnIndexOrThrow', mediaStore.Audio.Media.DURATION)
      )
      const album = plus.android.invoke(
        cursor,
        'getString',
        plus.android.invoke(cursor, 'getColumnIndexOrThrow', mediaStore.Audio.Media.ALBUM)
      )
      audioList.push({
        id: `${id}`,
        name: title || name,
        artist: artist && artist !== '<unknown>' ? artist : '未知',
        url: `/static/cover/0${songStore.state.coverIndex + 1}/${useRandom(1, 10)}.png`, // '/static/default-cover.svg',
        audio: `file://${path}`,
        createdTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
        duration: duration / 1000,
        album: album || '未知'
      })
    }

    // 关闭游标(重要,避免内存泄漏)
    plus.android.invoke(cursor, 'close')
    return audioList
  } catch {
    return []
  }
}

/**
 * 扫描 Android 目录
 */
export const useScanDir = (): Promise<SongItemType[]> => {
  return new Promise(resolve => {
    plus.android.requestPermissions(
      ['android.permission.READ_MEDIA_AUDIO'],
      res => {
        // 存在未授权权限
        if (res.deniedPresent.length) {
          uni.showModal({
            title: '提示',
            content: '需要存储权限才能继续,是否重新申请?',
            success: res => {
              if (res.cancel) return
              useScanDir()
            }
          })
          return
        }
        // 存在永久拒绝权限
        if (res.deniedAlways.length) {
          uni.showModal({
            title: '提示',
            content: '永久拒绝存储权限,无法继续,是否去设置?',
            success: res => {
              if (res.cancel) return
              try {
                const main = plus.android.runtimeMainActivity() as AndroidSetting.PlusAndroidInstanceObjectExtra
                const Intent = plus.android.importClass(
                  'android.content.Intent'
                ) as AndroidSetting.PlusAndroidClassObjectExtra
                const Settings = plus.android.importClass('android.provider.Settings') as AndroidSetting.Setting
                const Uri = plus.android.importClass('android.net.Uri') as AndroidSetting.URI
                const intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                const uri = Uri.fromParts('package', main.getPackageName(), null)
                intent.setData(uri)
                main.startActivity(intent)
              } catch {
                useToast('无法打开设置页')
              }
            }
          })
          return
        }
        useScanAndroidDirs().then(resolve)
      },
      () => {
        uni.showModal({ title: '提示', content: '权限申请失败', showCancel: false })
      }
    )
  })
}
  • 扫描获取歌曲列表
ts
/**
 * 歌曲项类型
 */
interface SongItemType {
  /**
   * 歌曲 ID
   */
  id: string
  /**
   * 歌曲名称
   */
  name: string
  /**
   * 歌手
   */
  artist: string
  /**
   * 封面 URL
   */
  url: string
  /**
   * 音频 URL
   */
  audio: string
  /**
   * 创建时间
   */
  createdTime: string
  /**
   * 时长(秒)
   */
  duration: number
  /**
   * 专辑
   */
  album: string
}
const res: SongItemType[] = await useScanDir()

播放歌曲

ts
/**
 * 当前播放的音频 id
 */
let currentPlayId: string = ''

/**
 * 音频播放
 */
export const useAudio = () => {
  const playState = computed(() => songStore.state.isPlay)
  let bgAudioManager: UniApp.BackgroundAudioManager | null = null
  const initAudio = () => {
    if (!songStore.playItem.value) return
    if (bgAudioManager) {
      // 避免重复初始化
      if (currentPlayId === songStore.state.playId) return
      bgAudioManager.stop()
    } else {
      bgAudioManager = uni.getBackgroundAudioManager()
    }
    currentPlayId = songStore.playItem.value.id
    bgAudioManager.title = songStore.playItem.value.name
    bgAudioManager.singer = songStore.playItem.value.artist
    bgAudioManager.src = songStore.playItem.value.audio
    bgAudioManager.coverImgUrl = songStore.playItem.value.url

    // bgAudioManager.onPlay(() => {
    //   console.log('播放中')
    // })

    // bgAudioManager.onPause(() => {
    //   console.log('暂停')
    // })

    // bgAudioManager.onStop(() => {
    //   console.log('停止')
    // })

    bgAudioManager.onEnded(() => {
      // console.log('播放结束')
      bgAudioManager = null
      songStore.onPlayPrevNext(1)
    })

    bgAudioManager.onTimeUpdate(() => {
      if (!bgAudioManager) return
      const currentTime = ~~bgAudioManager.currentTime
      const duration = ~~bgAudioManager.duration
      if (!duration) {
        songStore.onTogglePlay(false)
        useToast('播放失败')
        return
      }
      songStore.onUpdateDuration({ currentTime, duration })
    })

    bgAudioManager.onError(err => {
      if (!bgAudioManager) return
      songStore.onTogglePlay(false)
      // console.error('音频错误', err)
      useToast('播放失败')
    })
  }
  watch(
    playState,
    bool => {
      if (!songStore.playItem.value) return
      initAudio()
      if (!bgAudioManager) return
      if (bool) {
        bgAudioManager.seek(songStore.state.currentTime)
        bgAudioManager.play()
      } else {
        bgAudioManager.pause()
      }
    },
    { immediate: true }
  )
}

界面预览

在这里插入图片描述

资源演示

基于 MIT 许可发布