初始化
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user