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();
  }
}
相关推荐
孟健18 小时前
Claude Code 同需求写两版对比:装不装 frontend-design 差多远?
前端·ai编程·claude
梦de天空一米168820 小时前
【一个重复id让游戏卡在62%,聊聊配置表检查】
前端
ryn4839820 小时前
01 一个功能模块,怎么分层才不乱
前端·ai编程·claude
Amos_Web20 小时前
通过 Rspack 源码学 Rust(五):读懂 Result 与问号运算符的错误返回
前端
杨航 AI21 小时前
JEV的用法
java·前端·数据库
Amos_Web21 小时前
Rspack 源码解析(三十五):rspack_error 如何用 miette 渲染带源码定位的错误链
前端
去伪存真21 小时前
高敏资产场景下的大模型协同设计架构
前端·agent
修炼的dance21 小时前
OCR预处理测了108组,纠斜去噪有用,坑在顺序和力度
前端
疯华整猫21 小时前
Android 16 后台常驻适配:用 Trae 搭一套“前台服务 + 加急 Job”的可存活架构
前端
传奇开心果21 小时前
【Flutter踩坑排雷】第3课:const 误用坑|const 错误使用引发 UI 异常与性能损耗
android·flutter