Flutter 多端音频控制台:基于 audio_service 实现 iOS、Android 锁屏与通知中心播放控制

本文简介

本文是关于 Flutter 平台上 audio_service 库的使用实操,通过该库实现 iPhone、Android 手机下拉窗口、锁屏页面中的音频控制台。 audio_service 库可以实现的功能很多,这里只用来做音频控制台,并未涉及其他功能,且只在 iPhone 设备有测试。 以下是 audio_service 库的官方文档介绍:

This plugin wraps around your existing audio code to allow it to run in the background and interact with the media notification, the lock screen, headset buttons, wearables and Android Auto. It supports Android, iOS, web and Linux (via audio_service_mpris). 这个插件包装你现有的音频代码,允许它在后台运行,并与媒体通知,锁屏,耳机按钮,可穿戴设备和Android自动交互。它支持Android, iOS, web和Linux(通过audio_service_mpris)。

依赖

  1. 文中的代码会依赖我之前文章中的全局音频播放单例控制器 AudioPlayerUtil ,相关信息请移步Flutter 全局音频播放单例实现(附完整源码)------基于 just_audio 的零依赖方案
  2. audio_service: ^0.18.18
  3. just_audio: ^0.10.5

继承 audio_service 中的 BaseAudioHandler 实现 AudioCustomHandler

在 AudioCustomHandler 类中对接系统控制台与 Flutter 软件的音频交互逻辑。 为了释放在 AudioCustomHandler 初始化时占用的资源,该类使用单例模式。

  • _instance: AudioCustomHandler 的单例对象
  • static AudioCustomHandler of():单例获取函数
dart 复制代码
class AudioCustomHandler extends BaseAudioHandler {
  static AudioCustomHandler? _instance;

  static AudioCustomHandler of() {
    _instance ??= AudioCustomHandler();
    return _instance!;
  }
  // ......
}

AudioCustomHandler 的初始化函数

初始化控制台的状态、功能、信息

控制台要提前进行初始化,后续控制台状态变更只需要复制当前状态,然后修改对应状态即可。

  • controls:设置控制台功能
    • 上一首:MediaControl.skipToPrevious
    • 播放:MediaControl.play
    • 暂停:MediaControl.pause
    • 下一首:MediaControl.skipToNext
  • systemActions:设置软件支持的系统级操作
    • 进度切换:MediaAction.seek
  • processingState:音频处理状态
    • idle:还没有加载任何资源。
    • loading:资源加载中。
    • buffering:正在缓存资源。
    • ready:资源有足够的缓冲,可用于回放。
    • completed:到达资源的终点。
    • error:资源加载异常。
    • playing:播放状态。
    • updatePosition:播放位置。
    • bufferedPosition:缓冲位置

监听音频播放状态、信息

这里要用到全局音频播放单例 ,控制台的状态变化务必使用 playbackState.value.copyWith 拷贝当前状态,然后对需要更新的状态进行更新。

  • _streamSubscriptions:该参数用于存储订阅信息,以便在软件退出时进行释放。
  • 订阅播放状态:AudioPlayerUtil.of().playerStateStream
  • 订阅播放信息变化:AudioPlayerUtil.of().currentIndexStream
  • 订阅播放进度变化:AudioPlayerUtil.of().positionStream
dart 复制代码
class AudioCustomHandler extends BaseAudioHandler {

  // ......

  final List<StreamSubscription> _streamSubscriptions = [];
  /// 初始化
  AudioCustomHandler init() {
    // 初始化控制台的状态、功能、信息
    playbackState.add(
      PlaybackState(
        controls: [
          MediaControl.skipToPrevious,
          MediaControl.play,
          MediaControl.pause,
          MediaControl.skipToNext,
        ],
        systemActions: {MediaAction.seek},
        processingState: AudioProcessingState.ready,
        playing: false,
        updatePosition: Duration.zero,
        bufferedPosition: Duration.zero,
      ),
    );
    // 监听播放器状态变化,同步到通知栏
    _streamSubscriptions.add(
      AudioPlayerUtil.of().playerStateStream.listen((state) {
        AudioProcessingState processingState =
            state.processingState == ProcessingState.completed
            ? AudioProcessingState.completed
            : state.processingState == ProcessingState.ready
            ? AudioProcessingState.ready
            : state.processingState == ProcessingState.loading
            ? AudioProcessingState.loading
            : state.processingState == ProcessingState.buffering
            ? AudioProcessingState.buffering
            : AudioProcessingState.idle;
        playbackState.add(
          playbackState.value.copyWith(
            playing: state.playing,
            processingState: processingState,
          ),
        );
      }),
    );
    // 监听当前播放歌曲
    _streamSubscriptions.add(
      AudioPlayerUtil.of().currentIndexStream.listen((index) {
        mediaItem.add(
          MediaItem(
            id: 'media_id',
            title: AudioPlayerUtil.of().currentAudio?.name ?? '未知歌曲',
            artist: AudioPlayerUtil.of().currentAudio?.artist,
            duration: AudioPlayerUtil.of().duration,
            artUri: Uri.parse(
              AudioPlayerUtil.of().currentAudio?.image ??
                  'assets/images/logo.png',
            ),
          ),
        );
      }),
    );
    // 监听播放进度
    _streamSubscriptions.add(
      AudioPlayerUtil.of().positionStream.listen((position) {
        playbackState.add(
          playbackState.value.copyWith(
            updatePosition: position,
            bufferedPosition: position,
          ),
        );
      }),
    );

    return this;
  }
  
  // ......
  
}

方法介绍

这里的方法都是对 **BaseAudioHandler 方法的重写,然后使用全局音频控制器 AudioPlayerUtil **进行控制。

  • 音频播放:Future<void> play()
  • 音频暂停:Future<void> pause()
  • 音频停止:Future<void> stop()
  • 指定音频播放位置:Future<void> seek(Duration position)
  • 跳转到指定音频:Future<void> skipToQueueItem(int index)
  • 下一首:Future<void> skipToNext()
  • 上一首:Future<void> skipToPrevious()
dart 复制代码
class AudioCustomHandler extends BaseAudioHandler {

  // ......

  @override
  Future<void> play() => AudioPlayerUtil.of().play();

  @override
  Future<void> pause() => AudioPlayerUtil.of().pause();

  @override
  Future<void> stop() => AudioPlayerUtil.of().stop();

  @override
  Future<void> seek(Duration position) => AudioPlayerUtil.of().seek(position);

  @override
  Future<void> skipToQueueItem(int index) =>
      AudioPlayerUtil.of().seek(Duration.zero, index: index);

  @override
  Future<void> skipToNext() => AudioPlayerUtil.of().next();

  @override
  Future<void> skipToPrevious() => AudioPlayerUtil.of().previous();

}

audio_service 库的初始化

初始化位置一般在启动页,在用户同意协议之后。

  • builder:这个参数需要的是 AudioHandler 对象,AudioCustomHandler.of().init() 函数会返回 AudioCustomHandler 对象,AudioCustomHandler 继承自 BaseAudioHandler, BaseAudioHandler 又继承自 AudioHandler
  • config:这个参数我并未深究,其用途读者可以自行查阅 audio_service 的文档
dart 复制代码
// ....
    AudioService.init(
      builder: () => AudioCustomHandler.of().init(),
      config: AudioServiceConfig(
        androidNotificationChannelId: 'com.xxx.xxxxxx.audio',
        androidNotificationChannelName: 'xxxxxx',
      ),
    );
// ...

资源销毁

单例模式中的资源不释放/销毁问题也不大,因为单例的释放/销毁一般都伴随着整个进程的结束。但为了养成一个良好的编程习惯,还是要销毁。 销毁时机可以放在主页面的销毁函数中,一般主页面销毁意味着整个 App 的退出,进程的结束。

dart 复制代码
class AudioCustomHandler extends BaseAudioHandler {

  // ......

  Future<void> dispose() async {
    while (_streamSubscriptions.isNotEmpty) {
      await _streamSubscriptions.removeLast().cancel();
    }
    return Future.value();
  }
}

附上源码

dart 复制代码
import 'dart:async';

import 'package:audio_service/audio_service.dart';
import 'package:ephemeris_mobile/utils/AudioPlayerUtil.dart';
import 'package:just_audio/just_audio.dart';

class AudioCustomHandler extends BaseAudioHandler {
  final List<StreamSubscription> _streamSubscriptions = [];
  static AudioCustomHandler? _instance;

  static AudioCustomHandler of() {
    _instance ??= AudioCustomHandler();
    return _instance!;
  }

  /// 初始化
  AudioCustomHandler init() {
    // 初始化控制台的状态、功能、信息
    playbackState.add(
      PlaybackState(
        controls: [
          MediaControl.skipToPrevious,
          MediaControl.play,
          MediaControl.pause,
          MediaControl.skipToNext,
        ],
        systemActions: {MediaAction.seek},
        processingState: AudioProcessingState.ready,
        playing: false,
        updatePosition: Duration.zero,
        bufferedPosition: Duration.zero,
      ),
    );
    // 监听播放器状态变化,同步到通知栏
    _streamSubscriptions.add(
      AudioPlayerUtil.of().playerStateStream.listen((state) {
        AudioProcessingState processingState =
            state.processingState == ProcessingState.completed
            ? AudioProcessingState.completed
            : state.processingState == ProcessingState.ready
            ? AudioProcessingState.ready
            : state.processingState == ProcessingState.loading
            ? AudioProcessingState.loading
            : state.processingState == ProcessingState.buffering
            ? AudioProcessingState.buffering
            : AudioProcessingState.idle;
        playbackState.add(
          playbackState.value.copyWith(
            playing: state.playing,
            processingState: processingState,
          ),
        );
      }),
    );
    // 监听当前播放歌曲
    _streamSubscriptions.add(
      AudioPlayerUtil.of().currentIndexStream.listen((index) {
        mediaItem.add(
          MediaItem(
            id: 'media_id',
            title: AudioPlayerUtil.of().currentAudio?.name ?? '未知歌曲',
            artist: AudioPlayerUtil.of().currentAudio?.artist,
            duration: AudioPlayerUtil.of().duration,
            artUri: Uri.parse(
              AudioPlayerUtil.of().currentAudio?.image ??
                  'assets/images/logo.png',
            ),
          ),
        );
      }),
    );
    // 监听播放进度
    _streamSubscriptions.add(
      AudioPlayerUtil.of().positionStream.listen((position) {
        playbackState.add(
          playbackState.value.copyWith(
            updatePosition: position,
            bufferedPosition: position,
          ),
        );
      }),
    );

    return this;
  }

  @override
  Future<void> play() => AudioPlayerUtil.of().play();

  @override
  Future<void> pause() => AudioPlayerUtil.of().pause();

  @override
  Future<void> stop() => AudioPlayerUtil.of().stop();

  @override
  Future<void> seek(Duration position) => AudioPlayerUtil.of().seek(position);

  @override
  Future<void> skipToQueueItem(int index) =>
      AudioPlayerUtil.of().seek(Duration.zero, index: index);

  @override
  Future<void> skipToNext() => AudioPlayerUtil.of().next();

  @override
  Future<void> skipToPrevious() => AudioPlayerUtil.of().previous();

  Future<void> dispose() async {
    while (_streamSubscriptions.isNotEmpty) {
      await _streamSubscriptions.removeLast().cancel();
    }
    return Future.value();
  }
}
相关推荐
作业逆流成河7 小时前
🎉 enum-plus 发布新版本了!
前端·javascript·前端框架
WYiQIU7 小时前
高级Web前端开发工程师2025年面试题总结及参考答案【含刷题资源库】
前端·vue.js·面试·职场和发展·前端框架·reactjs·飞书
WuWuII7 小时前
SSE服务端单向推送消息到前端
前端·推送
.又是新的一天.7 小时前
04-Fiddler详解+抓包定位问题
前端·测试工具·fiddler
克里斯蒂亚L8 小时前
OpenLayers - 画全国轨道线路图
前端
GISer_Jing8 小时前
小米前端面试
前端·面试·职场和发展
静西子8 小时前
Vue标签页切换时的异步更新问题
前端·javascript·vue.js
时间的情敌8 小时前
Vue 3.0 源码导读
前端·javascript·vue.js
自由日记8 小时前
css属性使用手册
前端·css·html