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, ), ), ); } }