import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:hgdj/assets_tool/app_colors.dart'; import 'package:hgdj/assets_tool/images.dart'; import 'package:hgdj/hj_utils/date_time_util.dart'; import 'package:hgdj/hj_utils/screen.dart'; import 'package:hgdj/tools_base/widget/net_image_widget.dart'; import 'audio_player_bottomsheet.dart'; import 'widget/acg_source_manager.dart'; // 播放页面 class AudioPlayerContent extends StatefulWidget { final int index; final AudioPlayerManager manager; final ACGSourceManager sourceManager; const AudioPlayerContent(this.sourceManager, {super.key, required this.manager, this.index = 0}); @override State createState() => _AudioPlayerContentState(); } class _AudioPlayerContentState extends State { AudioPlayerManager get manager => widget.manager; AudioPlayMod playMod = AudioPlayMod.loopList; @override void initState() { super.initState(); // manager 的 initPlayer / dispose 由 VoiceNovelReadLogic 统管: // - onInit 已 initPlayer // - onClose 已 dispose // 此处不要再调 initPlayer,否则 4 个 stream listener 重复注册导致旧 sub 泄漏 manager .play(widget.sourceManager.allEpisodes[widget.index].getRealAudioUrl); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: manager.audioValue, builder: (ctx, audioValue, child) { double max = (audioValue.duration?.inSeconds ?? 1).toDouble(); if (max == 0) max = 1; final position = (audioValue.position?.inSeconds ?? 0).toDouble(); final progress = position / max; return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ 12.sizeBoxH, NetworkImageLoader( imageUrl: widget.sourceManager.mediaInfo?.coverH ?? '', width: 111, height: 148, ), 18.sizeBoxH, Row( children: [ Text( '正在播放:第${(audioValue.onPlayIndex ?? 0) + 1}集', style: TextStyle( color: Colors.white, fontSize: 16.sp, fontWeight: FontWeight.w500), ), ], ), 18.sizeBoxH, _progressRow(audioValue, progress, max), 14.sizeBoxH, _controlRow(audioValue), 18.sizeBoxH, ], ); }, ); } // 进度条行:左右 ±10s 快进退、中间滑块、两侧时间 Widget _progressRow( AudioPlayerValue audioValue, double progress, double max) { return Row( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( enableFeedback: false, onTap: () => manager.seekTenMinsWithDirection(-1), child: Image.asset('noval_time_back.png'.acgImgPath, width: 24), ), 8.sizeBoxH, // 38px:原 35px 部分机型会裁掉 "MM:SS" 末位 SizedBox( width: 38, height: 19, child: Text( buildMMSS(audioValue.position?.inSeconds ?? 0), maxLines: 1, softWrap: false, style: TextStyle( color: Colors.white.withValues(alpha: .9), fontSize: 12, fontWeight: FontWeight.w500), ), ), ], ), 10.sizeBoxW, Expanded( child: AudioSlider( value: progress, onChange: (value) => manager.seek((max * value).toInt()), //进度 ), ), 10.sizeBoxW, Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ InkWell( enableFeedback: false, onTap: () => manager.seekTenMinsWithDirection(1), child: Image.asset('noval_time_forword.png'.acgImgPath, width: 24), ), 8.sizeBoxH, SizedBox( height: 19, width: 38, child: audioValue.duration == null ? const CupertinoActivityIndicator(color: AppColors.actionRed) : Text( buildMMSS(audioValue.duration!.inSeconds), maxLines: 1, softWrap: false, style: TextStyle( color: Colors.white.withValues(alpha: .9), fontSize: 12, fontWeight: FontWeight.w500, height: 1, ), ), ), ], ), ], ); } // 控制行:循环模式、上一集/播放暂停/下一集、播放列表 Widget _controlRow(AudioPlayerValue audioValue) { final isLoop = playMod == AudioPlayMod.loop; final isPlaying = audioValue.isPlaying ?? false; return Row( children: [ // 循环模式切换 InkWell( enableFeedback: false, onTap: () => setState(() { playMod = isLoop ? AudioPlayMod.loopList : AudioPlayMod.loop; manager.setPlayMod(playMod); }), child: Column( children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 150), transitionBuilder: (child, anim) => ScaleTransition(scale: anim, child: child), child: Image.asset( isLoop ? 'single_cycle.png'.acgImgPath : 'list_cycle.png'.acgImgPath, // key 必须按状态区分,AnimatedSwitcher 才会把它当成新 child 触发动画 key: ValueKey(isLoop ? 'loop' : 'loop_list'), width: 24, ), ), 2.sizeBoxH, Text( isLoop ? '单曲循环' : '多集循环', style: TextStyle( color: Colors.white.withValues(alpha: .9), fontSize: 12, fontWeight: FontWeight.w400), ), ], ), ), const Spacer(), Row( children: [ InkWell( enableFeedback: false, onTap: () => manager.playForward(), child: Image.asset('forward.png'.acgImgPath, width: 24), ), 40.sizeBoxW, InkWell( enableFeedback: false, onTap: () => isPlaying ? manager.pause() : manager.resume(), child: AnimatedSwitcher( duration: const Duration(milliseconds: 150), transitionBuilder: (child, anim) => ScaleTransition(scale: anim, child: child), child: Image.asset( isPlaying ? 'pause.png'.acgImgPath : 'play.png'.acgImgPath, key: ValueKey(isPlaying ? 'pause' : 'play'), width: 36, ), ), ), 40.sizeBoxW, InkWell( enableFeedback: false, onTap: () => manager.playNext(), child: Image.asset('next.png'.acgImgPath, width: 24), ), ], ), const Spacer(), InkWell( enableFeedback: false, onTap: _showPlaylist, child: Column( children: [ Image.asset('play_list.png'.acgImgPath, width: 24), 2.sizeBoxH, Text( '播放列表', style: TextStyle( color: Colors.white.withValues(alpha: .9), fontSize: 12, fontWeight: FontWeight.w400), ), ], ), ), ], ); } // 弹出播放列表,点击切集 void _showPlaylist() { Get.bottomSheet(AudioPlayerBottomSheet( manager.sourceManger?.mediaInfo?.title ?? '', currentPlayIndex: widget.sourceManager.index, allAudiobooks: manager.sourceManger?.allEpisodes ?? [], indexHandler: (index) => manager.playWithIndex(index), unit: manager.sourceManger?.mediaInfo?.unit, hasPermission: manager.sourceManger?.mediaInfo?.hasPermission ?? false, )); } } /// 播放器进度条:内置 Material Slider,拖动/点击由框架处理; /// 轨道和 thumb 用自定义 shape 套 hgdj 的橙红配色。 class AudioSlider extends StatelessWidget { final double? value; final Function(double value) onChange; const AudioSlider({super.key, required this.value, required this.onChange}); @override Widget build(BuildContext context) { return SizedBox( height: 24, child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 2, overlayShape: SliderComponentShape.noOverlay, trackShape: GradientTrackShape(), thumbShape: const GradientThumbShape(radius: 8), ), child: Slider( min: 0, max: 1, value: (value ?? 0).clamp(0.0, 1.0), onChanged: onChange, ), ), ); } } /// 进度轨道:左侧(已播放)填 actionRed,右侧(未播放)填灰色 class GradientTrackShape extends SliderTrackShape { @override Rect getPreferredRect({ bool isDiscrete = false, bool isEnabled = false, Offset offset = Offset.zero, required RenderBox parentBox, required SliderThemeData sliderTheme, }) { final trackHeight = sliderTheme.trackHeight ?? 2; final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; return Rect.fromLTWH( offset.dx, trackTop, parentBox.size.width, trackHeight); } @override void paint( PaintingContext context, Offset offset, { required Animation enableAnimation, bool isDiscrete = false, bool isEnabled = false, required RenderBox parentBox, Offset? secondaryOffset, required SliderThemeData sliderTheme, required TextDirection textDirection, required Offset thumbCenter, }) { final trackRect = getPreferredRect( parentBox: parentBox, sliderTheme: sliderTheme, offset: offset, ); final activePaint = Paint()..color = AppColors.actionRed; final inactivePaint = Paint()..color = const Color(0xff3D3D3D); // 左侧已播放 context.canvas.drawRect( Rect.fromLTRB( trackRect.left, trackRect.top, thumbCenter.dx, trackRect.bottom), activePaint, ); // 右侧未播放 context.canvas.drawRect( Rect.fromLTRB( thumbCenter.dx, trackRect.top, trackRect.right, trackRect.bottom), inactivePaint, ); } } /// 圆形 thumb:外圈半透明 actionRed,内圈白 class GradientThumbShape extends SliderComponentShape { final double radius; const GradientThumbShape({this.radius = 8}); @override Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.fromRadius(radius); @override void paint( PaintingContext context, Offset center, { required Animation activationAnimation, required Animation enableAnimation, required bool isDiscrete, required TextPainter labelPainter, required RenderBox parentBox, required Size sizeWithOverflow, required SliderThemeData sliderTheme, required TextDirection textDirection, required double textScaleFactor, required double value, }) { final canvas = context.canvas; final outerPaint = Paint() ..color = AppColors.actionRed.withValues(alpha: 0.7); final innerPaint = Paint()..color = Colors.white; canvas.drawCircle(center, radius, outerPaint); canvas.drawCircle(center, radius - 3, innerPaint); } }