296 lines
9.3 KiB
Dart
296 lines
9.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../data/models/models.dart';
|
|
import '../theme/app_icons.dart';
|
|
import '../theme/yanting_text.dart';
|
|
import '../theme/yanting_tokens.dart';
|
|
import '../theme/wise_tokens.dart';
|
|
import 'app_card.dart';
|
|
|
|
class PlayerStateModel {
|
|
const PlayerStateModel({
|
|
this.audioId = '',
|
|
this.reportId = '',
|
|
this.title = '',
|
|
this.durationSec = 0,
|
|
this.positionSec = 0,
|
|
this.playing = false,
|
|
this.speed = 1.0,
|
|
});
|
|
|
|
final String audioId;
|
|
final String reportId;
|
|
final String title;
|
|
final int durationSec;
|
|
final int positionSec;
|
|
final bool playing;
|
|
final double speed;
|
|
|
|
bool get hasAudio => audioId.isNotEmpty;
|
|
|
|
PlayerStateModel copyWith({
|
|
String? audioId,
|
|
String? reportId,
|
|
String? title,
|
|
int? durationSec,
|
|
int? positionSec,
|
|
bool? playing,
|
|
double? speed,
|
|
}) {
|
|
return PlayerStateModel(
|
|
audioId: audioId ?? this.audioId,
|
|
reportId: reportId ?? this.reportId,
|
|
title: title ?? this.title,
|
|
durationSec: durationSec ?? this.durationSec,
|
|
positionSec: positionSec ?? this.positionSec,
|
|
playing: playing ?? this.playing,
|
|
speed: speed ?? this.speed,
|
|
);
|
|
}
|
|
}
|
|
|
|
class MiniPlayer extends StatelessWidget {
|
|
const MiniPlayer({required this.player, required this.onToggle, super.key});
|
|
|
|
final PlayerStateModel player;
|
|
final VoidCallback onToggle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!player.hasAudio) return const SizedBox.shrink();
|
|
final ratio = player.durationSec == 0
|
|
? 0.0
|
|
: player.positionSec / player.durationSec;
|
|
return DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
color: YantingColors.secondary,
|
|
border: Border(top: BorderSide(color: YantingColors.border)),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FractionallySizedBox(
|
|
widthFactor: ratio.clamp(0, 1),
|
|
child: const SizedBox(
|
|
height: 2,
|
|
child: ColoredBox(color: YantingColors.primary),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: YantingColors.primary,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(
|
|
AppIcons.disc,
|
|
color: YantingColors.primaryForeground,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
player.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: YantingText.meta.copyWith(
|
|
color: YantingColors.foreground,
|
|
fontWeight: FontWeight.w600,
|
|
fontFeatures: null,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: YantingText.meta.copyWith(fontSize: 11),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: onToggle,
|
|
icon: Icon(
|
|
player.playing ? AppIcons.pause : AppIcons.playCircle,
|
|
size: player.playing ? 24 : 28,
|
|
),
|
|
color: YantingColors.foreground,
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class PlayerCard extends StatelessWidget {
|
|
const PlayerCard({
|
|
required this.title,
|
|
required this.durationSec,
|
|
required this.player,
|
|
required this.onStart,
|
|
required this.onToggle,
|
|
required this.onSeek,
|
|
required this.onSpeed,
|
|
super.key,
|
|
});
|
|
|
|
final String title;
|
|
final int durationSec;
|
|
final PlayerStateModel player;
|
|
final VoidCallback onStart;
|
|
final VoidCallback onToggle;
|
|
final void Function(int delta) onSeek;
|
|
final VoidCallback onSpeed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final active = player.hasAudio && player.title == title;
|
|
final position = active ? player.positionSec : 0;
|
|
final ratio = durationSec == 0 ? 0.0 : position / durationSec;
|
|
return AppCard(
|
|
color: YantingColors.secondary,
|
|
borderColor: YantingColors.border,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('音频解读', style: YantingText.listTitle),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
title,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: YantingText.meta.copyWith(fontSize: 12.5),
|
|
),
|
|
const SizedBox(height: 16),
|
|
LinearProgressIndicator(
|
|
value: ratio.clamp(0, 1),
|
|
minHeight: 4,
|
|
borderRadius: BorderRadius.circular(YantingRadius.pill),
|
|
backgroundColor: YantingColors.border,
|
|
color: YantingColors.primary,
|
|
),
|
|
const SizedBox(height: WiseSpacing.x2),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
formatDuration(position),
|
|
style: YantingText.meta.copyWith(fontSize: 11),
|
|
),
|
|
Text(
|
|
formatDuration(durationSec),
|
|
style: YantingText.meta.copyWith(fontSize: 11),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
SizedBox(
|
|
height: 56,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_SkipButton(label: '-15', onPressed: () => onSeek(-15)),
|
|
const SizedBox(width: 26),
|
|
IconButton.filled(
|
|
onPressed: active ? onToggle : onStart,
|
|
icon: Icon(
|
|
active && player.playing
|
|
? AppIcons.pause
|
|
: AppIcons.play,
|
|
size: 28,
|
|
),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: YantingColors.primary,
|
|
foregroundColor: YantingColors.primaryForeground,
|
|
fixedSize: const Size(56, 56),
|
|
),
|
|
),
|
|
const SizedBox(width: 26),
|
|
_SkipButton(label: '+15', onPressed: () => onSeek(15)),
|
|
],
|
|
),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton(
|
|
onPressed: onSpeed,
|
|
style: TextButton.styleFrom(
|
|
backgroundColor: YantingColors.background,
|
|
foregroundColor: YantingColors.foreground,
|
|
side: const BorderSide(color: YantingColors.border),
|
|
shape: const StadiumBorder(),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
),
|
|
child: Text(
|
|
'${player.speed.toStringAsFixed(1)}x',
|
|
style: YantingText.meta.copyWith(
|
|
color: YantingColors.foreground,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。',
|
|
style: YantingText.meta.copyWith(fontSize: 11.5, height: 1.6),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SkipButton extends StatelessWidget {
|
|
const _SkipButton({required this.label, required this.onPressed});
|
|
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return TextButton(
|
|
onPressed: onPressed,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: YantingColors.foreground,
|
|
minimumSize: const Size(40, 40),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: YantingText.meta.copyWith(
|
|
color: YantingColors.foreground,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|