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,42 @@
import '../../../core/contracts/player_gestures.dart';
export '../../../core/contracts/player_gestures.dart' show GestureAction;
String gestureActionLabel(GestureAction a) => switch (a) {
GestureAction.none => '无操作',
GestureAction.playPause => '播放/暂停',
GestureAction.fullscreen => '全屏',
GestureAction.toggleControls => '显示/隐藏控制栏',
GestureAction.seekBackward => '后退',
GestureAction.seekForward => '前进',
GestureAction.previousItem => '上一个',
GestureAction.nextItem => '下一个',
GestureAction.exit => '退出',
};
enum GestureKind { leftClick, leftDoubleClick, rightClick, rightDoubleClick }
List<GestureAction> allowedActionsFor(GestureKind kind) {
switch (kind) {
case GestureKind.leftClick:
case GestureKind.rightClick:
return const [
GestureAction.playPause,
GestureAction.toggleControls,
GestureAction.none,
];
case GestureKind.leftDoubleClick:
case GestureKind.rightDoubleClick:
return const [
GestureAction.playPause,
GestureAction.fullscreen,
GestureAction.seekBackward,
GestureAction.seekForward,
GestureAction.previousItem,
GestureAction.nextItem,
GestureAction.exit,
GestureAction.none,
];
}
}
@@ -0,0 +1,35 @@
import 'gesture_action.dart';
class GestureBindings {
final double keyboardHoldLeftSpeed;
final double keyboardHoldRightSpeed;
final GestureAction mouseLeftClickAction;
final GestureAction mouseLeftDoubleClickAction;
final GestureAction mouseRightClickAction;
final GestureAction mouseRightDoubleClickAction;
const GestureBindings({
required this.keyboardHoldLeftSpeed,
required this.keyboardHoldRightSpeed,
required this.mouseLeftClickAction,
required this.mouseLeftDoubleClickAction,
required this.mouseRightClickAction,
required this.mouseRightDoubleClickAction,
});
static const defaults = GestureBindings(
keyboardHoldLeftSpeed: 0.5,
keyboardHoldRightSpeed: 3.0,
mouseLeftClickAction: GestureAction.playPause,
mouseLeftDoubleClickAction: GestureAction.fullscreen,
mouseRightClickAction: GestureAction.toggleControls,
mouseRightDoubleClickAction: GestureAction.none,
);
GestureAction actionFor(GestureKind kind) => switch (kind) {
GestureKind.leftClick => mouseLeftClickAction,
GestureKind.leftDoubleClick => mouseLeftDoubleClickAction,
GestureKind.rightClick => mouseRightClickAction,
GestureKind.rightDoubleClick => mouseRightDoubleClickAction,
};
}
+179
View File
@@ -0,0 +1,179 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/utils/app_logger.dart';
import 'playlist/cross_server_playlist.dart';
import 'playlist/pending_cross_server_sequence_provider.dart';
class PlayerExitResult {
final int? positionTicks;
final String serverId;
final String itemId;
final String? mediaSourceId;
const PlayerExitResult({
required this.positionTicks,
required this.serverId,
required this.itemId,
required this.mediaSourceId,
});
}
class DirectStreamLaunchData {
final String url;
final Map<String, String> headers;
final String title;
final int? durationSeconds;
final String? seriesName;
final int? episode;
const DirectStreamLaunchData({
required this.url,
this.headers = const <String, String>{},
required this.title,
this.durationSeconds,
this.seriesName,
this.episode,
});
}
class PlayerLauncher {
static const _tag = 'PlayerLauncher';
static Future<PlayerExitResult?> launch({
required BuildContext context,
required String itemId,
String? serverId,
String? mediaSourceId,
int preferredSubtitleStreamIndex = -1,
int? preferredAudioStreamIndex,
int? startPositionTicks,
bool fromStart = false,
String? backdropUrl,
WidgetRef? ref,
List<CrossServerPlaylistEntry>? crossServerSequence,
DirectStreamLaunchData? directStream,
}) async {
if (crossServerSequence != null && crossServerSequence.isNotEmpty) {
assert(
ref != null,
'PlayerLauncher.launch: ref is required when crossServerSequence is provided',
);
ref!.read(pendingCrossServerSequenceProvider.notifier).state =
List.unmodifiable(crossServerSequence);
}
AppLogger.debug(
_tag,
'launch serverId=$serverId itemId=$itemId mediaSourceId=$mediaSourceId '
'subtitleIndex=$preferredSubtitleStreamIndex '
'audioIndex=$preferredAudioStreamIndex '
'startPositionTicks=$startPositionTicks '
'fromStart=$fromStart '
'hasBackdrop=${backdropUrl?.isNotEmpty ?? false} '
'directStream=${directStream != null} '
'crossServerSequence=${crossServerSequence?.length ?? 0}',
);
final queryParams = <String, String>{};
if (serverId != null && serverId.isNotEmpty) {
queryParams['serverId'] = serverId;
}
if (mediaSourceId != null) queryParams['mediaSourceId'] = mediaSourceId;
if (backdropUrl != null && backdropUrl.isNotEmpty) {
queryParams['backdropUrl'] = backdropUrl;
}
queryParams['subtitleIndex'] = '$preferredSubtitleStreamIndex';
if (preferredAudioStreamIndex != null) {
queryParams['audioIndex'] = '$preferredAudioStreamIndex';
}
if (fromStart) {
queryParams['fromStart'] = '1';
} else if (startPositionTicks != null && startPositionTicks > 0) {
queryParams['startPositionTicks'] = '$startPositionTicks';
}
final uri = Uri(
path: '/player/$itemId',
queryParameters: queryParams.isNotEmpty ? queryParams : null,
);
AppLogger.debug(_tag, 'navigating to: ${uri.toString()}');
OverlayEntry? androidLaunchCover;
void removeAndroidLaunchCover() {
final launchCover = androidLaunchCover;
androidLaunchCover = null;
launchCover?.remove();
}
try {
if (Platform.isAndroid) {
final playerView = View.of(context);
androidLaunchCover = _insertAndroidLaunchCover(context);
if (androidLaunchCover != null) {
await WidgetsBinding.instance.endOfFrame;
if (!context.mounted) return null;
}
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
if (!context.mounted) return null;
await _waitForAndroidLandscape(playerView);
if (!context.mounted) return null;
}
final playerResult = context.push<PlayerExitResult>(
uri.toString(),
extra: directStream,
);
if (androidLaunchCover != null) {
await WidgetsBinding.instance.endOfFrame;
removeAndroidLaunchCover();
}
return await playerResult;
} finally {
removeAndroidLaunchCover();
if (Platform.isAndroid) {
await SystemChrome.setPreferredOrientations(
const <DeviceOrientation>[],
);
}
}
}
static OverlayEntry? _insertAndroidLaunchCover(BuildContext context) {
final overlay = Overlay.maybeOf(context, rootOverlay: true);
if (overlay == null) return null;
final launchCover = OverlayEntry(
builder: (context) => const Positioned.fill(
child: IgnorePointer(child: ColoredBox(color: Color(0xFF08090C))),
),
);
overlay.insert(launchCover);
return launchCover;
}
static Future<void> _waitForAndroidLandscape(
ui.FlutterView playerView,
) async {
const pollInterval = Duration(milliseconds: 16);
final deadline = DateTime.now().add(const Duration(milliseconds: 500));
while (true) {
final windowSize = playerView.physicalSize;
if (windowSize.width > windowSize.height) return;
if (DateTime.now().isAfter(deadline)) return;
await Future<void>.delayed(pollInterval);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,613 @@
part of 'player_page.dart';
extension _AndroidChrome on _PlayerPageState {
static const _androidSpeeds = [
0.25,
0.5,
0.75,
1.0,
1.25,
1.5,
1.75,
2.0,
2.5,
3.0,
];
void _seekBy(int seconds) {
final session = _session;
if (session == null) return;
final target = session.engine.state.position + Duration(seconds: seconds);
unawaited(
session.engine.seek(target < Duration.zero ? Duration.zero : target),
);
}
void _onAndroidSpeedIncrement() {
final idx = _androidSpeeds.indexWhere((s) => s > _playbackRate);
if (idx >= 0) _setPlaybackRate(_androidSpeeds[idx]);
}
void _onAndroidSpeedDecrement() {
final idx = _androidSpeeds.lastIndexWhere((s) => s < _playbackRate);
if (idx >= 0) _setPlaybackRate(_androidSpeeds[idx]);
}
List<Widget> _buildAndroidTopBar(PlaybackSession session) {
return [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _goBack,
),
const SizedBox(width: 4),
Expanded(
child: Text(
_buildTitleText(session),
style: const TextStyle(color: Colors.white, fontSize: 15),
overflow: TextOverflow.ellipsis,
),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
QualityBadge(quality: _activeQualityInfo(session)),
const SizedBox(width: 8),
NetworkSpeedIndicator(monitor: session.speedMonitor),
],
),
const SizedBox(height: 6),
const PlayerClockText(),
],
),
const SizedBox(width: 4),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white),
onSelected: (value) {
switch (value) {
case 'fit:contain':
_setVideoFit(BoxFit.contain);
case 'fit:fill':
_setVideoFit(BoxFit.fill);
case 'fit:cover':
_setVideoFit(BoxFit.cover);
case 'pip':
unawaited(_enterAndroidPip());
case 'debug_hud':
_debugHudVisible.value = !_debugHudVisible.value;
}
},
itemBuilder: (context) => [
CheckedPopupMenuItem<String>(
value: 'fit:contain',
checked: _videoFit.value == BoxFit.contain,
child: const Text('画面比例 - 适应'),
),
CheckedPopupMenuItem<String>(
value: 'fit:fill',
checked: _videoFit.value == BoxFit.fill,
child: const Text('画面比例 - 拉伸'),
),
CheckedPopupMenuItem<String>(
value: 'fit:cover',
checked: _videoFit.value == BoxFit.cover,
child: const Text('画面比例 - 裁切'),
),
if (_androidPip.isSupported && !_isAndroidLocked)
const PopupMenuItem<String>(value: 'pip', child: Text('画中画')),
if (kDebugMode)
CheckedPopupMenuItem<String>(
value: 'debug_hud',
checked: _debugHudVisible.value,
child: const Text('调试 HUD'),
),
],
),
];
}
Widget _buildAndroidBottomBar(PlaybackSession session) {
final seekSettings = ref.read(playerSettingsProvider).value;
final seekFwd = seekSettings?.seekForwardSeconds ?? 15;
final seekBwd = seekSettings?.seekBackwardSeconds ?? 15;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_AndroidTimeLabel(
player: session.engine,
showPosition: true,
seekPreview: _seekPreview,
),
const SizedBox(width: 8),
Expanded(
child: DebouncedSeekBar(
player: session.engine,
onDragChanged: (t) => _seekPreview.value = t,
),
),
const SizedBox(width: 8),
_AndroidTimeLabel(
player: session.engine,
showPosition: false,
seekPreview: _seekPreview,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: LayoutBuilder(
builder: (context, constraints) {
final hasVersionSwitch =
session.context.allMediaSources.length > 1 ||
_crossServerCards.isNotEmpty;
final hasEpisode = session.context.seriesId != null;
const alwaysVisibleWidth = 120.0 + 96.0 + 96.0;
final secondaryMenuEntries = <PopupMenuEntry<VoidCallback>>[
if (hasVersionSwitch)
PopupMenuItem<VoidCallback>(
value: _toggleVersionBar,
child: const ListTile(
leading: Icon(Icons.switch_video),
title: Text('切换版本'),
dense: true,
),
),
PopupMenuItem<VoidCallback>(
value: () => _togglePanel(PlayerPanelCategory.danmaku),
child: const ListTile(
leading: Icon(Icons.comment_outlined),
title: Text('弹幕'),
dense: true,
),
),
PopupMenuItem<VoidCallback>(
value: () => _togglePanel(PlayerPanelCategory.speed),
child: const ListTile(
leading: Icon(Icons.speed),
title: Text('倍速'),
dense: true,
),
),
if (hasEpisode)
PopupMenuItem<VoidCallback>(
value: () => _togglePanel(PlayerPanelCategory.episode),
child: const ListTile(
leading: Icon(Icons.playlist_play),
title: Text('选集'),
dense: true,
),
),
];
final secondaryInlineCount =
(hasVersionSwitch ? 1 : 0) + 2 + (hasEpisode ? 1 : 0);
const iconButtonWidth = 48.0;
final overflowMenuButtonWidth = secondaryMenuEntries.isNotEmpty
? iconButtonWidth
: 0.0;
final showAllInline =
constraints.maxWidth >=
alwaysVisibleWidth +
secondaryInlineCount * iconButtonWidth +
overflowMenuButtonWidth;
return Row(
children: [
AndroidBottomTransportControls(
player: session.engine,
hasPrev: _playlist?.hasPrev ?? false,
hasNext: _playlist?.hasNext ?? false,
onPrevItem: () => unawaited(_playlist!.prev()),
onNextItem: () => unawaited(_playlist!.next()),
),
IconButton(
tooltip: '快退',
icon: const Icon(
Icons.fast_rewind,
color: Colors.white,
size: 24,
),
onPressed: () => _seekBy(-seekBwd),
),
IconButton(
tooltip: '快进',
icon: const Icon(
Icons.fast_forward,
color: Colors.white,
size: 24,
),
onPressed: () => _seekBy(seekFwd),
),
const Spacer(),
if (showAllInline) ...[
if (hasVersionSwitch)
IconButton(
icon: const Icon(
Icons.switch_video,
color: Colors.white,
size: 24,
),
onPressed: _toggleVersionBar,
tooltip: '切换版本',
),
],
IconButton(
icon: const Icon(
Icons.subtitles_outlined,
color: Colors.white,
size: 24,
),
onPressed: () => _togglePanel(PlayerPanelCategory.subtitle),
tooltip: '字幕',
),
IconButton(
icon: const Icon(
Icons.audiotrack,
color: Colors.white,
size: 24,
),
onPressed: () => _togglePanel(PlayerPanelCategory.audio),
tooltip: '音轨',
),
if (showAllInline) ...[
IconButton(
icon: const Icon(
Icons.comment_outlined,
color: Colors.white,
size: 24,
),
onPressed: () =>
_togglePanel(PlayerPanelCategory.danmaku),
tooltip: '弹幕',
),
IconButton(
icon: const Icon(
Icons.speed,
color: Colors.white,
size: 24,
),
onPressed: () => _togglePanel(PlayerPanelCategory.speed),
tooltip: '倍速',
),
if (hasEpisode)
IconButton(
icon: const Icon(
Icons.playlist_play,
color: Colors.white,
size: 24,
),
onPressed: () =>
_togglePanel(PlayerPanelCategory.episode),
tooltip: '选集',
),
] else if (secondaryMenuEntries.isNotEmpty)
PopupMenuButton<VoidCallback>(
icon: const Icon(
Icons.more_vert,
color: Colors.white,
size: 24,
),
tooltip: '更多',
onSelected: (callback) => callback(),
itemBuilder: (_) => secondaryMenuEntries,
),
],
);
},
),
),
],
);
}
Widget _buildAndroidLeftBar() {
return AndroidLockButton(onLock: () => _setAndroidLocked(true));
}
Widget _buildAndroidRightBar(PlaybackSession session) {
return AndroidSpeedStrip(
currentRate: _playbackRate,
onIncrement: _onAndroidSpeedIncrement,
onDecrement: _onAndroidSpeedDecrement,
onTapRate: () => _togglePanel(PlayerPanelCategory.speed),
);
}
Widget _buildAndroidGestureLayer(
PlaybackSession session,
VoidCallback toggleControls,
VoidCallback showControls,
) {
return AndroidGestureLayer(
enabled: !_isAndroidLocked,
player: session.engine,
onToggleControls: toggleControls,
onDoubleTapCenter: () {
unawaited(
session.engine.state.playing
? session.engine.pause()
: session.engine.play(),
);
showControls();
},
onDoubleTapLeft: () {
final secs =
ref.read(playerSettingsProvider).value?.seekBackwardSeconds ?? 15;
_seekBy(-secs);
setState(
() => _androidFeedbackData = GestureFeedbackData(
kind: GestureFeedbackKind.doubleTapSeek,
doubleTapSeconds: secs,
doubleTapIsLeft: true,
),
);
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) {
setState(
() => _androidFeedbackData = const GestureFeedbackData.none(),
);
}
});
},
onDoubleTapRight: () {
final secs =
ref.read(playerSettingsProvider).value?.seekForwardSeconds ?? 15;
_seekBy(secs);
setState(
() => _androidFeedbackData = GestureFeedbackData(
kind: GestureFeedbackKind.doubleTapSeek,
doubleTapSeconds: secs,
doubleTapIsLeft: false,
),
);
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) {
setState(
() => _androidFeedbackData = const GestureFeedbackData.none(),
);
}
});
},
onBrightnessChange: (delta) async {
final gen = _brightnessGen;
try {
final current = await ScreenBrightness.instance.application;
if (!mounted || gen != _brightnessGen) return;
final next = (current + delta).clamp(0.0, 1.0);
await ScreenBrightness.instance.setApplicationScreenBrightness(next);
if (!mounted || gen != _brightnessGen) return;
_lastSetBrightness = next;
setState(
() => _androidFeedbackData = GestureFeedbackData(
kind: GestureFeedbackKind.brightness,
value: next,
),
);
} catch (_) {}
},
onVolumeChange: (delta) {
final oldStep = (_volumeFraction * _maxVolumeLevel).round();
_volumeFraction = (_volumeFraction + delta).clamp(0.0, 1.0);
final newStep = (_volumeFraction * _maxVolumeLevel).round();
if (newStep != oldStep) {
unawaited(
SystemAudioController.instance.adjustVolume(newStep - oldStep),
);
}
_systemVolumeOsdTimer?.cancel();
setState(
() => _androidFeedbackData = GestureFeedbackData(
kind: GestureFeedbackKind.volume,
value: _volumeFraction,
),
);
},
onVerticalDragEnd: () {
_brightnessGen++;
if (_lastSetBrightness != null) {
unawaited(
_playerSettingsNotifier.setAndroidBrightness(_lastSetBrightness!),
);
_lastSetBrightness = null;
}
setState(() => _androidFeedbackData = const GestureFeedbackData.none());
},
onHorizontalSeekStart: () {},
onHorizontalSeekUpdate: (seekDelta) {
final current = session.engine.state.position;
final duration = session.engine.state.duration;
final target = current + seekDelta;
final clamped = target < Duration.zero
? Duration.zero
: (target > duration ? duration : target);
_seekPreview.value = clamped;
setState(
() => _androidFeedbackData = GestureFeedbackData(
kind: GestureFeedbackKind.seek,
seekTarget: clamped,
totalDuration: duration,
seekIsForward: !seekDelta.isNegative,
),
);
},
onHorizontalSeekEnd: () {
final target = _seekPreview.value;
if (target != null) {
unawaited(session.engine.seek(target));
}
_seekPreview.value = null;
setState(() => _androidFeedbackData = const GestureFeedbackData.none());
},
onLongPressStart: (isLeftSide) {
final settings = ref.read(playerSettingsProvider).value;
final holdSpeed = isLeftSide
? (settings?.keyboardHoldLeftSpeed ?? 0.5)
: (settings?.keyboardHoldRightSpeed ?? 3.0);
_savedRateBeforeLongPress = _playbackRate;
_setPlaybackRate(holdSpeed);
setState(
() => _androidFeedbackData = GestureFeedbackData(
kind: GestureFeedbackKind.longPressSpeed,
speedRate: holdSpeed,
),
);
},
onLongPressEnd: () {
final restore = _savedRateBeforeLongPress ?? 1.0;
_savedRateBeforeLongPress = null;
_setPlaybackRate(restore);
if (_androidFeedbackData.kind == GestureFeedbackKind.longPressSpeed) {
setState(
() => _androidFeedbackData = const GestureFeedbackData.none(),
);
}
},
);
}
Widget _buildAndroidGestureFeedback() {
return AndroidGestureFeedback(data: _androidFeedbackData);
}
Widget _buildAndroidDrawerLayer(PlaybackSession session) {
return AnimatedBuilder(
animation: _drawer,
builder: (context, _) {
final category = _drawer.category;
if (category == null) return const SizedBox.shrink();
return Stack(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _drawer.close,
child: const ColoredBox(
color: Colors.black38,
child: SizedBox.expand(),
),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 320,
child: Material(
color: const Color(0xF0181818),
child: PlayerPanelShell(
icon: panelCategoryIcon(category),
title: panelCategoryLabel(category),
width: double.infinity,
onClose: _drawer.close,
child: _panelBody(session, category),
),
),
),
],
);
},
);
}
}
class _AndroidTimeLabel extends StatefulWidget {
const _AndroidTimeLabel({
required this.player,
required this.showPosition,
required this.seekPreview,
});
final PlayerEngine player;
final bool showPosition;
final ValueNotifier<Duration?> seekPreview;
@override
State<_AndroidTimeLabel> createState() => _AndroidTimeLabelState();
}
class _AndroidTimeLabelState extends State<_AndroidTimeLabel> {
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((p) {
if (mounted) setState(() => _position = p);
});
_durSub = widget.player.stream.duration.listen((d) {
if (mounted) setState(() => _duration = d);
});
if (widget.showPosition) {
widget.seekPreview.addListener(_onSeekPreviewChanged);
}
}
@override
void didUpdateWidget(covariant _AndroidTimeLabel 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((p) {
if (mounted) setState(() => _position = p);
});
_durSub = widget.player.stream.duration.listen((d) {
if (mounted) setState(() => _duration = d);
});
}
if (oldWidget.seekPreview != widget.seekPreview) {
oldWidget.seekPreview.removeListener(_onSeekPreviewChanged);
if (widget.showPosition) {
widget.seekPreview.addListener(_onSeekPreviewChanged);
}
}
}
void _onSeekPreviewChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
unawaited(_posSub?.cancel());
unawaited(_durSub?.cancel());
widget.seekPreview.removeListener(_onSeekPreviewChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
final Duration display;
if (widget.showPosition) {
display = widget.seekPreview.value ?? _position;
} else {
display = _duration;
}
return Text(
formatDurationClock(display),
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
),
);
}
}
@@ -0,0 +1,465 @@
part of 'player_page.dart';
extension _PlayerPageBottomBar on _PlayerPageState {
Widget _buildBottomBar(PlaybackSession session) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_pip.isPip) _buildLogoRow(session),
DebouncedSeekBar(
player: session.engine,
onDragChanged: (t) => _seekPreview.value = t,
),
SizedBox(
height: _pip.isPip ? 32 : 52,
child: ValueListenableBuilder<int>(
valueListenable: _barVersion,
builder: (context, _, _) {
if (_pip.isPip) return _buildPipBottomRow(session);
return _buildFullBottomRow(session);
},
),
),
],
);
}
Widget _buildLogoRow(PlaybackSession session) {
final logoUrl = session.context.logoUrl;
if (logoUrl == null) return const SizedBox.shrink();
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
return Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8),
child: Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200, maxHeight: 48),
child: CachedNetworkImage(
imageUrl: logoUrl,
httpHeaders: imageHeaders,
fit: BoxFit.contain,
alignment: Alignment.bottomLeft,
memCacheWidth: 600,
fadeInDuration: const Duration(milliseconds: 200),
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
),
);
}
List<Widget> _transportControls(
PlaybackSession session, {
double? iconSize,
bool compact = false,
}) {
final pl = _playlist;
final hasPrev = pl?.hasPrev ?? false;
final hasNext = pl?.hasNext ?? false;
final btnConstraints = compact
? const BoxConstraints(minWidth: 36, minHeight: 36)
: null;
final btnPadding = compact ? EdgeInsets.zero : null;
return [
IconButton(
onPressed: hasPrev ? () => unawaited(pl!.prev()) : null,
icon: Icon(
Icons.skip_previous,
color: hasPrev ? Colors.white : Colors.white38,
size: iconSize,
),
tooltip: '上一集',
constraints: btnConstraints,
padding: btnPadding,
),
PlayerPlayPauseButton(
player: session.engine,
iconSize: compact ? 24.0 : null,
constraints: btnConstraints,
),
IconButton(
onPressed: hasNext ? () => unawaited(pl!.next()) : null,
icon: Icon(
Icons.skip_next,
color: hasNext ? Colors.white : Colors.white38,
size: iconSize,
),
tooltip: '下一集',
constraints: btnConstraints,
padding: btnPadding,
),
];
}
Widget _buildPipBottomRow(PlaybackSession session) {
return Row(
children: [
const Spacer(),
PlayerPositionIndicator(
player: session.engine,
seekPreview: _seekPreview,
),
const SizedBox(width: 4),
IconButton(
onPressed: () => unawaited(_togglePip()),
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 20),
tooltip: '退出画中画 (P)',
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
],
);
}
Widget _buildPipCenterControls(PlaybackSession session) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(40),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: _transportControls(session, iconSize: 26, compact: true),
),
),
),
);
}
Widget _buildFullBottomRow(PlaybackSession session) {
final allMediaSources = session.context.allMediaSources;
final embySubtitles = session.context.embySubtitles;
final hasPicker = session.context.seriesId != null;
final compactRow = Row(
children: [
..._transportControls(session, iconSize: 24, compact: true),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: PlayerPositionIndicator(
player: session.engine,
seekPreview: _seekPreview,
),
),
),
_compactMoreButton(session),
IconButton(
tooltip: _fullscreen.isFullscreen ? '退出全屏' : '全屏',
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
onPressed: () => unawaited(_toggleFullscreen()),
icon: Icon(
_fullscreen.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
size: 28,
),
),
],
);
final row = Row(
children: [
..._transportControls(session),
if (!Platform.isAndroid)
PlayerVolumeButton(
player: session.engine,
volumeBoost: ref.watch(
audioSettingsProvider.select(
(s) => s.value?.volumeBoost ?? false,
),
),
),
PlayerPositionIndicator(
player: session.engine,
seekPreview: _seekPreview,
),
const Spacer(),
if (allMediaSources.length > 1 || _crossServerCards.isNotEmpty)
ValueListenableBuilder<bool>(
valueListenable: _versionBarOpen,
builder: (context, open, _) => PanelToggleAffordance(
isOpen: open,
onTap: _toggleVersionBar,
tooltip: '切换版本',
child: const Icon(
Icons.switch_video,
color: Colors.white,
size: 26,
),
),
),
CompositedTransformTarget(
link: _panelLinks[PlayerPanelCategory.subtitle]!,
child: SubtitleTracksButton(
player: session.engine,
embySubtitles: embySubtitles,
isPanelOpen: _drawer.category == PlayerPanelCategory.subtitle,
onTogglePanel: () => _togglePanel(PlayerPanelCategory.subtitle),
),
),
CompositedTransformTarget(
link: _panelLinks[PlayerPanelCategory.audio]!,
child: AudioTracksButton(
player: session.engine,
isPanelOpen: _drawer.category == PlayerPanelCategory.audio,
onTogglePanel: () => _togglePanel(PlayerPanelCategory.audio),
),
),
CompositedTransformTarget(
link: _panelLinks[PlayerPanelCategory.speed]!,
child: SpeedButton(
currentRate: _playbackRate,
isPanelOpen: _drawer.category == PlayerPanelCategory.speed,
onTogglePanel: () => _togglePanel(PlayerPanelCategory.speed),
),
),
CompositedTransformTarget(
link: _panelLinks[PlayerPanelCategory.fit]!,
child: FitButton(
currentFit: _videoFit.value,
isPanelOpen: _drawer.category == PlayerPanelCategory.fit,
onTogglePanel: () => _togglePanel(PlayerPanelCategory.fit),
),
),
if (hasPicker) _panelIcon(PlayerPanelCategory.episode),
_panelIcon(PlayerPanelCategory.danmaku),
if (_isDesktopWindow)
IconButton(
onPressed: () => unawaited(_togglePip()),
icon: const Icon(
Icons.picture_in_picture,
color: Colors.white,
size: 24,
),
tooltip: '画中画 (P)',
),
IconButton(
tooltip: _fullscreen.isFullscreen ? '退出全屏' : '全屏',
onPressed: () => unawaited(_toggleFullscreen()),
icon: Icon(
_fullscreen.isFullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
color: Colors.white,
size: 28,
),
),
],
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < Breakpoints.compact) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: compactRow,
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: IntrinsicWidth(child: row),
),
);
},
);
}
Widget _compactMoreButton(PlaybackSession session) {
final hasVersion =
session.context.allMediaSources.length > 1 ||
_crossServerCards.isNotEmpty;
final hasPicker = session.context.seriesId != null;
final items = <_CompactMoreItem>[
if (hasVersion)
_CompactMoreItem(
icon: Icons.switch_video,
label: '切换版本',
onTap: _toggleVersionBar,
),
_CompactMoreItem.panel(
PlayerPanelCategory.subtitle,
(c) => _showCompactPanelSheet(session, c),
),
_CompactMoreItem.panel(
PlayerPanelCategory.audio,
(c) => _showCompactPanelSheet(session, c),
),
_CompactMoreItem.panel(
PlayerPanelCategory.speed,
(c) => _showCompactPanelSheet(session, c),
),
_CompactMoreItem.panel(
PlayerPanelCategory.fit,
(c) => _showCompactPanelSheet(session, c),
),
if (hasPicker)
_CompactMoreItem.panel(
PlayerPanelCategory.episode,
(c) => _showCompactPanelSheet(session, c),
),
_CompactMoreItem.panel(
PlayerPanelCategory.danmaku,
(c) => _showCompactPanelSheet(session, c),
),
];
return IconButton(
tooltip: '更多',
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_horiz, color: Colors.white, size: 26),
onPressed: () => _showCompactMoreSheet(items),
);
}
Future<void> _showCompactPanelSheet(
PlaybackSession session,
PlayerPanelCategory category,
) async {
_versionBarOpen.value = false;
_drawer.close();
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) {
if (category == PlayerPanelCategory.episode) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _episodeBodyKey.currentState?.ensureLoaded(),
);
}
return SafeArea(
top: false,
child: Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
),
child: PlayerPanelShell(
icon: panelCategoryIcon(category),
title: panelCategoryLabel(category),
width: double.infinity,
onClose: () => Navigator.of(context).pop(),
child: _panelBody(session, category),
),
),
);
},
);
}
Future<void> _showCompactMoreSheet(List<_CompactMoreItem> items) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (sheetContext) => SafeArea(
top: false,
child: Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
decoration: BoxDecoration(
color: kPanelBg,
borderRadius: BorderRadius.circular(18),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Row(
children: [
const Text(
'更多控制',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(sheetContext).pop(),
icon: const Icon(
Icons.close,
color: Colors.white70,
size: 20,
),
),
],
),
),
panelDivider(),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.56,
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, _) =>
panelDivider(),
itemBuilder: (context, i) {
final item = items[i];
return ListTile(
leading: Icon(item.icon, color: Colors.white70),
title: Text(
item.label,
style: const TextStyle(color: Colors.white),
),
trailing: const Icon(
Icons.chevron_right_rounded,
color: Colors.white38,
),
onTap: () {
Navigator.of(sheetContext).pop();
item.onTap();
},
);
},
),
),
],
),
),
),
);
}
}
class _CompactMoreItem {
final IconData icon;
final String label;
final VoidCallback onTap;
const _CompactMoreItem({
required this.icon,
required this.label,
required this.onTap,
});
factory _CompactMoreItem.panel(
PlayerPanelCategory category,
ValueChanged<PlayerPanelCategory> onToggle,
) {
return _CompactMoreItem(
icon: panelCategoryIcon(category),
label: panelCategoryLabel(category),
onTap: () => onToggle(category),
);
}
}
@@ -0,0 +1,164 @@
part of 'player_page.dart';
bool _isDanmakuLayoutChange(DanmakuPanelSettings a, DanmakuPanelSettings b) {
return a.maxRows != b.maxRows ||
a.fontSize != b.fontSize ||
a.lineHeight != b.lineHeight ||
a.fontFamily != b.fontFamily ||
a.area != b.area;
}
void _syncDanmakuFeederForSession(
DanmakuFeeder feeder,
PlaybackSession session,
double posSec, {
bool clear = false,
}) {
feeder.seekTo(posSec, clear: clear);
if (session.isDanmakuActive) {
feeder.playFrom(posSec);
} else {
feeder.freezeAt(posSec);
}
}
extension _PlayerPageDanmaku on _PlayerPageState {
DanmakuOption _buildDanmakuOption(DanmakuPanelSettings settings) {
final base = settings.toDanmakuOption(screenHeight: _danmakuOverlayHeight);
if (_playbackRate == 1.0 || _playbackRate <= 0) return base;
return base.copyWith(duration: base.duration / _playbackRate);
}
void _applyDanmakuPanelSettings(DanmakuPanelSettings settings) {
_panelSettings = settings;
_feeder.setPanelSettings(settings, option: _buildDanmakuOption(settings));
}
void _wireDanmakuListeners() {
final session = ref.read(danmakuSessionProvider).value;
if (session != null) {
_sessionState = session;
if (session is DanmakuSessionMatched &&
session.commentStatus == DanmakuCommentStatus.ready &&
!identical(session, _lastSyncedDanmakuSession)) {
_lastSyncedDanmakuSession = session;
_feeder.setComments(session.comments);
final s = _session;
if (s != null) {
_syncDanmakuFeederForSession(
_feeder,
s,
s.engine.state.position.inMilliseconds / 1000.0,
);
}
}
}
final settingsPanel = ref
.read(danmakuSettingsProvider)
.value
?.panelSettings;
if (settingsPanel != null && settingsPanel != _panelSettings) {
_applyDanmakuPanelSettings(settingsPanel);
}
ref.listen<AsyncValue<DanmakuSessionState>>(danmakuSessionProvider, (
prev,
next,
) {
final state = next.value;
if (state == null) return;
_sessionState = state;
if (state is DanmakuSessionMatched &&
state.commentStatus == DanmakuCommentStatus.ready) {
_lastSyncedDanmakuSession = state;
_feeder.setComments(state.comments);
final s = _session;
if (s != null) {
final posSec = s.engine.state.position.inMilliseconds / 1000.0;
_syncDanmakuFeederForSession(_feeder, s, posSec);
}
} else {
_lastSyncedDanmakuSession = null;
_feeder.setComments(const []);
}
_invalidateBottomBar();
_refreshPanel();
_invalidateDanmakuOverlay();
});
ref.listen<AsyncValue<DanmakuSettingsState>>(danmakuSettingsProvider, (
prev,
next,
) {
final settings = next.value;
if (settings == null) return;
final newPanel = settings.panelSettings;
final oldPanel = _panelSettings;
final panelChanged = oldPanel != newPanel;
final previousSettings = prev?.value;
final keywordsChanged =
previousSettings != null &&
!listEquals(
previousSettings.blockedKeywords,
settings.blockedKeywords,
);
final enabledChanged = oldPanel.enabled != newPanel.enabled;
final offsetChanged = oldPanel.offset != newPanel.offset;
final layoutChanged = _isDanmakuLayoutChange(oldPanel, newPanel);
final opacityChanged = oldPanel.opacity != newPanel.opacity;
if (panelChanged) {
_applyDanmakuPanelSettings(newPanel);
if (enabledChanged) {
_invalidateBottomBar();
}
_invalidateDanmakuOverlay();
final s = _session;
if ((enabledChanged ||
offsetChanged ||
layoutChanged ||
opacityChanged) &&
s != null) {
final posSec = s.engine.state.position.inMilliseconds / 1000.0;
_syncDanmakuFeederForSession(_feeder, s, posSec, clear: true);
}
}
if (panelChanged || keywordsChanged) {
_refreshPanel();
}
});
}
bool get _danmakuLoaded =>
_sessionState is DanmakuSessionMatched ||
_sessionState is DanmakuSessionEmpty;
bool get _danmakuSearching =>
_sessionState is DanmakuSessionMatching ||
(_sessionState is DanmakuSessionMatched &&
(_sessionState as DanmakuSessionMatched).commentStatus ==
DanmakuCommentStatus.loading);
List<DanmakuMatchCandidate> get _danmakuMatches {
final s = _sessionState;
return s is DanmakuSessionMatched ? s.matches : const [];
}
int get _danmakuSelectedMatchIndex {
final s = _sessionState;
return s is DanmakuSessionMatched ? s.selectedIndex : -1;
}
int get _danmakuCommentCount {
final s = _sessionState;
return s is DanmakuSessionMatched ? s.comments.length : 0;
}
}
@@ -0,0 +1,308 @@
part of 'player_page.dart';
extension _PlayerPageLifecycle on _PlayerPageState {
void _onPanelChanged() {
if (_drawer.isOpen && _drawer.category == PlayerPanelCategory.episode) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _episodeBodyKey.currentState?.ensureLoaded(),
);
}
if (mounted) setState(() {});
}
Future<void> _initialize() async {
try {
final directStream = widget.directStream;
final activeSession = ref.read(activeSessionProvider);
if (activeSession == null && directStream == null) {
if (mounted) {
setState(() => _errorMessage = '没有活跃的会话');
}
return;
}
AppLogger.debug(
_tag,
directStream != null
? 'init direct stream'
: 'init server=${activeSession!.serverUrl} '
'userId=${activeSession.userId}',
);
_prewarmPlayerEngine();
final bootstrap = await (
ref.read(deviceIdProvider.future),
ref.read(appVersionProvider.future),
ref.read(playbackResolverProvider.future),
ref.read(embyGatewayProvider.future),
).wait;
final deviceId = bootstrap.$1;
final appVersion = bootstrap.$2;
final resolver = bootstrap.$3;
final gateway = bootstrap.$4;
if (!mounted) return;
final traktDepsFuture = directStream == null
? _loadTraktDeps()
: Future.value(null);
final prefetchFuture = directStream == null
? _loadPrefetchedPlaybackInfo(activeSession!)
: Future.value(null);
final scrobbleStatus = ref.read(traktScrobbleStatusProvider.notifier);
final pendingSeq = ref.read(pendingCrossServerSequenceProvider);
if (pendingSeq != null) {
ref.read(pendingCrossServerSequenceProvider.notifier).state = null;
}
final settingsFuture = (
ref
.read(subtitleSettingsProvider.future)
.then(
(value) => value,
onError: (Object e) {
AppLogger.warn(_tag, 'load subtitle settings failed', e);
return const SubtitleStyleSettings();
},
),
Future.value(
ref.read(playerSettingsProvider).value ?? const PlayerSettingsState(),
),
ref
.read(danmakuSettingsProvider.future)
.then(
(value) => value,
onError: (Object e) {
AppLogger.warn(_tag, 'load danmaku settings failed', e);
return const DanmakuSettingsState();
},
),
ref
.read(audioSettingsProvider.future)
.then(
(value) => value,
onError: (Object e) {
AppLogger.warn(_tag, 'load audio settings failed', e);
return const AudioSettingsState();
},
),
).wait;
final Future<Playlist> playlistFuture;
if (directStream != null) {
final itemType = directStream.episode != null ? 'Episode' : 'Movie';
final syntheticItem = EmbyRawItem(
Id: widget.itemId,
Name: directStream.title,
Type: itemType,
SeriesName: directStream.seriesName,
IndexNumber: directStream.episode,
RunTimeTicks: directStream.durationSeconds == null
? null
: directStream.durationSeconds! * kTicksPerSecond,
);
playlistFuture = Future<Playlist>.value(
SingleItemPlaylist(
item: PlaylistItem(
itemId: widget.itemId,
displayLabel: directStream.title,
playFromStart: true,
preloadedItem: syntheticItem,
),
),
);
} else {
playlistFuture = playlistFromLaunch(
itemId: widget.itemId,
mediaSourceId: widget.mediaSourceId,
startPositionTicks: widget.startPositionTicks,
preferredAudioStreamIndex: widget.preferredAudioStreamIndex,
preferredSubtitleStreamIndex: widget.preferredSubtitleStreamIndex,
playFromStart: widget.fromStart,
crossServerSequence: pendingSeq,
loadDetail: (id) {
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
return ref.read(
mediaDetailDataProvider((serverId: serverId, itemId: id)).future,
);
},
loadEpisodes: ({required seriesId, required seasonId}) {
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
return ref.read(
seriesEpisodesDataProvider((
serverId: serverId,
seriesId: seriesId,
seasonId: seasonId,
)).future,
);
},
loadSeasons: (seriesId) {
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
return ref.read(
seriesSeasonsDataProvider((
serverId: serverId,
seriesId: seriesId,
)).future,
);
},
);
}
final ready = await (settingsFuture, playlistFuture).wait;
final settings = ready.$1;
_subtitleSettings = settings.$1;
final playerSettings =
ref.read(playerSettingsProvider).value ?? settings.$2;
final danmakuSettings = settings.$3;
if (!mounted) return;
final audioSettings = settings.$4;
_prefetchedPlaybackInfo = await prefetchFuture;
final traktDeps = await traktDepsFuture;
if (!mounted) return;
_builder = PlaybackSessionBuilder(
resolver: resolver,
gateway: gateway,
metadataResolver: MediaSourceMetadataResolver(
probeService: MediaProbeService.instance,
),
engineHolder: _engineHolder,
readActiveSession: () {
if (!mounted) throw const CancelledException('player unmounted');
return ref.read(activeSessionProvider)!;
},
deviceId: deviceId,
deviceName: kEmbyDeviceName,
playerSettingsNotifier: _playerSettingsNotifier,
audioSettingsNotifier: _audioSettingsNotifier,
subtitleSettingsNotifier: ref.read(subtitleSettingsProvider.notifier),
loadItemDetail: (serverId, itemId) async {
if (!mounted) throw const CancelledException('player unmounted');
final res = await ref.read(
mediaDetailDataProvider((
serverId: serverId,
itemId: itemId,
)).future,
);
return res.base;
},
readEngineOverride: () {
if (!mounted) throw const CancelledException('player unmounted');
return ref.read(playerEngineOverrideProvider).value ??
PlayerEngineOverride.auto;
},
traktGateway: traktDeps?.gateway,
traktQueue: traktDeps?.queue,
traktAppVersion: appVersion,
traktOnSuccess: scrobbleStatus.recordSuccess,
traktOnError: scrobbleStatus.recordError,
);
_initialPlayerSettings = playerSettings;
_initialDanmakuSettings = danmakuSettings;
_initialAudioSettings = audioSettings;
_panelSettings = danmakuSettings.panelSettings;
_playlist = ready.$2;
if (!mounted) {
_playlist?.dispose();
_playlist = null;
return;
}
_playlist!.current.addListener(_onCurrentItemChanged);
_onCurrentItemChanged();
} catch (e, s) {
AppLogger.error(_tag, 'initialize failed', e, s);
if (mounted) setState(() => _errorMessage = formatUserError(e));
}
}
void _prewarmPlayerEngine() {
if (_engineHolder.current != null) return;
final override =
ref.read(playerEngineOverrideProvider).value ??
PlayerEngineOverride.auto;
final kind = resolvePlayerEngineKind(
override,
isDolbyVision: false,
videoCodec: null,
);
unawaited(() async {
try {
final engine = await _engineHolder.acquire(
kind,
() => PlaybackSessionBuilder.createEngine(kind),
);
AppLogger.debug(_tag, 'engine prewarmed kind=${kind.name}');
if (engine is Media3PlayerEngine) {
await engine.prewarmNative();
}
} catch (e) {
AppLogger.debug(_tag, 'engine prewarm failed', e);
}
}());
}
Future<({AuthedTraktGateway gateway, TraktPendingSyncQueue queue})?>
_loadTraktDeps() async {
try {
final traktGateway = ref.read(authedTraktGatewayProvider);
final queue = await ref.read(traktSyncQueueProvider.future);
unawaited(queue.drain());
return (gateway: traktGateway, queue: queue);
} catch (e) {
AppLogger.warn(_tag, 'trakt deps unavailable, scrobble disabled', e);
return null;
}
}
Future<({int startTimeTicks, Map<String, dynamic> info})?>
_loadPrefetchedPlaybackInfo(AuthedSession activeSession) async {
try {
final startTimeTicks = widget.fromStart
? 0
: (widget.startPositionTicks ?? 0);
final serverId = widget.serverId ?? activeSession.serverId;
final key = (
serverId: serverId,
itemId: widget.itemId,
mediaSourceId: widget.mediaSourceId,
startTimeTicks: startTimeTicks,
);
AppLogger.debug(
_tag,
'playbackInfo prefetch await ${describePlaybackInfoPrefetchKey(key)}',
);
final prefetched = await ref.read(
playbackInfoPrefetchProvider(key).future,
);
if (prefetched == null || prefetched.info.isEmpty) {
AppLogger.debug(
_tag,
'playbackInfo prefetch miss ${describePlaybackInfoPrefetchKey(key)}',
);
return null;
}
AppLogger.debug(
_tag,
'playbackInfo prefetch hit ${describePlaybackInfoPrefetchKey(key)}',
);
return (startTimeTicks: prefetched.startTimeTicks, info: prefetched.info);
} catch (e) {
AppLogger.debug(_tag, 'playbackInfo prefetch failed; fallback', e);
return null;
}
}
int _prefetchStartTimeTicksFor(PlaybackRequest req) {
if (req.playFromStart) return 0;
final explicitTicks = req.startPositionTicks;
if (explicitTicks != null && explicitTicks > 0) return explicitTicks;
return req.preloadedItem?.UserData?.PlaybackPositionTicks ?? 0;
}
}
@@ -0,0 +1,121 @@
part of 'player_page.dart';
extension _PlayerPageOverlays on _PlayerPageState {
List<Widget> _buildVideoOverlays(PlaybackSession session) {
return [
Positioned(
top: 20,
left: 0,
right: 0,
bottom: 0,
child: ValueListenableBuilder<int>(
valueListenable: _danmakuOverlayVersion,
builder: (context, value, child) {
if (!_danmakuLoaded || !_panelSettings.enabled) {
return const SizedBox.shrink();
}
return IgnorePointer(
child: ValueListenableBuilder<bool>(
valueListenable: _bufferingOverlayVisible,
builder: (context, buffering, child) {
return Opacity(opacity: buffering ? 0.0 : 1.0, child: child);
},
child: LayoutBuilder(
builder: (context, constraints) {
final screenHeight = constraints.maxHeight.isFinite
? constraints.maxHeight
: null;
_danmakuOverlayHeight = screenHeight;
final option = _buildDanmakuOption(_panelSettings);
if (screenHeight != null &&
(screenHeight != _danmakuOverlaySyncedHeight ||
!identical(
_panelSettings,
_danmakuOverlaySyncedSettings,
))) {
_danmakuOverlaySyncedHeight = screenHeight;
_danmakuOverlaySyncedSettings = _panelSettings;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_feeder.setPanelSettings(
_panelSettings,
option: _buildDanmakuOption(_panelSettings),
);
});
}
return DanmakuScreen(
option: option,
createdController: (c) {
_feeder.attach(c);
_feeder.setPanelSettings(
_panelSettings,
option: option,
);
final pos = session.engine.state.position;
_syncDanmakuFeederForSession(
_feeder,
session,
pos.inMilliseconds / 1000.0,
);
},
);
},
),
),
);
},
),
),
Positioned.fill(
child: ValueListenableBuilder<int>(
valueListenable: _subtitleOverlayVersion,
builder: (context, value, child) {
return StyledSubtitleOverlay(
player: session.engine,
settings: _subtitleSettings,
externalLines: session.externalSubtitleLines,
externalActive: session.activeExternalSubtitle,
);
},
),
),
Positioned.fill(
child: BufferingOverlay(
player: session.engine,
speedMonitor: session.speedMonitor,
suppress: _switchInFlight,
onBufferingChanged: (value) {
if (mounted) _bufferingOverlayVisible.value = value;
},
),
),
ValueListenableBuilder<bool>(
valueListenable: _debugHudVisible,
builder: (context, visible, _) {
if (!visible) return const SizedBox.shrink();
return PlayerDebugHud(
engine: session.engine,
frameMonitor: _frameJankMonitor,
);
},
),
];
}
List<Widget> _buildPipOverlays(PlaybackSession session) {
return [
Positioned.fill(
child: BufferingOverlay(
player: session.engine,
speedMonitor: session.speedMonitor,
onBufferingChanged: (value) {
if (mounted) _bufferingOverlayVisible.value = value;
},
),
),
];
}
}
@@ -0,0 +1,87 @@
part of 'player_page.dart';
extension _PlayerPageRelink on _PlayerPageState {
bool _handleStreamFailure() {
if (!mounted ||
_switchInFlight ||
_engineFallbackInFlight ||
_relink.pending) {
return false;
}
final s = _session;
if (s == null ||
s.phase.value != PlaybackPhase.playing ||
!s.engine.state.playing) {
return false;
}
final ticks = ticksFromDuration(s.engine.state.position);
if (s.lastFailureTerminal) {
AppLogger.warn(_tag, 'stream failure terminal → skip relink, surface error');
_rememberFailurePosition(ticks);
s.phase.removeListener(_onSessionPhaseChanged);
_attachWakelockSession(null);
unawaited(s.dispose());
setState(() {
_session = null;
_errorMessage = '当前版本源无法播放(服务器拒绝,HTTP 4xx)。请切换其他版本源重试。';
});
_invalidateBottomBar();
return true;
}
_relink.schedule(
onGiveUp: () {
AppLogger.warn(
_tag,
'relink gave up after ${_relink.attempts} attempts',
);
_rememberFailurePosition(ticks);
s.phase.removeListener(_onSessionPhaseChanged);
_attachWakelockSession(null);
unawaited(s.dispose());
setState(() {
_session = null;
_errorMessage = '播放链接已失效,多次重连失败';
});
_invalidateBottomBar();
},
onRelink: (delay) {
AppLogger.debug(
_tag,
'relink scheduled attempt=${_relink.attempts} '
'delay=${delay.inSeconds}s posTicks=$ticks',
);
final base = _lastRequest;
if (!mounted || base == null) return;
final req = base.copyWith(
startPositionTicks: ticks > 0 ? ticks : null,
playFromStart: false,
);
unawaited(_switchTo(req));
},
);
return true;
}
void _rememberFailurePosition(int ticks) {
final base = _lastRequest;
if (base == null || ticks <= 0) return;
_lastRequest = base.copyWith(
startPositionTicks: ticks,
playFromStart: false,
);
}
void _armRelinkSustain() {
_relink.armSustain();
}
}
@@ -0,0 +1,381 @@
part of 'player_page.dart';
extension _PlayerPageSession on _PlayerPageState {
Future<void> _switchTo(
PlaybackRequest req, {
PlayerEngineKind? forcedEngineKind,
}) async {
if (_switchInFlight) {
_pendingResync = true;
AppLogger.debug(_tag, '_switchTo busy → will resync to current');
return;
}
final builder = _builder;
if (builder == null) {
AppLogger.warn(_tag, '_switchTo before initialize');
return;
}
_switchInFlight = true;
final stickyOverride =
ref.read(playerEngineOverrideProvider).value ??
PlayerEngineOverride.auto;
final effectiveForcedEngineKind =
forcedEngineKind ??
(stickyOverride == PlayerEngineOverride.auto
? _forcedEngineBySource[_fallbackKeyForRequest(req)]
: null);
PlayerEngineKind? resolvedEngineKind;
var resolvedIsDolbyVision = false;
_relink.cancelPending();
AppLogger.debug(
_tag,
'_switchTo itemId=${req.itemId} '
'mediaSourceId=${req.mediaSourceId} '
'forced=${effectiveForcedEngineKind?.name ?? '<none>'}',
);
final old = _session;
if (old != null) {
old.phase.removeListener(_onSessionPhaseChanged);
}
_attachWakelockSession(null);
if (mounted) {
setState(() {
_session = null;
_videoVisible = false;
_errorMessage = null;
});
_drawer.close();
_versionBarOpen.value = false;
_invalidateBottomBar();
}
PlaybackSession? next;
try {
_lastRequest = req;
final priorDisposeFuture = old?.dispose();
if (!mounted) return;
final prefetched = _prefetchedPlaybackInfo;
_prefetchedPlaybackInfo = null;
final expectedStartTimeTicks = _prefetchStartTimeTicksFor(req);
final usablePrefetch =
prefetched != null &&
prefetched.info.isNotEmpty &&
req.itemId == widget.itemId &&
prefetched.startTimeTicks == expectedStartTimeTicks
? prefetched.info
: null;
if (prefetched != null && usablePrefetch == null) {
AppLogger.debug(
_tag,
'drop prefetched PlaybackInfo: item=${req.itemId} '
'prefetchTicks=${prefetched.startTimeTicks} '
'expectedTicks=$expectedStartTimeTicks',
);
}
next = await builder.build(
request: req,
feeder: _feeder,
priorDisposeFuture: priorDisposeFuture,
preloadedPlaybackInfo: usablePrefetch,
forcedEngineKind: effectiveForcedEngineKind,
onEngineResolved: (kind, isDolbyVision) {
resolvedEngineKind = kind;
resolvedIsDolbyVision = isDolbyVision;
},
);
if (!mounted) {
await next.dispose();
return;
}
next.phase.addListener(_onSessionPhaseChanged);
next.onStreamFailure = _handleStreamFailure;
next.onExternalSubtitleError = _showExternalSubtitleError;
setState(() => _session = next);
unawaited(
next.engine.stream.position
.firstWhere((pos) => pos > Duration.zero)
.then((_) {
if (!mounted || !identical(_session, next)) return;
setState(() => _videoVisible = true);
})
.catchError((_) {}),
);
_attachWakelockSession(next);
_attachAndroidPipPlayingListener(next);
unawaited(
ref
.read(danmakuSessionProvider.notifier)
.loadFor(next.context.danmakuMatchContext),
);
await next.start();
if (!mounted) return;
final manuallyConfirmedMedia3 =
stickyOverride == PlayerEngineOverride.media3 &&
playerEngineKindFromDisplayName(next.engine.displayName) ==
PlayerEngineKind.media3;
if (manuallyConfirmedMedia3) {
_forcedEngineBySource.remove(_fallbackKeyForRequest(req));
}
if (req.targetServer == null) {
final ctx = next.context;
final msid = ctx.mediaSourceId;
if (msid != null && msid.isNotEmpty) {
unawaited(
_lastPlayedVersionStore.save(
serverId: ctx.serverId,
itemId: ctx.itemId,
mediaSourceId: msid,
),
);
}
}
if (_playbackRate != 1.0) {
await next.setRate(_playbackRate);
}
_configureAndroidPip();
_armRelinkSustain();
} catch (e, s) {
if (e is CancelledException || !mounted) {
AppLogger.debug(_tag, '_switchTo aborted (unmounted/cancelled)');
if (next != null) {
next.phase.removeListener(_onSessionPhaseChanged);
try {
await next.dispose();
} catch (_) {}
}
return;
}
AppLogger.error(_tag, '_switchTo failed', e, s);
final failedKind = next == null
? resolvedEngineKind
: playerEngineKindFromDisplayName(next.engine.displayName);
if (mounted &&
await _tryFallbackFromSwitchFailure(
request: req,
failedKind: failedKind,
failedSession: next,
isDolbyVision: next?.context.isDolbyVision ?? resolvedIsDolbyVision,
)) {
return;
}
if (next != null) {
next.phase.removeListener(_onSessionPhaseChanged);
try {
await next.dispose();
} catch (disposeErr) {
AppLogger.warn(
_tag,
'_switchTo failed: dispose next failed',
disposeErr,
);
}
}
if (mounted) {
setState(() {
_session = null;
_errorMessage = formatUserError(e);
});
}
} finally {
_switchInFlight = false;
if (mounted && _session?.phase.value == PlaybackPhase.failed) {
_onSessionPhaseChanged();
}
if (mounted) {
setState(() {});
_invalidateBottomBar();
}
}
await _drainPendingResync();
}
Future<void> _drainPendingResync() async {
if (!_pendingResync || !mounted) return;
_pendingResync = false;
final current = _playlist?.current.value;
if (current == null || current.itemId.isEmpty) return;
if (current.itemId == _session?.context.itemId) return;
AppLogger.debug(_tag, '_drainPendingResync → realign to ${current.itemId}');
_onCurrentItemChanged();
}
void _onSessionPhaseChanged() {
final phase = _session?.phase.value;
AppLogger.debug(_tag, 'session phase: $phase');
_syncWakelockForSession(_session);
if (phase == PlaybackPhase.ended && !_switchInFlight) {
final pl = _playlist;
if (pl != null && pl.hasNext) {
unawaited(pl.next());
}
} else if (phase == PlaybackPhase.failed && mounted) {
if (_switchInFlight) return;
final failed = _session;
failed?.phase.removeListener(_onSessionPhaseChanged);
_attachWakelockSession(null);
if (_tryFallbackFromFailedSession(failed)) {
return;
}
if (failed != null) {
_rememberFailurePosition(ticksFromDuration(failed.engine.state.position));
}
setState(() {
_session = null;
_errorMessage = formatUserError(failed?.lastError);
});
if (failed != null) {
unawaited(failed.dispose());
}
_invalidateBottomBar();
}
}
Future<void> _retry() async {
final req = _lastRequest;
if (req == null) return;
_relink.reset();
if (mounted) setState(() => _errorMessage = null);
await _switchTo(req);
}
Future<bool> _tryFallbackFromSwitchFailure({
required PlaybackRequest request,
required PlayerEngineKind? failedKind,
required bool isDolbyVision,
PlaybackSession? failedSession,
}) async {
if (_engineFallbackInFlight || failedKind == null) return false;
final override =
ref.read(playerEngineOverrideProvider).value ??
PlayerEngineOverride.auto;
final key = _fallbackKeyForRequest(request);
final fallbackTarget = nextAutoFallbackEngine(
override: override,
failedEngineKind: failedKind,
isDolbyVision: isDolbyVision,
);
if (fallbackTarget == null) return false;
_forcedEngineBySource[key] = fallbackTarget;
_engineFallbackInFlight = true;
_relink.cancelPending();
failedSession?.phase.removeListener(_onSessionPhaseChanged);
AppLogger.warn(
_tag,
'${failedKind.name} switch failed for ${request.itemId} '
'mediaSource=${request.mediaSourceId ?? '<auto>'}; '
'fallback to ${fallbackTarget.name}',
);
if (failedSession != null) {
await failedSession.dispose();
}
if (!mounted) {
_engineFallbackInFlight = false;
return true;
}
setState(() {
_session = null;
_errorMessage = null;
});
unawaited(
Future<void>.microtask(() async {
try {
await _switchTo(request, forcedEngineKind: fallbackTarget);
} finally {
_engineFallbackInFlight = false;
}
}),
);
_invalidateBottomBar();
return true;
}
bool _tryFallbackFromFailedSession(PlaybackSession? failed) {
if (failed == null || _switchInFlight || _engineFallbackInFlight) {
return false;
}
final base = _lastRequest;
if (base == null) return false;
final override =
ref.read(playerEngineOverrideProvider).value ??
PlayerEngineOverride.auto;
final failedKind = playerEngineKindFromDisplayName(
failed.engine.displayName,
);
final key = _fallbackKeyForRequest(base);
final fallbackTarget = nextAutoFallbackEngine(
override: override,
failedEngineKind: failedKind,
isDolbyVision: failed.context.isDolbyVision,
);
if (fallbackTarget == null) return false;
_forcedEngineBySource[key] = fallbackTarget;
_engineFallbackInFlight = true;
_relink.cancelPending();
final ticks = ticksFromDuration(failed.engine.state.position);
final req = ticks > 0
? base.copyWith(startPositionTicks: ticks, playFromStart: false)
: base;
AppLogger.warn(
_tag,
'${failedKind?.name ?? "unknown"} failed for ${failed.context.itemId} '
'mediaSource=${failed.context.mediaSourceId ?? '<auto>'}; '
'fallback to ${fallbackTarget.name}',
);
setState(() {
_session = null;
_errorMessage = null;
});
unawaited(
failed.dispose().whenComplete(() async {
try {
await _switchTo(req, forcedEngineKind: fallbackTarget);
} finally {
_engineFallbackInFlight = false;
}
}),
);
_invalidateBottomBar();
return true;
}
String _fallbackKeyForRequest(PlaybackRequest request) {
return [
request.targetServer?.serverId ?? '',
request.itemId,
request.mediaSourceId ?? '',
].join(':');
}
void _showExternalSubtitleError(String message) {
if (!mounted) return;
showAppSnackBar(context, message, tone: AppSnackTone.warning);
}
}
@@ -0,0 +1,245 @@
part of 'player_page.dart';
extension _PlayerPageSources on _PlayerPageState {
PlaybackRequest _itemToRequest(PlaylistItem item) {
return PlaybackRequest(
itemId: item.itemId,
mediaSourceId: item.mediaSourceId,
startPositionTicks: item.startPositionTicks,
preferredAudioStreamIndex: item.preferredAudioStreamIndex,
preferredSubtitleStreamIndex: item.preferredSubtitleStreamIndex,
subtitleSettings: _subtitleSettings,
playerSettings: _lastRequest?.playerSettings ?? _initialPlayerSettings,
danmakuSettings: _lastRequest?.danmakuSettings ?? _initialDanmakuSettings,
audioSettings: _lastRequest?.audioSettings ?? _initialAudioSettings,
playFromStart: item.playFromStart,
targetServer: item.serverIdentity,
preloadedItem: item.preloadedItem,
directStreamUrl: widget.directStream?.url,
directStreamHeaders:
widget.directStream?.headers ?? const <String, String>{},
);
}
void _onCurrentItemChanged() {
final pl = _playlist;
if (pl == null) return;
final item = pl.current.value;
if (item.itemId.isEmpty) return;
_relink.reset();
unawaited(_switchTo(_itemToRequest(item)));
}
Future<void> _onEpisodePicked(EmbyRawItem episode) async {
final pl = _playlist;
if (pl == null) return;
if (episode.Id == _session?.context.itemId) return;
if (pl is SeriesEpisodePlaylist && episode.SeasonId != null) {
await pl.goToInSeason(seasonId: episode.SeasonId!, episodeId: episode.Id);
} else {
await pl.goToItemId(episode.Id);
}
}
Future<void> _switchVersion(String sourceId) async {
if (sourceId == _session?.context.mediaSourceId) return;
final base = _lastRequest;
if (base == null) return;
_relink.reset();
final pos = _session?.engine.state.position;
final resumeTicks = pos == null ? null : ticksFromDuration(pos);
final req = base.copyWith(
mediaSourceId: sourceId,
startPositionTicks: (resumeTicks != null && resumeTicks > 0)
? resumeTicks
: null,
playFromStart: resumeTicks != null && resumeTicks <= 0,
);
await _switchTo(req);
}
int? _currentResumeTicks() {
final pos = _session?.engine.state.position ?? Duration.zero;
final ticks = ticksFromDuration(pos);
return ticks > 0 ? ticks : null;
}
CrossServerSearchKey? _buildCrossServerKey(PlaybackContext ctx) {
final activeServerId = ctx.serverId.isEmpty ? null : ctx.serverId;
if (activeServerId == null) return null;
final providerIds = ctx.providerIds;
final type = ctx.itemType;
if (type == 'Movie') {
if (providerIds.isEmpty && ctx.itemName.isEmpty) return null;
return (
activeServerId: activeServerId,
anchorType: 'Movie',
providerIdQuery: encodeProviderIdsForKey(providerIds),
seriesProviderIdQuery: '',
parentIndexNumber: -1,
indexNumber: -1,
itemName: ctx.itemName,
excludedServerId: ctx.serverId,
);
}
if (type == 'Episode') {
final s = ctx.parentIndexNumber;
final e = ctx.itemIndexNumber;
if (s == null || e == null) return null;
final seriesProviderIds = ctx.seriesProviderIds;
if (providerIds.isEmpty && seriesProviderIds.isEmpty) return null;
return (
activeServerId: activeServerId,
anchorType: 'Episode',
providerIdQuery: encodeProviderIdsForKey(providerIds),
seriesProviderIdQuery: encodeProviderIdsForKey(seriesProviderIds),
parentIndexNumber: s,
indexNumber: e,
itemName: ctx.itemName,
excludedServerId: ctx.serverId,
);
}
return null;
}
List<CrossServerSourceCard> _filteredCrossServerCards(
List<CrossServerSourceCard> cards,
PlaybackContext ctx,
) {
final curServerId = ctx.serverId;
final curSourceId = ctx.mediaSourceId;
return cards
.where(
(c) => !(c.serverId == curServerId && c.mediaSourceId == curSourceId),
)
.toList(growable: false);
}
Future<void> _switchToCrossServer(CrossServerSourceCard card) async {
if (_switchInFlight) return;
_relink.reset();
final resumeTicks = _currentResumeTicks();
final identity = PlaybackServerIdentity.fromCard(card);
final oldPlaylist = _playlist;
oldPlaylist?.current.removeListener(_onCurrentItemChanged);
final newPlaylist = CrossServerPlaylist(
sequence: [
CrossServerPlaylistEntry(
serverId: card.serverId,
serverName: card.serverName,
serverUrl: card.serverUrl,
token: card.token,
userId: card.userId,
itemId: card.landingItemId,
label: card.item.Name,
preloadedItem: card.item.Id == card.landingItemId ? card.item : null,
),
],
initialIndex: 0,
);
_playlist = newPlaylist;
try {
oldPlaylist?.dispose();
} catch (e) {
AppLogger.warn(_tag, 'dispose old playlist failed', e);
}
final base = _lastRequest;
final req = PlaybackRequest(
itemId: card.landingItemId,
mediaSourceId: card.mediaSourceId,
startPositionTicks: resumeTicks,
subtitleSettings: _subtitleSettings,
playerSettings: base?.playerSettings ?? _initialPlayerSettings,
danmakuSettings: base?.danmakuSettings ?? _initialDanmakuSettings,
audioSettings: base?.audioSettings ?? _initialAudioSettings,
playFromStart: resumeTicks == null,
targetServer: identity,
);
newPlaylist.current.addListener(_onCurrentItemChanged);
await _switchTo(req);
}
void _toggleVersionBar() {
if (_versionBarOpen.value) {
_versionBarOpen.value = false;
} else {
_drawer.close();
_versionBarOpen.value = true;
}
}
void _onAudioTrackSelected(PlaybackSession session, AudioTrack track) {
final audioSettings = _lastRequest?.audioSettings ?? _initialAudioSettings;
if (!audioSettings.rememberAudioTrack) return;
final embyTracks = session.context.embyAudioTracks;
if (embyTracks.isEmpty) return;
var embyTrack = embyTracks.where((t) => t.index == track.index).firstOrNull;
if (embyTrack == null) {
final engineTracks = session.engine.state.tracks.embeddedAudio;
final pos = engineTracks.indexWhere((t) => t.id == track.id);
if (pos >= 0 && pos < embyTracks.length) embyTrack = embyTracks[pos];
}
if (embyTrack == null) return;
unawaited(
_audioSettingsNotifier.saveLastAudioTrack(
session.context.memoryKey,
RememberedAudioTrack(
streamIndex: embyTrack.index,
language: embyTrack.language,
codec: embyTrack.codec,
channels: embyTrack.channels,
displayTitle: embyTrack.displayTitle,
updatedAt: DateTime.now(),
),
),
);
}
void _onSubtitleSelected(PlaybackSession session, int? streamIndex) {
unawaited(session.selectSubtitleByStreamIndex(streamIndex));
if (streamIndex == null) {
unawaited(
_subtitleSettingsNotifier.saveLastSubtitleTrack(
session.context.memoryKey,
RememberedSubtitleTrack(streamIndex: -1, updatedAt: DateTime.now()),
),
);
return;
}
final embyTrack = session.context.embySubtitles
.where((track) => track.index == streamIndex)
.firstOrNull;
if (embyTrack == null) {
AppLogger.warn(
_tag,
'subtitle memory: streamIdx=$streamIndex not in context.embySubtitles; '
'skipping memory write',
);
return;
}
unawaited(
_subtitleSettingsNotifier.saveLastSubtitleTrack(
session.context.memoryKey,
RememberedSubtitleTrack(
streamIndex: embyTrack.index,
language: embyTrack.language,
title: embyTrack.title,
updatedAt: DateTime.now(),
),
),
);
}
}
@@ -0,0 +1,100 @@
part of 'player_page.dart';
extension _PlayerPageTopBar on _PlayerPageState {
String _buildTitleText(PlaybackSession session) {
final idx = session.context.itemIndexNumber;
final name = session.context.itemName;
final series = session.context.seriesName;
if (series != null && series.isNotEmpty) {
if (idx != null) return '$series ${formatEpisodeNumber(idx)} $name';
return '$series $name';
}
if (idx != null) return '${formatEpisodeNumber(idx)} $name';
return name;
}
MediaQualityInfo? _activeQualityInfo(PlaybackSession session) {
final srcId = session.context.mediaSourceId;
for (final src in session.context.allMediaSources) {
if (src.Id == srcId) return MediaQualityInfo.fromMediaSource(src);
}
if (session.context.allMediaSources.isNotEmpty) {
return MediaQualityInfo.fromMediaSource(
session.context.allMediaSources.first,
);
}
return null;
}
List<Widget> _buildTopBar(PlaybackSession session) {
if (_pip.isPip) {
return [
IconButton(
onPressed: _goBack,
icon: const Icon(Icons.arrow_back, color: Colors.white),
tooltip: '退出播放',
),
const Expanded(child: SizedBox.shrink()),
IconButton(
onPressed: () => unawaited(_pip.toggleAlwaysOnTop()),
icon: Icon(
_pip.isAlwaysOnTop ? Icons.push_pin : Icons.push_pin_outlined,
color: _pip.isAlwaysOnTop ? Colors.white : Colors.white54,
size: 20,
),
tooltip: _pip.isAlwaysOnTop ? '取消置顶' : '置顶',
),
IconButton(
onPressed: () => unawaited(windowManager.close()),
icon: const Icon(Icons.close, color: Colors.white, size: 20),
tooltip: '关闭',
),
];
}
return [
IconButton(
onPressed: _goBack,
icon: const Icon(Icons.arrow_back, color: Colors.white),
),
const SizedBox(width: 4),
Expanded(
child: SizedBox(
height: kMinInteractiveDimension,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
_buildTitleText(session),
style: const TextStyle(color: Colors.white, fontSize: 15),
overflow: TextOverflow.ellipsis,
),
),
),
),
const SizedBox(width: 8),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
QualityBadge(quality: _activeQualityInfo(session)),
const SizedBox(width: 8),
NetworkSpeedIndicator(monitor: session.speedMonitor),
if (!_fullscreen.isFullscreen &&
!_pip.isPip &&
_isDesktopWindow) ...[
const SizedBox(width: 8),
const WindowControls.forDarkSurface(),
],
],
),
const SizedBox(height: 6),
const PlayerClockText(),
],
),
];
}
}
@@ -0,0 +1,108 @@
part of 'player_page.dart';
extension _PlayerPageVersionBar on _PlayerPageState {
Widget _buildVersionBarLayer(PlaybackSession session) {
return ValueListenableBuilder<bool>(
valueListenable: _versionBarOpen,
builder: (context, open, _) {
return Stack(
fit: StackFit.expand,
children: [
if (open)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _versionBarOpen.value = false,
child: const SizedBox.expand(),
),
),
Positioned(
left: 16,
right: 16,
bottom: Platform.isAndroid ? 96 : 72,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: open
? _buildVersionBar(session)
: const SizedBox.shrink(key: ValueKey('version-bar-empty')),
),
),
],
);
},
);
}
Widget _buildVersionBar(PlaybackSession session) {
final entries = _buildVersionCardData(session);
if (entries.isEmpty) {
return const SizedBox.shrink(key: ValueKey('version-bar-empty'));
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: KeyedSubtree(
key: const ValueKey('version-bar'),
child: VersionPanel(
entries: entries,
onClose: () => _versionBarOpen.value = false,
),
),
);
}
List<VersionSourceCardData> _buildVersionCardData(PlaybackSession session) {
final ctx = session.context;
final localSources = ctx.allMediaSources;
final currentSourceId = ctx.mediaSourceId;
final currentServerName =
_lastRequest?.targetServer?.serverName ??
ref.read(activeSessionProvider)?.serverName ??
'本服务器';
final entries = <VersionSourceCardData>[];
for (final src in localSources) {
final isCurrent = src.Id != null && src.Id == currentSourceId;
final sourceId = src.Id;
entries.add(
VersionSourceCardData.fromLocalSource(
src,
serverName: currentServerName,
serverKey: ctx.serverId,
isCurrent: isCurrent,
tooltip: isCurrent ? null : '以该版本播放',
onTap: (isCurrent || sourceId == null)
? null
: () => _switchVersion(sourceId),
),
);
}
for (final card in _crossServerCards) {
entries.add(
VersionSourceCardData.fromCrossServer(
card,
onTap: () => _switchToCrossServer(card),
),
);
}
return entries;
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/foundation.dart';
import '../../../core/contracts/library.dart';
import '../session/playback_target.dart';
import 'playlist.dart';
@immutable
class CrossServerPlaylistEntry {
const CrossServerPlaylistEntry({
required this.serverId,
required this.serverName,
required this.serverUrl,
required this.token,
required this.userId,
required this.itemId,
this.label = '',
this.preloadedItem,
});
final String serverId;
final String serverName;
final String serverUrl;
final String token;
final String userId;
final String itemId;
final String label;
final EmbyRawItem? preloadedItem;
PlaybackServerIdentity toIdentity() => PlaybackServerIdentity(
serverId: serverId,
baseUrl: serverUrl,
token: token,
userId: userId,
serverName: serverName,
);
@override
bool operator ==(Object other) =>
other is CrossServerPlaylistEntry &&
other.serverId == serverId &&
other.serverName == serverName &&
other.serverUrl == serverUrl &&
other.token == token &&
other.userId == userId &&
other.itemId == itemId &&
other.label == label &&
other.preloadedItem == preloadedItem;
@override
int get hashCode => Object.hash(
serverId,
serverName,
serverUrl,
token,
userId,
itemId,
label,
preloadedItem,
);
}
class CrossServerPlaylist implements Playlist {
CrossServerPlaylist({
required List<CrossServerPlaylistEntry> sequence,
required int initialIndex,
bool initialPlayFromStart = false,
}) : assert(sequence.isNotEmpty, 'cross-server sequence cannot be empty'),
assert(initialIndex >= 0 && initialIndex < sequence.length),
_entries = List.unmodifiable(sequence),
_currentIndex = ValueNotifier<int>(initialIndex),
_current = ValueNotifier<PlaylistItem>(
_toItem(sequence[initialIndex], playFromStart: initialPlayFromStart),
);
final List<CrossServerPlaylistEntry> _entries;
final ValueNotifier<PlaylistItem> _current;
final ValueNotifier<int> _currentIndex;
bool _disposed = false;
@override
ValueListenable<PlaylistItem> get current => _current;
@override
ValueListenable<int> get currentIndex => _currentIndex;
@override
int get length => _entries.length;
@override
bool get hasNext => _currentIndex.value < _entries.length - 1;
@override
bool get hasPrev => _currentIndex.value > 0;
@override
Future<void> next() => goTo(_currentIndex.value + 1);
@override
Future<void> prev() => goTo(_currentIndex.value - 1);
@override
Future<void> goTo(int index) async {
if (_disposed) return;
if (index < 0 || index >= _entries.length) return;
if (index == _currentIndex.value) return;
final target = _entries[index];
_currentIndex.value = index;
_current.value = _toItem(target);
}
@override
Future<void> goToItemId(String itemId) async {
final idx = _entries.indexWhere((e) => e.itemId == itemId);
if (idx < 0) return;
await goTo(idx);
}
static PlaylistItem _toItem(
CrossServerPlaylistEntry e, {
bool playFromStart = false,
}) => PlaylistItem(
itemId: e.itemId,
serverId: e.serverId,
serverIdentity: e.toIdentity(),
displayLabel: e.label,
playFromStart: playFromStart,
preloadedItem: e.preloadedItem,
);
@override
void dispose() {
_disposed = true;
_current.dispose();
_currentIndex.dispose();
}
}
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/legacy.dart';
import 'cross_server_playlist.dart';
final pendingCrossServerSequenceProvider =
StateProvider<List<CrossServerPlaylistEntry>?>((ref) => null);
@@ -0,0 +1,93 @@
import 'package:flutter/foundation.dart';
import '../../../core/contracts/library.dart';
import '../session/playback_target.dart';
@immutable
class PlaylistItem {
const PlaylistItem({
required this.itemId,
this.mediaSourceId,
this.startPositionTicks,
this.preferredAudioStreamIndex,
this.preferredSubtitleStreamIndex,
this.serverId,
this.serverIdentity,
this.displayLabel = '',
this.playFromStart = false,
this.preloadedItem,
});
final String itemId;
final String? mediaSourceId;
final int? startPositionTicks;
final int? preferredAudioStreamIndex;
final int? preferredSubtitleStreamIndex;
final String? serverId;
final PlaybackServerIdentity? serverIdentity;
final String displayLabel;
final bool playFromStart;
final EmbyRawItem? preloadedItem;
@override
bool operator ==(Object other) =>
other is PlaylistItem &&
other.itemId == itemId &&
other.mediaSourceId == mediaSourceId &&
other.startPositionTicks == startPositionTicks &&
other.preferredAudioStreamIndex == preferredAudioStreamIndex &&
other.preferredSubtitleStreamIndex == preferredSubtitleStreamIndex &&
other.serverId == serverId &&
other.serverIdentity == serverIdentity &&
other.displayLabel == displayLabel &&
other.playFromStart == playFromStart &&
other.preloadedItem == preloadedItem;
@override
int get hashCode => Object.hash(
itemId,
mediaSourceId,
startPositionTicks,
preferredAudioStreamIndex,
preferredSubtitleStreamIndex,
serverId,
serverIdentity,
displayLabel,
playFromStart,
preloadedItem,
);
@override
String toString() =>
'PlaylistItem(itemId=$itemId, serverId=$serverId, label=$displayLabel)';
}
abstract class Playlist {
ValueListenable<PlaylistItem> get current;
ValueListenable<int> get currentIndex;
int get length;
bool get hasNext;
bool get hasPrev;
Future<void> next();
Future<void> prev();
Future<void> goTo(int index);
Future<void> goToItemId(String itemId);
void dispose();
}
@@ -0,0 +1,69 @@
import '../../../core/contracts/library.dart';
import 'cross_server_playlist.dart';
import 'playlist.dart';
import 'series_episode_playlist.dart';
import 'single_item_playlist.dart';
typedef LoadDetailFn = Future<MediaDetailRes> Function(String itemId);
Future<Playlist> playlistFromLaunch({
required String itemId,
String? mediaSourceId,
int? startPositionTicks,
int? preferredAudioStreamIndex,
int? preferredSubtitleStreamIndex,
bool playFromStart = false,
List<CrossServerPlaylistEntry>? crossServerSequence,
required LoadDetailFn loadDetail,
required LoadEpisodesFn loadEpisodes,
required LoadSeasonsFn loadSeasons,
}) async {
if (crossServerSequence != null && crossServerSequence.isNotEmpty) {
var initialIndex = crossServerSequence.indexWhere(
(e) => e.itemId == itemId,
);
if (initialIndex < 0) initialIndex = 0;
return CrossServerPlaylist(
sequence: crossServerSequence,
initialIndex: initialIndex,
initialPlayFromStart: playFromStart,
);
}
final detail = await loadDetail(itemId);
final base = detail.base;
if (base.Type == 'Episode' &&
base.SeriesId != null &&
base.SeasonId != null) {
final seriesPlaylist = SeriesEpisodePlaylist(
seriesId: base.SeriesId!,
loadEpisodes: loadEpisodes,
loadSeasons: loadSeasons,
);
await seriesPlaylist.initialize(
seasonId: base.SeasonId!,
episodeId: itemId,
initialIndexNumber: base.IndexNumber,
initialMediaSourceId: mediaSourceId,
initialStartPositionTicks: startPositionTicks,
initialPreferredAudioStreamIndex: preferredAudioStreamIndex,
initialPreferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
initialPlayFromStart: playFromStart,
preloadedItem: base,
);
return seriesPlaylist;
}
return SingleItemPlaylist(
item: PlaylistItem(
itemId: itemId,
mediaSourceId: mediaSourceId,
startPositionTicks: startPositionTicks,
preferredAudioStreamIndex: preferredAudioStreamIndex,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
playFromStart: playFromStart,
preloadedItem: base,
),
);
}
@@ -0,0 +1,253 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../../core/contracts/library.dart';
import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/format_utils.dart';
import 'playlist.dart';
typedef LoadEpisodesFn =
Future<List<EmbyRawItem>> Function({
required String seriesId,
required String seasonId,
});
typedef LoadSeasonsFn = Future<List<EmbyRawSeason>> Function(String seriesId);
class SeriesEpisodePlaylist implements Playlist {
SeriesEpisodePlaylist({
required this.seriesId,
required this.loadEpisodes,
required this.loadSeasons,
}) : _current = ValueNotifier<PlaylistItem>(_placeholder),
_currentIndex = ValueNotifier<int>(-1);
final String seriesId;
final LoadEpisodesFn loadEpisodes;
final LoadSeasonsFn loadSeasons;
final ValueNotifier<PlaylistItem> _current;
final ValueNotifier<int> _currentIndex;
String? _currentSeasonId;
List<EmbyRawItem> _episodes = const [];
String? _initialMediaSourceId;
int? _initialStartPositionTicks;
int? _initialPreferredAudio;
int? _initialPreferredSub;
bool _initialPlayFromStart = false;
bool _initialApplied = false;
bool _disposed = false;
bool _userNavigated = false;
static const PlaylistItem _placeholder = PlaylistItem(itemId: '');
Future<void> initialize({
required String seasonId,
required String episodeId,
int? initialIndexNumber,
String? initialMediaSourceId,
int? initialStartPositionTicks,
int? initialPreferredAudioStreamIndex,
int? initialPreferredSubtitleStreamIndex,
bool initialPlayFromStart = false,
EmbyRawItem? preloadedItem,
}) async {
_initialMediaSourceId = initialMediaSourceId;
_initialStartPositionTicks = initialStartPositionTicks;
_initialPreferredAudio = initialPreferredAudioStreamIndex;
_initialPreferredSub = initialPreferredSubtitleStreamIndex;
_initialPlayFromStart = initialPlayFromStart;
_initialApplied = true;
_currentSeasonId = seasonId;
_current.value = PlaylistItem(
itemId: episodeId,
mediaSourceId: initialMediaSourceId,
startPositionTicks: initialStartPositionTicks,
preferredAudioStreamIndex: initialPreferredAudioStreamIndex,
preferredSubtitleStreamIndex: initialPreferredSubtitleStreamIndex,
playFromStart: initialPlayFromStart,
displayLabel: preloadedItem != null ? _labelOf(preloadedItem) : '',
preloadedItem: preloadedItem,
);
unawaited(_resolveEpisodeList(seasonId, episodeId, initialIndexNumber));
}
Future<void> _resolveEpisodeList(
String seasonId,
String episodeId,
int? initialIndexNumber,
) async {
try {
await _loadSeasonEpisodes(seasonId);
} catch (e) {
AppLogger.warn(
'SeriesEpisodePlaylist',
'background episode list load failed',
e,
);
return;
}
if (_disposed || _userNavigated) return;
var idx = _episodes.indexWhere((e) => e.Id == episodeId);
if (idx < 0 && initialIndexNumber != null) {
idx = _episodes.indexWhere((e) => e.IndexNumber == initialIndexNumber);
}
AppLogger.debug(
'SeriesEpisodePlaylist',
'episodeListReady seriesId=$seriesId seasonId=$seasonId '
'episodeId=$episodeId idx=$idx '
'episodes=${_episodes.length} '
'episodeIds=${_episodes.map((e) => e.Id).toList()}',
);
if (_episodes.isEmpty) return;
_currentIndex.value = idx < 0 ? 0 : idx;
}
@override
ValueListenable<PlaylistItem> get current => _current;
@override
ValueListenable<int> get currentIndex => _currentIndex;
@override
int get length => _episodes.length;
@override
bool get hasNext => _episodes.isNotEmpty;
@override
bool get hasPrev => _episodes.isNotEmpty;
@override
Future<void> next() async {
if (_disposed || _episodes.isEmpty) return;
final cur = _currentIndex.value;
if (cur < _episodes.length - 1) {
_markMoved();
_setCurrent(cur + 1);
return;
}
await _crossSeason(direction: 1);
}
@override
Future<void> prev() async {
if (_disposed || _episodes.isEmpty) return;
final cur = _currentIndex.value;
if (cur > 0) {
_markMoved();
_setCurrent(cur - 1);
return;
}
await _crossSeason(direction: -1);
}
@override
Future<void> goTo(int index) async {
if (_disposed || index < 0 || index >= _episodes.length) return;
if (index == _currentIndex.value) return;
_markMoved();
_setCurrent(index);
}
@override
Future<void> goToItemId(String itemId) async {
if (_disposed) return;
final idx = _episodes.indexWhere((e) => e.Id == itemId);
if (idx < 0) return;
if (idx == _currentIndex.value) return;
_markMoved();
_setCurrent(idx);
}
Future<void> goToInSeason({
required String seasonId,
required String episodeId,
}) async {
if (_disposed) return;
if (seasonId != _currentSeasonId || _episodes.isEmpty) {
await _loadSeasonEpisodes(seasonId);
if (_disposed) return;
}
final idx = _episodes.indexWhere((e) => e.Id == episodeId);
if (idx < 0) return;
if (_currentSeasonId == seasonId && idx == _currentIndex.value) return;
_markMoved();
_setCurrent(idx);
}
Future<void> _crossSeason({required int direction}) async {
final curSeason = _currentSeasonId;
if (curSeason == null) return;
final seasons = await loadSeasons(seriesId);
if (_disposed) return;
final ordered = [...seasons]
..sort((a, b) => (a.IndexNumber ?? 0).compareTo(b.IndexNumber ?? 0));
final curPos = ordered.indexWhere((s) => s.Id == curSeason);
if (curPos < 0) return;
final targetPos = curPos + direction;
if (targetPos < 0 || targetPos >= ordered.length) return;
final target = ordered[targetPos];
await _loadSeasonEpisodes(target.Id);
if (_disposed || _episodes.isEmpty) return;
_markMoved();
_setCurrent(direction > 0 ? 0 : _episodes.length - 1);
}
Future<void> _loadSeasonEpisodes(String seasonId) async {
final eps = await loadEpisodes(seriesId: seriesId, seasonId: seasonId);
if (_disposed) return;
_currentSeasonId = seasonId;
_episodes = List.unmodifiable(eps);
}
void _markMoved() {
_initialApplied = true;
_userNavigated = true;
}
void _setCurrent(int index) {
_currentIndex.value = index;
_current.value = _itemFromEpisode(_episodes[index]);
}
PlaylistItem _itemFromEpisode(EmbyRawItem ep) {
final useInitial = !_initialApplied;
_initialApplied = true;
return PlaylistItem(
itemId: ep.Id,
mediaSourceId: useInitial ? _initialMediaSourceId : null,
startPositionTicks: useInitial ? _initialStartPositionTicks : null,
preferredAudioStreamIndex: useInitial ? _initialPreferredAudio : null,
preferredSubtitleStreamIndex: useInitial ? _initialPreferredSub : null,
displayLabel: _labelOf(ep),
playFromStart: useInitial && _initialPlayFromStart,
preloadedItem: ep,
);
}
String _labelOf(EmbyRawItem ep) {
final ep0 = ep.IndexNumber;
final name = ep.Name;
if (ep0 != null && name.isNotEmpty) {
return '${formatEpisodeNumber(ep0)} $name';
}
if (name.isNotEmpty) return name;
return ep.Id;
}
@override
void dispose() {
_disposed = true;
_current.dispose();
_currentIndex.dispose();
}
}
@@ -0,0 +1,39 @@
import 'package:flutter/foundation.dart';
import 'playlist.dart';
class SingleItemPlaylist implements Playlist {
SingleItemPlaylist({required PlaylistItem item})
: _current = ValueNotifier<PlaylistItem>(item),
_currentIndex = ValueNotifier<int>(0);
final ValueNotifier<PlaylistItem> _current;
final ValueNotifier<int> _currentIndex;
@override
ValueListenable<PlaylistItem> get current => _current;
@override
ValueListenable<int> get currentIndex => _currentIndex;
@override
int get length => 1;
@override
bool get hasNext => false;
@override
bool get hasPrev => false;
@override
Future<void> next() async {}
@override
Future<void> prev() async {}
@override
Future<void> goTo(int index) async {}
@override
Future<void> goToItemId(String itemId) async {}
@override
void dispose() {
_current.dispose();
_currentIndex.dispose();
}
}
@@ -0,0 +1,138 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../../../core/infra/pip/android_pip_controller.dart';
import '../../../shared/utils/app_logger.dart';
const _tag = 'AndroidPipManager';
class AndroidPipManager {
AndroidPipManager({required this._onStateChanged});
final VoidCallback _onStateChanged;
bool _isInPip = false;
bool _isDisposed = false;
bool _supported = false;
StreamSubscription<bool>? _modeSub;
StreamSubscription<PipAction>? _actionSub;
void Function(PipAction action)? onAction;
bool get isInPip => _isInPip;
bool get isSupported => _supported;
Future<void> initialize() async {
if (!Platform.isAndroid) return;
final supported = await AndroidPipController.instance.isSupported();
if (_isDisposed) return;
if (_supported != supported) {
_supported = supported;
_onStateChanged();
}
if (!_supported) return;
if (_modeSub != null || _actionSub != null) return;
final isInPip = await AndroidPipController.instance.isInPipMode();
if (_isDisposed) return;
if (_isInPip != isInPip) {
_isInPip = isInPip;
_onStateChanged();
}
_modeSub = AndroidPipController.instance.pipModeChanges.listen(
(isInPip) {
if (_isDisposed) return;
if (_isInPip == isInPip) return;
_isInPip = isInPip;
AppLogger.info(_tag, 'PiP mode changed: $isInPip');
_onStateChanged();
},
onError: (Object e) {
AppLogger.warn(_tag, 'PiP mode stream error', e);
},
);
_actionSub = AndroidPipController.instance.actionStream.listen(
(action) {
if (_isDisposed) return;
AppLogger.debug(_tag, 'PiP action: $action');
onAction?.call(action);
},
onError: (Object e) {
AppLogger.warn(_tag, 'PiP action stream error', e);
},
);
}
Future<void> configure({
int aspectX = 16,
int aspectY = 9,
bool autoEnterOnLeave = false,
bool isPlaying = false,
bool hasNext = false,
bool hasPrev = false,
bool useEpisodeActions = true,
}) async {
if (!_supported || _isDisposed) return;
await AndroidPipController.instance.configure(
aspectX: aspectX,
aspectY: aspectY,
autoEnterOnLeave: autoEnterOnLeave,
isPlaying: isPlaying,
hasNext: hasNext,
hasPrev: hasPrev,
useEpisodeActions: useEpisodeActions,
);
}
Future<bool> enter({int? aspectX, int? aspectY}) async {
if (!_supported || _isDisposed || _isInPip) return false;
return AndroidPipController.instance.enterPip(
aspectX: aspectX,
aspectY: aspectY,
);
}
Future<void> updatePlaybackState({
required bool isPlaying,
required bool hasNext,
required bool hasPrev,
required bool useEpisodeActions,
}) async {
if (!_supported || _isDisposed || !_isInPip) return;
await AndroidPipController.instance.updatePlaybackState(
isPlaying: isPlaying,
hasNext: hasNext,
hasPrev: hasPrev,
useEpisodeActions: useEpisodeActions,
);
}
void dispose() {
if (_isDisposed) return;
_isDisposed = true;
final modeSub = _modeSub;
_modeSub = null;
final actionSub = _actionSub;
_actionSub = null;
onAction = null;
unawaited(() async {
await AndroidPipController.instance.configure(
autoEnterOnLeave: false,
isPlaying: false,
);
await modeSub?.cancel();
await actionSub?.cancel();
await AndroidPipController.instance.dispose();
}());
}
}
@@ -0,0 +1,186 @@
import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter/material.dart' show Colors;
import '../../../../core/contracts/danmaku.dart';
import '../../../../core/media/danmaku_comment_pipeline.dart';
const double _kBackwardSeekThreshold = -1.0;
const double _kForwardSeekThreshold = 5.0;
const int _kMaxFeedPerTick = 10;
const double _kMaxFeedLagSec = 1.0;
enum DanmakuPlaybackState { detached, disabled, playing, frozen, seeking }
class DanmakuFeeder {
DanmakuController? _canvas;
List<DanmakuComment> _comments = const [];
DanmakuPanelSettings _settings = const DanmakuPanelSettings();
int _feedIndex = 0;
double _lastPosSec = -1;
DanmakuPlaybackState _state = DanmakuPlaybackState.detached;
double? _frozenPosSec;
void attach(DanmakuController controller) {
_canvas = controller;
_state = _settings.enabled
? DanmakuPlaybackState.frozen
: DanmakuPlaybackState.disabled;
}
void detach() {
_canvas = null;
_state = DanmakuPlaybackState.detached;
}
void setComments(List<DanmakuComment> commentsSortedByTime) {
_comments = commentsSortedByTime;
_canvas?.clear();
_feedIndex = 0;
_lastPosSec = -1;
_frozenPosSec = null;
}
void setPanelSettings(
DanmakuPanelSettings settings, {
DanmakuOption? option,
}) {
_settings = settings;
if (!_settings.enabled) {
_state = DanmakuPlaybackState.disabled;
} else if (_state == DanmakuPlaybackState.disabled) {
_state = _canvas == null
? DanmakuPlaybackState.detached
: DanmakuPlaybackState.frozen;
}
if (option != null) {
try {
_canvas?.updateOption(option);
} catch (e) {
debugPrint('[DanmakuFeeder] updateOption failed: $e; detaching canvas');
_canvas = null;
_state = DanmakuPlaybackState.detached;
}
}
}
void onPosition(double posSec) {
final canvas = _canvas;
if (canvas == null ||
_comments.isEmpty ||
!_settings.enabled ||
_state != DanmakuPlaybackState.playing) {
return;
}
final effectivePos = posSec - _settings.offset;
final last = _lastPosSec;
if (last >= 0 && _isTimelineJump(posSec - last)) {
canvas.clear();
_feedIndex = lowerBoundByTime(_comments, effectivePos);
}
_lastPosSec = posSec;
var fedThisTick = 0;
while (_feedIndex < _comments.length && fedThisTick < _kMaxFeedPerTick) {
final comment = _comments[_feedIndex];
if (comment.time > effectivePos) break;
_feedIndex++;
if (effectivePos - comment.time > _kMaxFeedLagSec) continue;
canvas.addDanmaku(_toContentItem(comment));
fedThisTick++;
}
}
void freezeAt(double posSec) {
final canvas = _canvas;
if (canvas == null) {
_state = DanmakuPlaybackState.detached;
return;
}
if (!_settings.enabled) {
_state = DanmakuPlaybackState.disabled;
return;
}
_state = DanmakuPlaybackState.frozen;
_frozenPosSec = posSec;
_lastPosSec = posSec;
if (canvas.running) {
canvas.pause();
}
}
void playFrom(double posSec) {
final canvas = _canvas;
if (canvas == null) {
_state = DanmakuPlaybackState.detached;
return;
}
if (!_settings.enabled) {
_state = DanmakuPlaybackState.disabled;
return;
}
final frozenPos = _frozenPosSec;
if (_state == DanmakuPlaybackState.frozen &&
frozenPos != null &&
posSec > frozenPos) {
final targetIndex = lowerBoundByTime(
_comments,
posSec - _settings.offset,
);
if (targetIndex > _feedIndex) {
_feedIndex = targetIndex;
}
}
_lastPosSec = posSec;
_frozenPosSec = null;
_state = DanmakuPlaybackState.playing;
if (!canvas.running) {
canvas.resume();
}
}
void seekTo(double posSec, {bool clear = true}) {
if (clear) _canvas?.clear();
_feedIndex = lowerBoundByTime(_comments, posSec - _settings.offset);
_lastPosSec = posSec;
_frozenPosSec = null;
if (_canvas == null) {
_state = DanmakuPlaybackState.detached;
} else {
_state = _settings.enabled
? DanmakuPlaybackState.seeking
: DanmakuPlaybackState.disabled;
}
}
bool _isTimelineJump(double deltaSec) {
return deltaSec < _kBackwardSeekThreshold ||
deltaSec > _kForwardSeekThreshold;
}
DanmakuContentItem _toContentItem(DanmakuComment c) {
final baseColor = _settings.colored ? c.color : Colors.white;
final opacity = _settings.opacity.clamp(0.0, 1.0);
final color = opacity >= 1.0
? baseColor
: baseColor.withValues(alpha: opacity);
return DanmakuContentItem(
c.text,
color: color,
type: switch (c.type) {
4 when !_settings.fixedToScroll => DanmakuItemType.bottom,
5 when !_settings.fixedToScroll => DanmakuItemType.top,
_ => DanmakuItemType.scroll,
},
);
}
}
@@ -0,0 +1,212 @@
import '../../../../core/contracts/cancellation.dart';
import '../../../../core/contracts/danmaku.dart';
import '../../../../core/domain/ports/danmaku_gateway.dart';
import '../../../../core/infra/danmaku_api/danmaku_client.dart';
import '../../../../core/media/danmaku_episode_parser.dart';
import '../../../../core/media/danmaku_match_picker.dart';
import '../../../../shared/utils/app_logger.dart';
const _tag = 'DanmakuMatcher';
typedef _SingleSourceMatcherResult = ({
List<DanmakuMatch> matches,
int pickedIndex,
});
const _emptySingleSourceResult = (matches: <DanmakuMatch>[], pickedIndex: -1);
class DanmakuMatcherResult {
final List<DanmakuMatchCandidate> matches;
final int pickedIndex;
const DanmakuMatcherResult({
required this.matches,
required this.pickedIndex,
});
static const empty = DanmakuMatcherResult(matches: [], pickedIndex: -1);
}
class DanmakuMatcher {
DanmakuMatcher({required this._gateway});
final DanmakuGateway _gateway;
Future<DanmakuMatcherResult> resolve({
required List<DanmakuSource> sources,
required DanmakuMatchContext context,
CancellationToken? cancelToken,
}) async {
if (sources.isEmpty) return DanmakuMatcherResult.empty;
final allMatches = <DanmakuMatchCandidate>[];
var pickedIndex = -1;
for (final source in sources) {
cancelToken?.throwIfCancelled();
_SingleSourceMatcherResult result;
try {
result = await _resolveSingle(
sourceUrl: source.url,
context: context,
cancelToken: cancelToken,
);
} on CancelledException {
rethrow;
} catch (e) {
AppLogger.warn(
_tag,
'source "${source.url}" match failed, skipping',
e,
);
continue;
}
cancelToken?.throwIfCancelled();
if (result.matches.isEmpty) continue;
final baseIndex = allMatches.length;
allMatches.addAll(
result.matches.map(
(match) => DanmakuMatchCandidate(source: source, match: match),
),
);
if (pickedIndex < 0 && result.pickedIndex >= 0) {
pickedIndex = baseIndex + result.pickedIndex;
}
}
if (allMatches.isEmpty) return DanmakuMatcherResult.empty;
return DanmakuMatcherResult(
matches: allMatches,
pickedIndex: pickedIndex < 0 ? 0 : pickedIndex,
);
}
Future<_SingleSourceMatcherResult> _resolveSingle({
required String sourceUrl,
required DanmakuMatchContext context,
CancellationToken? cancelToken,
}) async {
final fileName = buildDanmakuFileName(
name: context.name,
type: context.type,
seriesName: context.seriesName,
season: context.season,
episode: context.episode,
);
final episodeIndex = context.episode;
AppLogger.debug(
_tag,
'match request source="$sourceUrl" fileName="$fileName" '
'durationSec=${context.durationSec} targetEp=$episodeIndex',
);
var matches = await _gateway.match(
sourceUrl,
fileName,
context.durationSec,
);
cancelToken?.throwIfCancelled();
AppLogger.debug(
_tag,
'match received from "$sourceUrl": ${matches.length} entries',
);
if (matches.isEmpty &&
context.type == 'Episode' &&
context.seriesName != null &&
context.seriesName!.isNotEmpty) {
AppLogger.info(
_tag,
'exact match empty, falling back to searchEpisodes source="$sourceUrl" '
'seriesName="${context.seriesName}" ep=$episodeIndex',
);
matches = await _gateway.searchEpisodes(
sourceUrl,
context.seriesName!,
episodeIndex,
);
cancelToken?.throwIfCancelled();
AppLogger.debug(
_tag,
'searchEpisodes fallback: ${matches.length} entries',
);
}
if (matches.isEmpty) return _emptySingleSourceResult;
final pickIndex = pickBestMatchIndex(matches, episodeIndex);
if (episodeIndex != null) {
final pickedEp = extractEpisodeNumberFromTitle(
matches[pickIndex].episodeTitle,
);
if (pickedEp != episodeIndex) {
final corrected = await _correctViaBangumi(
sourceUrl: sourceUrl,
animeId: matches[pickIndex].animeId,
targetEpisode: episodeIndex,
animeTitle: matches[pickIndex].animeTitle,
cancelToken: cancelToken,
);
if (corrected != null) {
AppLogger.info(
_tag,
'bangumi correction applied: episodeId=${corrected.episodeId} '
'title="${corrected.episodeTitle}"',
);
return (matches: [corrected, ...matches], pickedIndex: 0);
}
}
}
return (matches: matches, pickedIndex: pickIndex);
}
Future<DanmakuMatch?> _correctViaBangumi({
required String sourceUrl,
required int animeId,
required int targetEpisode,
required String animeTitle,
CancellationToken? cancelToken,
}) async {
if (animeId <= 0) {
AppLogger.warn(
_tag,
'skip bangumi correction: animeId missing in match response',
);
return null;
}
AppLogger.debug(
_tag,
'bangumi correction probing: animeId=$animeId targetEp=$targetEpisode',
);
final episodes = await _gateway.getBangumiEpisodes(sourceUrl, animeId);
cancelToken?.throwIfCancelled();
if (episodes.isEmpty) {
AppLogger.warn(_tag, 'bangumi $animeId returned no episodes');
return null;
}
for (final ep in episodes) {
final n =
int.tryParse(ep.episodeNumber) ??
extractEpisodeNumberFromTitle(ep.episodeTitle);
if (n == targetEpisode) {
return DanmakuMatch(
episodeId: ep.episodeId,
animeId: animeId,
animeTitle: animeTitle,
episodeTitle: ep.episodeTitle,
);
}
}
AppLogger.warn(
_tag,
'bangumi $animeId has ${episodes.length} episodes but none matches '
'targetEp=$targetEpisode',
);
return null;
}
}
@@ -0,0 +1,279 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/contracts/cancellation.dart';
import '../../../../core/contracts/danmaku.dart';
import '../../../../core/domain/ports/danmaku_gateway.dart';
import '../../../../core/media/danmaku_comment_pipeline.dart';
import '../../../../providers/danmaku_settings_provider.dart';
import '../../../../providers/di_providers.dart';
import '../../../../shared/utils/app_logger.dart';
import 'danmaku_matcher.dart';
const _tag = 'DanmakuSession';
class DanmakuSessionNotifier extends AsyncNotifier<DanmakuSessionState> {
late final DanmakuGateway _gateway;
late final DanmakuMatcher _matcher;
CancellationToken? _token;
String? _currentItemId;
List<DanmakuSource> _sources = const [];
int _chConvert = 0;
@override
Future<DanmakuSessionState> build() async {
_gateway = ref.read(danmakuGatewayProvider);
_matcher = DanmakuMatcher(gateway: _gateway);
ref.onDispose(() => _token?.cancel('notifier disposed'));
return const DanmakuSessionIdle();
}
Future<void> loadFor(DanmakuMatchContext context) async {
final token = _swapToken('loadFor:${context.itemId}');
_currentItemId = context.itemId;
final settings = _readSettings();
final sources = settings.enabledSources;
if (!settings.enabled || sources.isEmpty) {
AppLogger.debug(
_tag,
'loadFor(${context.itemId}) -> Idle: '
'enabled=${settings.enabled} sources=${sources.length}',
);
_sources = const [];
state = const AsyncData(DanmakuSessionIdle());
return;
}
_sources = sources;
_chConvert = _chConvertFromMode(settings.chineseConvert);
state = const AsyncData(DanmakuSessionMatching());
try {
final result = await _matcher.resolve(
sources: _sources,
context: context,
cancelToken: token,
);
if (_isStale(token, context.itemId)) return;
if (result.matches.isEmpty || result.pickedIndex < 0) {
AppLogger.info(
_tag,
'loadFor(${context.itemId}) -> Empty: no matches found',
);
state = const AsyncData(DanmakuSessionEmpty());
return;
}
state = AsyncData(
DanmakuSessionMatched(
matches: result.matches,
selectedIndex: result.pickedIndex,
commentStatus: DanmakuCommentStatus.loading,
),
);
AppLogger.debug(
_tag,
'auto-select index=${result.pickedIndex} '
'(target ep=${context.episode})',
);
await _loadCommentsForCurrent(token: token);
} on CancelledException {
return;
} catch (e, st) {
if (_isStale(token, context.itemId)) return;
AppLogger.error(_tag, 'loadFor failed', e, st);
state = AsyncData(DanmakuSessionFailed(e.toString()));
}
}
Future<void> selectMatch(int index) async {
final current = state.value;
if (current is! DanmakuSessionMatched) return;
if (index < 0 || index >= current.matches.length) return;
if (index == current.selectedIndex &&
current.commentStatus == DanmakuCommentStatus.ready) {
return;
}
final token = _swapToken('selectMatch:$index');
state = AsyncData(
current.copyWith(
selectedIndex: index,
commentStatus: DanmakuCommentStatus.loading,
),
);
await _loadCommentsForCurrent(token: token);
}
Future<void> applyManualSelection({
required DanmakuSource source,
required int episodeId,
required int animeId,
required String animeTitle,
required String episodeTitle,
}) async {
if (source.url.isEmpty || episodeId <= 0) return;
final current = state.value;
final picked = DanmakuMatchCandidate(
source: source,
match: DanmakuMatch(
episodeId: episodeId,
animeId: animeId,
animeTitle: animeTitle,
episodeTitle: episodeTitle,
),
);
final previous = current is DanmakuSessionMatched ? current : null;
final newMatches = previous == null
? [picked]
: [
picked,
...previous.matches.where(
(m) => m.episodeId != episodeId || m.source.url != source.url,
),
];
final token = _swapToken('applyManual:$episodeId');
state = AsyncData(
DanmakuSessionMatched(
matches: newMatches,
selectedIndex: 0,
commentStatus: DanmakuCommentStatus.loading,
),
);
try {
await _loadCommentsForCurrent(token: token);
} catch (_) {
if (_isStale(token, _currentItemId)) return;
if (previous != null) {
state = AsyncData(previous);
} else {
state = const AsyncData(DanmakuSessionEmpty());
}
rethrow;
}
}
Future<List<DanmakuMatchCandidate>?> manualSearch(String query) async {
final trimmed = query.trim();
if (_sources.isEmpty || trimmed.isEmpty) return const [];
final token = _token;
final results = <DanmakuMatchCandidate>[];
for (final source in _sources) {
List<DanmakuMatch> matches;
try {
matches = await _gateway.match(source.url, trimmed, 0);
} on CancelledException {
return null;
} catch (e) {
if (token != null && token.isCancelled) return null;
AppLogger.warn(_tag, 'manualSearch source "${source.url}" failed', e);
continue;
}
if (token != null && token.isCancelled) return null;
results.addAll(
matches.map(
(match) => DanmakuMatchCandidate(source: source, match: match),
),
);
}
return results;
}
Future<List<DanmakuBangumiEpisode>?> listEpisodesForAnime(
DanmakuSource source,
int animeId,
) async {
if (source.url.isEmpty || animeId <= 0) return const [];
final token = _token;
final res = await _gateway.getBangumiEpisodes(source.url, animeId);
if (token != null && token.isCancelled) return null;
return res;
}
Future<void> _loadCommentsForCurrent({
required CancellationToken token,
}) async {
final current = state.value;
if (current is! DanmakuSessionMatched) return;
final selected = current.matches[current.selectedIndex];
final episodeId = selected.episodeId;
final settings = _readSettings();
try {
final raw = await _gateway.fetchComments(
selected.source.url,
episodeId,
chConvert: _chConvert,
);
token.throwIfCancelled();
final comments = processDanmakuComments(
raw,
blockedKeywords: settings.blockedKeywords,
mergeDuplicates: settings.mergeDuplicates,
);
if (_isStale(token, _currentItemId)) return;
AppLogger.debug(
_tag,
'comments ready from "${selected.source.displayName}" '
'for ep=$episodeId: ${comments.length} items',
);
final cur = state.value;
if (cur is DanmakuSessionMatched) {
state = AsyncData(
cur.copyWith(
commentStatus: DanmakuCommentStatus.ready,
comments: comments,
),
);
}
} on CancelledException {
return;
} catch (e) {
if (_isStale(token, _currentItemId)) return;
AppLogger.warn(_tag, 'load comments failed for ep=$episodeId', e);
final cur = state.value;
if (cur is DanmakuSessionMatched) {
state = AsyncData(
cur.copyWith(commentStatus: DanmakuCommentStatus.failed),
);
}
}
}
CancellationToken _swapToken(String reason) {
_token?.cancel(reason);
final t = CancellationToken();
_token = t;
return t;
}
bool _isStale(CancellationToken token, String? itemIdAtStart) {
if (token.isCancelled) return true;
if (token != _token) return true;
if (itemIdAtStart != null && itemIdAtStart != _currentItemId) return true;
return false;
}
DanmakuSettingsState _readSettings() =>
ref.read(danmakuSettingsProvider).value ?? const DanmakuSettingsState();
static int _chConvertFromMode(ChineseConvertMode mode) => switch (mode) {
ChineseConvertMode.toSimplified => 1,
ChineseConvertMode.toTraditional => 2,
ChineseConvertMode.none => 0,
};
}
final danmakuSessionProvider =
AsyncNotifierProvider.autoDispose<
DanmakuSessionNotifier,
DanmakuSessionState
>(DanmakuSessionNotifier.new);
@@ -0,0 +1,105 @@
import 'dart:async';
import '../../../core/contracts/playback.dart';
import '../../../core/domain/ports/emby_gateway.dart';
import '../../../shared/utils/app_logger.dart';
typedef PlaybackReportPayloadBuilder =
PlaybackReportPayload Function({required bool isPaused, String? eventName});
class EmbyPlaybackReporter {
EmbyPlaybackReporter({
required EmbyGateway gateway,
required AuthedRequestContext context,
this.enabled = true,
}) : _gateway = gateway,
_context = context;
static const _tag = 'Reporter';
final EmbyGateway _gateway;
final AuthedRequestContext _context;
final bool enabled;
bool _startedReported = false;
bool _stoppedReported = false;
Timer? _progressTimer;
bool get startedReported => _startedReported;
bool get stoppedReported => _stoppedReported;
Future<void> reportStarted(PlaybackReportPayload payload) async {
if (!enabled) return;
if (_startedReported) return;
_startedReported = true;
try {
await _gateway.reportPlaybackStarted(ctx: _context, payload: payload);
} catch (e) {
AppLogger.warn(_tag, 'Start report failed', e);
}
}
Future<void> reportProgress(PlaybackReportPayload payload) async {
if (!_startedReported) return;
try {
await _gateway.reportPlaybackProgress(ctx: _context, payload: payload);
} catch (e) {
AppLogger.warn(_tag, 'Progress report failed', e);
}
}
Future<void> reportStopped(PlaybackReportPayload payload) async {
if (!enabled) return;
if (!_startedReported) {
AppLogger.debug(_tag, 'stop report skipped: start not reported');
return;
}
if (_stoppedReported) {
AppLogger.debug(_tag, 'stop report skipped: already stopped');
return;
}
_stoppedReported = true;
_progressTimer?.cancel();
final sw = Stopwatch()..start();
try {
await _gateway.reportPlaybackStopped(ctx: _context, payload: payload);
AppLogger.debug(_tag, 'stop report ok in ${sw.elapsedMilliseconds}ms');
} catch (e) {
AppLogger.warn(
_tag,
'Stop report failed after ${sw.elapsedMilliseconds}ms',
e,
);
}
}
void startProgressTimer(PlaybackReportPayloadBuilder payloadBuilder) {
if (!enabled) return;
if (_progressTimer != null) return;
_progressTimer = Timer.periodic(const Duration(seconds: 8), (_) {
if (_startedReported && !_stoppedReported) {
unawaited(
reportProgress(
payloadBuilder(isPaused: false, eventName: 'TimeUpdate'),
),
);
}
});
}
void reset() {
_progressTimer?.cancel();
_progressTimer = null;
_startedReported = false;
_stoppedReported = false;
}
void dispose() {
_progressTimer?.cancel();
_progressTimer = null;
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import '../../../shared/utils/app_logger.dart';
const _tag = 'FrameJank';
@immutable
class FrameDiag {
final double fps;
final double lastBuildMs;
final double lastRasterMs;
final int jankCount;
const FrameDiag({
required this.fps,
required this.lastBuildMs,
required this.lastRasterMs,
required this.jankCount,
});
static const zero = FrameDiag(
fps: 0,
lastBuildMs: 0,
lastRasterMs: 0,
jankCount: 0,
);
}
class FrameJankMonitor {
FrameJankMonitor({this.jankThreshold = const Duration(milliseconds: 32)});
final Duration jankThreshold;
final ValueNotifier<FrameDiag> diag = ValueNotifier<FrameDiag>(
FrameDiag.zero,
);
TimingsCallback? _callback;
int _jankCount = 0;
int _windowFrames = 0;
final Stopwatch _windowClock = Stopwatch()..start();
double _fps = 0;
void start() {
if (kReleaseMode || _callback != null) return;
final cb = _onTimings;
_callback = cb;
SchedulerBinding.instance.addTimingsCallback(cb);
}
void _onTimings(List<FrameTiming> timings) {
if (timings.isEmpty) return;
for (final t in timings) {
if (t.buildDuration >= jankThreshold ||
t.rasterDuration >= jankThreshold) {
_jankCount++;
AppLogger.warn(
_tag,
'janky frame build=${_ms(t.buildDuration)} '
'raster=${_ms(t.rasterDuration)} total=${_ms(t.totalSpan)}',
);
}
}
_windowFrames += timings.length;
final elapsedMs = _windowClock.elapsedMilliseconds;
if (elapsedMs >= 500) {
_fps = _windowFrames * 1000 / elapsedMs;
_windowFrames = 0;
_windowClock.reset();
}
final last = timings.last;
diag.value = FrameDiag(
fps: _fps,
lastBuildMs: last.buildDuration.inMicroseconds / 1000,
lastRasterMs: last.rasterDuration.inMicroseconds / 1000,
jankCount: _jankCount,
);
}
void dispose() {
final cb = _callback;
if (cb != null) {
SchedulerBinding.instance.removeTimingsCallback(cb);
_callback = null;
}
diag.dispose();
}
static String _ms(Duration d) =>
'${(d.inMicroseconds / 1000).toStringAsFixed(1)}ms';
}
@@ -0,0 +1,222 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:screen_retriever/screen_retriever.dart';
import 'package:window_manager/window_manager.dart';
import '../../../shared/utils/app_logger.dart';
class FullscreenManager {
static const _tag = 'Fullscreen';
static bool get _isDesktop => Platform.isWindows || Platform.isMacOS;
FullscreenManager({required this._onStateChanged});
final VoidCallback _onStateChanged;
bool _isFullscreen = false;
bool _isChanging = false;
Rect? _windowBoundsBeforeFullscreen;
bool _wasMaximized = false;
bool _wasResizable = true;
bool _wasAlwaysOnTop = false;
bool _isDisposed = false;
Future<void>? _disposeRestoreFuture;
bool get isFullscreen => _isFullscreen;
Future<void> toggle() async {
if (_isDisposed) return;
if (!_isDesktop) return;
if (_isChanging) return;
_isChanging = true;
final wasFullscreen = _isFullscreen;
try {
if (Platform.isWindows) {
if (wasFullscreen) {
await _leaveWindowsFullscreen();
} else {
await _enterWindowsFullscreen();
}
} else {
await _toggleNativeFullscreen();
}
} catch (error) {
AppLogger.warn(_tag, 'Failed to toggle', error);
if (Platform.isWindows && !wasFullscreen) {
try {
await _leaveWindowsFullscreen(updateState: false);
} catch (_) {}
_setFullscreenState(false);
} else if (!Platform.isWindows) {
final actual = await _readNativeFullscreenState();
if (actual != null) _setFullscreenState(actual);
}
} finally {
_isChanging = false;
}
}
Future<void> leave({bool updateState = true}) async {
if (_isDisposed && updateState) return;
if (!_isFullscreen) return;
try {
if (Platform.isWindows) {
await _leaveWindowsFullscreen(updateState: updateState);
} else {
await windowManager.setFullScreen(false);
}
} catch (_) {}
_isFullscreen = false;
}
void dispose() {
if (_isDisposed) return;
_isDisposed = true;
if (_isFullscreen) {
_scheduleDisposeRestore();
}
}
@visibleForTesting
Future<void>? get disposeRestoreFuture => _disposeRestoreFuture;
void _scheduleDisposeRestore() {
if (_disposeRestoreFuture != null) return;
final restore = _restoreAfterDispose();
_disposeRestoreFuture = restore;
unawaited(restore);
}
Future<void> _restoreAfterDispose() async {
while (_isChanging) {
await Future<void>.delayed(Duration.zero);
}
if (!_isFullscreen) return;
_isChanging = true;
try {
if (Platform.isWindows) {
await _leaveWindowsFullscreen(updateState: false);
} else {
await windowManager.setFullScreen(false);
_isFullscreen = false;
}
} catch (error) {
AppLogger.warn(_tag, 'Failed to restore window on dispose', error);
} finally {
_isChanging = false;
}
}
void _setFullscreenState(bool fullscreen) {
if (_isFullscreen == fullscreen) return;
_isFullscreen = fullscreen;
if (!_isDisposed) {
_onStateChanged();
}
}
Future<bool?> _readNativeFullscreenState() async {
try {
return await windowManager.isFullScreen();
} catch (error) {
AppLogger.warn(_tag, 'Failed to read state', error);
return null;
}
}
Future<void> _toggleNativeFullscreen() async {
final current = await _readNativeFullscreenState() ?? _isFullscreen;
final next = !current;
await windowManager.setFullScreen(next);
final actual = await _readNativeFullscreenState() ?? next;
_setFullscreenState(actual);
if (_isDisposed) {
_scheduleDisposeRestore();
}
}
Future<void> _enterWindowsFullscreen() async {
final currentBounds = await windowManager.getBounds();
final display = await _displayForBounds(currentBounds);
_windowBoundsBeforeFullscreen = currentBounds;
_wasMaximized = await windowManager.isMaximized();
_wasResizable = await windowManager.isResizable();
_wasAlwaysOnTop = await windowManager.isAlwaysOnTop();
if (_wasMaximized) {
await windowManager.unmaximize();
}
await windowManager.setResizable(false);
await windowManager.setAlwaysOnTop(true);
await windowManager.setTitleBarStyle(
TitleBarStyle.hidden,
windowButtonVisibility: false,
);
await windowManager.setBounds(fullscreenBoundsForDisplay(display));
await windowManager.focus();
_setFullscreenState(true);
if (_isDisposed) {
_scheduleDisposeRestore();
}
}
Future<void> _leaveWindowsFullscreen({bool updateState = true}) async {
await windowManager.setTitleBarStyle(
TitleBarStyle.hidden,
windowButtonVisibility: false,
);
await windowManager.setResizable(_wasResizable);
await windowManager.setAlwaysOnTop(_wasAlwaysOnTop);
if (_wasMaximized) {
await windowManager.maximize();
} else {
final bounds = _windowBoundsBeforeFullscreen;
if (bounds != null) {
await windowManager.setBounds(bounds);
}
}
await windowManager.focus();
if (updateState) {
_setFullscreenState(false);
} else {
_isFullscreen = false;
}
}
Future<Display> _displayForBounds(Rect bounds) async {
final displays = await screenRetriever.getAllDisplays();
final center = bounds.center;
for (final display in displays) {
final rect = fullscreenBoundsForDisplay(display);
if (rect.contains(center)) return display;
}
if (displays.isNotEmpty) return displays.first;
return screenRetriever.getPrimaryDisplay();
}
@visibleForTesting
static Rect fullscreenBoundsForDisplay(Display display) {
final position = display.visiblePosition ?? Offset.zero;
final size = display.size;
if (Platform.isWindows) {
const border = 8.0;
return Rect.fromLTWH(
position.dx - border,
position.dy,
size.width + border * 2,
size.height + border,
);
}
return position & size;
}
}
@@ -0,0 +1,103 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
class NetworkSpeedMonitor {
NetworkSpeedMonitor({required this._totalRxBytes, DateTime Function()? now})
: _now = now ?? DateTime.now;
final Future<int?> Function() _totalRxBytes;
final DateTime Function() _now;
final ValueNotifier<double> speed = ValueNotifier<double>(0);
final ValueNotifier<bool> available = ValueNotifier<bool>(false);
Timer? _timer;
int _lastBytes = 0;
int? _latestTotalBytes;
int? get latestTotalBytes => _latestTotalBytes;
DateTime? _lastSampleAt;
int _sampleSessionId = 0;
bool _disposed = false;
bool _sampleInFlight = false;
void start() {
if (_disposed) return;
_stopActiveSampling(resetValue: true);
final sessionId = _sampleSessionId;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_disposed || sessionId != _sampleSessionId || _sampleInFlight) {
return;
}
_sampleInFlight = true;
unawaited(_sample(sessionId));
});
}
void stop() {
_stopActiveSampling(resetValue: true);
}
Future<void> _sample(int sessionId) async {
try {
final totalBytes = await _totalRxBytes();
if (_disposed || sessionId != _sampleSessionId) return;
if (totalBytes == null) {
available.value = false;
speed.value = 0.0;
return;
}
available.value = true;
final sampled = _sampleSpeedFromTotalBytes(totalBytes);
if (sampled != null) {
speed.value = sampled;
}
} finally {
if (!_disposed && sessionId == _sampleSessionId) {
_sampleInFlight = false;
}
}
}
double? _sampleSpeedFromTotalBytes(int totalBytes) {
final now = _now();
final lastSampleAt = _lastSampleAt;
final lastBytes = _lastBytes;
_lastBytes = totalBytes;
_latestTotalBytes = totalBytes;
_lastSampleAt = now;
if (lastSampleAt == null) return null;
if (totalBytes < lastBytes) return 0.0;
final elapsedMs = now.difference(lastSampleAt).inMilliseconds;
final deltaBytes = totalBytes - lastBytes;
if (elapsedMs <= 0 || deltaBytes <= 0) return 0.0;
return deltaBytes * 1000.0 / elapsedMs;
}
void _stopActiveSampling({required bool resetValue}) {
_timer?.cancel();
_timer = null;
_sampleSessionId++;
_sampleInFlight = false;
_lastBytes = 0;
_latestTotalBytes = null;
_lastSampleAt = null;
if (resetValue && !_disposed) {
available.value = false;
speed.value = 0;
}
}
void dispose() {
if (_disposed) return;
_stopActiveSampling(resetValue: false);
_disposed = true;
speed.dispose();
available.dispose();
}
}
@@ -0,0 +1,330 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:screen_retriever/screen_retriever.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
import '../../../shared/utils/app_logger.dart';
class PipManager {
static const _tag = 'Pip';
static const double _aspectRatio = 16 / 9;
static const Size _minPipSize = Size(360, 202.5);
static const Size _defaultPipSize = Size(480, 270);
static const Size _maxPipSize = Size(960, 540);
static const Size _unboundedMaximumSize = Size(100000, 100000);
static const double _edgeMargin = 24;
static const _keyPipX = 'smplayer.pip_last_x';
static const _keyPipY = 'smplayer.pip_last_y';
static const _keyPipWidth = 'smplayer.pip_last_width';
static const _keyPipHeight = 'smplayer.pip_last_height';
static const _keyPipAlwaysOnTop = 'smplayer.pip_always_on_top';
PipManager({required this._onStateChanged});
final VoidCallback _onStateChanged;
bool _isPip = false;
bool _isChanging = false;
bool _isDisposed = false;
Rect? _windowBoundsBefore;
bool _wasMaximized = false;
bool _wasResizable = true;
bool _wasAlwaysOnTop = false;
Future<void>? _disposeRestoreFuture;
Rect? _lastPipBounds;
bool _pipAlwaysOnTop = true;
static bool isActive = false;
bool get isPip => _isPip;
bool get isAlwaysOnTop => _pipAlwaysOnTop;
Future<void> toggle() async {
if (_isDisposed) return;
if (_isChanging) return;
if (_isPip) {
await leave();
} else {
await enter();
}
}
Future<void> enter() async {
if (_isDisposed) return;
if (_isPip || _isChanging) return;
_isChanging = true;
try {
_windowBoundsBefore = await windowManager.getBounds();
_wasMaximized = await windowManager.isMaximized();
_wasResizable = await windowManager.isResizable();
_wasAlwaysOnTop = await windowManager.isAlwaysOnTop();
_pipAlwaysOnTop = await _loadPersistedAlwaysOnTop();
final display = await _tryPrimaryDisplay();
if (_wasMaximized) {
await windowManager.unmaximize();
}
await windowManager.setMinimumSize(_minPipSize);
await windowManager.setMaximumSize(
display == null ? _maxPipSize : pipMaximumSizeForDisplay(display),
);
await windowManager.setAspectRatio(_aspectRatio);
await windowManager.setResizable(true);
await windowManager.setAlwaysOnTop(_pipAlwaysOnTop);
final lastBounds = _lastPipBounds ?? await _loadPersistedPipBounds();
final pipSize = lastBounds != null
? Size(lastBounds.width, lastBounds.height)
: _defaultPipSize;
final bounds =
lastBounds ??
(display == null
? _fallbackPipBounds(pipSize)
: pipBoundsForDisplay(display, pipSize));
await windowManager.setBounds(bounds);
await windowManager.focus();
_setPipState(true);
if (_isDisposed) {
_scheduleDisposeRestore();
}
} catch (error, stack) {
AppLogger.warn(_tag, 'Failed to enter pip', error);
AppLogger.debug(_tag, stack.toString());
try {
await _restoreWindowState(updateState: false);
} catch (_) {}
} finally {
_isChanging = false;
}
}
Future<void> leave({bool updateState = true}) async {
if (_isDisposed && updateState) return;
if (!_isPip) return;
if (_isChanging) {
return;
}
_isChanging = true;
try {
await _restoreWindowState(updateState: updateState);
} finally {
_isChanging = false;
}
}
Future<void> toggleAlwaysOnTop() => setAlwaysOnTop(!_pipAlwaysOnTop);
Future<void> setAlwaysOnTop(bool value) async {
if (_isDisposed) return;
if (!_isPip) return;
if (_pipAlwaysOnTop == value) return;
if (_isChanging) return;
_isChanging = true;
try {
if (_isDisposed || !_isPip) return;
await windowManager.setAlwaysOnTop(value);
_pipAlwaysOnTop = value;
await _persistAlwaysOnTop(value);
if (!_isDisposed) {
_onStateChanged();
}
} catch (error) {
AppLogger.warn(_tag, 'Failed to set pip always-on-top', error);
} finally {
_isChanging = false;
}
}
void dispose() {
if (_isDisposed) return;
_isDisposed = true;
if (_isPip) {
_scheduleDisposeRestore();
}
}
@visibleForTesting
Future<void>? get disposeRestoreFuture => _disposeRestoreFuture;
void _scheduleDisposeRestore() {
if (_disposeRestoreFuture != null) return;
final restore = _restoreAfterDispose();
_disposeRestoreFuture = restore;
unawaited(restore);
}
Future<void> _restoreAfterDispose() async {
while (_isChanging) {
await Future<void>.delayed(Duration.zero);
}
if (!_isPip) return;
_isChanging = true;
try {
await _restoreWindowState(updateState: false);
} finally {
_isChanging = false;
}
}
Future<void> _restoreWindowState({required bool updateState}) async {
try {
final bounds = await windowManager.getBounds();
_lastPipBounds = bounds;
unawaited(_persistPipBounds(bounds));
} catch (_) {}
try {
await windowManager.setAspectRatio(-1);
await windowManager.setMaximumSize(_unboundedMaximumSize);
await windowManager.setMinimumSize(const Size(960, 640));
await windowManager.setAlwaysOnTop(_wasAlwaysOnTop);
await windowManager.setResizable(_wasResizable);
if (_wasMaximized) {
await windowManager.maximize();
} else {
final bounds = _windowBoundsBefore;
if (bounds != null) {
await windowManager.setBounds(bounds);
}
}
await windowManager.focus();
} catch (error) {
AppLogger.warn(_tag, 'Failed to restore window', error);
}
_setPipState(false);
}
void _setPipState(bool pip) {
if (_isPip == pip) return;
_isPip = pip;
PipManager.isActive = pip;
if (!_isDisposed) {
_onStateChanged();
}
}
Future<Display?> _tryPrimaryDisplay() async {
try {
return await screenRetriever.getPrimaryDisplay();
} catch (error) {
AppLogger.warn(_tag, 'Failed to read primary display', error);
return null;
}
}
Rect _fallbackPipBounds(Size size) {
final fallback =
_windowBoundsBefore ?? Rect.fromLTWH(0, 0, size.width, size.height);
return Rect.fromLTWH(fallback.left, fallback.top, size.width, size.height);
}
@visibleForTesting
static Rect pipBoundsForDisplay(Display display, Size size) {
final visibleRect = _visibleRectForDisplay(display);
final fitted = _fitSizeToVisibleArea(size, visibleRect.size);
var left = visibleRect.right - fitted.width - _edgeMargin;
final top = visibleRect.bottom - fitted.height - _edgeMargin;
if (Platform.isWindows) {
const border = 8.0;
left -= border;
}
return Rect.fromLTWH(left, top, fitted.width, fitted.height);
}
@visibleForTesting
static Size pipMaximumSizeForDisplay(Display display) {
final visibleRect = _visibleRectForDisplay(display);
final fitted = _fitSizeToVisibleArea(_maxPipSize, visibleRect.size);
if (fitted.width < _minPipSize.width ||
fitted.height < _minPipSize.height) {
return _minPipSize;
}
return fitted;
}
static Rect _visibleRectForDisplay(Display display) {
final visiblePos = display.visiblePosition ?? Offset.zero;
final visibleSize = display.visibleSize ?? display.size;
return visiblePos & visibleSize;
}
static Size _fitSizeToVisibleArea(Size preferred, Size visibleSize) {
final maxWidth = visibleSize.width - _edgeMargin * 2;
final maxHeight = visibleSize.height - _edgeMargin * 2;
if (maxWidth <= 0 || maxHeight <= 0) return preferred;
var width = preferred.width;
var height = preferred.height;
if (width > maxWidth) {
width = maxWidth;
height = width / _aspectRatio;
}
if (height > maxHeight) {
height = maxHeight;
width = height * _aspectRatio;
}
return Size(width, height);
}
Future<Rect?> _loadPersistedPipBounds() async {
try {
final p = await SharedPreferences.getInstance();
final x = p.getDouble(_keyPipX);
final y = p.getDouble(_keyPipY);
final w = p.getDouble(_keyPipWidth);
final h = p.getDouble(_keyPipHeight);
if (x == null || y == null || w == null || h == null) return null;
if (w <= 0 || h <= 0) return null;
return Rect.fromLTWH(x, y, w, h);
} catch (_) {
return null;
}
}
Future<void> _persistPipBounds(Rect bounds) async {
try {
final p = await SharedPreferences.getInstance();
await Future.wait([
p.setDouble(_keyPipX, bounds.left),
p.setDouble(_keyPipY, bounds.top),
p.setDouble(_keyPipWidth, bounds.width),
p.setDouble(_keyPipHeight, bounds.height),
]);
} catch (_) {}
}
Future<bool> _loadPersistedAlwaysOnTop() async {
try {
final p = await SharedPreferences.getInstance();
return p.getBool(_keyPipAlwaysOnTop) ?? true;
} catch (_) {
return true;
}
}
Future<void> _persistAlwaysOnTop(bool value) async {
try {
final p = await SharedPreferences.getInstance();
await p.setBool(_keyPipAlwaysOnTop, value);
} catch (_) {}
}
}
@@ -0,0 +1,115 @@
import 'dart:async';
import '../../../core/contracts/trakt/trakt_response.dart';
import '../../../core/contracts/trakt/trakt_scrobble_target.dart';
import '../../../core/contracts/trakt/trakt_sync_job.dart';
import '../../../core/domain/ports/authed_trakt_gateway.dart';
import '../../../core/infra/trakt_api/trakt_pending_sync_queue.dart';
import '../../../shared/utils/app_logger.dart';
import 'trakt_scrobble_state_machine.dart';
const _tag = 'TraktScrobble';
class TraktScrobbleReporter {
TraktScrobbleReporter({
required this._gateway,
required this._queue,
required this._target,
required this._readProgress,
this._appVersion,
this._appDate,
this._onSuccess,
this._onError,
});
final AuthedTraktGateway _gateway;
final TraktPendingSyncQueue _queue;
final TraktScrobbleTarget? _target;
final double Function() _readProgress;
final String? _appVersion;
final String? _appDate;
final void Function()? _onSuccess;
final void Function(String error)? _onError;
final _sm = TraktScrobbleStateMachine();
bool get _enabled => _target != null;
Future<void> onPlay() => _dispatch(_sm.onPlay(), _readProgress());
Future<void> onPause() => _dispatch(_sm.onPause(), _readProgress());
Future<void> onCompleted() => _dispatch(_sm.onCompleted(), 100);
Future<void> onStop() =>
_dispatch(_sm.onStop(progress: _readProgress()), _readProgress());
Future<void> _dispatch(TraktScrobbleAction action, double progress) async {
if (!_enabled) return;
final type = _typeFor(action);
if (type == null) return;
final clamped = progress.clamp(0.0, 100.0).toDouble();
final body = _target!.toBody(
progress: clamped,
appVersion: _appVersion,
appDate: _appDate,
);
AppLogger.debug(
_tag,
'scrobble ${type.wire} progress=${clamped.toStringAsFixed(1)} '
'(${_target.kind.name})',
);
TraktScrobbleOutcome outcome;
try {
outcome = await _gateway.scrobble(type, body);
} catch (e) {
AppLogger.debug(_tag, 'scrobble threw, enqueue', e);
await _enqueue(type, body);
return;
}
switch (outcome.cls) {
case TraktResponseClass.success:
case TraktResponseClass.duplicate:
AppLogger.debug(_tag, 'scrobble ${type.wire} ok (${outcome.cls.name})');
await _queue.discardSupersededBy(type: type, body: body);
_onSuccess?.call();
case TraktResponseClass.invalid:
AppLogger.debug(_tag, 'scrobble ${type.wire} invalid → drop');
case TraktResponseClass.rateLimited:
case TraktResponseClass.unauthorized:
case TraktResponseClass.transient:
await _enqueue(type, body);
_onError?.call(outcome.cls.name);
}
}
Future<void> _enqueue(
TraktSyncJobType type,
Map<String, dynamic> body,
) async {
try {
await _queue.enqueue(type: type, body: body);
} catch (e) {
AppLogger.warn(_tag, 'enqueue failed', e);
}
}
static TraktSyncJobType? _typeFor(TraktScrobbleAction action) {
switch (action) {
case TraktScrobbleAction.none:
return null;
case TraktScrobbleAction.start:
return TraktSyncJobType.scrobbleStart;
case TraktScrobbleAction.pause:
return TraktSyncJobType.scrobblePause;
case TraktScrobbleAction.stop:
return TraktSyncJobType.scrobbleStop;
}
}
}
@@ -0,0 +1,45 @@
enum TraktScrobbleState { idle, started, paused, stopped }
enum TraktScrobbleAction { none, start, pause, stop }
class TraktScrobbleStateMachine {
TraktScrobbleState _state = TraktScrobbleState.idle;
TraktScrobbleState get state => _state;
TraktScrobbleAction onPlay() {
if (_state == TraktScrobbleState.started) return TraktScrobbleAction.none;
if (_state == TraktScrobbleState.stopped) return TraktScrobbleAction.none;
_state = TraktScrobbleState.started;
return TraktScrobbleAction.start;
}
TraktScrobbleAction onPause() {
if (_state != TraktScrobbleState.started) return TraktScrobbleAction.none;
_state = TraktScrobbleState.paused;
return TraktScrobbleAction.pause;
}
TraktScrobbleAction onCompleted() {
if (_state == TraktScrobbleState.stopped) return TraktScrobbleAction.none;
if (_state == TraktScrobbleState.idle) return TraktScrobbleAction.none;
_state = TraktScrobbleState.stopped;
return TraktScrobbleAction.stop;
}
TraktScrobbleAction onStop({required double progress}) {
if (_state == TraktScrobbleState.idle ||
_state == TraktScrobbleState.stopped) {
return TraktScrobbleAction.none;
}
_state = TraktScrobbleState.stopped;
if (progress < 1) return TraktScrobbleAction.none;
return TraktScrobbleAction.stop;
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/foundation.dart';
import '../../../core/contracts/playback.dart';
import '../../../providers/subtitle_settings_provider.dart';
enum InitialSubtitleSelectionType { unchanged, disabled, track }
class InitialSubtitleSelection {
final InitialSubtitleSelectionType type;
final EmbySubtitleTrack? track;
const InitialSubtitleSelection._({required this.type, this.track});
static const unchanged = InitialSubtitleSelection._(
type: InitialSubtitleSelectionType.unchanged,
);
static const disabled = InitialSubtitleSelection._(
type: InitialSubtitleSelectionType.disabled,
);
const InitialSubtitleSelection.track(EmbySubtitleTrack selectedTrack)
: this._(type: InitialSubtitleSelectionType.track, track: selectedTrack);
}
InitialSubtitleSelection resolveInitialSubtitleSelection({
required List<EmbySubtitleTrack> subtitles,
required int? preferredSubtitleStreamIndex,
required int? defaultSubtitleStreamIndex,
required RememberedSubtitleTrack? rememberedSubtitle,
}) {
if (preferredSubtitleStreamIndex != null &&
preferredSubtitleStreamIndex >= 0) {
return _selectionForStreamIndex(subtitles, preferredSubtitleStreamIndex);
}
if (rememberedSubtitle != null) {
if (rememberedSubtitle.isNone) {
return InitialSubtitleSelection.disabled;
}
final rememberedStreamIndex = matchRememberedSubtitle(
rememberedSubtitle,
subtitles,
);
if (rememberedStreamIndex != null) {
return _selectionForStreamIndex(subtitles, rememberedStreamIndex);
}
}
if (preferredSubtitleStreamIndex != null &&
preferredSubtitleStreamIndex < 0) {
return InitialSubtitleSelection.disabled;
}
if (defaultSubtitleStreamIndex != null && defaultSubtitleStreamIndex >= 0) {
return _selectionForStreamIndex(subtitles, defaultSubtitleStreamIndex);
}
if (defaultSubtitleStreamIndex == -1) {
return InitialSubtitleSelection.disabled;
}
return InitialSubtitleSelection.unchanged;
}
InitialSubtitleSelection _selectionForStreamIndex(
List<EmbySubtitleTrack> subtitles,
int streamIndex,
) {
final selectedTrack = subtitles
.where((subtitle) => subtitle.index == streamIndex)
.firstOrNull;
return selectedTrack == null
? InitialSubtitleSelection.unchanged
: InitialSubtitleSelection.track(selectedTrack);
}
@visibleForTesting
int? matchRememberedSubtitle(
RememberedSubtitleTrack remembered,
List<EmbySubtitleTrack> subtitles,
) {
if (subtitles.isEmpty) return null;
final wantedLanguage = remembered.language?.toLowerCase();
final wantedTitle = remembered.title?.toLowerCase();
if (wantedLanguage != null && wantedTitle != null) {
for (final subtitle in subtitles) {
if (subtitle.language?.toLowerCase() == wantedLanguage &&
subtitle.title?.toLowerCase() == wantedTitle) {
return subtitle.index;
}
}
}
if (wantedTitle != null) {
for (final subtitle in subtitles) {
if (subtitle.title?.toLowerCase() == wantedTitle) {
return subtitle.index;
}
}
}
if (wantedLanguage != null) {
for (final subtitle in subtitles) {
if (subtitle.language?.toLowerCase() == wantedLanguage) {
return subtitle.index;
}
}
}
for (final subtitle in subtitles) {
if (subtitle.index == remembered.streamIndex) {
return subtitle.index;
}
}
return null;
}
@@ -0,0 +1,3 @@
enum PlaybackPhase { preparing, playing, ended, failed, disposed }
@@ -0,0 +1,198 @@
import '../../../core/contracts/danmaku.dart';
import '../../../core/contracts/library.dart';
import '../../../core/contracts/playback.dart';
import '../../../providers/audio_settings_provider.dart';
import '../../../providers/danmaku_settings_provider.dart';
import '../../../providers/player_settings_provider.dart';
import '../../../providers/subtitle_settings_provider.dart';
import 'playback_target.dart';
String playbackMemoryKey({
required String serverId,
required String userId,
required String itemId,
String? seriesId,
}) => '$serverId:$userId:${seriesId ?? itemId}';
class PlaybackRequest {
final String itemId;
final String? mediaSourceId;
final int? startPositionTicks;
final int? preferredSubtitleStreamIndex;
final int? preferredAudioStreamIndex;
final SubtitleStyleSettings subtitleSettings;
final PlayerSettingsState playerSettings;
final DanmakuSettingsState danmakuSettings;
final AudioSettingsState audioSettings;
final bool playFromStart;
final PlaybackServerIdentity? targetServer;
final EmbyRawItem? preloadedItem;
final String? directStreamUrl;
final Map<String, String> directStreamHeaders;
const PlaybackRequest({
required this.itemId,
this.mediaSourceId,
this.startPositionTicks,
this.preferredSubtitleStreamIndex,
this.preferredAudioStreamIndex,
required this.subtitleSettings,
required this.playerSettings,
required this.danmakuSettings,
this.audioSettings = const AudioSettingsState(),
this.playFromStart = false,
this.targetServer,
this.preloadedItem,
this.directStreamUrl,
this.directStreamHeaders = const <String, String>{},
});
factory PlaybackRequest.fromEpisode({
required EmbyRawItem episode,
required SubtitleStyleSettings subtitleSettings,
required PlayerSettingsState playerSettings,
required DanmakuSettingsState danmakuSettings,
AudioSettingsState audioSettings = const AudioSettingsState(),
}) {
return PlaybackRequest(
itemId: episode.Id,
startPositionTicks: episode.UserData?.PlaybackPositionTicks,
subtitleSettings: subtitleSettings,
playerSettings: playerSettings,
danmakuSettings: danmakuSettings,
audioSettings: audioSettings,
preloadedItem: episode,
);
}
PlaybackRequest copyWith({
String? itemId,
String? mediaSourceId,
int? startPositionTicks,
int? preferredSubtitleStreamIndex,
int? preferredAudioStreamIndex,
SubtitleStyleSettings? subtitleSettings,
PlayerSettingsState? playerSettings,
DanmakuSettingsState? danmakuSettings,
AudioSettingsState? audioSettings,
bool? playFromStart,
PlaybackServerIdentity? targetServer,
Object? preloadedItem = _noChange,
}) {
return PlaybackRequest(
itemId: itemId ?? this.itemId,
mediaSourceId: mediaSourceId ?? this.mediaSourceId,
startPositionTicks: startPositionTicks ?? this.startPositionTicks,
preferredSubtitleStreamIndex:
preferredSubtitleStreamIndex ?? this.preferredSubtitleStreamIndex,
preferredAudioStreamIndex:
preferredAudioStreamIndex ?? this.preferredAudioStreamIndex,
subtitleSettings: subtitleSettings ?? this.subtitleSettings,
playerSettings: playerSettings ?? this.playerSettings,
danmakuSettings: danmakuSettings ?? this.danmakuSettings,
audioSettings: audioSettings ?? this.audioSettings,
playFromStart: playFromStart ?? this.playFromStart,
targetServer: targetServer ?? this.targetServer,
preloadedItem: identical(preloadedItem, _noChange)
? this.preloadedItem
: preloadedItem as EmbyRawItem?,
directStreamUrl: directStreamUrl,
directStreamHeaders: directStreamHeaders,
);
}
}
const Object _noChange = Object();
class PlaybackContext {
final String itemId;
final String itemName;
final String? itemType;
final int? itemIndexNumber;
final String? seriesName;
final String? seriesId;
final String? seasonId;
final String? mediaSourceId;
final String? playSessionId;
final int? runTimeTicks;
final String playMethod;
final bool isDolbyVision;
final List<EmbySubtitleTrack> embySubtitles;
final List<EmbyAudioTrack> embyAudioTracks;
final List<EmbyRawMediaSource> allMediaSources;
final Map<String, String> providerIds;
final Map<String, String> seriesProviderIds;
final int? parentIndexNumber;
final DanmakuMatchContext danmakuMatchContext;
final String serverId;
final String userId;
final String baseUrl;
final String token;
final String? logoUrl;
const PlaybackContext({
required this.itemId,
required this.itemName,
required this.itemType,
required this.itemIndexNumber,
required this.seriesName,
required this.seriesId,
required this.seasonId,
required this.mediaSourceId,
required this.playSessionId,
required this.runTimeTicks,
required this.playMethod,
this.isDolbyVision = false,
required this.danmakuMatchContext,
required this.serverId,
required this.userId,
required this.baseUrl,
required this.token,
this.embySubtitles = const [],
this.embyAudioTracks = const [],
this.allMediaSources = const [],
this.providerIds = const {},
this.seriesProviderIds = const {},
this.parentIndexNumber,
this.logoUrl,
});
String get memoryKey => playbackMemoryKey(
serverId: serverId,
userId: userId,
itemId: itemId,
seriesId: seriesId,
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,616 @@
import 'dart:async';
import '../../../core/contracts/auth.dart';
import '../../../core/contracts/cancellation.dart';
import '../../../core/contracts/danmaku.dart';
import '../../../core/contracts/library.dart';
import '../../../core/contracts/playback.dart';
import '../../../core/contracts/trakt/trakt_scrobble_target.dart';
import '../../../core/domain/ports/authed_trakt_gateway.dart';
import '../../../core/domain/ports/emby_gateway.dart';
import '../../../core/infra/emby_api/emby_headers.dart';
import '../../../core/infra/trakt_api/trakt_media_matcher.dart';
import '../../../core/infra/trakt_api/trakt_pending_sync_queue.dart';
import '../../../core/infra/emby_api/playback_resolver.dart';
import '../../../core/infra/player_engine/fvp_player_engine.dart';
import '../../../core/infra/player_engine/media3_player_engine.dart';
import '../../../core/infra/player_engine/media_kit_player_engine.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../../../core/media/external_subtitle_loader.dart';
import '../../../core/media/media_capability_analyzer.dart';
import '../../../core/media/media_source_metadata_resolver.dart';
import '../../../providers/audio_settings_provider.dart';
import '../../../providers/player_engine_override_provider.dart';
import '../../../providers/player_settings_provider.dart';
import '../../../providers/subtitle_settings_provider.dart';
import '../../../shared/mappers/media_image_url.dart';
import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/emby_ticks.dart';
import '../services/danmaku/danmaku_feeder.dart';
import '../services/emby_playback_reporter.dart';
import '../services/network_speed_monitor.dart';
import '../services/trakt_scrobble_reporter.dart';
import 'initial_subtitle_resolver.dart';
import 'playback_request.dart';
import 'playback_session.dart';
import 'player_engine_holder.dart';
import 'player_engine_resolution.dart';
const _tag = 'PlaybackSessionBuilder';
const _seriesIdentitySoftTimeout = Duration(milliseconds: 800);
const _engineOpenTimeout = Duration(seconds: 60);
class PlaybackSessionBuilder {
PlaybackSessionBuilder({
required this.resolver,
required this.gateway,
required this.metadataResolver,
required this.engineHolder,
required this.readActiveSession,
required this.deviceId,
required this.deviceName,
required this.playerSettingsNotifier,
required this.audioSettingsNotifier,
required this.subtitleSettingsNotifier,
required this.readEngineOverride,
this.loadItemDetail,
this.traktGateway,
this.traktQueue,
this.traktAppVersion,
this.traktOnSuccess,
this.traktOnError,
});
final PlaybackResolver resolver;
final EmbyGateway gateway;
final MediaSourceMetadataResolver metadataResolver;
final PlayerEngineHolder engineHolder;
final AuthedTraktGateway? traktGateway;
final TraktPendingSyncQueue? traktQueue;
final String? traktAppVersion;
final void Function()? traktOnSuccess;
final void Function(String error)? traktOnError;
Future<bool>? _traktConnectedCache;
final AuthedSession Function() readActiveSession;
final PlayerEngineOverride Function() readEngineOverride;
final String deviceId;
final String deviceName;
final PlayerSettingsNotifier playerSettingsNotifier;
final AudioSettingsNotifier audioSettingsNotifier;
final SubtitleSettingsNotifier subtitleSettingsNotifier;
final Future<EmbyRawItem> Function(String serverId, String itemId)?
loadItemDetail;
Future<PlaybackSession> build({
required PlaybackRequest request,
required DanmakuFeeder feeder,
CancellationToken? externalCancel,
Future<void>? priorDisposeFuture,
Map<String, dynamic>? preloadedPlaybackInfo,
PlayerEngineKind? forcedEngineKind,
void Function(PlayerEngineKind kind, bool isDolbyVision)? onEngineResolved,
}) async {
externalCancel?.throwIfCancelled();
final directStreamUrl = request.directStreamUrl?.trim();
final isDirectStream = directStreamUrl?.isNotEmpty ?? false;
final identity = request.targetServer;
final String effectiveServerId;
final String effectiveServerUrl;
final String effectiveToken;
final String effectiveUserId;
if (identity != null) {
effectiveServerId = identity.serverId;
effectiveServerUrl = identity.baseUrl;
effectiveToken = identity.token;
effectiveUserId = identity.userId;
} else if (isDirectStream) {
effectiveServerId = '';
effectiveServerUrl = '';
effectiveToken = '';
effectiveUserId = '';
} else {
final session = readActiveSession();
effectiveServerId = session.serverId;
effectiveServerUrl = session.serverUrl;
effectiveToken = session.token;
effectiveUserId = session.userId;
}
final ctx = AuthedRequestContext(
baseUrl: effectiveServerUrl,
token: effectiveToken,
userId: effectiveUserId,
);
final traktConnectedFuture = isDirectStream
? Future<bool>.value(false)
: _readTraktConnected();
final PlaybackRequestResult result;
if (isDirectStream) {
final item =
request.preloadedItem ??
EmbyRawItem(Id: request.itemId, Name: request.itemId, Type: 'Video');
result = PlaybackRequestResult(
streamUrl: directStreamUrl!,
directStream: true,
item: item,
startPositionSeconds: request.playFromStart
? 0
: (request.startPositionTicks ?? 0) / kTicksPerSecond,
durationSeconds: item.RunTimeTicks == null
? null
: item.RunTimeTicks! / kTicksPerSecond,
playMethod: 'DirectStream',
);
} else {
result = await resolver.prepare(
ctx: ctx,
payload: PlaybackRequestPayload(
itemId: request.itemId,
mediaSourceId: request.mediaSourceId,
startPositionTicks: request.startPositionTicks,
playFromStart: request.playFromStart,
),
preloadedItem: request.preloadedItem,
preloadedPlaybackInfo: preloadedPlaybackInfo,
);
}
externalCancel?.throwIfCancelled();
final providerIds = providerIdsOfItem(result.item);
final parentIndexNumber = (result.item.extra['ParentIndexNumber'] as num?)
?.toInt();
final memoryKey = playbackMemoryKey(
serverId: effectiveServerId,
userId: effectiveUserId,
itemId: result.item.Id,
seriesId: result.item.SeriesId,
);
final rememberedAudioFuture =
!isDirectStream && request.audioSettings.rememberAudioTrack
? _loadRememberedAudio(memoryKey)
: Future<RememberedAudioTrack?>.value();
final rememberedSubtitleFuture = isDirectStream
? Future<RememberedSubtitleTrack?>.value()
: _loadRememberedSubtitle(memoryKey);
final directStreamHeaders = Map<String, String>.unmodifiable(
request.directStreamHeaders,
);
final streamHeaders = isDirectStream
? directStreamHeaders
: EmbyRequestHeaders.media(
deviceId: deviceId,
deviceName: deviceName,
token: effectiveToken,
userId: effectiveUserId,
);
final engineHeaders = isDirectStream
? directStreamHeaders
: EmbyRequestHeaders.directMedia();
final externalSubtitleLoader = ExternalSubtitleLoader();
if (!isDirectStream) {
unawaited(
_prefetchInitialSubtitle(
request: request,
prepareResult: result,
rememberedSubtitleFuture: rememberedSubtitleFuture,
loader: externalSubtitleLoader,
streamHeaders: streamHeaders,
),
);
}
final seriesIdentityFuture = isDirectStream
? Future<
({Map<String, String> providerIds, int? productionYear})?
>.value()
: _loadSeriesIdentity(
ctx: ctx,
serverId: effectiveServerId,
item: result.item,
providerIds: providerIds,
traktConnectedFuture: traktConnectedFuture,
externalCancel: externalCancel,
);
final probeResult = isDirectStream
? null
: await metadataResolver.resolve(
source: result.mediaSource,
streamUrl: result.streamUrl,
item: result.item,
timeout: const Duration(seconds: 5),
);
externalCancel?.throwIfCancelled();
final capability = MediaCapabilityAnalyzer.analyze(
result.mediaSource,
probeResult: probeResult,
);
final isDolbyVision =
capability.rangeCategory == VideoRangeCategory.dolbyVision;
final isHdr = switch (capability.rangeCategory) {
VideoRangeCategory.hdr10 || VideoRangeCategory.hlg => true,
_ => false,
};
final engineOverride = readEngineOverride();
final engineKind =
forcedEngineKind ??
resolvePlayerEngineKind(
engineOverride,
isDolbyVision: isDolbyVision,
videoCodec: capability.codec,
);
AppLogger.debug(
_tag,
'route override=${engineOverride.name} '
'range=${capability.rangeCategory.name} isDV=$isDolbyVision isHDR=$isHdr '
'forced=${forcedEngineKind?.name ?? '<none>'} -> ${engineKind.name}',
);
onEngineResolved?.call(engineKind, isDolbyVision);
if (priorDisposeFuture != null) {
await priorDisposeFuture;
externalCancel?.throwIfCancelled();
}
final engine = await engineHolder.acquire(
engineKind,
() => createEngine(engineKind),
);
externalCancel?.throwIfCancelled();
if (engine is FvpPlayerEngine) {
engine
..setDolbyVision(isDolbyVision)
..configureHardwareDecoding(request.playerSettings.hwdecMode);
}
final initialVolume = request.audioSettings.volumeBoost
? request.playerSettings.volume
: request.playerSettings.volume.clamp(0.0, 100.0);
await engine.setVolume(initialVolume);
externalCancel?.throwIfCancelled();
final isTranscode = result.playMethod == 'Transcode';
final startSeconds = result.startPositionSeconds;
final startDuration = (!isTranscode && startSeconds > 0)
? Duration(milliseconds: (startSeconds * 1000).round())
: Duration.zero;
externalCancel?.throwIfCancelled();
AppLogger.debug(
_tag,
'open engine=${engineKind.name} method=${result.playMethod} '
'start=${startDuration.inSeconds}s itemId=${request.itemId}',
);
final engineOpenFuture = engine.open(
result.streamUrl,
start: startDuration,
play: false,
headers: engineHeaders,
cancel: externalCancel,
);
final awaitEngineOpenInBuild = !engine.usesPlatformSurface;
final reporter = EmbyPlaybackReporter(
gateway: gateway,
context: ctx,
enabled: !isDirectStream,
);
final speedMonitor = NetworkSpeedMonitor(
totalRxBytes: engine.totalRxBytes,
);
final seriesIdentity = await seriesIdentityFuture;
externalCancel?.throwIfCancelled();
final seriesProviderIds =
seriesIdentity?.providerIds ?? const <String, String>{};
final seriesProductionYear = seriesIdentity?.productionYear;
final context = PlaybackContext(
itemId: result.item.Id,
itemName: result.item.Name,
itemType: result.item.Type,
itemIndexNumber: result.item.IndexNumber,
seriesName: result.item.SeriesName,
seriesId: result.item.SeriesId,
seasonId: result.item.SeasonId,
mediaSourceId: result.mediaSource?.Id,
playSessionId: result.playSessionId,
runTimeTicks: result.item.RunTimeTicks,
playMethod: result.playMethod,
isDolbyVision: isDolbyVision,
embySubtitles: result.subtitles,
embyAudioTracks: result.audioTracks,
allMediaSources: mediaSourcesOfItem(result.item),
providerIds: providerIds,
seriesProviderIds: seriesProviderIds,
parentIndexNumber: parentIndexNumber,
danmakuMatchContext: _buildDanmakuMatchContext(result.item),
serverId: effectiveServerId,
userId: effectiveUserId,
baseUrl: effectiveServerUrl,
token: effectiveToken,
logoUrl: _buildLogoUrl(
result.item,
baseUrl: effectiveServerUrl,
token: effectiveToken,
),
);
final rememberedAudioTrack = await rememberedAudioFuture;
final rememberedPrimarySubtitle = await rememberedSubtitleFuture;
final traktConnected = await traktConnectedFuture;
final traktReporterFactory = isDirectStream
? null
: await _buildTraktReporterFactory(
item: result.item,
providerIds: providerIds,
seriesProviderIds: seriesProviderIds,
seriesProductionYear: seriesProductionYear,
parentIndexNumber: parentIndexNumber,
traktConnected: traktConnected,
);
externalCancel?.throwIfCancelled();
if (awaitEngineOpenInBuild) {
try {
await _runWithTimeout(engineOpenFuture, _engineOpenTimeout);
externalCancel?.throwIfCancelled();
} catch (_) {
try {
await engine.stop();
} catch (e) {
AppLogger.warn(_tag, 'engine.stop after open failure failed', e);
}
rethrow;
}
}
return PlaybackSession(
engine: engine,
reporter: reporter,
speedMonitor: speedMonitor,
feeder: feeder,
context: context,
request: request,
prepareResult: result,
streamHeaders: streamHeaders,
pendingEngineOpenFuture: awaitEngineOpenInBuild ? null : engineOpenFuture,
playerSettingsNotifier: playerSettingsNotifier,
traktReporterFactory: traktReporterFactory,
rememberedAudioTrack: rememberedAudioTrack,
rememberedPrimarySubtitle: rememberedPrimarySubtitle,
externalSubtitleLoader: externalSubtitleLoader,
);
}
Future<void> _prefetchInitialSubtitle({
required PlaybackRequest request,
required PlaybackRequestResult prepareResult,
required Future<RememberedSubtitleTrack?> rememberedSubtitleFuture,
required ExternalSubtitleLoader loader,
required Map<String, String> streamHeaders,
}) async {
try {
final rememberedSubtitle = await rememberedSubtitleFuture;
final selection = resolveInitialSubtitleSelection(
subtitles: prepareResult.subtitles,
preferredSubtitleStreamIndex: request.preferredSubtitleStreamIndex,
defaultSubtitleStreamIndex: prepareResult.defaultSubtitleIndex,
rememberedSubtitle: rememberedSubtitle,
);
final selectedTrack = selection.track;
final url = selectedTrack?.deliveryUrl;
final canPrefetch =
selection.type == InitialSubtitleSelectionType.track &&
selectedTrack != null &&
url != null &&
url.isNotEmpty &&
PlaybackResolver.isTextSubtitleCodec(selectedTrack.codec);
if (!canPrefetch) return;
AppLogger.debug(
_tag,
'prefetch initial subtitle index=${selectedTrack.index} '
'external=${selectedTrack.isExternal}',
);
await loader.load(
url,
headers: streamHeaders,
receiveTimeout: selectedTrack.isExternal
? null
: embeddedSubtitleFetchTimeout,
);
} catch (error) {
AppLogger.debug(_tag, 'initial subtitle prefetch failed', error);
}
}
Future<void> _runWithTimeout(Future<void> future, Duration timeout) {
return future.timeout(
timeout,
onTimeout: () {
throw TimeoutException('engine.open exceeded $timeout', timeout);
},
);
}
static PlayerEngine createEngine(PlayerEngineKind kind) {
switch (kind) {
case PlayerEngineKind.fvp:
return FvpPlayerEngine();
case PlayerEngineKind.mpv:
return MediaKitPlayerEngine();
case PlayerEngineKind.media3:
return Media3PlayerEngine();
}
}
Future<TraktScrobbleReporter Function(double Function())?>
_buildTraktReporterFactory({
required EmbyRawItem item,
required Map<String, String> providerIds,
required Map<String, String> seriesProviderIds,
required int? seriesProductionYear,
required int? parentIndexNumber,
required bool traktConnected,
}) async {
final tGateway = traktGateway;
final tQueue = traktQueue;
if (tGateway == null || tQueue == null) return null;
final target = TraktMediaMatcher.match(
itemType: item.Type,
itemName: item.Name,
productionYear: item.ProductionYear,
providerIds: providerIds,
seriesProviderIds: seriesProviderIds,
seriesName: item.SeriesName,
seriesProductionYear: seriesProductionYear,
parentIndexNumber: parentIndexNumber,
itemIndexNumber: item.IndexNumber,
);
if (target == null) {
AppLogger.debug(_tag, 'trakt: no match for ${item.Type} "${item.Name}"');
return null;
}
if (!traktConnected) {
AppLogger.debug(_tag, 'trakt: not connected, scrobble disabled');
return null;
}
final TraktScrobbleTarget matched = target;
return (readProgress) => TraktScrobbleReporter(
gateway: tGateway,
queue: tQueue,
target: matched,
readProgress: readProgress,
appVersion: traktAppVersion,
onSuccess: traktOnSuccess,
onError: traktOnError,
);
}
Future<bool> _readTraktConnected() {
final tGateway = traktGateway;
if (tGateway == null || traktQueue == null) {
return Future<bool>.value(false);
}
return _traktConnectedCache ??= tGateway.isConnected().catchError((
Object e,
) {
AppLogger.debug(_tag, 'trakt: isConnected failed', e);
return false;
});
}
Future<RememberedAudioTrack?> _loadRememberedAudio(String key) async {
try {
return await audioSettingsNotifier.getLastAudioTrack(key);
} catch (e) {
AppLogger.warn(_tag, 'load remembered audio failed', e);
return null;
}
}
Future<RememberedSubtitleTrack?> _loadRememberedSubtitle(String key) async {
try {
return await subtitleSettingsNotifier.getLastSubtitleTrack(key);
} catch (e) {
AppLogger.warn(_tag, 'load remembered subtitle failed', e);
return null;
}
}
Future<({Map<String, String> providerIds, int? productionYear})?>
_loadSeriesIdentity({
required AuthedRequestContext ctx,
required String serverId,
required EmbyRawItem item,
required Map<String, String> providerIds,
required Future<bool> traktConnectedFuture,
CancellationToken? externalCancel,
}) async {
if (item.Type != 'Episode' || !(item.SeriesId?.isNotEmpty ?? false)) {
return null;
}
final traktConnected = await traktConnectedFuture;
externalCancel?.throwIfCancelled();
if (!traktConnected) return null;
try {
final loadItem = loadItemDetail;
final Future<EmbyRawItem?> future = loadItem != null
? loadItem(serverId, item.SeriesId!).then<EmbyRawItem?>((v) => v)
: gateway
.getItemDetail(ctx, item.SeriesId!)
.then<EmbyRawItem?>((v) => v);
final seriesItem = await future.timeout(
_seriesIdentitySoftTimeout,
onTimeout: () {
AppLogger.debug(
_tag,
'fetch series identity timed out after '
'${_seriesIdentitySoftTimeout.inMilliseconds}ms',
);
return null;
},
);
externalCancel?.throwIfCancelled();
if (seriesItem == null) return null;
return (
providerIds: providerIdsOfItem(seriesItem),
productionYear: seriesItem.ProductionYear,
);
} on CancelledException {
rethrow;
} catch (e) {
AppLogger.warn(_tag, 'fetch series identity failed', e);
return null;
}
}
static DanmakuMatchContext _buildDanmakuMatchContext(EmbyRawItem item) {
return DanmakuMatchContext(
itemId: item.Id,
name: item.Name,
type: item.Type,
seriesName: item.SeriesName,
season: (item.extra['ParentIndexNumber'] as num?)?.toInt(),
episode: item.IndexNumber,
durationSec: ((item.RunTimeTicks ?? 0) / kTicksPerSecond).round(),
);
}
static String? _buildLogoUrl(
EmbyRawItem item, {
required String baseUrl,
required String token,
}) {
final isEpisode = item.Type == 'Episode';
final logoItemId = isEpisode && (item.SeriesId?.isNotEmpty ?? false)
? item.SeriesId!
: item.Id;
final logoTag = isEpisode ? null : item.ImageTags?['Logo'];
return EmbyImageUrl.logo(
baseUrl: baseUrl,
token: token,
itemId: logoItemId,
tag: logoTag,
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/foundation.dart';
import '../../../core/contracts/library.dart';
@immutable
class PlaybackServerIdentity {
const PlaybackServerIdentity({
required this.serverId,
required this.baseUrl,
required this.token,
required this.userId,
required this.serverName,
});
factory PlaybackServerIdentity.fromCard(CrossServerSourceCard card) =>
PlaybackServerIdentity(
serverId: card.serverId,
baseUrl: card.serverUrl,
token: card.token,
userId: card.userId,
serverName: card.serverName,
);
final String serverId;
final String baseUrl;
final String token;
final String userId;
final String serverName;
@override
bool operator ==(Object other) =>
other is PlaybackServerIdentity &&
other.serverId == serverId &&
other.baseUrl == baseUrl &&
other.token == token &&
other.userId == userId &&
other.serverName == serverName;
@override
int get hashCode => Object.hash(serverId, baseUrl, token, userId, serverName);
@override
String toString() =>
'PlaybackServerIdentity(serverId=$serverId, serverName=$serverName)';
}
@@ -0,0 +1,43 @@
import '../../../core/infra/player_engine/player_engine.dart';
import 'player_engine_resolution.dart';
class PlayerEngineHolder {
PlayerEngine? _engine;
PlayerEngineKind? _kind;
PlayerEngine? get current => _engine;
Future<PlayerEngine> acquire(
PlayerEngineKind kind,
PlayerEngine Function() create,
) async {
final existing = _engine;
if (existing != null && _kind == kind) {
return existing;
}
_engine = null;
_kind = null;
if (existing != null) {
await existing.dispose();
}
final next = create();
_engine = next;
_kind = kind;
return next;
}
Future<void> dispose() async {
final engine = _engine;
_engine = null;
_kind = null;
if (engine != null) {
await engine.dispose();
}
}
}
@@ -0,0 +1,59 @@
import 'dart:io' show Platform;
import '../../../providers/player_engine_override_provider.dart';
enum PlayerEngineKind { fvp, mpv, media3 }
PlayerEngineKind? playerEngineKindFromDisplayName(String displayName) {
return switch (displayName) {
'fvp' => PlayerEngineKind.fvp,
'mpv' => PlayerEngineKind.mpv,
'media3' => PlayerEngineKind.media3,
_ => null,
};
}
PlayerEngineKind? nextAutoFallbackEngine({
required PlayerEngineOverride override,
required PlayerEngineKind? failedEngineKind,
required bool isDolbyVision,
}) {
if (override != PlayerEngineOverride.auto) return null;
return switch (failedEngineKind) {
PlayerEngineKind.media3 =>
isDolbyVision ? PlayerEngineKind.fvp : PlayerEngineKind.mpv,
PlayerEngineKind.mpv => PlayerEngineKind.fvp,
PlayerEngineKind.fvp || null => null,
};
}
PlayerEngineKind resolvePlayerEngineKind(
PlayerEngineOverride override, {
required bool isDolbyVision,
String? videoCodec,
bool? isAndroid,
}) {
final onAndroid = isAndroid ?? Platform.isAndroid;
final codec = videoCodec?.toLowerCase();
final isProRes = codec == 'prores';
switch (override) {
case PlayerEngineOverride.fvp:
return PlayerEngineKind.fvp;
case PlayerEngineOverride.mpv:
return PlayerEngineKind.mpv;
case PlayerEngineOverride.media3:
if (!onAndroid) return PlayerEngineKind.mpv;
if (isProRes) return PlayerEngineKind.fvp;
return PlayerEngineKind.media3;
case PlayerEngineOverride.auto:
if (onAndroid) {
if (isProRes) return PlayerEngineKind.fvp;
return PlayerEngineKind.media3;
}
if (isDolbyVision || isProRes) return PlayerEngineKind.fvp;
return PlayerEngineKind.mpv;
}
}
@@ -0,0 +1,77 @@
import 'dart:async';
class RelinkScheduler {
RelinkScheduler({
this.maxAttempts = 3,
this.backoffs = const [
Duration.zero,
Duration(seconds: 2),
Duration(seconds: 5),
],
}) : assert(maxAttempts > 0),
assert(backoffs.isNotEmpty);
final int maxAttempts;
final List<Duration> backoffs;
int _attempts = 0;
bool _pending = false;
Timer? _backoffTimer;
Timer? _sustainTimer;
bool get pending => _pending;
int get attempts => _attempts;
bool schedule({
required void Function(Duration delay) onRelink,
required void Function() onGiveUp,
}) {
if (_pending) return false;
_sustainTimer?.cancel();
if (_attempts >= maxAttempts) {
onGiveUp();
return false;
}
final backoffIndex = _attempts.clamp(0, backoffs.length - 1);
final delay = backoffs[backoffIndex];
_attempts++;
_pending = true;
_backoffTimer?.cancel();
_backoffTimer = Timer(delay, () {
_pending = false;
onRelink(delay);
});
return true;
}
void armSustain({Duration sustain = const Duration(seconds: 30)}) {
_sustainTimer?.cancel();
_sustainTimer = Timer(sustain, () => _attempts = 0);
}
void cancelPending() {
_backoffTimer?.cancel();
_pending = false;
}
void reset() => _attempts = 0;
void dispose() {
_backoffTimer?.cancel();
_sustainTimer?.cancel();
_pending = false;
}
}
@@ -0,0 +1,77 @@
class StallDetector {
StallDetector({this.threshold = const Duration(seconds: 15)})
: assert(threshold > Duration.zero);
final Duration threshold;
Duration? _lastPos;
Duration? _lastBuffer;
int? _lastBytes;
Duration _lastAdvanceAt = Duration.zero;
bool _fired = false;
bool update({
required Duration position,
required Duration bufferedPosition,
required int? bytesDownloaded,
required bool isActivelyPlaying,
required Duration now,
}) {
if (!isActivelyPlaying) {
_lastPos = position;
_lastBuffer = bufferedPosition;
_lastBytes = bytesDownloaded;
_lastAdvanceAt = now;
return false;
}
if (_lastPos == null) {
_lastPos = position;
_lastBuffer = bufferedPosition;
_lastBytes = bytesDownloaded;
_lastAdvanceAt = now;
return false;
}
final positionAdvanced = position != _lastPos;
final bytesBecameAvailable = bytesDownloaded != null && _lastBytes == null;
final hasComparableByteSamples =
bytesDownloaded != null && _lastBytes != null;
final bytesAdvanced =
hasComparableByteSamples && bytesDownloaded != _lastBytes;
final bufferAdvanced = bufferedPosition != _lastBuffer;
final hasBufferedMedia =
bufferedPosition - position > const Duration(seconds: 2);
final transportAdvanced = bytesBecameAvailable ||
(hasComparableByteSamples
? bytesAdvanced
: bufferAdvanced || hasBufferedMedia);
final progressed = positionAdvanced || transportAdvanced;
_lastPos = position;
_lastBuffer = bufferedPosition;
_lastBytes = bytesDownloaded;
if (progressed) {
_lastAdvanceAt = now;
_fired = false;
return false;
}
if (_fired) return false;
if (now - _lastAdvanceAt >= threshold) {
_fired = true;
return true;
}
return false;
}
void reset() {
_lastPos = null;
_lastBuffer = null;
_lastBytes = null;
_lastAdvanceAt = Duration.zero;
_fired = false;
}
}
@@ -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),
),
],
),
);
}
}