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,可以在此赋值;

相关推荐
崔庆才丨静觅14 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606114 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了14 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅15 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅15 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
renke336415 小时前
Flutter for OpenHarmony:色彩捕手——基于HSL色轮与感知色差的交互式色觉训练系统
flutter
崔庆才丨静觅15 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment15 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅16 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊16 小时前
jwt介绍
前端