Flutter组件封装:audio/音频播放组件

一、需求来源

项目开发中遇到了音频链接播放的场景,第三方的界面又不满足 UI 的需求,顺手封装一个组件 AudioPlayerBar(支持 m3p8 链接)

暂停状态

播放中状态

二、使用demo

dart 复制代码
...
// 标题时长和图标
MediaRecordCard(
  desc: desc ?? "未知",
  timeLong: timeLong,
  onPlay: () {
    _isStarPlay.value = true;
  },
),
// 音频播放bar
AudioPlayerBar(
  url: url ?? '',
  onDuration: (val) {
    ddlog(val.toTime());
  },
),
...

三、源码

1、MediaRecordCard 媒体信息展示

php 复制代码
class MediaRecordCard extends StatelessWidget {
  const MediaRecordCard({
    super.key,
    required this.timeLong,
    required this.desc,
    this.isVideo = true,
    this.onPlay,
  });

  // final VisitEvaluateDetailModel? detailModel;
  /// 秒
  final int? timeLong;

  final String desc;

  final bool isVideo;

  final VoidCallback? onPlay;

  @override
  Widget build(BuildContext context) {
    var timestamp = (timeLong ?? 0) * 1000;
    final duration = Duration(milliseconds: timestamp);
    final timeLongDesc = duration.toTimeNew();

    return Container(
      padding: const EdgeInsets.all(12),
      decoration: const BoxDecoration(
        color: Colors.white,
        // border: Border.all(color: Colors.blue),
        borderRadius: BorderRadius.all(Radius.circular(8)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          NPair(
            isReverse: true,
            flexibleChild: false,
            icon: InkWell(
              onTap: onPlay,
              child: buildMediaBox(
                isVideo: isVideo,
              ),
            ),
            child: Expanded(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  NText(
                    desc,
                    fontSize: 16,
                    maxLines: 2,
                    fontWeight: FontWeight.w500,
                  ),
                  const SizedBox(height: 16),
                  NPair(
                    icon: Image(
                      image: "icon_time_long.png".toAssetImage(),
                      width: 14,
                      height: 14,
                    ),
                    child: NText(
                      timeLongDesc,
                      color: fontColor5D6D7E,
                      maxLines: 1,
                      fontSize: 14,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget buildMediaBox({required bool isVideo}) {
    final imageName = isVideo
        ? "icon_video_visit_evaluate.png"
        : "icon_audio_visit_evaluate.png";
    return Container(
      width: 80,
      height: 80,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: const Color(0xff00B578).withOpacity(0.1),
        borderRadius: const BorderRadius.all(Radius.circular(4)),
      ),
      child: Image(
        image: imageName.toAssetImage(),
        width: 33,
        height: 32,
      ),
    );
  }
}

2、AudioPlayerBar 组件源码

ini 复制代码
/// 音频播放 bar
class AudioPlayerBar extends StatefulWidget {
  const AudioPlayerBar({
    super.key,
    required this.url,
    this.duration,
    this.onDuration,
  });

  /// 链接
  final String url;

  /// 时长
  final Duration? duration;

  /// 时长回调
  final ValueChanged<Duration>? onDuration;

  @override
  State<StatefulWidget> createState() => _AudioPlayerBarState();
}

class _AudioPlayerBarState extends State<AudioPlayerBar> {
  AudioPlayer player = AudioPlayer();
  PlayerState? _playerState;
  late Duration? _duration = widget.duration;
  Duration? _position;

  StreamSubscription? _durationSubscription;
  StreamSubscription? _positionSubscription;
  StreamSubscription? _playerCompleteSubscription;
  StreamSubscription? _playerStateChangeSubscription;

  bool get _isPlaying => _playerState == PlayerState.playing;
  bool get _isPaused => _playerState == PlayerState.paused;

  String? get _positionText => _position?.toTime();

  @override
  void initState() {
    super.initState();

    player.setReleaseMode(ReleaseMode.stop);

    // Start the player as soon as the app is displayed.
    WidgetsBinding.instance.addPostFrameCallback((_) async {
      await player.setSource(UrlSource(widget.url));
      await player.resume();
    });

    // Use initial values from player
    _playerState = player.state;
    player.getDuration().then((value) {
      if (value == null) {
        return;
      }
      _duration = value;
      widget.onDuration?.call(value);
      setState(() {});
    });

    player.getCurrentPosition().then((value) {
      _position = value;
      setState(() {});
    });
    _initStreams();
  }

  @override
  void setState(VoidCallback fn) {
    // Subscriptions only can be closed asynchronously,
    // therefore events can occur after widget has been disposed.
    if (mounted) {
      super.setState(fn);
    }
  }

  @override
  void dispose() {
    player.stop();
    player.dispose();
    _durationSubscription?.cancel();
    _positionSubscription?.cancel();
    _playerCompleteSubscription?.cancel();
    _playerStateChangeSubscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (widget.url.startsWith("http") != true) {
      debugPrint("$widget 无效链接: $url");
      return const SizedBox();
    }
    final imgPath = _isPlaying
        ? 'assets/images/icon_pause.png'
        : 'assets/images/icon_play.png';

    final totalDesc = _duration == null ? "--" : _duration!.toTime();

    return Container(
      width: double.infinity,
      margin: const EdgeInsets.symmetric(vertical: 12).copyWith(top: 0),
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: white,
        borderRadius: BorderRadius.circular(29),
        border: Border.all(width: 0.5, color: lineColor),
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          InkWell(
            onTap: _isPlaying ? _pause : _play,
            child: Image(
              image: imgPath.toAssetImage(),
              width: 20,
              height: 20,
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 11),
            child: NText(
              _positionText ?? "",
              fontSize: 14,
              color: fontColor737373,
            ),
          ),
          Expanded(
            child: ClipRRect(
              borderRadius: BorderRadius.circular(3),
              child: LinearProgressIndicator(
                minHeight: 4,
                // 进度值,范围为0到1
                value: (_position != null &&
                        _duration != null &&
                        _position!.inMilliseconds > 0 &&
                        _position!.inMilliseconds < _duration!.inMilliseconds)
                    ? _position!.inMilliseconds / _duration!.inMilliseconds
                    : 0.0,
                backgroundColor:
                    const Color(0xFF5D6D7E).withOpacity(0.16), // 背景颜色
                valueColor: const AlwaysStoppedAnimation<Color>(
                    Color(0xFF5D6D7E)), // 进度条颜色
              ),
            ),
          ),
          const SizedBox(
            width: 11,
          ),
          NText(
            totalDesc,
            fontSize: 14,
            color: fontColor737373,
          ),
        ],
      ),
    );
  }

  void _initStreams() {
    _durationSubscription = player.onDurationChanged.listen((duration) {
      if (_duration?.toTime() == duration.toTime()) {
        return;
      }
      _duration = duration;
      widget.onDuration?.call(duration);
      setState(() {});
    });

    _positionSubscription = player.onPositionChanged.listen((p) {
      _position = p;
      setState(() {});
    });

    _playerCompleteSubscription = player.onPlayerComplete.listen((event) {
      _playerState = PlayerState.stopped;
      _position = Duration.zero;
      setState(() {});
    });

    _playerStateChangeSubscription =
        player.onPlayerStateChanged.listen((state) {
      _playerState = state;
      setState(() {});
    });
  }

  Future<void> _play() async {
    await player.resume();
    _playerState = PlayerState.playing;
    setState(() {});
  }

  Future<void> _pause() async {
    await player.pause();
    _playerState = PlayerState.paused;
    setState(() {});
  }

  Future<void> _stop() async {
    await player.stop();
    _playerState = PlayerState.stopped;
    _position = Duration.zero;
    setState(() {});
  }
}

四、最后

onDuration 会回调 Duration 类型的音频总时长,如果接口未返回时长 timeLong,可以在此赋值;

相关推荐
爱泡脚的鸡腿7 分钟前
HTML CSS 第二次笔记
前端·css
灯火不休ᝰ23 分钟前
前端处理pdf文件流,展示pdf
前端·pdf
智践行25 分钟前
Trae开发实战之转盘小程序
前端·trae
最新资讯动态31 分钟前
DialogHub上线OpenHarmony开源社区,高效开发鸿蒙应用弹窗
前端
lvbb6640 分钟前
框架修改思路
前端·javascript·vue.js
树上有只程序猿43 分钟前
Java程序员需要掌握的技术
前端
从零开始学安卓1 小时前
Kotlin(三) 协程
前端
阿镇吃橙子1 小时前
一些手写及业务场景处理问题汇总
前端·算法·面试
庸俗今天不摸鱼1 小时前
【万字总结】前端全方位性能优化指南(九)——FSP(First Screen Paint)像素级分析、RUM+合成监控、Lighthouse CI
前端·性能优化