import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../data/models/models.dart'; import '../theme/app_icons.dart'; import '../theme/yanting_text.dart'; import '../theme/wise_tokens.dart'; import 'app_buttons.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 colors = ShadTheme.of(context).colorScheme; final ratio = player.durationSec == 0 ? 0.0 : player.positionSec / player.durationSec; return DecoratedBox( decoration: BoxDecoration( color: colors.secondary, border: Border(top: BorderSide(color: colors.border)), ), child: Stack( children: [ Positioned( top: 0, left: 0, right: 0, child: Align( alignment: Alignment.centerLeft, child: FractionallySizedBox( widthFactor: ratio.clamp(0, 1), child: SizedBox( height: 2, child: ColoredBox(color: colors.primary), ), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), child: Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: colors.primary, borderRadius: BorderRadius.circular(8), ), child: Icon( AppIcons.disc, color: colors.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: colors.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), ), ], ), ), ShadIconButton.ghost( onPressed: onToggle, icon: Icon( player.playing ? AppIcons.pause : AppIcons.playCircle, size: player.playing ? 24 : 28, ), ), ], ), ), ], ), ); } } 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; final colors = ShadTheme.of(context).colorScheme; return AppCard( color: colors.secondary, borderColor: colors.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), ShadProgress(value: ratio.clamp(0, 1)), 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), SizedBox( width: 56, height: 56, child: AppIconButton( kind: AppButtonKind.primary, onPressed: active ? onToggle : onStart, icon: active && player.playing ? AppIcons.pause : AppIcons.play, ), ), const SizedBox(width: 26), _SkipButton(label: '+15', onPressed: () => onSeek(15)), ], ), Align( alignment: Alignment.centerRight, child: ShadButton.outline( size: ShadButtonSize.sm, onPressed: onSpeed, child: Text('${player.speed.toStringAsFixed(1)}x'), ), ), ], ), ), 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) { final colors = ShadTheme.of(context).colorScheme; return TextButton( onPressed: onPressed, style: TextButton.styleFrom( foregroundColor: colors.foreground, minimumSize: const Size(40, 40), padding: EdgeInsets.zero, ), child: Text( label, style: YantingText.meta.copyWith( color: colors.foreground, fontWeight: FontWeight.w600, ), ), ); } }