chore: prepare yanting monorepo handoff
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models/models.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 Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: AppCard(
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: WiseColors.primary,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed: onToggle,
|
||||
icon: Icon(player.playing ? Icons.pause : Icons.play_arrow),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: WiseColors.secondary,
|
||||
foregroundColor: WiseColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: WiseSpacing.x2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
player.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(
|
||||
'${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x',
|
||||
style: const TextStyle(color: Color(0xCCFFFFFF), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: WiseSpacing.x2),
|
||||
LinearProgressIndicator(
|
||||
value: ratio.clamp(0, 1),
|
||||
minHeight: 4,
|
||||
backgroundColor: const Color(0x33FFFFFF),
|
||||
color: WiseColors.secondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: WiseColors.secondary200,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('音频解读', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: WiseSpacing.x2),
|
||||
Text(title, style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: WiseSpacing.x3),
|
||||
LinearProgressIndicator(
|
||||
value: ratio.clamp(0, 1),
|
||||
minHeight: 6,
|
||||
backgroundColor: Colors.white,
|
||||
color: WiseColors.accent,
|
||||
),
|
||||
const SizedBox(height: WiseSpacing.x2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(formatDuration(position), style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(formatDuration(durationSec), style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: WiseSpacing.x3),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.outlined(onPressed: () => onSeek(-15), icon: const Icon(Icons.replay_10)),
|
||||
IconButton.filled(
|
||||
onPressed: active ? onToggle : onStart,
|
||||
icon: Icon(active && player.playing ? Icons.pause : Icons.play_arrow),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: WiseColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
IconButton.outlined(onPressed: () => onSeek(15), icon: const Icon(Icons.forward_10)),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: onSpeed, child: Text('${player.speed.toStringAsFixed(1)}x')),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user