初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -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);
}
}