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

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、5 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la7 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui7 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui