373 lines
12 KiB
Dart
373 lines
12 KiB
Dart
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<AudioPlayerContent> createState() => _AudioPlayerContentState();
|
|
}
|
|
|
|
class _AudioPlayerContentState extends State<AudioPlayerContent> {
|
|
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<double> 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<double> activationAnimation,
|
|
required Animation<double> 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);
|
|
}
|
|
}
|