Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import '../../../../core/infra/player_engine/player_engine.dart';
import '../player_play_pause_button.dart';
class AndroidBottomTransportControls extends StatelessWidget {
const AndroidBottomTransportControls({
super.key,
required this.player,
required this.hasPrev,
required this.hasNext,
required this.onPrevItem,
required this.onNextItem,
});
final PlayerEngine player;
final bool hasPrev;
final bool hasNext;
final VoidCallback onPrevItem;
final VoidCallback onNextItem;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: '上一集',
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
icon: Icon(
Icons.skip_previous,
color: hasPrev ? Colors.white : Colors.white38,
size: 28,
),
onPressed: hasPrev ? onPrevItem : null,
),
PlayerPlayPauseButton(
player: player,
iconSize: 30,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
IconButton(
tooltip: '下一集',
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
icon: Icon(
Icons.skip_next,
color: hasNext ? Colors.white : Colors.white38,
size: 28,
),
onPressed: hasNext ? onNextItem : null,
),
],
);
}
}
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import '../../../../shared/utils/format_utils.dart';
import '../player_hold_speed_prompt.dart';
enum GestureFeedbackKind {
none,
brightness,
volume,
seek,
doubleTapSeek,
longPressSpeed,
}
class GestureFeedbackData {
final GestureFeedbackKind kind;
final double value;
final Duration seekTarget;
final Duration totalDuration;
final bool seekIsForward;
final int doubleTapSeconds;
final bool doubleTapIsLeft;
final double speedRate;
const GestureFeedbackData({
this.kind = GestureFeedbackKind.none,
this.value = 0,
this.seekTarget = Duration.zero,
this.totalDuration = Duration.zero,
this.seekIsForward = true,
this.doubleTapSeconds = 0,
this.doubleTapIsLeft = true,
this.speedRate = 1.0,
});
const GestureFeedbackData.none() : this();
}
class AndroidGestureFeedback extends StatelessWidget {
final GestureFeedbackData data;
const AndroidGestureFeedback({super.key, required this.data});
@override
Widget build(BuildContext context) {
if (data.kind == GestureFeedbackKind.none) {
return const IgnorePointer(child: SizedBox.shrink());
}
return IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final screenHeight = constraints.maxHeight;
return Stack(children: [_buildContent(screenWidth, screenHeight)]);
},
),
);
}
Widget _buildContent(double screenWidth, double screenHeight) {
switch (data.kind) {
case GestureFeedbackKind.brightness:
case GestureFeedbackKind.volume:
return _buildTopIndicator();
case GestureFeedbackKind.seek:
return _buildSeekPreview();
case GestureFeedbackKind.longPressSpeed:
return _buildLongPressSpeed();
case GestureFeedbackKind.doubleTapSeek:
return _buildDoubleTapSeek(screenWidth, screenHeight);
case GestureFeedbackKind.none:
return const SizedBox.shrink();
}
}
Widget _buildTopIndicator() {
final isBrightness = data.kind == GestureFeedbackKind.brightness;
final icon = isBrightness
? Icons.brightness_6
: (data.value == 0
? Icons.volume_off
: (data.value < 0.5 ? Icons.volume_down : Icons.volume_up));
final clamped = data.value.clamp(0.0, 1.0);
return Positioned(
top: 64,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xE8181818),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 10),
_buildHorizontalProgress(clamped),
const SizedBox(width: 10),
SizedBox(
width: 36,
child: Text(
'${(clamped * 100).round()}%',
textAlign: TextAlign.right,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
],
),
),
),
);
}
Widget _buildHorizontalProgress(double value) {
return SizedBox(
width: 120,
height: 4,
child: LinearProgressIndicator(
value: value,
color: Colors.white,
backgroundColor: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
);
}
Widget _buildSeekPreview() {
final isForward = data.seekIsForward;
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xE8181818),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isForward ? Icons.fast_forward : Icons.fast_rewind,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
formatDurationClock(
data.seekTarget < Duration.zero
? Duration.zero
: data.seekTarget,
),
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
const Text(
' / ',
style: TextStyle(color: Colors.white54, fontSize: 15),
),
Text(
formatDurationClock(data.totalDuration),
style: const TextStyle(
color: Colors.white54,
fontSize: 15,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
),
);
}
Widget _buildLongPressSpeed() {
return Positioned(
top: 64,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: holdSpeedPromptBackgroundColor,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.fast_forward, color: Colors.white, size: 18),
const SizedBox(width: 6),
Text(
'${data.speedRate}x',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
Widget _buildDoubleTapSeek(double screenWidth, double screenHeight) {
return Positioned(
top: 0,
bottom: 0,
left: data.doubleTapIsLeft ? screenWidth * 0.15 : null,
right: !data.doubleTapIsLeft ? screenWidth * 0.15 : null,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xE8181818),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
data.doubleTapIsLeft ? Icons.fast_rewind : Icons.fast_forward,
color: Colors.white,
size: 20,
),
const SizedBox(width: 6),
Text(
'${data.doubleTapSeconds}s',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../core/infra/player_engine/player_engine.dart';
enum _DragMode { none, brightness, volume, seek }
class AndroidGestureLayer extends StatefulWidget {
const AndroidGestureLayer({
super.key,
required this.enabled,
required this.player,
required this.onToggleControls,
required this.onDoubleTapCenter,
required this.onDoubleTapLeft,
required this.onDoubleTapRight,
required this.onBrightnessChange,
required this.onVolumeChange,
required this.onVerticalDragEnd,
required this.onHorizontalSeekUpdate,
required this.onHorizontalSeekStart,
required this.onHorizontalSeekEnd,
required this.onLongPressStart,
required this.onLongPressEnd,
});
final bool enabled;
final PlayerEngine player;
final VoidCallback onToggleControls;
final VoidCallback onDoubleTapCenter;
final VoidCallback onDoubleTapLeft;
final VoidCallback onDoubleTapRight;
final ValueChanged<double> onBrightnessChange;
final ValueChanged<double> onVolumeChange;
final VoidCallback onVerticalDragEnd;
final ValueChanged<Duration> onHorizontalSeekUpdate;
final VoidCallback onHorizontalSeekStart;
final VoidCallback onHorizontalSeekEnd;
final ValueChanged<bool> onLongPressStart;
final VoidCallback onLongPressEnd;
@override
State<AndroidGestureLayer> createState() => _AndroidGestureLayerState();
}
class _AndroidGestureLayerState extends State<AndroidGestureLayer> {
_DragMode _dragMode = _DragMode.none;
Offset _panStartPos = Offset.zero;
double _cumulativeDx = 0;
double _cumulativeDy = 0;
Timer? _doubleTapTimer;
Offset _lastTapDownPos = Offset.zero;
void _handleTapDown(TapDownDetails details) {
_lastTapDownPos = details.localPosition;
}
void _handleTapUp(TapUpDetails details) {
final pos = _lastTapDownPos;
if (_doubleTapTimer != null) {
_doubleTapTimer!.cancel();
_doubleTapTimer = null;
widget.onToggleControls();
_executeDoubleTap(pos);
} else {
widget.onToggleControls();
_doubleTapTimer = Timer(const Duration(milliseconds: 300), () {
_doubleTapTimer = null;
});
}
}
void _executeDoubleTap(Offset pos) {
final width = context.size?.width ?? 0;
final third = width / 3;
if (pos.dx < third) {
widget.onDoubleTapLeft();
} else if (pos.dx > third * 2) {
widget.onDoubleTapRight();
} else {
widget.onDoubleTapCenter();
}
}
void _handlePanStart(DragStartDetails details) {
_panStartPos = details.localPosition;
_dragMode = _DragMode.none;
_cumulativeDx = 0;
_cumulativeDy = 0;
}
void _handlePanUpdate(DragUpdateDetails details) {
_cumulativeDx += details.delta.dx;
_cumulativeDy += details.delta.dy;
if (_dragMode == _DragMode.none) {
final absDx = _cumulativeDx.abs();
final absDy = _cumulativeDy.abs();
if (absDx < 20 && absDy < 20) return;
if (absDx > absDy * 1.5) {
_dragMode = _DragMode.seek;
widget.onHorizontalSeekStart();
} else if (absDy > absDx * 1.5) {
final screenWidth = context.size?.width ?? 1;
_dragMode = _panStartPos.dx < screenWidth / 2
? _DragMode.brightness
: _DragMode.volume;
} else {
return;
}
}
if (_dragMode == _DragMode.seek) {
final screenWidth = context.size?.width ?? 1;
final totalDuration = widget.player.state.duration;
final seekDelta = totalDuration * (_cumulativeDx / screenWidth) * 0.5;
widget.onHorizontalSeekUpdate(seekDelta);
} else if (_dragMode == _DragMode.brightness) {
final screenHeight = context.size?.height ?? 1;
final delta = -details.delta.dy / (screenHeight * 0.7);
widget.onBrightnessChange(delta);
} else if (_dragMode == _DragMode.volume) {
final screenHeight = context.size?.height ?? 1;
final delta = -details.delta.dy / (screenHeight * 1.5);
widget.onVolumeChange(delta);
}
}
void _handlePanEnd(DragEndDetails details) {
if (_dragMode == _DragMode.seek) {
widget.onHorizontalSeekEnd();
} else if (_dragMode == _DragMode.brightness ||
_dragMode == _DragMode.volume) {
widget.onVerticalDragEnd();
}
_dragMode = _DragMode.none;
}
@override
void dispose() {
_doubleTapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.enabled) return const SizedBox.expand();
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onPanStart: _handlePanStart,
onPanUpdate: _handlePanUpdate,
onPanEnd: _handlePanEnd,
onLongPressStart: (details) {
final screenWidth = context.size?.width ?? 1;
final isLeftSide = details.localPosition.dx < screenWidth / 2;
widget.onLongPressStart(isLeftSide);
},
onLongPressEnd: (_) => widget.onLongPressEnd(),
child: const SizedBox.expand(),
);
}
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/material.dart';
class AndroidLockButton extends StatelessWidget {
const AndroidLockButton({super.key, required this.onLock});
final VoidCallback onLock;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xB3000000),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onLock,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.lock_open, color: Colors.white, size: 22),
),
),
);
}
}
class AndroidLockedOverlay extends StatefulWidget {
const AndroidLockedOverlay({super.key, required this.onUnlock});
final VoidCallback onUnlock;
@override
State<AndroidLockedOverlay> createState() => _AndroidLockedOverlayState();
}
class _AndroidLockedOverlayState extends State<AndroidLockedOverlay> {
bool _iconVisible = true;
Timer? _hideTimer;
@override
void initState() {
super.initState();
_startTimer();
}
void _startTimer() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _iconVisible = false);
});
}
void _onTapBackground() {
if (!_iconVisible) {
setState(() => _iconVisible = true);
_startTimer();
}
}
@override
void dispose() {
_hideTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _onTapBackground,
child: Stack(
children: [
Positioned(
left: 56,
top: 0,
bottom: 0,
child: Center(
child: AnimatedOpacity(
opacity: _iconVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 160),
child: Material(
color: const Color(0xB3000000),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _iconVisible ? widget.onUnlock : null,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.lock, color: Colors.white, size: 28),
),
),
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import '../speed_button.dart';
class AndroidSpeedStrip extends StatelessWidget {
const AndroidSpeedStrip({
super.key,
required this.currentRate,
required this.onIncrement,
required this.onDecrement,
required this.onTapRate,
});
final double currentRate;
final VoidCallback onIncrement;
final VoidCallback onDecrement;
final VoidCallback onTapRate;
@override
Widget build(BuildContext context) {
const buttonConstraints = BoxConstraints(minWidth: 36, minHeight: 36);
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
decoration: BoxDecoration(
color: const Color(0xB3000000),
borderRadius: BorderRadius.circular(24),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onIncrement,
icon: const Icon(Icons.add, size: 20, color: Colors.white),
constraints: buttonConstraints,
padding: EdgeInsets.zero,
),
GestureDetector(
onTap: onTapRate,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
SpeedButton.formatRate(currentRate),
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
),
IconButton(
onPressed: onDecrement,
icon: const Icon(Icons.remove, size: 20, color: Colors.white),
constraints: buttonConstraints,
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,167 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../../../shared/utils/format_utils.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../services/network_speed_monitor.dart';
class BufferingOverlay extends StatefulWidget {
final PlayerEngine player;
final NetworkSpeedMonitor? speedMonitor;
final ValueChanged<bool>? onBufferingChanged;
final bool suppress;
const BufferingOverlay({
super.key,
required this.player,
this.speedMonitor,
this.onBufferingChanged,
this.suppress = false,
});
@override
State<BufferingOverlay> createState() => _BufferingOverlayState();
}
class _BufferingOverlayState extends State<BufferingOverlay> {
bool _isBuffering = false;
Timer? _debounceTimer;
Duration _position = Duration.zero;
Duration _buffer = Duration.zero;
bool _playing = false;
StreamSubscription<Duration>? _bufferSub;
StreamSubscription<Duration>? _positionSub;
StreamSubscription<bool>? _playingSub;
@override
void initState() {
super.initState();
_readState(widget.player);
_subscribe(widget.player);
}
@override
void didUpdateWidget(covariant BufferingOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
_cancelSubscriptions();
_setBuffering(false);
_readState(widget.player);
_subscribe(widget.player);
}
}
@override
void dispose() {
_debounceTimer?.cancel();
_cancelSubscriptions();
super.dispose();
}
void _readState(PlayerEngine player) {
_position = player.state.position;
_buffer = player.state.buffer;
_playing = player.state.playing;
}
void _subscribe(PlayerEngine player) {
_positionSub = player.stream.position.listen((p) {
_position = p;
_evaluate();
});
_bufferSub = player.stream.buffer.listen((b) {
_buffer = b;
_evaluate();
});
_playingSub = player.stream.playing.listen((playing) {
_playing = playing;
_evaluate();
});
}
void _cancelSubscriptions() {
unawaited(_positionSub?.cancel());
unawaited(_bufferSub?.cancel());
unawaited(_playingSub?.cancel());
_positionSub = null;
_bufferSub = null;
_playingSub = null;
}
void _setBuffering(bool value) {
if (_isBuffering == value) return;
setState(() => _isBuffering = value);
widget.onBufferingChanged?.call(value);
}
void _evaluate() {
final shouldBuffer =
_playing && _buffer <= _position && _position > Duration.zero;
if (shouldBuffer) {
if (_isBuffering || _debounceTimer != null) return;
_debounceTimer = Timer(const Duration(milliseconds: 200), () {
_debounceTimer = null;
if (!mounted) return;
if (_playing && _buffer <= _position && _position > Duration.zero) {
_setBuffering(true);
}
});
return;
}
_debounceTimer?.cancel();
_debounceTimer = null;
_setBuffering(false);
}
@override
Widget build(BuildContext context) {
final monitor = widget.speedMonitor;
return IgnorePointer(
child: Opacity(
opacity: widget.suppress ? 0.0 : 1.0,
child: AnimatedOpacity(
opacity: _isBuffering ? 1.0 : 0.0,
duration: const Duration(milliseconds: 100),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppLoadingRing(size: 40, color: Colors.white),
if (monitor != null) ...[
const SizedBox(height: 12),
ValueListenableBuilder<bool>(
valueListenable: monitor.available,
builder: (_, available, _) {
if (!available) return const SizedBox.shrink();
return ValueListenableBuilder<double>(
valueListenable: monitor.speed,
builder: (_, speed, _) {
return Text(
formatDownloadSpeed(speed),
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
fontFeatures: [FontFeature.tabularFigures()],
),
);
},
);
},
),
],
],
),
),
),
),
);
}
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import '../../../../core/contracts/danmaku.dart';
import 'danmaku_tokens.dart';
class DanmakuMatchTile extends StatelessWidget {
const DanmakuMatchTile({
super.key,
required this.candidate,
required this.selected,
required this.onTap,
this.trailing,
});
final DanmakuMatchCandidate candidate;
final bool selected;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: selected ? DanmakuTokens.selectedFill : Colors.transparent,
borderRadius: BorderRadius.circular(DanmakuTokens.radius),
border: selected
? Border.all(color: DanmakuTokens.accent.withValues(alpha: 0.5))
: null,
),
child: Row(
children: [
Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_off,
size: 15,
color: selected ? DanmakuTokens.accent : DanmakuTokens.textHint,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
candidate.animeTitle,
style: TextStyle(
color: selected
? DanmakuTokens.textPrimary
: DanmakuTokens.textSecondary,
fontSize: 12,
fontWeight: selected
? FontWeight.w600
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (candidate.episodeTitle.isNotEmpty)
Text(
candidate.episodeTitle,
style: const TextStyle(
color: DanmakuTokens.textHint,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
candidate.source.displayName,
style: const TextStyle(
color: DanmakuTokens.textFaint,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
),
),
);
}
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
class DanmakuTokens {
DanmakuTokens._();
static const Color accent = Colors.lightBlueAccent;
static const Color selectedFill = Colors.white12;
static const Color textPrimary = Colors.white;
static const Color textSecondary = Colors.white70;
static const Color textHint = Colors.white38;
static const Color textFaint = Colors.white30;
static const double radius = 6.0;
}
@@ -0,0 +1,461 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart';
import '../../../core/contracts/danmaku.dart';
import '../../../core/media/danmaku_episode_parser.dart';
import '../../../shared/utils/format_utils.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../services/danmaku/danmaku_session_notifier.dart';
part 'danmaku_search_dialog_view.dart';
const _kBg = Color(0xE8181818);
const _kDivider = Color(0x33FFFFFF);
const _kAccent = Color(0xFF4FA8FF);
enum _Phase {
idle,
searching,
results,
empty,
error,
episodesLoading,
episodes,
applying,
}
class DanmakuSearchDialog extends ConsumerStatefulWidget {
final String currentItemId;
final bool Function() isItemStillCurrent;
final String? initialQuery;
final List<DanmakuMatchCandidate> initialResults;
final int initialSelectedIndex;
final int? targetEpisode;
const DanmakuSearchDialog({
super.key,
required this.currentItemId,
required this.isItemStillCurrent,
this.initialQuery,
this.initialResults = const [],
this.initialSelectedIndex = -1,
this.targetEpisode,
});
@override
ConsumerState<DanmakuSearchDialog> createState() =>
_DanmakuSearchDialogState();
}
class _DanmakuSearchDialogState extends ConsumerState<DanmakuSearchDialog> {
DanmakuSessionNotifier get _notifier =>
ref.read(danmakuSessionProvider.notifier);
final TextEditingController _queryCtrl = TextEditingController();
final FocusNode _focusNode = FocusNode();
final ScrollController _episodesScrollCtrl = ScrollController();
final Map<String, List<DanmakuBangumiEpisode>> _episodeCache = {};
static const double _kEpisodeItemExtent = 68.0;
_Phase _phase = _Phase.idle;
List<DanmakuMatchCandidate> _results = const [];
DanmakuMatchCandidate? _activeMatch;
List<DanmakuBangumiEpisode> _episodes = const [];
String? _messageText;
bool _messageIsError = false;
int? _flashEpisodeId;
DanmakuSource? _flashSource;
int _searchSeq = 0;
int _episodeSeq = 0;
int _applySeq = 0;
Offset _transitionOffset = Offset.zero;
bool get _busy =>
_phase == _Phase.searching ||
_phase == _Phase.episodesLoading ||
_phase == _Phase.applying;
String _episodeCacheKey(DanmakuMatchCandidate match) =>
'${match.source.id}:${match.animeId}';
@override
void initState() {
super.initState();
final q = widget.initialQuery;
if (q != null && q.isNotEmpty) {
_queryCtrl.text = q;
_queryCtrl.selection = TextSelection.collapsed(offset: q.length);
}
if (widget.initialResults.isNotEmpty) {
_results = widget.initialResults;
_phase = _Phase.results;
final idx = widget.initialSelectedIndex;
if (idx >= 0 && idx < widget.initialResults.length) {
_flashEpisodeId = widget.initialResults[idx].episodeId;
_flashSource = widget.initialResults[idx].source;
}
}
}
@override
void dispose() {
_queryCtrl.dispose();
_focusNode.dispose();
_episodesScrollCtrl.dispose();
super.dispose();
}
int? _findTargetEpisodeIndex(List<DanmakuBangumiEpisode> episodes) {
final target = widget.targetEpisode;
if (target == null) return null;
for (var i = 0; i < episodes.length; i++) {
final n = extractEpisodeNumberFromTitle(episodes[i].episodeTitle);
if (n == target) return i;
}
return null;
}
void _scheduleEpisodeScroll(int targetIdx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_episodesScrollCtrl.hasClients) return;
final position = _episodesScrollCtrl.position;
final viewport = position.viewportDimension;
final desired =
targetIdx * _kEpisodeItemExtent -
(viewport - _kEpisodeItemExtent) / 2;
final clamped = desired.clamp(0.0, position.maxScrollExtent);
_episodesScrollCtrl.jumpTo(clamped);
});
}
void _dismiss() {
Navigator.of(context, rootNavigator: true).maybePop();
}
bool _ensureItemStillCurrent() {
if (widget.isItemStillCurrent()) return true;
_dismiss();
return false;
}
Future<void> _submit() async {
if (!_ensureItemStillCurrent()) return;
final query = _queryCtrl.text.trim();
if (query.isEmpty) {
setState(() {
_phase = _Phase.idle;
_messageText = '请输入番剧名';
_messageIsError = true;
_flashEpisodeId = null;
});
return;
}
final seq = ++_searchSeq;
_episodeSeq++;
_applySeq++;
_transitionOffset = Offset.zero;
setState(() {
_phase = _Phase.searching;
_activeMatch = null;
_episodes = const [];
_messageText = null;
_messageIsError = false;
_flashEpisodeId = null;
});
try {
final matches = await _notifier.manualSearch(query);
if (!mounted || seq != _searchSeq) return;
if (matches == null) {
_dismiss();
return;
}
setState(() {
_results = matches;
_phase = matches.isEmpty ? _Phase.empty : _Phase.results;
_messageText = null;
_messageIsError = false;
});
} catch (_) {
if (!mounted || seq != _searchSeq) return;
setState(() {
_phase = _Phase.error;
_messageText = '搜索失败,请检查弹幕服务地址';
_messageIsError = true;
});
}
}
Future<void> _openAnime(DanmakuMatchCandidate match) async {
if (_busy) return;
if (!_ensureItemStillCurrent()) return;
if (match.animeId <= 0) {
await _applyDirect(match);
return;
}
final cacheKey = _episodeCacheKey(match);
final cached = _episodeCache[cacheKey];
if (cached != null) {
if (cached.isEmpty) {
_transitionOffset = const Offset(-0.08, 0);
setState(() {
_phase = _Phase.results;
_activeMatch = null;
_episodes = const [];
_messageText = '该番剧未返回分集列表,可直接使用搜索结果"直接加载"';
_messageIsError = false;
_flashEpisodeId = null;
});
return;
}
if (cached.length == 1) {
_activeMatch = match;
_episodes = cached;
await _applyEpisode(match, cached.single);
return;
}
_transitionOffset = const Offset(0.08, 0);
final targetIdx = _findTargetEpisodeIndex(cached);
setState(() {
_activeMatch = match;
_episodes = cached;
_phase = _Phase.episodes;
_messageText = null;
_messageIsError = false;
_flashEpisodeId = targetIdx != null
? cached[targetIdx].episodeId
: null;
});
if (targetIdx != null) _scheduleEpisodeScroll(targetIdx);
return;
}
final seq = ++_episodeSeq;
_applySeq++;
_transitionOffset = const Offset(0.08, 0);
setState(() {
_activeMatch = match;
_episodes = const [];
_phase = _Phase.episodesLoading;
_messageText = null;
_messageIsError = false;
_flashEpisodeId = null;
});
try {
final episodes = await _notifier.listEpisodesForAnime(
match.source,
match.animeId,
);
if (!mounted || seq != _episodeSeq) return;
if (episodes == null) {
_dismiss();
return;
}
_episodeCache[cacheKey] = episodes;
if (episodes.isEmpty) {
_transitionOffset = const Offset(-0.08, 0);
setState(() {
_phase = _Phase.results;
_activeMatch = null;
_episodes = const [];
_messageText = '该番剧未返回分集列表,可直接使用搜索结果"直接加载"';
_messageIsError = false;
});
return;
}
if (episodes.length == 1) {
_activeMatch = match;
_episodes = episodes;
await _applyEpisode(match, episodes.single);
return;
}
_transitionOffset = const Offset(0.08, 0);
final targetIdx = _findTargetEpisodeIndex(episodes);
setState(() {
_episodes = episodes;
_phase = _Phase.episodes;
_messageText = null;
_messageIsError = false;
_flashEpisodeId = targetIdx != null
? episodes[targetIdx].episodeId
: null;
});
if (targetIdx != null) _scheduleEpisodeScroll(targetIdx);
} catch (_) {
if (!mounted || seq != _episodeSeq) return;
_transitionOffset = const Offset(-0.08, 0);
setState(() {
_phase = _Phase.results;
_activeMatch = null;
_episodes = const [];
_messageText = '加载分集失败,请重试';
_messageIsError = true;
_flashEpisodeId = null;
});
}
}
void _backToResults() {
_episodeSeq++;
_applySeq++;
_transitionOffset = const Offset(-0.08, 0);
setState(() {
_phase = _Phase.results;
_activeMatch = null;
_episodes = const [];
_flashEpisodeId = null;
});
}
Future<void> _applyEpisode(
DanmakuMatchCandidate match,
DanmakuBangumiEpisode episode,
) async {
if (!_ensureItemStillCurrent()) return;
final seq = ++_applySeq;
_searchSeq++;
_episodeSeq++;
_transitionOffset = Offset.zero;
setState(() {
_flashEpisodeId = episode.episodeId;
_phase = _Phase.applying;
_messageText = null;
_messageIsError = false;
});
await Future<void>.delayed(const Duration(milliseconds: 100));
if (!mounted || seq != _applySeq) return;
if (!_ensureItemStillCurrent()) return;
try {
await _notifier.applyManualSelection(
source: match.source,
episodeId: episode.episodeId,
animeId: match.animeId,
animeTitle: match.animeTitle,
episodeTitle: episode.episodeTitle,
);
if (!mounted || seq != _applySeq) return;
Navigator.of(context, rootNavigator: true).pop();
} catch (_) {
if (!mounted || seq != _applySeq) return;
setState(() {
_phase = _episodes.isEmpty ? _Phase.results : _Phase.episodes;
_messageText = '加载弹幕失败,请重试';
_messageIsError = true;
_flashEpisodeId = null;
});
}
}
Future<void> _applyDirect(DanmakuMatchCandidate match) async {
if (!_ensureItemStillCurrent()) return;
final seq = ++_applySeq;
_searchSeq++;
_episodeSeq++;
_transitionOffset = Offset.zero;
setState(() {
_flashEpisodeId = match.episodeId;
_flashSource = match.source;
_phase = _Phase.applying;
_messageText = null;
_messageIsError = false;
});
await Future<void>.delayed(const Duration(milliseconds: 100));
if (!mounted || seq != _applySeq) return;
if (!_ensureItemStillCurrent()) return;
try {
await _notifier.applyManualSelection(
source: match.source,
episodeId: match.episodeId,
animeId: match.animeId,
animeTitle: match.animeTitle,
episodeTitle: match.episodeTitle,
);
if (!mounted || seq != _applySeq) return;
Navigator.of(context, rootNavigator: true).pop();
} catch (_) {
if (!mounted || seq != _applySeq) return;
setState(() {
_phase = _Phase.results;
_messageText = '加载弹幕失败,请重试';
_messageIsError = true;
_flashEpisodeId = null;
_flashSource = null;
});
}
}
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
DismissIntent: CallbackAction<DismissIntent>(
onInvoke: (_) {
_dismiss();
return null;
},
),
},
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: _kBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _kDivider),
boxShadow: const [
BoxShadow(
color: Colors.black54,
blurRadius: 20,
offset: Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
const FDivider(style: .delta(color: _kDivider, padding: .value(EdgeInsets.zero))),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: _buildSearchField(),
),
const FDivider(style: .delta(color: _kDivider, padding: .value(EdgeInsets.zero))),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: _slideFade,
child: _buildBody(),
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,592 @@
part of 'danmaku_search_dialog.dart';
extension _DanmakuSearchDialogView on _DanmakuSearchDialogState {
Widget _slideFade(Widget child, Animation<double> animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: _transitionOffset,
end: Offset.zero,
).animate(curved),
child: child,
),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 10),
child: Row(
children: [
const Icon(Icons.search, color: Colors.white, size: 20),
const SizedBox(width: 8),
const Text(
'搜索弹幕源',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
onPressed: _dismiss,
icon: const Icon(Icons.close, color: Colors.white70),
tooltip: '关闭',
),
],
),
);
}
Widget _buildSearchField() {
return SizedBox(
height: 44,
child: TextField(
controller: _queryCtrl,
focusNode: _focusNode,
autofocus: true,
enabled: !_busy,
style: const TextStyle(color: Colors.white, fontSize: 13),
cursorColor: Colors.lightBlueAccent,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
filled: true,
fillColor: Colors.white.withValues(alpha: 0.08),
hintText: '输入番剧名后按 Enter 搜索',
hintStyle: const TextStyle(color: Colors.white38, fontSize: 13),
prefixIcon: const Icon(Icons.search, color: Colors.white54, size: 18),
suffixIcon: IconButton(
onPressed: _busy ? null : _submit,
icon: const Icon(Icons.arrow_forward, size: 18),
color: Colors.white70,
tooltip: '搜索',
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 0,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kDivider),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kDivider),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kAccent),
),
),
onSubmitted: (_) => _submit(),
),
);
}
Widget _buildBody() {
switch (_phase) {
case _Phase.idle:
return _buildStateBody(
key: const ValueKey('idle'),
icon: Icons.search,
title: '输入番剧名,按 Enter 搜索',
subtitle: '可手动选择具体集数',
showMessage: true,
);
case _Phase.searching:
return _buildLoadingBody(
key: const ValueKey('searching'),
text: '正在搜索…',
);
case _Phase.results:
return _buildResultsBody(
key: const ValueKey('results'),
disabled: false,
);
case _Phase.empty:
return _buildStateBody(
key: const ValueKey('empty'),
icon: Icons.search_off,
title: '未找到匹配的弹幕源',
subtitle: '换个剧名试试',
);
case _Phase.error:
return _buildStateBody(
key: const ValueKey('error'),
icon: Icons.error_outline,
title: _messageText ?? '搜索失败',
subtitle: '请检查弹幕服务地址',
);
case _Phase.episodesLoading:
return _buildEpisodesLoadingBody();
case _Phase.episodes:
return _buildEpisodesBody(
key: const ValueKey('episodes'),
disabled: false,
);
case _Phase.applying:
return _buildApplyingBody();
}
}
Widget _buildStateBody({
required Key key,
required IconData icon,
required String title,
String? subtitle,
bool showMessage = false,
}) {
return Center(
key: key,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white24, size: 32),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
if (showMessage && _messageText != null) ...[
const SizedBox(height: 16),
_buildMessageBanner(),
],
],
),
),
);
}
Widget _buildLoadingBody({required Key key, required String text}) {
return Center(
key: key,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppLoadingRing(size: 22, color: Colors.white),
const SizedBox(height: 12),
Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
);
}
Widget _buildResultsBody({required Key key, required bool disabled}) {
return Column(
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'搜索结果 ${_results.length}',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
if (_messageText != null) ...[
const SizedBox(height: 12),
_buildMessageBanner(),
],
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: _results.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (_, i) => _buildResultTile(_results[i], disabled),
),
),
],
);
}
Widget _buildEpisodesLoadingBody() {
return Column(
key: const ValueKey('episodes-loading'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEpisodesHeader(disabled: false),
const SizedBox(height: 16),
const Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppLoadingRing(size: 22, color: Colors.white),
SizedBox(height: 12),
Text(
'正在加载分集…',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
),
],
);
}
Widget _buildEpisodesBody({
required Key key,
required bool disabled,
bool useScrollController = true,
}) {
return Column(
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEpisodesHeader(disabled: disabled),
if (_messageText != null) ...[
const SizedBox(height: 12),
_buildMessageBanner(),
],
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
controller: useScrollController ? _episodesScrollCtrl : null,
padding: EdgeInsets.zero,
itemCount: _episodes.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (_, i) => _buildEpisodeTile(_episodes[i], i, disabled),
),
),
],
);
}
Widget _buildApplyingBody() {
final base = _activeMatch != null && _episodes.isNotEmpty
? _buildEpisodesBody(
key: const ValueKey('applying-episodes'),
disabled: true,
useScrollController: false,
)
: _buildResultsBody(
key: const ValueKey('applying-results'),
disabled: true,
);
return Stack(
key: const ValueKey('applying'),
fit: StackFit.expand,
children: [
base,
Positioned.fill(
child: ColoredBox(color: Colors.black.withValues(alpha: 0.18)),
),
const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppLoadingRing(size: 22, color: Colors.white),
SizedBox(height: 12),
Text(
'正在加载弹幕…',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
],
);
}
Widget _buildEpisodesHeader({required bool disabled}) {
return Row(
children: [
IconButton(
onPressed: disabled ? null : _backToResults,
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_back, color: Colors.white70),
tooltip: '返回',
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_activeMatch?.animeTitle ?? '选择分集',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
const Text(
'选择要加载的集数',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
],
);
}
Widget _buildMessageBanner() {
final color = _messageIsError ? Colors.redAccent : _kAccent;
final icon = _messageIsError ? Icons.error_outline : Icons.info_outline;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
_messageText ?? '',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
),
);
}
Widget _buildResultTile(DanmakuMatchCandidate match, bool disabled) {
final highlighted =
_flashEpisodeId == match.episodeId &&
_flashSource?.id == match.source.id;
final canDrill = match.animeId > 0;
final subtitle = match.episodeTitle.isNotEmpty
? match.episodeTitle
: canDrill
? '进入分集列表后选择具体集数'
: '直接加载当前搜索结果';
final showTargetHint = widget.targetEpisode != null && canDrill;
return InkWell(
onTap: disabled
? null
: () {
if (canDrill) {
_openAnime(match);
} else {
_applyDirect(match);
}
},
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: highlighted
? Colors.white12
: Colors.white.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: highlighted
? Colors.lightBlueAccent.withValues(alpha: 0.7)
: Colors.white.withValues(alpha: 0.08),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
match.animeTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
height: 1.35,
),
),
const SizedBox(height: 4),
Text(
match.source.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
if (showTargetHint) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.adjust,
size: 12,
color: Colors.white38,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'目标:${formatEpisodeNumber(widget.targetEpisode)}(点击选择)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
),
),
),
],
),
],
],
),
),
const SizedBox(width: 12),
_buildResultAffordance(canDrill: canDrill),
],
),
),
);
}
Widget _buildResultAffordance({required bool canDrill}) {
if (canDrill) {
return const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('选择集数', style: TextStyle(color: Colors.white54, fontSize: 11)),
SizedBox(width: 2),
Icon(Icons.chevron_right, size: 16, color: Colors.white54),
],
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _kAccent.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _kAccent.withValues(alpha: 0.4)),
),
child: const Text(
'直接加载',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildEpisodeTile(
DanmakuBangumiEpisode episode,
int index,
bool disabled,
) {
final highlighted = _flashEpisodeId == episode.episodeId;
final epNum = episode.episodeNumber.trim();
final episodeLabel = epNum.isEmpty
? formatEpisodeNumber(index + 1)!
: '$epNum集';
return InkWell(
onTap: disabled || _activeMatch == null
? null
: () => _applyEpisode(_activeMatch!, episode),
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: highlighted
? Colors.white12
: Colors.white.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: highlighted
? Colors.lightBlueAccent.withValues(alpha: 0.7)
: Colors.white.withValues(alpha: 0.08),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episodeLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
if (episode.episodeTitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episode.episodeTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
height: 1.35,
),
),
],
],
),
),
const SizedBox(width: 12),
const Text(
'点击加载',
style: TextStyle(color: Colors.white54, fontSize: 11),
),
],
),
),
);
}
}
@@ -0,0 +1,409 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/contracts/danmaku.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../../../core/media/danmaku_heatmap.dart';
import '../../../providers/danmaku_settings_provider.dart';
import '../../../shared/utils/format_utils.dart';
import '../services/danmaku/danmaku_session_notifier.dart';
const double _kSeekBarHeight = 28.0;
class DebouncedSeekBar extends ConsumerStatefulWidget {
final PlayerEngine player;
final void Function(Duration?)? onDragChanged;
const DebouncedSeekBar({super.key, required this.player, this.onDragChanged});
@override
ConsumerState<DebouncedSeekBar> createState() => _DebouncedSeekBarState();
}
class _DebouncedSeekBarState extends ConsumerState<DebouncedSeekBar> {
StreamSubscription<Duration>? _posSub;
StreamSubscription<Duration>? _durSub;
StreamSubscription<Duration>? _bufSub;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
Duration _buffer = Duration.zero;
bool _dragging = false;
double _dragFraction = 0.0;
bool _hovering = false;
double? _hoverFraction;
List<DanmakuComment>? _cachedComments;
double? _cachedDurationSec;
DanmakuHeatmapData? _cachedHeatmap;
PlayerEngine get _player => widget.player;
@override
void initState() {
super.initState();
_position = _player.state.position;
_duration = _player.state.duration;
_buffer = _player.state.buffer;
_subscribeStreams();
}
@override
void didUpdateWidget(covariant DebouncedSeekBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
unawaited(_posSub?.cancel());
unawaited(_durSub?.cancel());
unawaited(_bufSub?.cancel());
_position = _player.state.position;
_duration = _player.state.duration;
_buffer = _player.state.buffer;
_subscribeStreams();
}
}
void _subscribeStreams() {
_posSub = _player.stream.position.listen((p) {
if (!_dragging && mounted) setState(() => _position = p);
});
_durSub = _player.stream.duration.listen((d) {
if (mounted) setState(() => _duration = d);
});
_bufSub = _player.stream.buffer.listen((b) {
if (mounted) setState(() => _buffer = b);
});
}
@override
void dispose() {
_posSub?.cancel();
_durSub?.cancel();
_bufSub?.cancel();
super.dispose();
}
double _fraction(Duration d) {
final total = _duration.inMilliseconds;
if (total <= 0) return 0.0;
return (d.inMilliseconds / total).clamp(0.0, 1.0);
}
void _onDown(PointerDownEvent e, double width) {
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
setState(() {
_dragging = true;
_dragFraction = fraction;
});
widget.onDragChanged?.call(_duration * fraction);
}
void _onMove(PointerMoveEvent e, double width) {
if (!_dragging) return;
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
setState(() {
_dragFraction = fraction;
});
widget.onDragChanged?.call(_duration * fraction);
}
void _onUp(PointerUpEvent e, double width) {
if (!_dragging) return;
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
final target = _duration * fraction;
unawaited(_player.seek(target));
setState(() {
_dragging = false;
_position = target;
_hovering = false;
_hoverFraction = null;
});
widget.onDragChanged?.call(null);
}
void _onCancel(PointerCancelEvent e) {
if (!_dragging) return;
setState(() {
_dragging = false;
_hovering = false;
_hoverFraction = null;
});
widget.onDragChanged?.call(null);
}
List<DanmakuComment>? _watchDanmakuComments() {
final state = ref.watch(danmakuSessionProvider).value;
if (state is DanmakuSessionMatched &&
state.commentStatus == DanmakuCommentStatus.ready) {
return state.comments;
}
return null;
}
DanmakuHeatmapData? _resolveHeatmap(
List<DanmakuComment>? comments,
double durationSec,
bool enabled,
) {
if (!enabled || comments == null) {
_cachedComments = null;
_cachedDurationSec = null;
_cachedHeatmap = null;
return null;
}
if (identical(comments, _cachedComments) &&
_cachedDurationSec == durationSec) {
return _cachedHeatmap;
}
final h = computeHeatmap(comments: comments, durationSec: durationSec);
_cachedComments = comments;
_cachedDurationSec = durationSec;
_cachedHeatmap = h;
return h;
}
@override
Widget build(BuildContext context) {
final posFrac = _dragging ? _dragFraction : _fraction(_position);
final bufFrac = _fraction(_buffer);
final active = _hovering || _dragging;
final showBubble = _dragging || (_hovering && _hoverFraction != null);
final bubbleFrac = _dragging ? _dragFraction : (_hoverFraction ?? 0.0);
final bubbleTime = _duration * bubbleFrac;
final heatmapEnabled = ref.watch(
danmakuSettingsProvider.select(
(v) => v.value?.panelSettings.heatmapEnabled ?? true,
),
);
final comments = heatmapEnabled ? _watchDanmakuComments() : null;
final durationSec = _duration.inMilliseconds / 1000.0;
final heatmap = _resolveHeatmap(comments, durationSec, heatmapEnabled);
final heatmapVisible = heatmap?.visible == true;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
const bubbleWidth = 60.0;
const bubbleHalfWidth = bubbleWidth / 2;
final bubbleLeft = showBubble
? (w * bubbleFrac - bubbleHalfWidth).clamp(0.0, w - bubbleWidth)
: 0.0;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovering = true),
onHover: (event) {
final frac = (event.localPosition.dx / w).clamp(0.0, 1.0);
setState(() => _hoverFraction = frac);
},
onExit: (_) {
if (!_dragging) {
setState(() {
_hovering = false;
_hoverFraction = null;
});
}
},
child: Stack(
clipBehavior: Clip.none,
children: [
Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (e) => _onDown(e, w),
onPointerMove: (e) => _onMove(e, w),
onPointerUp: (e) => _onUp(e, w),
onPointerCancel: _onCancel,
child: SizedBox(
height: _kSeekBarHeight,
child: CustomPaint(
size: Size(w, _kSeekBarHeight),
painter: _SeekBarPainter(
position: posFrac,
buffer: bufFrac,
active: active,
heatmap: heatmapVisible ? heatmap : null,
),
),
),
),
if (showBubble)
Positioned(
bottom: _kSeekBarHeight + 6,
left: bubbleLeft,
child: IgnorePointer(
child: Container(
width: bubbleWidth,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: const Color(0xCC000000),
borderRadius: BorderRadius.circular(4),
),
child: Text(
formatDurationClock(bubbleTime),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
),
),
],
),
);
},
),
);
}
}
class _SeekBarPainter extends CustomPainter {
final double position;
final double buffer;
final bool active;
final DanmakuHeatmapData? heatmap;
const _SeekBarPainter({
required this.position,
required this.buffer,
required this.active,
this.heatmap,
});
static const double _heatmapBaselineY = 11.0;
static const double _heatmapMaxHeight = 10.0;
static const double _heatmapPixelsPerBucket = 4.0;
static const double _heatmapGamma = 0.6;
static const Color _heatmapLowColor = Color(0xCC82B1FF);
static const Color _heatmapHighColor = Color(0xFF2962FF);
@override
void paint(Canvas canvas, Size size) {
_paintHeatmap(canvas, size);
_paintSeekBar(canvas, size);
}
void _paintHeatmap(Canvas canvas, Size size) {
final h = heatmap;
if (h == null || h.buckets.isEmpty || size.width <= 0) return;
final targetCount = (size.width / _heatmapPixelsPerBucket).floor().clamp(
1,
h.buckets.length,
);
final samples = downsampleHeatmap(h.buckets, targetCount);
if (samples.isEmpty) return;
final points = <Offset>[];
var peak = 0.0;
for (var i = 0; i < samples.length; i++) {
final shaped = math
.pow(samples[i].clamp(0.0, 1.0), _heatmapGamma)
.toDouble();
if (shaped > peak) peak = shaped;
final cx = samples.length == 1
? size.width / 2
: i / (samples.length - 1) * size.width;
points.add(Offset(cx, _heatmapBaselineY - _heatmapMaxHeight * shaped));
}
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
for (var i = 1; i < points.length; i++) {
linePath.lineTo(points[i].dx, points[i].dy);
}
final color = Color.lerp(_heatmapLowColor, _heatmapHighColor, peak)!;
final fillPath = Path.from(linePath)
..lineTo(points.last.dx, _heatmapBaselineY)
..lineTo(points.first.dx, _heatmapBaselineY)
..close();
canvas.drawPath(
fillPath,
Paint()
..style = PaintingStyle.fill
..color = color.withValues(alpha: color.a * 0.18),
);
canvas.drawPath(
linePath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.4
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..color = color,
);
}
void _paintSeekBar(Canvas canvas, Size size) {
final trackH = active ? 5.0 : 3.0;
final cy = size.height / 2;
final top = cy - trackH / 2;
final bottom = cy + trackH / 2;
final r = Radius.circular(trackH / 2);
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTRB(0, top, size.width, bottom), r),
Paint()..color = const Color(0x3DFFFFFF),
);
if (buffer > 0) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(0, top, size.width * buffer, bottom),
r,
),
Paint()..color = const Color(0x3DFFFFFF),
);
}
if (position > 0) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(0, top, size.width * position, bottom),
r,
),
Paint()..color = const Color(0xFFFF0000),
);
}
if (active) {
canvas.drawCircle(
Offset(size.width * position, cy),
6.4,
Paint()..color = const Color(0xFFFF0000),
);
}
}
@override
bool shouldRepaint(_SeekBarPainter old) =>
position != old.position ||
buffer != old.buffer ||
active != old.active ||
!identical(heatmap, old.heatmap);
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'panel_toggle_affordance.dart';
class FitButton extends StatelessWidget {
final BoxFit currentFit;
final bool isPanelOpen;
final VoidCallback onTogglePanel;
const FitButton({
super.key,
required this.currentFit,
required this.isPanelOpen,
required this.onTogglePanel,
});
static const options = [
(fit: BoxFit.contain, label: '适应屏幕', subtitle: 'Contain'),
(fit: BoxFit.fill, label: '拉伸', subtitle: 'Stretch'),
(fit: BoxFit.cover, label: '裁剪', subtitle: 'Crop'),
];
@override
Widget build(BuildContext context) {
return PanelToggleAffordance(
isOpen: isPanelOpen,
onTap: onTogglePanel,
tooltip: '画面比例',
child: const Icon(Icons.aspect_ratio, color: Colors.white, size: 28),
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../../../shared/utils/format_utils.dart';
import '../services/network_speed_monitor.dart';
class NetworkSpeedIndicator extends StatelessWidget {
const NetworkSpeedIndicator({super.key, required this.monitor});
final NetworkSpeedMonitor monitor;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: monitor.available,
builder: (context, available, _) {
return ValueListenableBuilder<double>(
valueListenable: monitor.speed,
builder: (context, speed, _) {
final speedText = available ? formatDownloadSpeed(speed) : '--';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Text(
speedText,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
},
);
},
);
}
}
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
class PanelToggleAffordance extends StatefulWidget {
static const double _radius = 8;
static const Color _activeFill = Color(0x2EFFFFFF);
static const Duration _openDuration = Duration(milliseconds: 220);
static const Duration _closeDuration = Duration(milliseconds: 130);
final bool isOpen;
final VoidCallback onTap;
final String? tooltip;
final EdgeInsets padding;
final Widget child;
const PanelToggleAffordance({
super.key,
required this.isOpen,
required this.onTap,
required this.child,
this.tooltip,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
});
@override
State<PanelToggleAffordance> createState() => _PanelToggleAffordanceState();
}
class _PanelToggleAffordanceState extends State<PanelToggleAffordance>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _t;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: PanelToggleAffordance._openDuration,
reverseDuration: PanelToggleAffordance._closeDuration,
value: widget.isOpen ? 1.0 : 0.0,
);
_t = CurvedAnimation(
parent: _ctrl,
curve: Curves.easeInOutCubic,
reverseCurve: Curves.easeInCubic,
);
}
@override
void didUpdateWidget(covariant PanelToggleAffordance oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isOpen != widget.isOpen) {
if (widget.isOpen) {
_ctrl.forward();
} else {
_ctrl.reverse();
}
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final inkWell = Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(PanelToggleAffordance._radius),
onTap: widget.onTap,
child: AnimatedBuilder(
animation: _t,
builder: (context, child) {
final fill = Color.lerp(
Colors.transparent,
PanelToggleAffordance._activeFill,
_t.value,
)!;
return Container(
padding: widget.padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
PanelToggleAffordance._radius,
),
color: fill,
),
child: child,
);
},
child: widget.child,
),
),
);
final t = widget.tooltip;
if (t == null) return inkWell;
return Tooltip(message: t, child: inkWell);
}
}
@@ -0,0 +1,199 @@
import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:forui/forui.dart';
import '../../../../core/contracts/danmaku.dart';
import '../../../../providers/danmaku_settings_provider.dart';
import '../../../../shared/widgets/app_loading_ring.dart';
import '../danmaku/danmaku_match_tile.dart';
import '../player_drawer_panel.dart';
import 'panel_setting_rows.dart';
part 'danmaku_panel_body_view.dart';
extension DanmakuPanelSettingsCanvas on DanmakuPanelSettings {
DanmakuOption toDanmakuOption({double? screenHeight}) {
final double effectiveArea;
if (maxRows > 0 && screenHeight != null && screenHeight > 0) {
final lineHeightPx = _measureDanmakuLineHeight(
fontSize: fontSize,
lineHeight: lineHeight,
fontFamily: fontFamily,
);
final computed = (maxRows + 0.5) * lineHeightPx / screenHeight;
effectiveArea = computed.clamp(0.05, 1.0);
} else {
effectiveArea = area;
}
return DanmakuOption(
opacity: 1.0,
area: effectiveArea,
fontSize: fontSize,
fontWeight: bold ? 7 : 4,
fontFamily: fontFamily,
duration: 10.0 / speed.clamp(0.1, 5.0),
lineHeight: lineHeight,
hideTop: fixedToScroll ? true : !showTop,
hideScroll: !showScroll,
hideBottom: fixedToScroll ? true : !showBottom,
strokeWidth: strokeWidth,
safeArea: true,
massiveMode: false,
);
}
}
double _measureDanmakuLineHeight({
required double fontSize,
required double lineHeight,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: '弹幕',
style: TextStyle(
fontSize: fontSize,
height: lineHeight,
fontFamily: fontFamily,
),
),
textDirection: TextDirection.ltr,
)..layout();
return painter.height;
}
enum _DanmakuView { main, settings, filter, search }
class DanmakuPanelBody extends StatefulWidget {
final DanmakuPanelSettings settings;
final ValueChanged<DanmakuPanelSettings> onChanged;
final DanmakuSessionState sessionState;
final bool danmakuGlobalEnabled;
final bool danmakuSourceConfigured;
final List<DanmakuMatchCandidate> matches;
final int selectedMatchIndex;
final ValueChanged<int> onMatchSelected;
final VoidCallback onOpenSearch;
final bool isLoading;
final int commentCount;
final List<String> blockedKeywords;
final ValueChanged<String> onAddKeyword;
final ValueChanged<String> onRemoveKeyword;
final VoidCallback? onRetry;
final VoidCallback? onGoToSettings;
final VoidCallback? onToggleDanmakuEnabled;
const DanmakuPanelBody({
super.key,
required this.settings,
required this.onChanged,
required this.sessionState,
this.danmakuGlobalEnabled = true,
this.danmakuSourceConfigured = true,
this.matches = const [],
this.selectedMatchIndex = -1,
required this.onMatchSelected,
required this.onOpenSearch,
this.isLoading = false,
this.commentCount = 0,
this.blockedKeywords = const [],
required this.onAddKeyword,
required this.onRemoveKeyword,
this.onRetry,
this.onGoToSettings,
this.onToggleDanmakuEnabled,
});
@override
State<DanmakuPanelBody> createState() => _DanmakuPanelBodyState();
}
class _DanmakuPanelBodyState extends State<DanmakuPanelBody> {
_DanmakuView _view = _DanmakuView.main;
bool _navForward = true;
DanmakuPanelSettings get s => widget.settings;
void _emit(DanmakuPanelSettings next) => widget.onChanged(next);
void _navigateTo(_DanmakuView view) {
if (view == _view) return;
setState(() {
_navForward = view != _DanmakuView.main;
_view = view;
});
}
void _back() => _navigateTo(_DanmakuView.main);
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final isEntering =
(child.key as ValueKey<_DanmakuView>).value == _view;
final enterOffset = _navForward
? const Offset(0.2, 0.0)
: const Offset(-0.2, 0.0);
final exitOffset = _navForward
? const Offset(-0.15, 0.0)
: const Offset(0.15, 0.0);
final slide = isEntering
? Tween<Offset>(
begin: enterOffset,
end: Offset.zero,
).animate(animation)
: Tween<Offset>(
begin: Offset.zero,
end: exitOffset,
).animate(animation);
return ClipRect(
child: SlideTransition(
position: slide,
child: FadeTransition(opacity: animation, child: child),
),
);
},
child: KeyedSubtree(
key: ValueKey<_DanmakuView>(_view),
child: _buildView(),
),
),
);
}
Widget _buildView() {
if (_view == _DanmakuView.main) {
final emptyState = _buildEmptyState();
if (emptyState != null) return emptyState;
}
return switch (_view) {
_DanmakuView.main => _buildMainMenu(),
_DanmakuView.settings => _buildSettingsDrawer(),
_DanmakuView.filter => _buildFilterDrawer(),
_DanmakuView.search => _buildSearchDrawer(),
};
}
final TextEditingController _keywordController = TextEditingController();
@override
void dispose() {
_keywordController.dispose();
super.dispose();
}
}
@@ -0,0 +1,745 @@
part of 'danmaku_panel_body.dart';
extension _DanmakuPanelBodyView on _DanmakuPanelBodyState {
Widget? _buildEmptyState() {
final state = widget.sessionState;
if (state is DanmakuSessionIdle) {
if (!widget.danmakuSourceConfigured) {
return _emptyStatePage(
icon: Icons.subtitles,
title: '弹幕未配置',
description: '请先在设置中配置弹幕数据源',
actions: [
if (widget.onGoToSettings != null)
_emptyStateCta('前往设置', widget.onGoToSettings!),
],
);
}
if (widget.danmakuGlobalEnabled) {
return _emptyStatePage(
icon: Icons.subtitles,
title: '正在搜索弹幕…',
showSpinner: true,
);
}
return _emptyStatePage(
icon: Icons.subtitles,
title: '弹幕已关闭',
description: '开启后将自动匹配弹幕',
actions: [
if (widget.onToggleDanmakuEnabled != null)
_emptyStateCta('开启弹幕', widget.onToggleDanmakuEnabled!),
],
);
}
if (state is DanmakuSessionMatching ||
(state is DanmakuSessionMatched &&
state.commentStatus == DanmakuCommentStatus.loading)) {
return _emptyStatePage(
icon: Icons.subtitles,
title: '正在搜索弹幕…',
showSpinner: true,
);
}
if (state is DanmakuSessionEmpty) {
return _emptyStatePage(
icon: Icons.search_off,
title: '未找到匹配弹幕',
description: '可尝试手动搜索匹配',
actions: [_emptyStateCta('搜索弹幕', widget.onOpenSearch)],
);
}
if (state is DanmakuSessionFailed) {
return _emptyStatePage(
icon: Icons.error_outline,
title: '加载失败',
description: '弹幕匹配请求出错',
actions: [
if (widget.onRetry != null) _emptyStateCta('重试', widget.onRetry!),
_emptyStateCta('搜索弹幕', widget.onOpenSearch),
],
);
}
return null;
}
Widget _emptyStatePage({
required IconData icon,
required String title,
String? description,
List<Widget> actions = const [],
bool showSpinner = false,
}) {
return SingleChildScrollView(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showSpinner)
Stack(
alignment: Alignment.center,
children: [
Icon(icon, color: const Color(0x61FFFFFF), size: 40),
const AppLoadingRing(size: 52, color: Color(0x99FFFFFF)),
],
)
else
Icon(icon, color: const Color(0x61FFFFFF), size: 40),
const SizedBox(height: 12),
Text(
title,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (description != null) ...[
const SizedBox(height: 4),
Text(
description,
style: const TextStyle(color: Colors.white38, fontSize: 12),
textAlign: TextAlign.center,
),
],
if (actions.isNotEmpty) ...[
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
actions[i],
],
],
),
],
],
),
),
);
}
Widget _emptyStateCta(String label, VoidCallback onTap) {
return TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.white12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: Text(label, style: const TextStyle(fontSize: 12)),
);
}
Widget _buildMainMenu() {
final hasMatch =
widget.selectedMatchIndex >= 0 &&
widget.selectedMatchIndex < widget.matches.length;
final matchTitle = hasMatch
? widget.matches[widget.selectedMatchIndex].animeTitle
: null;
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionHeader('弹幕数据'),
_menuItem(
icon: Icons.search,
label: '搜索弹幕',
hasCheck: hasMatch,
subtitle: matchTitle,
onTap: widget.onOpenSearch,
),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsetsDirectional.only(start: 16)),
),
),
_sectionHeader('操作'),
_menuItem(
icon: Icons.tune,
label: '显示设置',
onTap: () => _navigateTo(_DanmakuView.settings),
),
_menuItem(
icon: Icons.filter_alt_outlined,
label: '过滤关键词',
badge: widget.blockedKeywords.isNotEmpty
? '${widget.blockedKeywords.length}'
: null,
onTap: () => _navigateTo(_DanmakuView.filter),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: PanelToggleRow(
label: '高能进度条',
value: s.heatmapEnabled,
onChanged: (v) => _emit(s.copyWith(heatmapEnabled: v)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: PanelToggleRow(
label: '弹幕',
value: s.enabled,
onChanged: (v) => _emit(s.copyWith(enabled: v)),
),
),
const SizedBox(height: 4),
],
),
);
}
Widget _sectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Text(
title,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.4,
),
),
);
}
Widget _menuItem({
required IconData icon,
required String label,
String? subtitle,
String? badge,
bool hasCheck = false,
bool showArrow = true,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
if (hasCheck)
const Icon(Icons.check, color: Colors.white70, size: 16)
else
Icon(icon, color: Colors.white54, size: 16),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
if (subtitle != null) ...[
const SizedBox(height: 1),
Text(
subtitle,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (badge != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(8),
),
child: Text(
badge,
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
),
const SizedBox(width: 4),
],
if (showArrow)
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
],
),
),
);
}
Widget _buildSettingsDrawer() {
return PlayerDrawerPanel(
title: '显示设置',
onBack: _back,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PanelToggleRow(
label: '固定弹幕转滚动',
value: s.fixedToScroll,
onChanged: (v) => _emit(s.copyWith(fixedToScroll: v)),
),
PanelStepperRow(
label: '弹幕行数',
display: s.maxRows == 0 ? '自动' : '${s.maxRows}',
onDecrement: s.maxRows > 0
? () => _emit(s.copyWith(maxRows: s.maxRows - 1))
: null,
onIncrement: s.maxRows < 10
? () => _emit(s.copyWith(maxRows: s.maxRows + 1))
: null,
),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.zero),
),
),
const SizedBox(height: 6),
PanelSliderRow(
label: '延迟显示',
value: s.offset,
min: -10.0,
max: 10.0,
step: 0.5,
display:
'${s.offset >= 0 ? '+' : ''}${s.offset.toStringAsFixed(1)}s',
onChanged: (v) => _emit(s.copyWith(offset: _round(v, -10.0, 10.0))),
),
PanelSliderRow(
label: '行间距',
value: s.lineHeight,
min: 1.0,
max: 3.0,
step: 0.1,
display: s.lineHeight.toStringAsFixed(1),
onChanged: (v) =>
_emit(s.copyWith(lineHeight: _round(v, 1.0, 3.0))),
),
PanelSliderRow(
label: '基础速度',
value: s.speed,
min: 0.25,
max: 2.0,
step: 0.25,
display: '${s.speed.toStringAsFixed(2)}x',
onChanged: (v) => _emit(s.copyWith(speed: v.clamp(0.25, 2.0))),
),
PanelSliderRow(
label: '字号大小',
value: s.fontSize,
min: 12,
max: 40,
step: 2,
display: s.fontSize.round().toString(),
onChanged: (v) => _emit(s.copyWith(fontSize: _round(v, 12, 40))),
),
PanelToggleRow(
label: '粗体',
value: s.bold,
onChanged: (v) => _emit(s.copyWith(bold: v)),
),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.zero),
),
),
const SizedBox(height: 6),
PanelSliderRow(
label: '不透明度',
value: s.opacity,
min: 0.1,
max: 1.0,
step: 0.1,
display: '${(s.opacity * 100).round()}%',
onChanged: (v) => _emit(s.copyWith(opacity: _round(v, 0.1, 1.0))),
),
PanelSliderRow(
label: '描边大小',
value: s.strokeWidth,
min: 0.0,
max: 3.0,
step: 0.5,
display: s.strokeWidth.toStringAsFixed(1),
onChanged: (v) =>
_emit(s.copyWith(strokeWidth: _round(v, 0.0, 3.0))),
),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.zero),
),
),
_fontFamilyRow(),
const SizedBox(height: 8),
_resetButton(),
const SizedBox(height: 4),
],
),
);
}
static double _round(double v, double min, double max) =>
(v.clamp(min, max) * 10).round() / 10;
static const _fontPresets = [
(label: '系统默认', value: null),
(label: 'PingFang SC', value: 'PingFang SC'),
(label: 'Heiti SC', value: 'Heiti SC'),
(label: 'Arial', value: 'Arial'),
];
Widget _fontFamilyRow() {
final current = s.fontFamily;
final label =
_fontPresets.where((p) => p.value == current).firstOrNull?.label ??
'系统默认';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
const Expanded(
child: Text(
'字体',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
),
GestureDetector(
onTap: _showFontPicker,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(
color: Colors.lightBlueAccent,
fontSize: 13,
),
),
const SizedBox(width: 2),
const Icon(
Icons.unfold_more,
color: Colors.lightBlueAccent,
size: 14,
),
],
),
),
],
),
);
}
void _showFontPicker() {
showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF252525),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(14)),
),
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
'选择字体',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
for (final p in _fontPresets)
ListTile(
title: Text(
p.label,
style: TextStyle(
color: Colors.white,
fontFamily: p.value,
fontSize: 14,
),
),
trailing: s.fontFamily == p.value
? const Icon(Icons.check, color: Colors.white70, size: 18)
: null,
onTap: () {
Navigator.pop(context);
_emit(s.copyWith(fontFamily: p.value));
},
),
const SizedBox(height: 16),
],
),
);
}
Widget _resetButton() {
return SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => _emit(defaultDanmakuPanelSettings()),
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
backgroundColor: Colors.white10,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 10),
),
child: const Text('重置', style: TextStyle(fontSize: 13)),
),
);
}
Widget _buildFilterDrawer() {
return PlayerDrawerPanel(
title: '弹幕过滤',
onBack: _back,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_keywordInput(),
const SizedBox(height: 8),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.zero),
),
),
const SizedBox(height: 4),
_keywordList(),
const SizedBox(height: 4),
],
),
);
}
Widget _keywordInput() {
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white12),
),
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(left: 10),
child: Icon(
Icons.filter_alt_outlined,
color: Colors.white38,
size: 16,
),
),
Expanded(
child: TextField(
controller: _keywordController,
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: const InputDecoration(
hintText: '输入要过滤的关键词',
hintStyle: TextStyle(color: Colors.white38, fontSize: 13),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
isDense: true,
),
onSubmitted: _addKeyword,
inputFormatters: [LengthLimitingTextInputFormatter(30)],
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 18),
color: Colors.white54,
padding: const EdgeInsets.only(right: 8),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => _addKeyword(_keywordController.text),
),
],
),
);
}
void _addKeyword(String raw) {
final kw = raw.trim();
if (kw.isEmpty) return;
if (widget.blockedKeywords.contains(kw)) {
_keywordController.clear();
return;
}
widget.onAddKeyword(kw);
_keywordController.clear();
}
Widget _keywordList() {
final keywords = widget.blockedKeywords;
if (keywords.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Text(
'暂无屏蔽关键词',
style: TextStyle(color: Colors.white38, fontSize: 12),
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final kw in keywords)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(
Icons.label_outline,
color: Colors.white38,
size: 14,
),
const SizedBox(width: 8),
Expanded(
child: Text(
kw,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
),
IconButton(
icon: const Icon(Icons.close, size: 14),
color: Colors.white38,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
onPressed: () => widget.onRemoveKeyword(kw),
),
],
),
),
],
);
}
Widget _buildSearchDrawer() {
return PlayerDrawerPanel(
title: '搜索弹幕',
onBack: _back,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_matchesContent(),
const SizedBox(height: 8),
_manualSearchTrigger(),
const SizedBox(height: 4),
],
),
);
}
Widget _matchesContent() {
if (widget.isLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppLoadingRing(size: 14, color: Colors.white54),
SizedBox(width: 8),
Text(
'正在匹配弹幕源…',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
);
}
final matches = widget.matches;
if (matches.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.white24, size: 14),
SizedBox(width: 6),
Expanded(
child: Text(
'未匹配到弹幕源,可手动搜索',
style: TextStyle(color: Colors.white38, fontSize: 12),
),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
'弹幕源 (${matches.length} 条)',
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
),
...List.generate(matches.length, (i) {
final m = matches[i];
final selected = i == widget.selectedMatchIndex;
return DanmakuMatchTile(
candidate: m,
selected: selected,
onTap: () => widget.onMatchSelected(i),
);
}),
],
);
}
Widget _manualSearchTrigger() {
return SizedBox(
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: widget.onOpenSearch,
style: OutlinedButton.styleFrom(
alignment: Alignment.centerLeft,
foregroundColor: Colors.white,
side: const BorderSide(color: Color(0x664FA8FF)),
backgroundColor: const Color(0x10FFFFFF),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
icon: const Icon(Icons.manage_search, size: 16),
label: const Row(
children: [
Expanded(
child: Text(
'搜索并手动选择分集',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
),
Icon(Icons.chevron_right, size: 14, color: Colors.white38),
],
),
),
);
}
}
@@ -0,0 +1,460 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/contracts/library.dart';
import '../../../../providers/di_providers.dart';
import '../../../../providers/tmdb_settings_provider.dart';
import '../../../../shared/utils/emby_ticks.dart';
import '../../../../shared/utils/format_utils.dart';
import '../../../../shared/utils/tmdb_episode_helpers.dart';
import '../../../../shared/utils/tmdb_key_utils.dart';
import '../../../../shared/widgets/app_inline_alert.dart';
import '../../../../shared/widgets/app_loading_ring.dart';
import '../../../../shared/widgets/episode_thumb.dart';
import '../../../../shared/widgets/season_selector.dart';
import '../player_panel_shell.dart';
const Color _kActiveColor = Color(0xFF4FA8FF);
class EpisodePanelBody extends ConsumerStatefulWidget {
final String seriesId;
final String? initialSeasonId;
final String currentEpisodeId;
final ValueChanged<EmbyRawItem> onEpisodeSelected;
final String serverId;
final String baseUrl;
final String token;
const EpisodePanelBody({
super.key,
required this.seriesId,
required this.currentEpisodeId,
required this.onEpisodeSelected,
required this.serverId,
required this.baseUrl,
required this.token,
this.initialSeasonId,
});
@override
ConsumerState<EpisodePanelBody> createState() => EpisodePanelBodyState();
}
class EpisodePanelBodyState extends ConsumerState<EpisodePanelBody> {
String? _selectedSeasonId;
bool _loadStarted = false;
@override
void initState() {
super.initState();
}
void ensureLoaded() {
if (_loadStarted) return;
_loadStarted = true;
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
if (!_loadStarted) {
return const SizedBox.shrink();
}
final serverId = widget.serverId;
if (serverId.isEmpty) return const SizedBox.shrink();
final seasonsAsync = ref.watch(
seriesSeasonsDataProvider((
serverId: serverId,
seriesId: widget.seriesId,
)),
);
return seasonsAsync.when(
loading: _loading,
error: (_, _) => _error(),
data: (seasons) {
if (seasons.isEmpty) return _empty('暂无可用季');
final effectiveSeasonId = _resolveSeasonId(seasons);
final effectiveSeason = seasons.firstWhere(
(s) => s.Id == effectiveSeasonId,
orElse: () => seasons.first,
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (seasons.length > 1) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Align(
alignment: Alignment.centerLeft,
child: SeasonSelector(
seasons: seasons,
selectedId: effectiveSeasonId,
onSelect: (seasonId) {
setState(() => _selectedSeasonId = seasonId);
},
),
),
),
const Divider(color: kPanelDivider, height: 1),
],
Flexible(
child: _EpisodeList(
seriesId: widget.seriesId,
seasonId: effectiveSeasonId,
seasonNumber: seasonNumberFrom(effectiveSeason),
currentEpisodeId: widget.currentEpisodeId,
serverId: widget.serverId,
baseUrl: widget.baseUrl,
token: widget.token,
onSelected: widget.onEpisodeSelected,
),
),
],
);
},
);
}
String _resolveSeasonId(List<EmbyRawSeason> seasons) {
final selected = _selectedSeasonId;
if (selected != null && seasons.any((s) => s.Id == selected)) {
return selected;
}
final initial = widget.initialSeasonId;
if (initial != null && seasons.any((s) => s.Id == initial)) {
return initial;
}
return seasons.first.Id;
}
Widget _loading() => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing(size: 22, color: Colors.white)),
);
Widget _empty(String text) => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 13),
),
),
);
Widget _error() => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: AppInlineAlert(message: '选集加载失败'),
);
}
class _EpisodeList extends ConsumerStatefulWidget {
final String seriesId;
final String seasonId;
final int? seasonNumber;
final String? currentEpisodeId;
final String serverId;
final String baseUrl;
final String token;
final ValueChanged<EmbyRawItem> onSelected;
const _EpisodeList({
required this.seriesId,
required this.seasonId,
required this.seasonNumber,
required this.currentEpisodeId,
required this.serverId,
required this.baseUrl,
required this.token,
required this.onSelected,
});
@override
ConsumerState<_EpisodeList> createState() => _EpisodeListState();
}
class _EpisodeListState extends ConsumerState<_EpisodeList> {
final ScrollController _scrollController = ScrollController();
bool _hasScrolledToActive = false;
static const _itemHeight = 76.0;
@override
void didUpdateWidget(covariant _EpisodeList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.seasonId != widget.seasonId ||
oldWidget.currentEpisodeId != widget.currentEpisodeId) {
_hasScrolledToActive = false;
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _scrollToActive(List<EmbyRawItem> episodes) {
if (_hasScrolledToActive || widget.currentEpisodeId == null) return;
final idx = episodes.indexWhere((e) => e.Id == widget.currentEpisodeId);
if (idx < 0) return;
_hasScrolledToActive = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) return;
final viewport = _scrollController.position.viewportDimension;
final target = idx * _itemHeight - (viewport - _itemHeight) / 2;
final clamped = target.clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(clamped);
});
}
@override
Widget build(BuildContext context) {
final serverId = widget.serverId;
final episodesAsync = ref.watch(
seriesEpisodesDataProvider((
serverId: serverId,
seriesId: widget.seriesId,
seasonId: widget.seasonId,
)),
);
final tmdbSettings = ref.watch(tmdbSettingsProvider).value;
final canRequest = tmdbSettings?.canRequest ?? false;
String tmdbSeriesId = '';
if (canRequest) {
final seriesItem = ref
.watch(
mediaDetailDataProvider((
serverId: serverId,
itemId: widget.seriesId,
)),
)
.value
?.base;
if (seriesItem != null) tmdbSeriesId = tmdbIdFromItem(seriesItem);
}
final canUseTmdbSeason =
canRequest && tmdbSeriesId.isNotEmpty && widget.seasonNumber != null;
final tmdbSeason = canUseTmdbSeason
? ref
.watch(
tmdbSeasonDetailProvider((
tmdbId: tmdbSeriesId,
seasonNumber: widget.seasonNumber!,
)),
)
.value
: null;
final tmdbStillUrls = tmdbStillUrlsByEpisode(
tmdbSeason,
tmdbSettings?.effectiveImageBaseUrl,
);
return episodesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing(size: 22, color: Colors.white)),
),
error: (_, _) => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: AppInlineAlert(message: '选集加载失败'),
),
data: (episodes) {
if (episodes.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'暂无分集',
style: TextStyle(color: Colors.white54, fontSize: 13),
),
),
);
}
_scrollToActive(episodes);
return ListView.separated(
controller: _scrollController,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: episodes.length,
separatorBuilder: (_, _) => const Divider(
color: kPanelDivider,
height: 1,
indent: 12,
endIndent: 12,
),
itemBuilder: (_, i) {
final ep = episodes[i];
final tmdbStillUrl = ep.IndexNumber == null
? null
: tmdbStillUrls[ep.IndexNumber];
return _EpisodeTile(
episode: ep,
seriesId: widget.seriesId,
tmdbStillUrl: tmdbStillUrl,
isActive: ep.Id == widget.currentEpisodeId,
baseUrl: widget.baseUrl,
token: widget.token,
onTap: () => widget.onSelected(ep),
);
},
);
},
);
}
}
class _EpisodeTile extends StatelessWidget {
final EmbyRawItem episode;
final String seriesId;
final String? tmdbStillUrl;
final bool isActive;
final String baseUrl;
final String token;
final VoidCallback onTap;
const _EpisodeTile({
required this.episode,
required this.seriesId,
required this.tmdbStillUrl,
required this.isActive,
required this.baseUrl,
required this.token,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final ep = formatEpisodeNumber(episode.IndexNumber) ?? '';
final runtime = episode.RunTimeTicks != null
? '${(episode.RunTimeTicks! / kTicksPerMinute).round()} 分钟'
: '';
final progress = playbackProgress(episode);
return InkWell(
onTap: isActive ? null : onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 96,
height: 54,
color: Colors.white12,
child: Stack(
fit: StackFit.expand,
children: [
EpisodeThumb(
episode: episode,
seriesId: seriesId,
baseUrl: baseUrl,
token: token,
tmdbStillUrl: tmdbStillUrl,
maxWidth: 240,
),
if (isActive)
Container(
color: Colors.black54,
alignment: Alignment.center,
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 28,
),
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(
_kActiveColor,
),
),
),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (ep.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: isActive ? _kActiveColor : Colors.white12,
borderRadius: BorderRadius.circular(3),
),
child: Text(
ep,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
],
Expanded(
child: Text(
episode.Name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isActive ? _kActiveColor : Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 4),
if (runtime.isNotEmpty)
Text(
runtime,
style: const TextStyle(
color: Colors.white54,
fontSize: 11,
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../player_panel_shell.dart' show panelDivider;
import 'selection_panel_entry.dart';
class PanelSelectionList<T> extends StatelessWidget {
final List<SelectionPanelEntry<T>> entries;
final ValueChanged<T> onSelected;
const PanelSelectionList({
super.key,
required this.entries,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in entries)
if (entry.isDivider) panelDivider() else _itemTile(entry),
],
),
);
}
Widget _itemTile(SelectionPanelEntry<T> entry) {
return GestureDetector(
onTap: () {
if (entry.value != null) onSelected(entry.value as T);
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
SizedBox(
width: 24,
child: entry.isActive
? const Icon(Icons.check, size: 16, color: Color(0xFF2196F3))
: null,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
entry.label!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: entry.isActive
? const Color(0xFF2196F3)
: Colors.white,
fontWeight: entry.isActive
? FontWeight.w600
: FontWeight.normal,
fontSize: 13,
),
),
if (entry.subtitle != null && entry.subtitle != entry.label)
Text(
entry.subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
),
),
],
),
),
if (entry.badge != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
child: Text(
entry.badge!,
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
),
],
if (entry.isLoading) ...[
const SizedBox(width: 8),
const SizedBox.square(
dimension: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Color(0xFF2196F3),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
const _kRowLabelStyle = TextStyle(color: Colors.white70, fontSize: 13);
const _kRowValueStyle = TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
);
const _kPanelSliderTheme = SliderThemeData(
trackHeight: 3,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: RoundSliderOverlayShape(overlayRadius: 14),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.white10,
);
class PanelGroupHeader extends StatelessWidget {
final String title;
const PanelGroupHeader({super.key, required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4, left: 2),
child: Text(
title,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
class PanelSliderRow extends StatelessWidget {
final String label;
final double value;
final double min;
final double max;
final double step;
final String display;
final ValueChanged<double> onChanged;
const PanelSliderRow({
super.key,
required this.label,
required this.value,
required this.min,
required this.max,
required this.step,
required this.display,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final divisions = ((max - min) / step).round();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: Text(label, style: _kRowLabelStyle)),
Text(display, style: _kRowValueStyle),
],
),
SizedBox(
height: 28,
child: SliderTheme(
data: _kPanelSliderTheme,
child: Slider(
value: value.clamp(min, max),
min: min,
max: max,
divisions: divisions,
padding: EdgeInsets.zero,
onChanged: onChanged,
),
),
),
],
),
);
}
}
class PanelToggleRow extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool> onChanged;
const PanelToggleRow({
super.key,
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(child: Text(label, style: _kRowLabelStyle)),
FSwitch(
value: value,
onChange: onChanged,
style: FSwitchStyleDelta.delta(
trackColor: FVariantsValueDelta.delta([
FVariantValueDeltaOperation.all(Colors.white12),
FVariantValueDeltaOperation.exact({
FSwitchVariantConstraint.selected,
}, Colors.white38),
]),
thumbColor: FVariantsValueDelta.delta([
FVariantValueDeltaOperation.all(Colors.white38),
FVariantValueDeltaOperation.exact({
FSwitchVariantConstraint.selected,
}, Colors.white),
]),
),
),
],
),
);
}
}
class PanelStepperRow extends StatelessWidget {
final String label;
final String display;
final VoidCallback? onDecrement;
final VoidCallback? onIncrement;
const PanelStepperRow({
super.key,
required this.label,
required this.display,
this.onDecrement,
this.onIncrement,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(child: Text(label, style: _kRowLabelStyle)),
_StepButton(icon: Icons.remove, onPressed: onDecrement),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 46,
child: Text(
display,
style: _kRowValueStyle,
textAlign: TextAlign.center,
),
),
),
_StepButton(icon: Icons.add, onPressed: onIncrement),
],
),
);
}
}
class _StepButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
const _StepButton({required this.icon, required this.onPressed});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 28,
child: FButton.icon(
variant: FButtonVariant.ghost,
size: FButtonSizeVariant.xs,
onPress: onPressed,
child: Icon(
icon,
size: 14,
color: onPressed != null ? Colors.white70 : Colors.white24,
),
),
);
}
}
@@ -0,0 +1,27 @@
class SelectionPanelEntry<T> {
final T? value;
final String? label;
final String? subtitle;
final String? badge;
final bool isActive;
final bool isLoading;
final bool isDivider;
const SelectionPanelEntry.item({
required T this.value,
required String this.label,
this.subtitle,
this.badge,
this.isActive = false,
this.isLoading = false,
}) : isDivider = false;
const SelectionPanelEntry.divider()
: value = null,
label = null,
subtitle = null,
badge = null,
isActive = false,
isLoading = false,
isDivider = true;
}
@@ -0,0 +1,513 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../../core/contracts/playback.dart';
import '../../../../core/infra/player_engine/player_engine.dart';
import '../../../../providers/subtitle_settings_provider.dart';
import '../player_drawer_panel.dart';
import '../player_panel_shell.dart';
import 'panel_selection_list.dart';
import 'panel_setting_rows.dart';
import 'selection_panel_entry.dart';
const _activeColor = Color(0xFF2196F3);
String? _translateLanguage(String? code) {
if (code == null || code.isEmpty) return null;
return kLanguageNames[code.toLowerCase()];
}
enum _SubtitlePanelView { subtitles, style }
class SubtitlePanelBody extends StatefulWidget {
final SubtitleStyleSettings subtitleSettings;
final ValueChanged<SubtitleStyleSettings> onSubtitleSettingsChanged;
final List<EmbySubtitleTrack> subtitles;
final ValueListenable<int?> activeSubtitleStreamIndex;
final ValueListenable<bool> subtitleLoading;
final void Function(int? streamIndex)? onSubtitleSelected;
const SubtitlePanelBody({
super.key,
required this.subtitleSettings,
required this.onSubtitleSettingsChanged,
required this.subtitles,
required this.activeSubtitleStreamIndex,
required this.subtitleLoading,
this.onSubtitleSelected,
});
@override
State<SubtitlePanelBody> createState() => _SubtitlePanelBodyState();
}
class _SubtitlePanelBodyState extends State<SubtitlePanelBody> {
_SubtitlePanelView _selectedView = _SubtitlePanelView.subtitles;
int? _activeStreamIndex;
bool _isSubtitleLoading = false;
@override
void initState() {
super.initState();
widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged);
widget.subtitleLoading.addListener(_onLoadingChanged);
_activeStreamIndex = widget.activeSubtitleStreamIndex.value;
_isSubtitleLoading = widget.subtitleLoading.value;
}
@override
void didUpdateWidget(covariant SubtitlePanelBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.activeSubtitleStreamIndex !=
widget.activeSubtitleStreamIndex) {
oldWidget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged);
widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged);
_activeStreamIndex = widget.activeSubtitleStreamIndex.value;
}
if (oldWidget.subtitleLoading != widget.subtitleLoading) {
oldWidget.subtitleLoading.removeListener(_onLoadingChanged);
widget.subtitleLoading.addListener(_onLoadingChanged);
_isSubtitleLoading = widget.subtitleLoading.value;
}
}
@override
void dispose() {
widget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged);
widget.subtitleLoading.removeListener(_onLoadingChanged);
super.dispose();
}
void _onActiveIndexChanged() {
if (!mounted) return;
setState(() => _activeStreamIndex = widget.activeSubtitleStreamIndex.value);
}
void _onLoadingChanged() {
if (!mounted) return;
setState(() => _isSubtitleLoading = widget.subtitleLoading.value);
}
void _selectSubtitle(int? streamIndex) {
if (!mounted) return;
setState(() => _activeStreamIndex = streamIndex);
widget.onSubtitleSelected?.call(streamIndex);
}
@override
Widget build(BuildContext context) {
if (Platform.isAndroid) return _buildAndroidBody();
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 150, child: _leftMenu()),
const FDivider(
axis: Axis.vertical,
style: .delta(color: kPanelDivider, padding: .value(EdgeInsets.zero)),
),
Expanded(child: _content()),
],
);
}
Widget _buildAndroidBody() {
if (_selectedView == _SubtitlePanelView.style) {
return PlayerDrawerPanel(
title: '字幕样式',
onBack: () =>
setState(() => _selectedView = _SubtitlePanelView.subtitles),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _subtitleStyleRows(),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_androidStyleEntry(),
panelDivider(),
Flexible(child: _subtitleList()),
],
);
}
Widget _androidStyleEntry() {
return InkWell(
onTap: () => setState(() => _selectedView = _SubtitlePanelView.style),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
const Icon(
Icons.closed_caption_outlined,
color: Colors.white54,
size: 16,
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'字幕样式',
style: TextStyle(color: Colors.white, fontSize: 13),
),
),
Text(
_subtitleStyleLabel(),
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
],
),
),
);
}
Widget _content() {
switch (_selectedView) {
case _SubtitlePanelView.subtitles:
return _subtitleList();
case _SubtitlePanelView.style:
return _subtitleSettings();
}
}
Widget _leftMenu() {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_menuItem(
view: _SubtitlePanelView.subtitles,
label: '字幕',
subtitle: _activeSubtitleLabel(),
icon: Icons.subtitles_outlined,
),
_menuItem(
view: _SubtitlePanelView.style,
label: '字幕样式',
subtitle: _subtitleStyleLabel(),
icon: Icons.closed_caption_outlined,
),
],
),
);
}
Widget _menuItem({
required _SubtitlePanelView view,
required String label,
required String subtitle,
required IconData icon,
}) {
final selected = _selectedView == view;
return GestureDetector(
onTap: () => setState(() => _selectedView = view),
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: selected
? BoxDecoration(
color: Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
)
: null,
child: Row(
children: [
Icon(
icon,
size: 16,
color: selected ? _activeColor : Colors.white54,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: selected ? _activeColor : Colors.white,
fontSize: 13,
fontWeight: selected
? FontWeight.w600
: FontWeight.normal,
),
),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: selected
? _activeColor.withOpacity(0.7)
: Colors.white38,
fontSize: 11,
),
),
],
),
),
Icon(
Icons.chevron_right,
size: 16,
color: selected ? _activeColor : Colors.white24,
),
],
),
),
);
}
Widget _subtitleList() {
final embedded = widget.subtitles
.where((track) => !track.isExternal)
.toList(growable: false);
final external = widget.subtitles
.where((track) => track.isExternal)
.toList(growable: false);
const disableValue = -1;
final entries = <SelectionPanelEntry<int>>[
SelectionPanelEntry.item(
value: disableValue,
label: '禁用',
isActive: _activeStreamIndex == null,
),
if (embedded.isNotEmpty) const SelectionPanelEntry.divider(),
for (final track in embedded)
SelectionPanelEntry.item(
value: track.index,
label: track.title ?? track.language ?? '字幕 ${track.index}',
subtitle: _translateLanguage(track.language),
isActive: _activeStreamIndex == track.index,
isLoading: _isSubtitleLoading && _activeStreamIndex == track.index,
),
if (external.isNotEmpty) const SelectionPanelEntry.divider(),
for (final track in external)
SelectionPanelEntry.item(
value: track.index,
label: track.title ?? track.language ?? '外挂字幕 ${track.index}',
subtitle: _translateLanguage(track.language),
badge: '外挂',
isActive: _activeStreamIndex == track.index,
isLoading: _isSubtitleLoading && _activeStreamIndex == track.index,
),
];
return PanelSelectionList<int>(
entries: entries,
onSelected: (value) =>
_selectSubtitle(value == disableValue ? null : value),
);
}
Widget _subtitleSettings() {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PanelGroupHeader(title: '主字幕样式'),
..._subtitleStyleRows(),
const SizedBox(height: 8),
],
),
);
}
List<Widget> _subtitleStyleRows() {
final settings = widget.subtitleSettings;
return [
PanelSliderRow(
label: '位置',
value: settings.position.toDouble(),
min: 0,
max: 150,
step: 5,
display: settings.position.toString(),
onChanged: (v) => widget.onSubtitleSettingsChanged(
settings.copyWith(position: v.round()),
),
),
PanelSliderRow(
label: '大小',
value: settings.fontSize,
min: 10,
max: 120,
step: 2,
display: settings.fontSize.round().toString(),
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(fontSize: v)),
),
PanelSliderRow(
label: '延迟',
value: settings.delay,
min: -10.0,
max: 10.0,
step: 0.5,
display:
'${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s',
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(delay: v)),
),
PanelSliderRow(
label: '背景',
value: settings.bgOpacity,
min: 0.0,
max: 1.0,
step: 0.1,
display: '${(settings.bgOpacity * 100).round()}%',
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(bgOpacity: v)),
),
];
}
String _activeSubtitleLabel() {
final index = _activeStreamIndex;
if (index == null) return '禁用';
final track = widget.subtitles
.where((subtitle) => subtitle.index == index)
.firstOrNull;
if (track == null) return '字幕';
return track.title ?? track.language ?? '字幕 ${track.index}';
}
String _subtitleStyleLabel() {
final settings = widget.subtitleSettings;
return '${settings.fontSize.round()} / ${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s';
}
}
class AudioPanelBody extends StatefulWidget {
final PlayerEngine player;
final void Function(AudioTrack track)? onAudioTrackSelected;
const AudioPanelBody({
super.key,
required this.player,
this.onAudioTrackSelected,
});
@override
State<AudioPanelBody> createState() => _AudioPanelBodyState();
}
class _AudioPanelBodyState extends State<AudioPanelBody> {
AudioTrack _activeAudio = const AudioTrack.auto();
List<AudioTrack> _audioTracks = const [];
StreamSubscription<PlayerTrack>? _trackSub;
StreamSubscription<PlayerTracks>? _tracksSub;
@override
void initState() {
super.initState();
_syncFromPlayer(widget.player);
_subscribePlayer(widget.player);
}
@override
void didUpdateWidget(covariant AudioPanelBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
_cancelSubscriptions();
_syncFromPlayer(widget.player);
_subscribePlayer(widget.player);
}
}
@override
void dispose() {
_cancelSubscriptions();
super.dispose();
}
void _syncFromPlayer(PlayerEngine player) {
_activeAudio = player.state.track.audio;
_audioTracks = player.state.tracks.embeddedAudio;
}
void _subscribePlayer(PlayerEngine player) {
_trackSub = player.stream.track.listen((track) {
if (!mounted) return;
setState(() => _activeAudio = track.audio);
});
_tracksSub = player.stream.tracks.listen((tracks) {
if (!mounted) return;
setState(() => _audioTracks = tracks.embeddedAudio);
});
}
void _cancelSubscriptions() {
unawaited(_trackSub?.cancel());
unawaited(_tracksSub?.cancel());
_trackSub = null;
_tracksSub = null;
}
Future<void> _selectAudio(AudioTrack track) async {
if (!mounted) return;
setState(() => _activeAudio = track);
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
try {
await widget.player.setAudioTrack(track);
widget.onAudioTrackSelected?.call(track);
} catch (_) {
if (mounted) {
setState(() => _activeAudio = widget.player.state.track.audio);
}
}
}
@override
Widget build(BuildContext context) {
return _audioList();
}
Widget _audioList() {
final entries = <SelectionPanelEntry<AudioTrack>>[
for (final t in _audioTracks)
SelectionPanelEntry.item(
value: t,
label: t.title ?? t.language ?? '音频 ${t.id}',
subtitle: _translateLanguage(t.language) ?? t.language,
isActive: _activeAudio.id == t.id,
),
if (_audioTracks.isNotEmpty) const SelectionPanelEntry.divider(),
SelectionPanelEntry.item(
value: const AudioTrack.no(),
label: '关闭音频',
isActive: _activeAudio.id == 'no',
),
];
return PanelSelectionList<AudioTrack>(
entries: entries,
onSelected: (track) => unawaited(_selectAudio(track)),
);
}
}
@@ -0,0 +1,72 @@
import 'dart:async';
import 'package:flutter/material.dart';
class PlayerClockText extends StatefulWidget {
const PlayerClockText({super.key, this.style});
final TextStyle? style;
@override
State<PlayerClockText> createState() => _PlayerClockTextState();
}
class _PlayerClockTextState extends State<PlayerClockText> {
Timer? _timer;
late String _text;
@override
void initState() {
super.initState();
_text = formatClock(DateTime.now());
_scheduleNextTick();
}
void _scheduleNextTick() {
final now = DateTime.now();
final next = DateTime(
now.year,
now.month,
now.day,
now.hour,
now.minute,
).add(const Duration(minutes: 1));
_timer = Timer(next.difference(now), _tick);
}
void _tick() {
if (!mounted) return;
setState(() => _text = formatClock(DateTime.now()));
_scheduleNextTick();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
_text,
style:
widget.style ??
const TextStyle(
color: Colors.white70,
fontSize: 12,
height: 1.0,
letterSpacing: 0.5,
fontFeatures: [FontFeature.tabularFigures()],
),
);
}
}
String formatClock(DateTime t) {
final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0');
return '$h:$m';
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/widgets/top_drag_area.dart';
class ControlsChrome extends StatelessWidget {
final bool visible;
final List<Widget> topBar;
final double topBarLeftInset;
final double topBarTopInset;
final Widget bottomBar;
final Widget? centerBar;
final VoidCallback? onControlsAreaTap;
final ValueChanged<bool>? onControlsHoverChanged;
final Widget? leftBar;
final Widget? rightBar;
final bool isWindowed;
final bool isPip;
const ControlsChrome({
super.key,
required this.visible,
required this.topBar,
this.topBarLeftInset = 8,
this.topBarTopInset = 8,
required this.bottomBar,
this.centerBar,
this.leftBar,
this.rightBar,
required this.onControlsAreaTap,
this.onControlsHoverChanged,
this.isWindowed = false,
this.isPip = false,
});
Widget _withControlsAreaTap(Widget child) {
final onTap = onControlsAreaTap;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap ?? () {},
child: child,
);
}
Widget _withTopDragHandle(Widget child) {
if (isPip) {
return DragToMoveArea(child: child);
}
return TopDragArea(enabled: isWindowed, child: child);
}
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
return IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 160),
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
left: 0,
top: 0,
right: 0,
child: MouseRegion(
onEnter: (_) => onControlsHoverChanged?.call(true),
onExit: (_) => onControlsHoverChanged?.call(false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black87, Colors.transparent],
),
),
child: _withTopDragHandle(
Padding(
padding: EdgeInsets.fromLTRB(
topBarLeftInset,
topBarTopInset,
tokens.chromeInset,
isPip ? 12 : 28,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: topBar,
),
),
),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MouseRegion(
onEnter: (_) => onControlsHoverChanged?.call(true),
onExit: (_) => onControlsHoverChanged?.call(false),
child: _withControlsAreaTap(
Container(
padding: EdgeInsets.fromLTRB(
0,
isPip ? 8 : 20,
0,
isPip ? 6 : 8,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black87, Colors.transparent],
),
),
child: bottomBar,
),
),
),
),
if (leftBar != null)
Positioned(
left: 28,
top: 0,
bottom: 0,
child: Center(child: leftBar!),
),
if (rightBar != null)
Positioned(
right: 16,
top: 0,
bottom: 0,
child: Center(child: rightBar!),
),
if (centerBar != null)
Positioned.fill(
child: Center(
child: MouseRegion(
onEnter: (_) => onControlsHoverChanged?.call(true),
onExit: (_) => onControlsHoverChanged?.call(false),
child: centerBar!,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../services/frame_jank_monitor.dart';
const int _kLowBufferLeadMs = 1000;
class PlayerDebugHud extends StatelessWidget {
const PlayerDebugHud({
super.key,
required this.engine,
required this.frameMonitor,
});
final PlayerEngine engine;
final FrameJankMonitor frameMonitor;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.only(top: 56, left: 12),
child: IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(8),
),
child: DefaultTextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.5,
fontFamily: 'monospace',
fontFamilyFallback: ['Consolas', 'Menlo', 'monospace'],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'DEBUG',
style: TextStyle(
color: Colors.lightGreenAccent,
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
),
),
Text('engine: ${engine.displayName}'),
ValueListenableBuilder<PlaybackStats?>(
valueListenable: engine.debugStats,
builder: (context, stats, _) => _StatsBlock(stats: stats),
),
ValueListenableBuilder<FrameDiag>(
valueListenable: frameMonitor.diag,
builder: (context, diag, _) => _FrameBlock(diag: diag),
),
],
),
),
),
),
),
);
}
}
class _StatsBlock extends StatelessWidget {
const _StatsBlock({required this.stats});
final PlaybackStats? stats;
@override
Widget build(BuildContext context) {
final s = stats;
if (s == null) {
return const Text('engine: (no stats yet)');
}
final low = s.bufferLeadMs < _kLowBufferLeadMs;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('decoder: ${s.decoder} (hwdec=${s.hwdec})'),
Text('isDV: ${s.isDolbyVision} rate: ${s.rate}x'),
Text('bitrate: ${s.bitrateMbps.toStringAsFixed(1)} Mbps'),
Text(
'bufferLead: ${s.bufferLeadMs} ms',
style: TextStyle(
color: low ? Colors.orangeAccent : Colors.white,
fontWeight: low ? FontWeight.bold : FontWeight.normal,
),
),
],
);
}
}
class _FrameBlock extends StatelessWidget {
const _FrameBlock({required this.diag});
final FrameDiag diag;
@override
Widget build(BuildContext context) {
final rasterHot = diag.lastRasterMs >= 32;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 4),
Text('fps: ${diag.fps.toStringAsFixed(0)}'),
Text(
'raster: ${diag.lastRasterMs.toStringAsFixed(1)} ms',
style: TextStyle(
color: rasterHot ? Colors.orangeAccent : Colors.white,
fontWeight: rasterHot ? FontWeight.bold : FontWeight.normal,
),
),
Text('build: ${diag.lastBuildMs.toStringAsFixed(1)} ms'),
Text('jank frames: ${diag.jankCount}'),
],
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'player_panel_shell.dart';
class PlayerDrawerPanel extends StatelessWidget {
final String title;
final VoidCallback onBack;
final Widget child;
const PlayerDrawerPanel({
super.key,
required this.title,
required this.onBack,
required this.child,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_header(),
panelDivider(),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: child,
),
),
],
),
);
}
Widget _header() {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 12, 4),
child: Row(
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: Colors.white70,
size: 15,
),
onPressed: onBack,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
tooltip: '返回',
),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'speed_button.dart';
const holdSpeedPromptBackgroundColor = Color(0x45181818);
class HoldSpeedPrompt extends StatelessWidget {
final bool visible;
final bool isLeft;
final double rate;
const HoldSpeedPrompt({
super.key,
required this.visible,
required this.isLeft,
required this.rate,
});
@override
Widget build(BuildContext context) {
final label = isLeft
? '${SpeedButton.formatRate(rate)} 慢放中'
: '${SpeedButton.formatRate(rate)} 快进中';
return Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 160),
child: Padding(
padding: const EdgeInsets.only(top: 64),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: holdSpeedPromptBackgroundColor,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isLeft ? Icons.fast_rewind : Icons.fast_forward,
color: Colors.white,
size: 18,
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
const kPanelBg = Color(0xE8181818);
const kPanelDivider = Color(0x33FFFFFF);
Widget panelDivider() => const FDivider(
style: .delta(color: kPanelDivider, padding: .value(EdgeInsets.zero)),
);
class PlayerPanelShell extends StatelessWidget {
final IconData icon;
final String title;
final double width;
final VoidCallback onClose;
final Widget child;
const PlayerPanelShell({
super.key,
required this.icon,
required this.title,
required this.width,
required this.onClose,
required this.child,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Container(
width: width,
decoration: BoxDecoration(
color: kPanelBg,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_header(),
panelDivider(),
Flexible(child: child),
],
),
),
);
}
Widget _header() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 10),
child: Row(
children: [
Icon(icon, color: Colors.white70, size: 18),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white54, size: 18),
onPressed: onClose,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
),
);
}
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/infra/player_engine/player_engine.dart';
class PlayerPlayPauseButton extends StatefulWidget {
final PlayerEngine player;
final double? iconSize;
final BoxConstraints? constraints;
const PlayerPlayPauseButton({
super.key,
required this.player,
this.iconSize,
this.constraints,
});
@override
State<PlayerPlayPauseButton> createState() => _PlayerPlayPauseButtonState();
}
class _PlayerPlayPauseButtonState extends State<PlayerPlayPauseButton> {
late bool _playing;
StreamSubscription<bool>? _sub;
@override
void initState() {
super.initState();
_playing = widget.player.state.playing;
_sub = widget.player.stream.playing.listen((playing) {
if (mounted) setState(() => _playing = playing);
});
}
@override
void didUpdateWidget(covariant PlayerPlayPauseButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
unawaited(_sub?.cancel());
_playing = widget.player.state.playing;
_sub = widget.player.stream.playing.listen((playing) {
if (mounted) setState(() => _playing = playing);
});
}
}
@override
void dispose() {
unawaited(_sub?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: _playing ? '暂停' : '播放',
constraints: widget.constraints,
padding: widget.constraints != null ? EdgeInsets.zero : null,
onPressed: () {
if (_playing) {
unawaited(widget.player.pause());
} else {
unawaited(widget.player.play());
}
},
icon: Icon(
_playing ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: widget.iconSize ?? 30,
),
);
}
}
@@ -0,0 +1,99 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../../../shared/utils/format_utils.dart';
class PlayerPositionIndicator extends StatefulWidget {
final PlayerEngine player;
final ValueNotifier<Duration?>? seekPreview;
const PlayerPositionIndicator({
super.key,
required this.player,
this.seekPreview,
});
@override
State<PlayerPositionIndicator> createState() =>
_PlayerPositionIndicatorState();
}
class _PlayerPositionIndicatorState extends State<PlayerPositionIndicator> {
late Duration _position;
late Duration _duration;
StreamSubscription<Duration>? _posSub;
StreamSubscription<Duration>? _durSub;
@override
void initState() {
super.initState();
_position = widget.player.state.position;
_duration = widget.player.state.duration;
_posSub = widget.player.stream.position.listen((position) {
if (mounted) setState(() => _position = position);
});
_durSub = widget.player.stream.duration.listen((duration) {
if (mounted) setState(() => _duration = duration);
});
}
@override
void didUpdateWidget(covariant PlayerPositionIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
unawaited(_posSub?.cancel());
unawaited(_durSub?.cancel());
_position = widget.player.state.position;
_duration = widget.player.state.duration;
_posSub = widget.player.stream.position.listen((position) {
if (mounted) setState(() => _position = position);
});
_durSub = widget.player.stream.duration.listen((duration) {
if (mounted) setState(() => _duration = duration);
});
}
}
@override
void dispose() {
unawaited(_posSub?.cancel());
unawaited(_durSub?.cancel());
super.dispose();
}
Widget _buildText(Duration position, Duration duration) {
return SizedBox(
width: 126,
child: Text(
'${formatDurationClock(position)} / ${formatDurationClock(duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
fontFeatures: [ui.FontFeature.tabularFigures()],
),
),
);
}
@override
Widget build(BuildContext context) {
final seekPreview = widget.seekPreview;
if (seekPreview != null) {
return ValueListenableBuilder<Duration?>(
valueListenable: seekPreview,
builder: (context, preview, _) {
final displayPos = preview ?? _position;
return _buildText(displayPos, _duration);
},
);
}
return _buildText(_position, _duration);
}
}
@@ -0,0 +1,249 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../../../shared/widgets/smart_image.dart';
import '../../../shared/widgets/top_drag_area.dart';
import '../../../shared/widgets/window_controls.dart';
class PlayerLoadingBackdrop extends StatelessWidget {
const PlayerLoadingBackdrop({super.key, this.backdropUrl});
final String? backdropUrl;
@override
Widget build(BuildContext context) {
final url = backdropUrl;
if (url == null || url.isEmpty) {
return const Positioned.fill(child: ColoredBox(color: Colors.black));
}
return Positioned.fill(
child: Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
RepaintBoundary(
child: Transform.scale(
scale: 1.08,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 24, sigmaY: 24),
child: SmartImage(
url: url,
fit: BoxFit.cover,
borderRadius: 0,
fallbackIcon: null,
memCacheWidth: 480,
placeholder: const ColoredBox(color: Colors.black),
),
),
),
),
const ColoredBox(color: Colors.black54),
],
),
);
}
}
class PlayerLoadingIndicator extends StatelessWidget {
const PlayerLoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: AppLoadingRing(size: 44, color: Colors.white));
}
}
class PlayerStatusTopBar extends StatelessWidget {
const PlayerStatusTopBar({
super.key,
required this.title,
required this.onBack,
required this.topInset,
required this.leftInset,
required this.showWindowControls,
this.enableWindowDrag = false,
});
final String title;
final VoidCallback onBack;
final double topInset;
final double leftInset;
final bool showWindowControls;
final bool enableWindowDrag;
@override
Widget build(BuildContext context) {
final trimmed = title.trim();
final shown = trimmed.isEmpty ? '播放器' : trimmed;
final Widget bar = SafeArea(
bottom: false,
child: Row(
children: [
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back, color: Colors.white),
tooltip: '返回',
),
const SizedBox(width: 4),
Expanded(
child: Text(
shown,
style: const TextStyle(color: Colors.white, fontSize: 15),
overflow: TextOverflow.ellipsis,
),
),
if (showWindowControls) const WindowControls.forDarkSurface(),
],
),
);
return Positioned(
top: topInset,
left: leftInset,
right: 12,
child: enableWindowDrag ? TopDragArea(child: bar) : bar,
);
}
}
class PlayerErrorCard extends StatelessWidget {
const PlayerErrorCard({
super.key,
required this.message,
required this.onBack,
required this.onRetry,
});
final String message;
final VoidCallback onBack;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Material(
color: const Color(0xFF1C1F26),
borderRadius: BorderRadius.circular(16),
elevation: 8,
shadowColor: Colors.black,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 12, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(
0xFFFFB020,
).withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.error_outline,
color: Color(0xFFFFC46B),
size: 22,
),
),
const SizedBox(width: 14),
const Expanded(
child: Text(
'无法播放此媒体',
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: onBack,
icon: const Icon(Icons.close, color: Colors.white70),
tooltip: '关闭播放器',
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 140),
child: SingleChildScrollView(
child: SelectableText(
message,
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
height: 1.45,
),
),
),
),
),
const FDivider(
style: .delta(
color: Colors.white12,
padding: .value(EdgeInsets.only(top: 20)),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onBack,
icon: const Icon(Icons.arrow_back, size: 18),
label: const Text('返回详情'),
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
),
const SizedBox(width: 10),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重试'),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
),
],
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,702 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../gestures/gesture_action.dart';
import '../gestures/gesture_bindings.dart';
import 'player_controls_chrome.dart';
import 'player_hold_speed_prompt.dart';
class PlayerVideoWidget extends StatefulWidget {
final PlayerEngine player;
final ValueListenable<BoxFit> fit;
final List<Widget> topBar;
final Widget bottomBar;
final Widget? centerBar;
final List<Widget> overlays;
final VoidCallback? onControlsAreaTap;
final bool isFullscreen;
final Future<void> Function() onToggleFullscreen;
final bool isPip;
final Future<void> Function()? onTogglePip;
final double topBarLeftInset;
final double topBarTopInset;
final bool lockControls;
final bool keyboardEnabled;
final int seekForwardSeconds;
final int seekBackwardSeconds;
final bool volumeBoost;
final GestureBindings gestureBindings;
final double currentRate;
final ValueChanged<double>? onSetRate;
final VoidCallback? onPrevItem;
final VoidCallback? onNextItem;
final VoidCallback? onExit;
final void Function(Offset globalPosition)? onSecondaryContextMenu;
final Widget Function(VoidCallback toggleControls, VoidCallback showControls)?
mobileGestureBuilder;
final Widget? gestureFeedback;
final bool suppressControlsChrome;
final Widget? leftBar;
final Widget? rightBar;
const PlayerVideoWidget({
super.key,
required this.player,
required this.fit,
required this.topBar,
required this.bottomBar,
this.centerBar,
this.overlays = const <Widget>[],
this.onControlsAreaTap,
required this.isFullscreen,
required this.onToggleFullscreen,
this.isPip = false,
this.onTogglePip,
this.topBarLeftInset = 8,
this.topBarTopInset = 8,
this.lockControls = false,
this.keyboardEnabled = true,
this.seekForwardSeconds = 15,
this.seekBackwardSeconds = 15,
this.volumeBoost = false,
this.gestureBindings = GestureBindings.defaults,
this.currentRate = 1.0,
this.onSetRate,
this.onPrevItem,
this.onNextItem,
this.onExit,
this.onSecondaryContextMenu,
this.mobileGestureBuilder,
this.gestureFeedback,
this.suppressControlsChrome = false,
this.leftBar,
this.rightBar,
});
@override
State<PlayerVideoWidget> createState() => _PlayerVideoWidgetState();
}
class _PlayerVideoWidgetState extends State<PlayerVideoWidget> {
static const double _volumeStep = 5;
static const double _pipResizeEdgeSize = 10;
static const List<ResizeEdge> _pipResizeEdges = <ResizeEdge>[
ResizeEdge.topLeft,
ResizeEdge.top,
ResizeEdge.topRight,
ResizeEdge.left,
ResizeEdge.right,
ResizeEdge.bottomLeft,
ResizeEdge.bottom,
ResizeEdge.bottomRight,
];
bool _controlsVisible = true;
bool _cursorVisible = true;
bool _hoveringControls = false;
Timer? _controlsHideTimer;
Timer? _cursorHideTimer;
StreamSubscription<bool>? _playingSub;
Offset? _lastMousePosition;
bool _holdingLeft = false;
bool _holdingRight = false;
double? _savedRateBeforeHold;
bool _holdSpeedPromptVisible = false;
bool _holdSpeedPromptIsLeft = false;
double _holdSpeedPromptRate = 1.0;
Timer? _pendingSeekLeft;
Timer? _pendingSeekRight;
static const _arrowHoldThreshold = Duration(milliseconds: 200);
static const _topHoverTrigger = 120.0;
static const _bottomHoverTrigger = 180.0;
Timer? _secondaryTapTimer;
Offset? _secondaryTapDownPos;
@override
void initState() {
super.initState();
_playingSub = widget.player.stream.playing.listen((playing) {
if (!mounted) return;
if (playing) {
_scheduleHide();
_scheduleHideCursor();
} else {
_showControls(lock: true);
_showCursor(lock: true);
}
});
if (widget.player.state.playing) {
_scheduleHide();
_scheduleHideCursor();
}
}
@override
void didUpdateWidget(covariant PlayerVideoWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
unawaited(_playingSub?.cancel());
_playingSub = widget.player.stream.playing.listen((playing) {
if (!mounted) return;
if (playing) {
_scheduleHide();
_scheduleHideCursor();
} else {
_showControls(lock: true);
_showCursor(lock: true);
}
});
}
if (oldWidget.lockControls != widget.lockControls) {
if (widget.lockControls) {
_controlsHideTimer?.cancel();
_cursorHideTimer?.cancel();
_showControls(lock: true);
_showCursor(lock: true);
} else if (widget.player.state.playing) {
_scheduleHide();
_scheduleHideCursor();
}
}
if (oldWidget.keyboardEnabled && !widget.keyboardEnabled) {
_cancelPendingArrowSeeks();
_restoreHoldRateIfNeeded();
}
}
@override
void dispose() {
_controlsHideTimer?.cancel();
_cursorHideTimer?.cancel();
_secondaryTapTimer?.cancel();
_cancelPendingArrowSeeks();
_restoreHoldRateIfNeeded();
unawaited(_playingSub?.cancel());
super.dispose();
}
void _cancelPendingArrowSeeks() {
_pendingSeekLeft?.cancel();
_pendingSeekLeft = null;
_pendingSeekRight?.cancel();
_pendingSeekRight = null;
}
void _showControls({bool lock = false}) {
_controlsHideTimer?.cancel();
if (!_controlsVisible && mounted) {
setState(() => _controlsVisible = true);
}
if (!lock && widget.player.state.playing) {
_scheduleHide();
}
}
void _scheduleHide() {
_controlsHideTimer?.cancel();
if (_hoveringControls || widget.lockControls) return;
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
if (mounted &&
widget.player.state.playing &&
!_hoveringControls &&
!widget.lockControls) {
setState(() => _controlsVisible = false);
}
});
}
void _showCursor({bool lock = false}) {
_cursorHideTimer?.cancel();
if (!_cursorVisible && mounted) {
setState(() => _cursorVisible = true);
}
if (!lock && widget.player.state.playing) {
_scheduleHideCursor();
}
}
void _scheduleHideCursor() {
_cursorHideTimer?.cancel();
if (_hoveringControls || widget.lockControls) return;
_cursorHideTimer = Timer(const Duration(seconds: 3), () {
if (mounted &&
widget.player.state.playing &&
!_hoveringControls &&
!widget.lockControls) {
setState(() => _cursorVisible = false);
}
});
}
bool _inEdgeZone(double localY, double viewHeight) =>
localY <= _topHoverTrigger || localY >= viewHeight - _bottomHoverTrigger;
Future<void> _togglePlayPause() async {
if (widget.player.state.playing) {
await widget.player.pause();
} else {
await widget.player.play();
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (!widget.keyboardEnabled) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowLeft ||
key == LogicalKeyboardKey.arrowRight) {
return _handleArrowKey(event, key == LogicalKeyboardKey.arrowLeft);
}
final isRepeat = event is KeyRepeatEvent;
if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored;
if (key == LogicalKeyboardKey.space) {
if (isRepeat) return KeyEventResult.handled;
unawaited(_togglePlayPause());
_showControls();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowUp) {
if (Platform.isAndroid) return KeyEventResult.ignored;
final maxVol = widget.volumeBoost ? 150.0 : 100.0;
final next = (widget.player.state.volume + _volumeStep).clamp(
0.0,
maxVol,
);
unawaited(widget.player.setVolume(next));
_showControls();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
if (Platform.isAndroid) return KeyEventResult.ignored;
final maxVol = widget.volumeBoost ? 150.0 : 100.0;
final next = (widget.player.state.volume - _volumeStep).clamp(
0.0,
maxVol,
);
unawaited(widget.player.setVolume(next));
_showControls();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.keyF ||
(key == LogicalKeyboardKey.escape && widget.isFullscreen)) {
if (isRepeat) return KeyEventResult.handled;
unawaited(widget.onToggleFullscreen());
_showControls();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.keyP) {
if (isRepeat) return KeyEventResult.handled;
final togglePip = widget.onTogglePip;
if (togglePip != null) {
unawaited(togglePip());
_showControls();
return KeyEventResult.handled;
}
}
if (key == LogicalKeyboardKey.escape && widget.isPip) {
if (isRepeat) return KeyEventResult.handled;
final togglePip = widget.onTogglePip;
if (togglePip != null) {
unawaited(togglePip());
_showControls();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
KeyEventResult _handleArrowKey(KeyEvent event, bool isLeft) {
final seekSeconds = isLeft
? -widget.seekBackwardSeconds
: widget.seekForwardSeconds;
if (event is KeyDownEvent) {
if (isLeft) {
_pendingSeekLeft?.cancel();
_pendingSeekLeft = Timer(_arrowHoldThreshold, () {
_pendingSeekLeft = null;
if (!mounted) return;
_enterHoldSpeed(isLeft: true);
});
} else {
_pendingSeekRight?.cancel();
_pendingSeekRight = Timer(_arrowHoldThreshold, () {
_pendingSeekRight = null;
if (!mounted) return;
_enterHoldSpeed(isLeft: false);
});
}
return KeyEventResult.handled;
}
if (event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
final pending = isLeft ? _pendingSeekLeft : _pendingSeekRight;
if (pending != null) {
pending.cancel();
if (isLeft) {
_pendingSeekLeft = null;
} else {
_pendingSeekRight = null;
}
_seekBy(seekSeconds);
_showControls();
}
final wasHolding = isLeft ? _holdingLeft : _holdingRight;
if (wasHolding) {
if (isLeft) {
_holdingLeft = false;
} else {
_holdingRight = false;
}
if (!_holdingLeft && !_holdingRight) {
_hideHoldSpeedPrompt();
final restore = _savedRateBeforeHold ?? widget.currentRate;
_savedRateBeforeHold = null;
widget.onSetRate?.call(restore);
}
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _enterHoldSpeed({required bool isLeft}) {
final holding = isLeft ? _holdingLeft : _holdingRight;
if (holding) return;
final holdSpeed = isLeft
? widget.gestureBindings.keyboardHoldLeftSpeed
: widget.gestureBindings.keyboardHoldRightSpeed;
_savedRateBeforeHold ??= widget.currentRate;
widget.onSetRate?.call(holdSpeed);
if (isLeft) {
_holdingLeft = true;
} else {
_holdingRight = true;
}
_showHoldSpeedPrompt(isLeft: isLeft, rate: holdSpeed);
}
void _showHoldSpeedPrompt({required bool isLeft, required double rate}) {
if (!mounted) return;
setState(() {
_holdSpeedPromptVisible = true;
_holdSpeedPromptIsLeft = isLeft;
_holdSpeedPromptRate = rate;
});
}
void _hideHoldSpeedPrompt() {
if (!_holdSpeedPromptVisible) return;
if (!mounted) {
_holdSpeedPromptVisible = false;
return;
}
setState(() => _holdSpeedPromptVisible = false);
}
void _restoreHoldRateIfNeeded() {
if (!_holdingLeft && !_holdingRight) return;
_holdingLeft = false;
_holdingRight = false;
_hideHoldSpeedPrompt();
final restore = _savedRateBeforeHold;
_savedRateBeforeHold = null;
if (restore != null) widget.onSetRate?.call(restore);
}
void _seekBy(int seconds) {
final target = widget.player.state.position + Duration(seconds: seconds);
unawaited(
widget.player.seek(target < Duration.zero ? Duration.zero : target),
);
}
void _toggleControlsVisibility() {
if (_controlsVisible) {
setState(() => _controlsVisible = false);
_controlsHideTimer?.cancel();
} else {
_showControls();
_showCursor();
}
}
void _handleSecondaryTap() {
final contextMenu = widget.onSecondaryContextMenu;
if (contextMenu != null) {
contextMenu(_secondaryTapDownPos ?? Offset.zero);
return;
}
if (widget.gestureBindings.mouseRightDoubleClickAction ==
GestureAction.none) {
_dispatchGesture(GestureKind.rightClick);
return;
}
final pending = _secondaryTapTimer;
if (pending != null && pending.isActive) {
pending.cancel();
_secondaryTapTimer = null;
_dispatchGesture(GestureKind.rightDoubleClick);
return;
}
_secondaryTapTimer = Timer(kDoubleTapTimeout, () {
_secondaryTapTimer = null;
if (!mounted) return;
_dispatchGesture(GestureKind.rightClick);
});
}
void _dispatchGesture(GestureKind kind) {
final action = widget.gestureBindings.actionFor(kind);
switch (action) {
case GestureAction.none:
return;
case GestureAction.playPause:
unawaited(_togglePlayPause());
break;
case GestureAction.toggleControls:
_toggleControlsVisibility();
return;
case GestureAction.fullscreen:
unawaited(widget.onToggleFullscreen());
break;
case GestureAction.seekBackward:
_seekBy(-widget.seekBackwardSeconds);
break;
case GestureAction.seekForward:
_seekBy(widget.seekForwardSeconds);
break;
case GestureAction.previousItem:
widget.onPrevItem?.call();
break;
case GestureAction.nextItem:
widget.onNextItem?.call();
break;
case GestureAction.exit:
widget.onExit?.call();
break;
}
_showControls();
_showCursor();
}
Widget _withPipResizeAffordance(Widget child) {
if (!widget.isPip) return child;
return DragToResizeArea(
resizeEdgeSize: _pipResizeEdgeSize,
resizeEdgeColor: Colors.transparent,
enableResizeEdges: _pipResizeEdges,
child: child,
);
}
@override
Widget build(BuildContext context) {
final isWindowed = !widget.isFullscreen && !widget.isPip;
return Focus(
autofocus: true,
onKeyEvent: _handleKeyEvent,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final viewHeight = constraints.maxHeight;
if (widget.mobileGestureBuilder != null) {
return _withPipResizeAffordance(
Stack(
fit: StackFit.expand,
children: [
PlayerVideoSurface(
player: widget.player,
fit: widget.fit,
),
widget.mobileGestureBuilder!(
_toggleControlsVisibility,
_showControls,
),
...widget.overlays,
if (widget.gestureFeedback != null)
widget.gestureFeedback!,
HoldSpeedPrompt(
visible: _holdSpeedPromptVisible,
isLeft: _holdSpeedPromptIsLeft,
rate: _holdSpeedPromptRate,
),
ControlsChrome(
visible:
_controlsVisible &&
!widget.suppressControlsChrome,
topBar: widget.topBar,
topBarLeftInset: widget.topBarLeftInset,
topBarTopInset: widget.topBarTopInset,
bottomBar: widget.bottomBar,
centerBar: widget.centerBar,
leftBar: widget.leftBar,
rightBar: widget.rightBar,
onControlsAreaTap: widget.onControlsAreaTap,
isWindowed: isWindowed,
isPip: widget.isPip,
onControlsHoverChanged: (hovering) {
_hoveringControls = hovering;
if (!hovering && widget.player.state.playing) {
_scheduleHide();
}
},
),
],
),
);
} else {
return MouseRegion(
cursor: _cursorVisible
? SystemMouseCursors.basic
: SystemMouseCursors.none,
onEnter: (event) {
_lastMousePosition = event.position;
_showCursor();
if (widget.isPip ||
_inEdgeZone(event.localPosition.dy, viewHeight)) {
_showControls();
}
},
onExit: (_) {
_lastMousePosition = null;
},
onHover: (event) {
final pos = event.position;
final last = _lastMousePosition;
_lastMousePosition = pos;
if (last != null &&
_cursorVisible &&
_controlsVisible &&
(pos - last).distanceSquared < 9) {
return;
}
_showCursor();
if (widget.isPip ||
_inEdgeZone(event.localPosition.dy, viewHeight)) {
_showControls();
}
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.lockControls
? null
: () => _dispatchGesture(GestureKind.leftClick),
onDoubleTap:
widget.lockControls ||
widget
.gestureBindings
.mouseLeftDoubleClickAction ==
GestureAction.none
? null
: () => _dispatchGesture(GestureKind.leftDoubleClick),
onSecondaryTap: widget.lockControls
? null
: _handleSecondaryTap,
onSecondaryTapDown: widget.lockControls
? null
: (d) => _secondaryTapDownPos = d.globalPosition,
child: _withPipResizeAffordance(
Stack(
fit: StackFit.expand,
children: [
PlayerVideoSurface(
player: widget.player,
fit: widget.fit,
),
...widget.overlays,
HoldSpeedPrompt(
visible: _holdSpeedPromptVisible,
isLeft: _holdSpeedPromptIsLeft,
rate: _holdSpeedPromptRate,
),
ControlsChrome(
visible:
_controlsVisible &&
!widget.suppressControlsChrome,
topBar: widget.topBar,
topBarLeftInset: widget.topBarLeftInset,
topBarTopInset: widget.topBarTopInset,
bottomBar: widget.bottomBar,
centerBar: widget.centerBar,
leftBar: widget.leftBar,
rightBar: widget.rightBar,
onControlsAreaTap: widget.onControlsAreaTap,
isWindowed: isWindowed,
isPip: widget.isPip,
onControlsHoverChanged: (hovering) {
_hoveringControls = hovering;
if (!hovering && widget.player.state.playing) {
_scheduleHide();
}
},
),
],
),
),
),
);
}
},
),
),
],
),
);
}
}
class PlayerVideoSurface extends StatelessWidget {
final PlayerEngine player;
final ValueListenable<BoxFit> fit;
const PlayerVideoSurface({
super.key,
required this.player,
required this.fit,
});
@override
Widget build(BuildContext context) {
final child = player.buildVideoView(context, fit);
if (player.usesPlatformSurface) return child;
return ColoredBox(color: Colors.black, child: child);
}
}
@@ -0,0 +1,103 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/infra/player_engine/player_engine.dart';
class PlayerVolumeButton extends StatefulWidget {
final PlayerEngine player;
final bool volumeBoost;
const PlayerVolumeButton({
super.key,
required this.player,
this.volumeBoost = false,
});
@override
State<PlayerVolumeButton> createState() => _PlayerVolumeButtonState();
}
class _PlayerVolumeButtonState extends State<PlayerVolumeButton> {
late double _volume;
StreamSubscription<double>? _sub;
@override
void initState() {
super.initState();
_volume = widget.player.state.volume;
_sub = widget.player.stream.volume.listen((volume) {
if (mounted) setState(() => _volume = volume);
});
}
@override
void didUpdateWidget(covariant PlayerVolumeButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
unawaited(_sub?.cancel());
_volume = widget.player.state.volume;
_sub = widget.player.stream.volume.listen((volume) {
if (mounted) setState(() => _volume = volume);
});
}
}
@override
void dispose() {
unawaited(_sub?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
final muted = _volume <= 0;
final max = widget.volumeBoost ? 150.0 : 100.0;
return SizedBox(
width: 122,
child: Row(
children: [
IconButton(
tooltip: muted ? '取消静音' : '静音',
onPressed: () => unawaited(widget.player.toggleMute()),
icon: Icon(
muted
? Icons.volume_off
: (_volume < 45 ? Icons.volume_down : Icons.volume_up),
color: Colors.white,
size: 24,
),
),
Expanded(
child: SliderTheme(
data: const SliderThemeData(
trackHeight: 3,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: RoundSliderOverlayShape(overlayRadius: 12),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.white10,
showValueIndicator: ShowValueIndicator.onlyForContinuous,
valueIndicatorShape: PaddleSliderValueIndicatorShape(),
valueIndicatorColor: Color(0xCC1E1E1E),
valueIndicatorTextStyle: TextStyle(
color: Colors.white,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
),
),
child: Slider(
value: _volume.clamp(0.0, max),
min: 0,
max: max,
label: '${_volume.round()}',
onChanged: (value) => unawaited(widget.player.setVolume(value)),
),
),
),
],
),
);
}
}
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'player_panel_controller.dart';
String panelCategoryLabel(PlayerPanelCategory c) {
switch (c) {
case PlayerPanelCategory.subtitle:
return '字幕';
case PlayerPanelCategory.audio:
return '音轨';
case PlayerPanelCategory.speed:
return '播放速度';
case PlayerPanelCategory.fit:
return '画面比例';
case PlayerPanelCategory.episode:
return '选集';
case PlayerPanelCategory.danmaku:
return '弹幕';
}
}
IconData panelCategoryIcon(PlayerPanelCategory c) {
switch (c) {
case PlayerPanelCategory.subtitle:
return Icons.subtitles_outlined;
case PlayerPanelCategory.audio:
return Icons.audiotrack;
case PlayerPanelCategory.speed:
return Icons.speed;
case PlayerPanelCategory.fit:
return Icons.aspect_ratio;
case PlayerPanelCategory.episode:
return Icons.format_list_numbered;
case PlayerPanelCategory.danmaku:
return Icons.forum_outlined;
}
}
@@ -0,0 +1,20 @@
import 'package:flutter/foundation.dart';
enum PlayerPanelCategory { subtitle, audio, speed, fit, episode, danmaku }
class PlayerPanelController extends ValueNotifier<PlayerPanelCategory?> {
PlayerPanelController() : super(null);
bool get isOpen => value != null;
PlayerPanelCategory? get category => value;
void open(PlayerPanelCategory category) => value = category;
void toggle(PlayerPanelCategory category) =>
value = (value == category) ? null : category;
void close() => value = null;
}
@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../player_panel_shell.dart';
import 'panel_category_meta.dart';
import 'player_panel_controller.dart';
class PlayerPanelPopover extends StatefulWidget {
final PlayerPanelController controller;
final Map<PlayerPanelCategory, LayerLink> anchorLinks;
final Set<PlayerPanelCategory> downwardCategories;
final Widget Function(PlayerPanelCategory) bodyBuilder;
final double width;
const PlayerPanelPopover({
super.key,
required this.controller,
required this.anchorLinks,
this.downwardCategories = const {},
required this.bodyBuilder,
this.width = 340,
});
@override
State<PlayerPanelPopover> createState() => _PlayerPanelPopoverState();
}
class _PlayerPanelPopoverState extends State<PlayerPanelPopover>
with SingleTickerProviderStateMixin {
late final AnimationController _anim;
late final Animation<double> _t;
late final Animation<double> _scale;
PlayerPanelCategory? _visibleCategory;
bool _showing = false;
@override
void initState() {
super.initState();
_anim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
reverseDuration: const Duration(milliseconds: 130),
value: widget.controller.value != null ? 1.0 : 0.0,
);
_t = CurvedAnimation(
parent: _anim,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_scale = Tween<double>(begin: 0.96, end: 1.0).animate(_t);
_visibleCategory = widget.controller.value;
_showing = widget.controller.value != null;
_anim.addStatusListener(_onStatus);
widget.controller.addListener(_onController);
}
void _onController() {
final c = widget.controller.value;
if (c != null) {
setState(() {
_visibleCategory = c;
_showing = true;
});
_anim.forward();
} else {
_anim.reverse();
}
}
void _onStatus(AnimationStatus status) {
if (status == AnimationStatus.dismissed && mounted && _showing) {
setState(() => _showing = false);
}
}
@override
void dispose() {
widget.controller.removeListener(_onController);
_anim.removeStatusListener(_onStatus);
_anim.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final category = _visibleCategory;
if (!_showing || category == null) return const SizedBox.shrink();
final link = widget.anchorLinks[category];
if (link == null) return const SizedBox.shrink();
final downward = widget.downwardCategories.contains(category);
final maxH = (MediaQuery.of(context).size.height * 0.55).clamp(0.0, 360.0);
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.controller.close,
),
),
Positioned(
left: 0,
top: 0,
child: CompositedTransformFollower(
link: link,
targetAnchor: downward ? Alignment.bottomRight : Alignment.topRight,
followerAnchor: downward
? Alignment.topRight
: Alignment.bottomRight,
offset: Offset(0, downward ? 8 : -8),
showWhenUnlinked: false,
child: FadeTransition(
opacity: _t,
child: ScaleTransition(
scale: _scale,
alignment: downward
? Alignment.topRight
: Alignment.bottomRight,
child: _bubble(category, maxH),
),
),
),
),
],
);
}
Widget _bubble(PlayerPanelCategory category, double maxHeight) {
return CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.escape):
widget.controller.close,
},
child: Focus(
autofocus: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: PlayerPanelShell(
icon: panelCategoryIcon(category),
title: panelCategoryLabel(category),
width: widget.width,
onClose: widget.controller.close,
child: widget.bodyBuilder(category),
),
),
),
),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import '../../../core/contracts/library.dart';
class QualityBadge extends StatelessWidget {
const QualityBadge({super.key, required this.quality});
final MediaQualityInfo? quality;
@override
Widget build(BuildContext context) {
final label = _buildLabel();
if (label == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
);
}
String? _buildLabel() {
final q = quality;
if (q == null) return null;
final res = q.resolutionLabel;
if (res.isEmpty) return null;
final range = q.dynamicRangeLabel;
if (range == 'SDR' || range.isEmpty) return res;
final shortRange = range == 'Dolby Vision' ? 'DV' : range;
return '$res $shortRange';
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'panel_toggle_affordance.dart';
class SpeedButton extends StatelessWidget {
final double currentRate;
final bool isPanelOpen;
final VoidCallback onTogglePanel;
const SpeedButton({
super.key,
required this.currentRate,
required this.isPanelOpen,
required this.onTogglePanel,
});
static const speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
static String formatRate(double rate) {
return rate == rate.roundToDouble() ? '${rate.toInt()}x' : '${rate}x';
}
@override
Widget build(BuildContext context) {
return PanelToggleAffordance(
isOpen: isPanelOpen,
onTap: onTogglePanel,
tooltip: '播放速度',
child: Text(
formatRate(currentRate),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
}
}
@@ -0,0 +1,195 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../core/contracts/playback.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../../../providers/subtitle_settings_provider.dart';
class StyledSubtitleOverlay extends StatefulWidget {
final PlayerEngine player;
final SubtitleStyleSettings settings;
final ValueListenable<List<String>>? externalLines;
final ValueListenable<EmbySubtitleTrack?>? externalActive;
const StyledSubtitleOverlay({
super.key,
required this.player,
required this.settings,
this.externalLines,
this.externalActive,
});
@override
State<StyledSubtitleOverlay> createState() => _StyledSubtitleOverlayState();
}
class _StyledSubtitleOverlayState extends State<StyledSubtitleOverlay> {
static const _mpvReferenceHeight = 720.0;
static const _baseEdgeInset = 24.0;
List<String> _subtitle = const [];
StreamSubscription<List<String>>? _subscription;
@override
void initState() {
super.initState();
_bindPlayer(widget.player);
widget.externalLines?.addListener(_onExternalChanged);
widget.externalActive?.addListener(_onExternalChanged);
}
@override
void didUpdateWidget(covariant StyledSubtitleOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
unawaited(_subscription?.cancel());
_bindPlayer(widget.player);
}
if (oldWidget.externalLines != widget.externalLines) {
oldWidget.externalLines?.removeListener(_onExternalChanged);
widget.externalLines?.addListener(_onExternalChanged);
}
if (oldWidget.externalActive != widget.externalActive) {
oldWidget.externalActive?.removeListener(_onExternalChanged);
widget.externalActive?.addListener(_onExternalChanged);
}
}
@override
void dispose() {
unawaited(_subscription?.cancel());
widget.externalLines?.removeListener(_onExternalChanged);
widget.externalActive?.removeListener(_onExternalChanged);
super.dispose();
}
void _bindPlayer(PlayerEngine player) {
_subtitle = player.state.subtitle;
_subscription = player.stream.subtitle.listen((value) {
if (mounted) setState(() => _subtitle = value);
});
}
void _onExternalChanged() {
if (mounted) setState(() {});
}
bool get _externalIsActive => widget.externalActive?.value != null;
List<String> get _activeLines =>
_externalIsActive ? (widget.externalLines?.value ?? const []) : _subtitle;
@override
Widget build(BuildContext context) {
final primaryText = _joinLines(_activeLines);
if (primaryText.isEmpty) {
return const SizedBox.shrink();
}
return IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
if (!width.isFinite ||
!height.isFinite ||
width <= 0 ||
height <= 0) {
return const SizedBox.shrink();
}
final fontSize = _scaleFontSize(widget.settings.fontSize, height);
final position = widget.settings.position.clamp(0, 150).toDouble();
final verticalProgress = math.min(position, 100.0) / 100.0;
final extraLowering = math.max(0.0, position - 100.0) / 50.0;
final bottomInset =
_baseEdgeInset * (1.0 - extraLowering.clamp(0.0, 1.0));
final lineCount = math.max(1, primaryText.split('\n').length);
final estimatedBlockHeight = math.min(
height * 0.5,
lineCount * fontSize * 1.32 + 12.0,
);
final verticalRange = math.max(
0.0,
height - _baseEdgeInset - bottomInset - estimatedBlockHeight,
);
final top = _baseEdgeInset + verticalRange * verticalProgress;
final horizontalInset = math.min(_baseEdgeInset, width * 0.06);
return Stack(
fit: StackFit.expand,
children: [
Positioned(
left: horizontalInset,
right: horizontalInset,
top: top,
child: _buildLine(
text: primaryText,
fontSize: fontSize,
bgOpacity: widget.settings.bgOpacity.clamp(0.0, 1.0),
),
),
],
);
},
),
);
}
String _joinLines(List<String> lines) => lines
.map((line) => line.trim())
.where((line) => line.isNotEmpty)
.join('\n');
double _scaleFontSize(double settingsFontSize, double height) =>
(settingsFontSize * height / _mpvReferenceHeight).clamp(8.0, 200.0);
Widget _buildLine({
required String text,
required double fontSize,
required double bgOpacity,
}) {
final child = Text(
text,
textAlign: TextAlign.center,
softWrap: true,
textScaler: TextScaler.noScaling,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1.32,
letterSpacing: 0.0,
fontWeight: FontWeight.w600,
shadows: const [
Shadow(color: Colors.black, blurRadius: 2, offset: Offset(0, 1)),
Shadow(color: Colors.black87, blurRadius: 4),
],
),
);
if (bgOpacity > 0) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withOpacity(bgOpacity),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: child,
),
);
}
return child;
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../../../core/contracts/playback.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import 'panel_toggle_affordance.dart';
class SubtitleTracksButton extends StatelessWidget {
final PlayerEngine player;
final List<EmbySubtitleTrack> embySubtitles;
final bool isPanelOpen;
final VoidCallback onTogglePanel;
const SubtitleTracksButton({
super.key,
required this.player,
required this.embySubtitles,
required this.isPanelOpen,
required this.onTogglePanel,
});
@override
Widget build(BuildContext context) {
return StreamBuilder<PlayerTracks>(
key: ObjectKey(player),
stream: player.stream.tracks,
initialData: player.state.tracks,
builder: (_, snapshot) {
final hasSubtitles =
(snapshot.data?.embeddedSubtitles.isNotEmpty ?? false) ||
embySubtitles.isNotEmpty;
if (!hasSubtitles) return const SizedBox.shrink();
return PanelToggleAffordance(
isOpen: isPanelOpen,
onTap: onTogglePanel,
tooltip: '字幕',
child: const Icon(
Icons.subtitles_outlined,
color: Color(0xFFFFFFFF),
size: 28,
),
);
},
);
}
}
class AudioTracksButton extends StatelessWidget {
final PlayerEngine player;
final bool isPanelOpen;
final VoidCallback onTogglePanel;
const AudioTracksButton({
super.key,
required this.player,
required this.isPanelOpen,
required this.onTogglePanel,
});
@override
Widget build(BuildContext context) {
return StreamBuilder<PlayerTracks>(
key: ObjectKey(player),
stream: player.stream.tracks,
initialData: player.state.tracks,
builder: (_, snapshot) {
if ((snapshot.data?.embeddedAudio.length ?? 0) <= 1) {
return const SizedBox.shrink();
}
return PanelToggleAffordance(
isOpen: isPanelOpen,
onTap: onTogglePanel,
tooltip: '音轨',
child: const Icon(
Icons.multitrack_audio,
color: Color(0xFFFFFFFF),
size: 28,
),
);
},
);
}
}
@@ -0,0 +1,225 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:forui/forui.dart' as forui;
import '../../../shared/widgets/all_versions_sheet.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
import '../../../shared/widgets/version_filter.dart';
import '../../../shared/widgets/version_grouping.dart';
import '../../../shared/widgets/version_source_card.dart';
import 'player_panel_shell.dart';
class VersionPanel extends StatefulWidget {
final List<VersionSourceCardData> entries;
final VoidCallback onClose;
const VersionPanel({super.key, required this.entries, required this.onClose});
@override
State<VersionPanel> createState() => _VersionPanelState();
}
class _VersionPanelState extends State<VersionPanel> {
final ScrollController _scrollController = ScrollController();
String? _selectedBucket;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final entries = widget.entries;
final allGroups = groupVersionEntries(entries);
final bucketsPresent = <String>{
for (final e in entries) versionBucketOf(e.resolutionLabel),
};
final buckets = kVersionBucketOrder
.where(bucketsPresent.contains)
.toList(growable: false);
final activeEntry = entries
.where((entry) => entry.isCurrent || entry.selected)
.firstOrNull;
final defaultBucket = activeEntry == null
? (buckets.isNotEmpty ? buckets.first : null)
: versionBucketOf(activeEntry.resolutionLabel);
final selected =
_selectedBucket != null && bucketsPresent.contains(_selectedBucket)
? _selectedBucket!
: defaultBucket;
if (selected != _selectedBucket) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _selectedBucket = selected);
});
}
final filteredGroups = selected == null
? allGroups
: allGroups
.where(
(group) =>
versionBucketOf(group.representative.resolutionLabel) ==
selected,
)
.toList(growable: false);
final inlineLimit = inlineVersionLimit();
final inlineGroups = filteredGroups
.take(inlineLimit)
.toList(growable: true);
final activeGroupIndex = filteredGroups.indexWhere(
(group) => group.hasActiveMember,
);
if (activeGroupIndex >= inlineLimit && inlineGroups.isNotEmpty) {
inlineGroups[inlineGroups.length - 1] = filteredGroups[activeGroupIndex];
}
final hasMergedGroup = allGroups.any((group) => group.members.length > 1);
final showAllVersionsButton =
filteredGroups.length > inlineLimit || hasMergedGroup;
final itemCount = inlineGroups.length;
return Material(
color: Colors.transparent,
child: Container(
decoration: BoxDecoration(
color: kPanelBg,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_header(
buckets,
selected,
allGroups: allGroups,
sourceCount: entries.length,
showAllVersionsButton: showAllVersionsButton,
),
panelDivider(),
Padding(
padding: EdgeInsets.only(
top: Platform.isAndroid ? 8 : 12,
bottom: Platform.isAndroid ? 10 : 14,
),
child: SizedBox(
height: Platform.isAndroid ? 56 : 120,
child: HoverScrollableRow(
controller: _scrollController,
builder: (_, controller) => ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(
horizontal: Platform.isAndroid ? 10 : 16,
),
itemCount: itemCount,
itemBuilder: (_, i) => Padding(
padding: EdgeInsets.only(
right: i < itemCount - 1
? (Platform.isAndroid ? 6 : 12)
: 0,
),
child: VersionSourceCard(
data: inlineGroups[i].displayData,
compact: Platform.isAndroid,
onMergedServersTap: inlineGroups[i].members.length > 1
? () => showAllVersionsSheet(
context: context,
groups: [inlineGroups[i]],
title: '选择服务器',
)
: null,
),
),
),
),
),
),
],
),
),
);
}
Widget _header(
List<String> buckets,
String? selected, {
required List<VersionSourceGroup> allGroups,
required int sourceCount,
required bool showAllVersionsButton,
}) {
final isAndroid = Platform.isAndroid;
return Padding(
padding: isAndroid
? const EdgeInsets.fromLTRB(16, 10, 8, 8)
: const EdgeInsets.fromLTRB(16, 12, 8, 10),
child: Row(
children: [
const Icon(
Icons.video_file_outlined,
color: Colors.white70,
size: 18,
),
const SizedBox(width: 8),
Text(
'切换版本',
style: TextStyle(
color: Colors.white,
fontSize: isAndroid ? 14 : 15,
fontWeight: FontWeight.w600,
),
),
if (showAllVersionsButton) ...[
const SizedBox(width: 10),
forui.FButton(
variant: forui.FButtonVariant.secondary,
size: forui.FButtonSizeVariant.xs,
mainAxisSize: MainAxisSize.min,
onPress: () =>
showAllVersionsSheet(context: context, groups: allGroups),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('全部 $sourceCount 个版本'),
const SizedBox(width: 2),
const Icon(Icons.chevron_right_rounded, size: 16),
],
),
),
],
const SizedBox(width: 16),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var i = 0; i < buckets.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
VersionFilterChip(
label: buckets[i],
selected: selected == buckets[i],
showDot: true,
onTap: () => setState(() => _selectedBucket = buckets[i]),
),
],
],
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close, color: Colors.white54, size: 18),
onPressed: widget.onClose,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
),
);
}
}