Initial commit
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user