Files

1277 lines
42 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:go_router/go_router.dart';
import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import '../../core/contracts/cancellation.dart';
import '../../core/contracts/danmaku.dart';
import '../../core/contracts/auth.dart';
import '../../core/contracts/library.dart';
import '../../core/domain/ports/authed_trakt_gateway.dart';
import '../../core/infra/player_engine/media3_player_engine.dart';
import '../../core/infra/player_engine/player_engine.dart';
import '../../core/infra/system_audio/system_audio_controller.dart';
import '../../core/infra/trakt_api/trakt_pending_sync_queue.dart';
import '../../core/media/media_probe_service.dart';
import '../../core/media/media_source_metadata_resolver.dart';
import '../../providers/trakt_scrobble_status_provider.dart';
import '../../shared/app_info.dart';
import '../../shared/constants/breakpoints.dart';
import '../settings/settings_page.dart';
import '../../providers/audio_settings_provider.dart';
import '../../providers/danmaku_settings_provider.dart';
import '../../providers/di_providers.dart';
import '../../providers/last_played_version_store.dart';
import '../../providers/playback_active_provider.dart';
import '../../providers/player_engine_override_provider.dart';
import '../../providers/player_settings_provider.dart';
import '../../providers/emby_headers_provider.dart';
import '../../providers/playback_info_prefetch_provider.dart';
import '../../providers/session_provider.dart';
import '../../providers/subtitle_settings_provider.dart';
import '../../shared/utils/app_logger.dart';
import '../../shared/utils/cross_server_search_key.dart';
import '../../shared/utils/emby_ticks.dart';
import '../../shared/utils/format_utils.dart';
import '../../shared/utils/platform_device_name.dart';
import '../../shared/utils/user_error_formatter.dart';
import '../../shared/widgets/app_dialog.dart';
import '../../shared/widgets/app_loading_ring.dart';
import '../../shared/widgets/app_snack_bar.dart';
import '../../shared/widgets/version_source_card.dart';
import '../../shared/widgets/window_controls.dart';
import 'gestures/gesture_action.dart';
import 'gestures/gesture_bindings.dart';
import 'player_launcher.dart';
import 'playlist/cross_server_playlist.dart';
import 'playlist/pending_cross_server_sequence_provider.dart';
import 'playlist/playlist.dart';
import 'playlist/playlist_factory.dart';
import 'playlist/series_episode_playlist.dart';
import 'playlist/single_item_playlist.dart';
import 'services/danmaku/danmaku_feeder.dart';
import 'services/danmaku/danmaku_session_notifier.dart';
import 'services/frame_jank_monitor.dart';
import 'services/fullscreen_manager.dart';
import 'services/android_pip_manager.dart';
import 'services/pip_manager.dart';
import '../../core/infra/pip/android_pip_controller.dart';
import 'session/playback_phase.dart';
import 'session/playback_request.dart';
import 'session/playback_session.dart';
import 'session/playback_target.dart';
import 'session/playback_session_builder.dart';
import 'session/player_engine_holder.dart';
import 'session/player_engine_resolution.dart';
import 'session/relink_scheduler.dart';
import 'widgets/buffering_overlay.dart';
import 'widgets/danmaku_search_dialog.dart';
import 'widgets/debounced_seek_bar.dart';
import 'widgets/network_speed_indicator.dart';
import 'widgets/player_clock_text.dart';
import 'widgets/panel_toggle_affordance.dart';
import 'widgets/popover/panel_category_meta.dart';
import 'widgets/popover/player_panel_controller.dart';
import 'widgets/popover/player_panel_popover.dart';
import 'widgets/panels/danmaku_panel_body.dart';
import 'widgets/panels/episode_panel_body.dart';
import 'widgets/panels/panel_selection_list.dart';
import 'widgets/panels/selection_panel_entry.dart';
import 'widgets/player_panel_shell.dart';
import 'widgets/panels/tracks_panel_body.dart';
import 'widgets/fit_button.dart';
import 'widgets/player_debug_hud.dart';
import 'widgets/player_play_pause_button.dart';
import 'widgets/player_position_indicator.dart';
import 'widgets/player_video_widget.dart';
import 'widgets/player_volume_button.dart';
import 'widgets/player_status_scaffold.dart';
import 'widgets/quality_badge.dart';
import 'widgets/speed_button.dart';
import 'widgets/android/android_bottom_transport_controls.dart';
import 'widgets/android/android_gesture_feedback.dart';
import 'widgets/android/android_gesture_layer.dart';
import 'widgets/android/android_lock_overlay.dart';
import 'widgets/android/android_speed_strip.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'widgets/styled_subtitle_overlay.dart';
import 'widgets/track_selector_button.dart';
import 'widgets/version_panel.dart';
part 'player_page_bottom_bar.dart';
part 'player_page_danmaku.dart';
part 'player_page_lifecycle.dart';
part 'player_page_overlays.dart';
part 'player_page_relink.dart';
part 'player_page_session.dart';
part 'player_page_sources.dart';
part 'player_page_top_bar.dart';
part 'player_page_version_bar.dart';
part 'player_page_android_chrome.dart';
const _tag = 'PlayerPage';
class PlayerPage extends ConsumerStatefulWidget {
final String itemId;
final String? serverId;
final String? mediaSourceId;
final String? backdropUrl;
final int? preferredSubtitleStreamIndex;
final int? preferredAudioStreamIndex;
final int? startPositionTicks;
final bool fromStart;
final DirectStreamLaunchData? directStream;
const PlayerPage({
super.key,
required this.itemId,
this.serverId,
this.mediaSourceId,
this.backdropUrl,
this.preferredSubtitleStreamIndex,
this.preferredAudioStreamIndex,
this.startPositionTicks,
this.fromStart = false,
this.directStream,
});
@override
ConsumerState<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends ConsumerState<PlayerPage>
with WidgetsBindingObserver {
int _maxVolumeLevel = 15;
double _volumeFraction = 0.0;
PlaybackSession? _session;
PlaybackRequest? _lastRequest;
bool _videoVisible = false;
PlaybackSessionBuilder? _builder;
Playlist? _playlist;
({int startTimeTicks, Map<String, dynamic> info})? _prefetchedPlaybackInfo;
bool _switchInFlight = false;
bool _engineFallbackInFlight = false;
final Map<String, PlayerEngineKind> _forcedEngineBySource =
<String, PlayerEngineKind>{};
final PlayerEngineHolder _engineHolder = PlayerEngineHolder();
bool _pendingResync = false;
String? _errorMessage;
final RelinkScheduler _relink = RelinkScheduler();
final FrameJankMonitor _frameJankMonitor = FrameJankMonitor();
late final FullscreenManager _fullscreen = FullscreenManager(
onStateChanged: () {
if (mounted) {
setState(() {});
_invalidateBottomBar();
}
},
);
late final PipManager _pip = PipManager(
onStateChanged: () {
if (mounted) {
setState(() {});
_invalidateBottomBar();
}
},
);
late final AndroidPipManager _androidPip = AndroidPipManager(
onStateChanged: () {
if (mounted) {
setState(() {});
_invalidateBottomBar();
}
},
);
StreamSubscription<bool>? _androidPipPlayingSub;
bool get _isAndroidPip => Platform.isAndroid && _androidPip.isInPip;
final ValueNotifier<BoxFit> _videoFit = ValueNotifier<BoxFit>(BoxFit.contain);
final ValueNotifier<int> _barVersion = ValueNotifier<int>(0);
final ValueNotifier<bool> _debugHudVisible = ValueNotifier<bool>(false);
final PlayerPanelController _drawer = PlayerPanelController();
final Map<PlayerPanelCategory, LayerLink> _panelLinks = {
for (final c in PlayerPanelCategory.values) c: LayerLink(),
};
final GlobalKey<EpisodePanelBodyState> _episodeBodyKey =
GlobalKey<EpisodePanelBodyState>();
final ValueNotifier<bool> _versionBarOpen = ValueNotifier<bool>(false);
final ValueNotifier<Duration?> _seekPreview = ValueNotifier<Duration?>(null);
final ValueNotifier<int> _danmakuOverlayVersion = ValueNotifier<int>(0);
final ValueNotifier<int> _subtitleOverlayVersion = ValueNotifier<int>(0);
final ValueNotifier<bool> _bufferingOverlayVisible = ValueNotifier<bool>(
false,
);
List<CrossServerSourceCard> _crossServerCards = const [];
CrossServerSearchKey? _lastCrossServerSearchKey;
bool _isDanmakuSearchOpen = false;
bool _backNavigationInFlight = false;
bool _playerStartupBegun = false;
bool _isAndroidLocked = false;
int _brightnessGen = 0;
double? _lastSetBrightness;
GestureFeedbackData _androidFeedbackData = const GestureFeedbackData.none();
StreamSubscription<SystemVolumeEvent>? _systemVolumeSub;
Timer? _systemVolumeOsdTimer;
double? _savedRateBeforeLongPress;
double _playbackRate = 1.0;
SubtitleStyleSettings _subtitleSettings = const SubtitleStyleSettings();
PlayerSettingsState _initialPlayerSettings = const PlayerSettingsState();
DanmakuSettingsState _initialDanmakuSettings = const DanmakuSettingsState();
AudioSettingsState _initialAudioSettings = const AudioSettingsState();
final DanmakuFeeder _feeder = DanmakuFeeder();
DanmakuSessionState _sessionState = const DanmakuSessionIdle();
DanmakuPanelSettings _panelSettings = const DanmakuPanelSettings();
double? _danmakuOverlayHeight;
double? _danmakuOverlaySyncedHeight;
DanmakuPanelSettings? _danmakuOverlaySyncedSettings;
Timer? _danmakuPanelPersistTimer;
DanmakuPanelSettings? _pendingDanmakuPanelSettings;
DanmakuSessionState? _lastSyncedDanmakuSession;
late final StateController<bool> _playbackActiveNotifier;
late final PlayerSettingsNotifier _playerSettingsNotifier;
late final AudioSettingsNotifier _audioSettingsNotifier;
late final SubtitleSettingsNotifier _subtitleSettingsNotifier;
late final DanmakuSettingsNotifier _danmakuSettingsNotifier;
late final LastPlayedVersionStore _lastPlayedVersionStore;
StreamSubscription<bool>? _wakelockPlayingSub;
PlaybackSession? _wakelockSession;
bool _wakelockEnabled = false;
bool get _isDesktopWindow => Platform.isWindows || Platform.isMacOS;
double get _topBarLeftInset => _fullscreen.isFullscreen ? 8 : 12;
double get _topBarTopInset => _fullscreen.isFullscreen ? 8 : 12;
String get _fallbackTitle => _playlist?.current.value.displayLabel ?? '';
bool get _showFallbackWindowControls =>
!_fullscreen.isFullscreen && !_pip.isPip && _isDesktopWindow;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_playbackActiveNotifier = ref.read(playbackActiveProvider.notifier);
_playerSettingsNotifier = ref.read(playerSettingsProvider.notifier);
_audioSettingsNotifier = ref.read(audioSettingsProvider.notifier);
_subtitleSettingsNotifier = ref.read(subtitleSettingsProvider.notifier);
_danmakuSettingsNotifier = ref.read(danmakuSettingsProvider.notifier);
_lastPlayedVersionStore = ref.read(lastPlayedVersionStoreProvider);
_drawer.addListener(_onPanelChanged);
_frameJankMonitor.start();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(playbackActiveProvider.notifier).state = true;
});
if (Platform.isAndroid || Platform.isIOS) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_beginStartupAfterRouteAnimation();
});
} else {
_beginPlayerStartup();
}
}
void _beginStartupAfterRouteAnimation() {
final routeAnimation = ModalRoute.of(context)?.animation;
if (routeAnimation == null ||
routeAnimation.status == AnimationStatus.completed) {
_beginPlayerStartup();
return;
}
late final AnimationStatusListener statusListener;
statusListener = (AnimationStatus status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
routeAnimation.removeStatusListener(statusListener);
if (mounted && status == AnimationStatus.completed) {
_beginPlayerStartup();
}
}
};
routeAnimation.addStatusListener(statusListener);
}
void _beginPlayerStartup() {
if (_playerStartupBegun || !mounted) return;
_playerStartupBegun = true;
if (Platform.isAndroid) {
unawaited(_beginAndroidPlayerStartup());
return;
}
unawaited(_initialize());
}
Future<void> _beginAndroidPlayerStartup() async {
try {
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
if (!mounted || _backNavigationInFlight) return;
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
if (!mounted || _backNavigationInFlight) return;
} catch (error, stack) {
AppLogger.warn(_tag, 'configure Android immersive mode failed', error);
AppLogger.debug(_tag, stack.toString());
}
if (!mounted || _backNavigationInFlight) return;
_enableAndroidSystemAudio();
_restoreAndroidBrightness();
_androidPip.onAction = _handleAndroidPipAction;
unawaited(
_androidPip
.initialize()
.then((_) {
if (!mounted) return;
_configureAndroidPip();
})
.catchError((Object error, StackTrace stack) {
AppLogger.warn(_tag, 'initialize Android PiP failed', error);
AppLogger.debug(_tag, stack.toString());
}),
);
unawaited(_initialize());
}
void _restoreAndroidBrightness() {
final savedBrightness = ref
.read(playerSettingsProvider)
.value
?.androidBrightness;
if (savedBrightness == null) return;
unawaited(
ScreenBrightness.instance
.setApplicationScreenBrightness(savedBrightness)
.catchError((_) {}),
);
}
void _enableAndroidSystemAudio() {
unawaited(
SystemAudioController.instance
.getVolume()
.then((event) {
_volumeFraction = event.percent;
_maxVolumeLevel = event.max;
})
.catchError((Object error, StackTrace stack) {
AppLogger.warn(_tag, 'initial volume fetch failed', error);
}),
);
unawaited(
SystemAudioController.instance.setKeyIntercept(true).catchError((
Object error,
StackTrace stack,
) {
AppLogger.warn(_tag, 'enable volume key intercept failed', error);
AppLogger.debug(_tag, stack.toString());
}),
);
_systemVolumeSub = SystemAudioController.instance.volumeChanges.listen(
_showAndroidSystemVolumeOsd,
onError: (Object error, StackTrace stack) {
AppLogger.warn(_tag, 'system volume event failed', error);
AppLogger.debug(_tag, stack.toString());
},
);
}
void _showAndroidSystemVolumeOsd(SystemVolumeEvent event) {
if (!mounted) return;
_volumeFraction = event.percent;
_maxVolumeLevel = event.max;
_systemVolumeOsdTimer?.cancel();
setState(
() => _androidFeedbackData = GestureFeedbackData(
kind: GestureFeedbackKind.volume,
value: event.percent,
),
);
_systemVolumeOsdTimer = Timer(const Duration(milliseconds: 800), () {
if (!mounted || _androidFeedbackData.kind != GestureFeedbackKind.volume) {
return;
}
setState(() => _androidFeedbackData = const GestureFeedbackData.none());
});
}
void _disableAndroidSystemAudio() {
if (!Platform.isAndroid) return;
unawaited(
SystemAudioController.instance.setKeyIntercept(false).catchError((
Object error,
StackTrace stack,
) {
AppLogger.warn(_tag, 'disable volume key intercept failed', error);
AppLogger.debug(_tag, stack.toString());
}),
);
unawaited(_systemVolumeSub?.cancel());
_systemVolumeSub = null;
_systemVolumeOsdTimer?.cancel();
_systemVolumeOsdTimer = null;
}
void _onMatchSelected(int index) {
if (index == _danmakuSelectedMatchIndex) return;
unawaited(ref.read(danmakuSessionProvider.notifier).selectMatch(index));
}
Future<void> _openDanmakuSearchDialog() async {
final session = _session;
if (session == null) return;
if (_sessionState is DanmakuSessionIdle) return;
final dialogItemId = session.context.itemId;
final matches = _danmakuMatches;
final dCtx = session.context.danmakuMatchContext;
final initialQuery =
dCtx.type == 'Episode' && (dCtx.seriesName?.isNotEmpty ?? false)
? dCtx.seriesName!
: session.context.itemName;
setState(() => _isDanmakuSearchOpen = true);
_invalidateBottomBar();
try {
final size = MediaQuery.sizeOf(context);
await showAppRawDialog<void>(
context,
useRootNavigator: true,
constraints: BoxConstraints(
maxWidth: math.min(size.width - 48, 520.0),
maxHeight: math.min(size.height - 112, 560.0),
),
builder: (_) => DanmakuSearchDialog(
currentItemId: dialogItemId,
isItemStillCurrent: () =>
mounted && _session?.context.itemId == dialogItemId,
initialQuery: initialQuery,
initialResults: matches,
initialSelectedIndex: _danmakuSelectedMatchIndex,
targetEpisode: dCtx.episode,
),
);
} finally {
if (mounted) {
setState(() => _isDanmakuSearchOpen = false);
_invalidateBottomBar();
}
}
}
void _attachWakelockSession(PlaybackSession? session) {
if (identical(_wakelockSession, session)) {
_syncWakelockForSession(session);
return;
}
unawaited(_wakelockPlayingSub?.cancel());
_wakelockPlayingSub = null;
_wakelockSession = session;
if (session == null) {
_setWakelockEnabled(false);
return;
}
_wakelockPlayingSub = session.engine.stream.playing.listen((_) {
_syncWakelockForSession(session);
});
_syncWakelockForSession(session);
}
void _syncWakelockForSession(PlaybackSession? session) {
if (!identical(_wakelockSession, session)) return;
final shouldEnable =
session != null &&
session.phase.value == PlaybackPhase.playing &&
session.engine.state.playing;
_setWakelockEnabled(shouldEnable);
}
void _setWakelockEnabled(bool enabled) {
if (_wakelockEnabled == enabled) return;
_wakelockEnabled = enabled;
unawaited(
(enabled ? WakelockPlus.enable() : WakelockPlus.disable()).catchError((
Object error,
StackTrace stack,
) {
AppLogger.warn(
_tag,
'wakelock ${enabled ? 'enable' : 'disable'} failed',
error,
);
AppLogger.debug(_tag, stack.toString());
}),
);
}
void _onDanmakuPanelChanged(DanmakuPanelSettings next) {
final enabledChanged = _panelSettings.enabled != next.enabled;
final offsetChanged = _panelSettings.offset != next.offset;
final layoutChanged = _isDanmakuLayoutChange(_panelSettings, next);
_applyDanmakuPanelSettings(next);
if (enabledChanged) {
_invalidateBottomBar();
}
if (enabledChanged || layoutChanged) {
_invalidateDanmakuOverlay();
}
if (enabledChanged || offsetChanged || layoutChanged) {
final s = _session;
if (s != null) {
final posSec = s.engine.state.position.inMilliseconds / 1000.0;
_syncDanmakuFeederForSession(_feeder, s, posSec, clear: true);
}
}
_refreshPanel();
_danmakuSettingsNotifier.setPanelSettings(next);
_scheduleDanmakuPanelSettingsPersist(next);
}
void _scheduleDanmakuPanelSettingsPersist(DanmakuPanelSettings next) {
_pendingDanmakuPanelSettings = next;
_danmakuPanelPersistTimer?.cancel();
_danmakuPanelPersistTimer = Timer(const Duration(milliseconds: 250), () {
final pending = _pendingDanmakuPanelSettings;
_pendingDanmakuPanelSettings = null;
if (pending == null) return;
unawaited(_danmakuSettingsNotifier.persistPanelSettings(pending));
});
}
void _flushDanmakuPanelSettingsPersist() {
_danmakuPanelPersistTimer?.cancel();
_danmakuPanelPersistTimer = null;
final pending = _pendingDanmakuPanelSettings;
_pendingDanmakuPanelSettings = null;
if (pending == null) return;
unawaited(_danmakuSettingsNotifier.persistPanelSettings(pending));
}
void _retryDanmaku() {
final session = _session;
if (session == null) return;
unawaited(
ref
.read(danmakuSessionProvider.notifier)
.loadFor(session.context.danmakuMatchContext),
);
}
void _goToDanmakuSettings() {
_drawer.close();
showSettingsDialog(context, initialTab: 'danmaku');
}
void _toggleDanmakuGlobalEnabled() {
unawaited(_enableDanmakuForCurrentSession());
}
Future<void> _enableDanmakuForCurrentSession() async {
final notifier = ref.read(danmakuSettingsProvider.notifier);
await notifier.setEnabled(true);
if (!mounted) return;
final session = _session;
if (session == null) return;
await ref
.read(danmakuSessionProvider.notifier)
.loadFor(session.context.danmakuMatchContext);
}
void _onSubtitleSettingsChanged(SubtitleStyleSettings next) {
_subtitleSettings = next;
_refreshPanel();
_invalidateSubtitleOverlay();
_session?.applySubtitleStyle(next);
ref.read(subtitleSettingsProvider.notifier).setSettings(next);
}
void _setPlaybackRate(double rate) {
_playbackRate = rate;
unawaited(_session?.setRate(rate) ?? Future<void>.value());
_feeder.setPanelSettings(
_panelSettings,
option: _buildDanmakuOption(_panelSettings),
);
_invalidateBottomBar();
_refreshPanel();
}
void _setVideoFit(BoxFit fit) {
_videoFit.value = fit;
_invalidateBottomBar();
_refreshPanel();
}
void _togglePanel(PlayerPanelCategory c) {
_versionBarOpen.value = false;
_drawer.toggle(c);
}
Widget _panelIcon(PlayerPanelCategory c) {
return CompositedTransformTarget(
link: _panelLinks[c]!,
child: PanelToggleAffordance(
isOpen: _drawer.category == c,
onTap: () => _togglePanel(c),
tooltip: panelCategoryLabel(c),
child: Icon(panelCategoryIcon(c), color: Colors.white, size: 26),
),
);
}
Widget _panelBody(PlaybackSession session, PlayerPanelCategory c) {
switch (c) {
case PlayerPanelCategory.subtitle:
session.prefetchExternalSubtitles();
return SubtitlePanelBody(
subtitleSettings: _subtitleSettings,
onSubtitleSettingsChanged: _onSubtitleSettingsChanged,
subtitles: session.context.embySubtitles,
activeSubtitleStreamIndex: session.activeSubtitleStreamIndex,
subtitleLoading: session.subtitleLoading,
onSubtitleSelected: (streamIndex) =>
_onSubtitleSelected(session, streamIndex),
);
case PlayerPanelCategory.audio:
return AudioPanelBody(
player: session.engine,
onAudioTrackSelected: (track) =>
_onAudioTrackSelected(session, track),
);
case PlayerPanelCategory.speed:
return PanelSelectionList<double>(
entries: [
for (final speed in SpeedButton.speeds)
SelectionPanelEntry.item(
value: speed,
label: SpeedButton.formatRate(speed),
isActive: speed == _playbackRate,
),
],
onSelected: _setPlaybackRate,
);
case PlayerPanelCategory.fit:
return PanelSelectionList<BoxFit>(
entries: [
for (final option in FitButton.options)
SelectionPanelEntry.item(
value: option.fit,
label: option.label,
subtitle: option.subtitle,
isActive: option.fit == _videoFit.value,
),
],
onSelected: _setVideoFit,
);
case PlayerPanelCategory.episode:
return EpisodePanelBody(
key: _episodeBodyKey,
seriesId: session.context.seriesId!,
initialSeasonId: session.context.seasonId,
currentEpisodeId: session.context.itemId,
serverId: session.context.serverId,
baseUrl: session.context.baseUrl,
token: session.context.token,
onEpisodeSelected: _onEpisodePicked,
);
case PlayerPanelCategory.danmaku:
final danmakuState = ref.read(danmakuSettingsProvider).value;
return DanmakuPanelBody(
settings: _panelSettings,
onChanged: _onDanmakuPanelChanged,
sessionState: _sessionState,
danmakuGlobalEnabled: danmakuState?.enabled ?? false,
danmakuSourceConfigured:
(danmakuState?.enabledSources ?? const []).isNotEmpty,
matches: _danmakuMatches,
selectedMatchIndex: _danmakuSelectedMatchIndex,
onMatchSelected: _onMatchSelected,
onOpenSearch: _openDanmakuSearchDialog,
isLoading: _danmakuSearching,
commentCount: _danmakuCommentCount,
blockedKeywords: danmakuState?.blockedKeywords ?? const [],
onAddKeyword: (kw) =>
ref.read(danmakuSettingsProvider.notifier).addBlockedKeyword(kw),
onRemoveKeyword: (kw) => ref
.read(danmakuSettingsProvider.notifier)
.removeBlockedKeyword(kw),
onRetry: _retryDanmaku,
onGoToSettings: _goToDanmakuSettings,
onToggleDanmakuEnabled: _toggleDanmakuGlobalEnabled,
);
}
}
Future<void> _toggleFullscreen() async {
if (_pip.isPip) {
await _pip.leave();
}
await _fullscreen.toggle();
}
Future<void> _togglePip() async {
if (!_isDesktopWindow) return;
if (_fullscreen.isFullscreen) {
await _fullscreen.leave();
}
await _pip.toggle();
}
void _goBack() {
if (_backNavigationInFlight) return;
_backNavigationInFlight = true;
unawaited(_goBackAfterLeavingPip());
}
Future<void> _goBackAfterLeavingPip() async {
try {
try {
unawaited(_session?.pause() ?? Future<void>.value());
} catch (_) {}
if (_pip.isPip) {
await _pip.leave();
if (!mounted) return;
}
if (_fullscreen.isFullscreen) {
await _fullscreen.leave();
if (!mounted) return;
}
if (context.canPop()) {
final session = _session;
final ticks = session != null
? ticksFromDuration(session.engine.state.position)
: null;
final result = session != null
? PlayerExitResult(
positionTicks: ticks,
serverId: session.context.serverId,
itemId: session.context.itemId,
mediaSourceId: session.context.mediaSourceId,
)
: null;
context.pop(result);
} else {
context.go('/');
}
} catch (error, stack) {
AppLogger.warn(_tag, 'goBack failed', error);
AppLogger.debug(_tag, stack.toString());
} finally {
_backNavigationInFlight = false;
}
}
Future<void> _showPlayerContextMenu(Offset globalPosition) async {
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (overlay == null) return;
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromRect(
globalPosition & const Size(40, 40),
Offset.zero & overlay.size,
),
items: [
CheckedPopupMenuItem<String>(
value: 'hud',
checked: _debugHudVisible.value,
child: const Text('调试信息 HUD'),
),
],
);
if (selected == 'hud') {
_debugHudVisible.value = !_debugHudVisible.value;
}
}
void _invalidateBottomBar() => _barVersion.value++;
void _setAndroidLocked(bool locked) {
if (!mounted || _isAndroidLocked == locked) return;
setState(() => _isAndroidLocked = locked);
_configureAndroidPip();
}
void _handleAndroidPipAction(PipAction action) {
final session = _session;
if (session == null) return;
switch (action) {
case PipAction.play:
unawaited(session.engine.play());
case PipAction.pause:
unawaited(session.engine.pause());
case PipAction.next:
final pl = _playlist;
if (pl != null && pl.hasNext) unawaited(pl.next());
case PipAction.prev:
final pl = _playlist;
if (pl != null && pl.hasPrev) unawaited(pl.prev());
case PipAction.seekForward:
final secs =
ref.read(playerSettingsProvider).value?.seekForwardSeconds ?? 15;
final target = session.engine.state.position + Duration(seconds: secs);
unawaited(session.engine.seek(target));
case PipAction.seekBackward:
final secs =
ref.read(playerSettingsProvider).value?.seekBackwardSeconds ?? 15;
final target = session.engine.state.position - Duration(seconds: secs);
unawaited(
session.engine.seek(target < Duration.zero ? Duration.zero : target),
);
}
}
void _configureAndroidPip() {
if (!Platform.isAndroid) return;
final session = _session;
final settings = ref.read(playerSettingsProvider).value;
const int aspectX = 16;
const int aspectY = 9;
unawaited(
_androidPip.configure(
aspectX: aspectX,
aspectY: aspectY,
autoEnterOnLeave:
(settings?.autoPip ?? true) &&
session != null &&
session.engine.state.playing &&
!_isAndroidLocked,
isPlaying: session?.engine.state.playing ?? false,
hasNext: _playlist?.hasNext ?? false,
hasPrev: _playlist?.hasPrev ?? false,
useEpisodeActions: settings?.pipShowEpisodeActions ?? true,
),
);
}
void _attachAndroidPipPlayingListener(PlaybackSession? session) {
unawaited(_androidPipPlayingSub?.cancel());
_androidPipPlayingSub = null;
if (session == null || !Platform.isAndroid) return;
_androidPipPlayingSub = session.engine.stream.playing.listen((_) {
_configureAndroidPip();
if (_androidPip.isInPip) {
final settings = ref.read(playerSettingsProvider).value;
unawaited(
_androidPip.updatePlaybackState(
isPlaying: session.engine.state.playing,
hasNext: _playlist?.hasNext ?? false,
hasPrev: _playlist?.hasPrev ?? false,
useEpisodeActions: settings?.pipShowEpisodeActions ?? true,
),
);
}
});
}
Future<void> _enterAndroidPip() async {
if (!Platform.isAndroid || !_androidPip.isSupported) return;
_drawer.close();
_versionBarOpen.value = false;
await _androidPip.enter(aspectX: 16, aspectY: 9);
}
void _refreshPanel() {
if (mounted) setState(() {});
}
void _invalidateDanmakuOverlay() => _danmakuOverlayVersion.value++;
void _invalidateSubtitleOverlay() => _subtitleOverlayVersion.value++;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (Platform.isAndroid && state == AppLifecycleState.paused) {
_session?.engine.pause();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_disableAndroidSystemAudio();
if (Platform.isAndroid) {
SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(
ScreenBrightness.instance.resetApplicationScreenBrightness().catchError(
(_) {},
),
);
}
_flushDanmakuPanelSettingsPersist();
_relink.dispose();
_frameJankMonitor.dispose();
unawaited(_androidPipPlayingSub?.cancel());
_androidPipPlayingSub = null;
Future<void>.microtask(() {
try {
_playbackActiveNotifier.state = false;
} catch (_) {}
});
unawaited(_wakelockPlayingSub?.cancel());
_wakelockPlayingSub = null;
_wakelockSession = null;
_setWakelockEnabled(false);
_session?.phase.removeListener(_onSessionPhaseChanged);
_playlist?.current.removeListener(_onCurrentItemChanged);
final playlist = _playlist;
_playlist = null;
try {
playlist?.dispose();
} catch (e) {
AppLogger.warn(_tag, 'dispose: playlist failed', e);
}
_drawer.removeListener(_onPanelChanged);
for (final step in <(String, void Function())>[
('fullscreen', _fullscreen.dispose),
('pip', _pip.dispose),
('androidPip', _androidPip.dispose),
('feederDetach', _feeder.detach),
('videoFit', _videoFit.dispose),
('barVersion', _barVersion.dispose),
('debugHudVisible', _debugHudVisible.dispose),
('drawer', _drawer.dispose),
('versionBarOpen', _versionBarOpen.dispose),
('seekPreview', _seekPreview.dispose),
('danmakuOverlayVer', _danmakuOverlayVersion.dispose),
('subtitleOverlayVer', _subtitleOverlayVersion.dispose),
('bufferingOverlayVisible', _bufferingOverlayVisible.dispose),
]) {
try {
step.$2();
} catch (e) {
AppLogger.warn(_tag, 'dispose: ${step.$1} failed', e);
}
}
final session = _session;
_session = null;
final holder = _engineHolder;
if (session != null) {
unawaited(session.dispose().whenComplete(holder.dispose));
} else {
unawaited(holder.dispose());
}
super.dispose();
}
@override
Widget build(BuildContext context) {
_wireDanmakuListeners();
ref.listen<bool>(
audioSettingsProvider.select((s) => s.value?.volumeBoost ?? false),
(prev, next) {
if (prev == true && next == false) {
final s = _session;
if (s != null && s.engine.state.volume > 100) {
unawaited(s.engine.setVolume(100));
}
}
},
);
final session = _session;
final error = _errorMessage;
final currentCrossServerSearchKey = session == null
? null
: _buildCrossServerKey(session.context);
if (currentCrossServerSearchKey != null) {
_lastCrossServerSearchKey = currentCrossServerSearchKey;
}
final retainedCrossServerSearchKey = session == null
? _lastCrossServerSearchKey
: currentCrossServerSearchKey;
final asyncCrossServerCards = retainedCrossServerSearchKey == null
? null
: ref.watch(
crossServerSearchDataProvider(retainedCrossServerSearchKey),
);
if (session != null) {
if (currentCrossServerSearchKey != null &&
asyncCrossServerCards != null) {
_crossServerCards = _filteredCrossServerCards(
asyncCrossServerCards.maybeWhen(
data: (list) => list,
orElse: () =>
asyncCrossServerCards.value ?? const <CrossServerSourceCard>[],
),
session.context,
);
} else {
_crossServerCards = const [];
}
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _goBack();
},
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
if (session != null)
Positioned.fill(child: _buildPlayingStack(session)),
if (session == null || !_videoVisible) ...[
PlayerLoadingBackdrop(backdropUrl: widget.backdropUrl),
PlayerStatusTopBar(
title: _fallbackTitle,
onBack: _goBack,
topInset: _topBarTopInset,
leftInset: _topBarLeftInset,
showWindowControls: _showFallbackWindowControls,
enableWindowDrag: _showFallbackWindowControls,
),
],
if ((session == null || !_videoVisible) && error == null)
const PlayerLoadingIndicator(),
if (error != null && session == null) ...[
const Positioned.fill(child: ColoredBox(color: Colors.black)),
PlayerStatusTopBar(
title: _fallbackTitle,
onBack: _goBack,
topInset: _topBarTopInset,
leftInset: _topBarLeftInset,
showWindowControls: _showFallbackWindowControls,
enableWindowDrag: _showFallbackWindowControls,
),
PlayerErrorCard(message: error, onBack: _goBack, onRetry: _retry),
],
],
),
),
);
}
Widget _buildPlayingStack(PlaybackSession session) {
final seekSettings = ref.watch(
playerSettingsProvider.select(
(s) => s.whenData(
(d) => (fwd: d.seekForwardSeconds, bwd: d.seekBackwardSeconds),
),
),
);
final seekFwd = seekSettings.value?.fwd ?? 15;
final seekBwd = seekSettings.value?.bwd ?? 15;
final volumeBoost = ref.watch(
audioSettingsProvider.select((s) => s.value?.volumeBoost ?? false),
);
final playerSettings = ref.watch(playerSettingsProvider).value;
final desktopBindings = playerSettings == null
? GestureBindings.defaults
: GestureBindings(
keyboardHoldLeftSpeed: playerSettings.keyboardHoldLeftSpeed,
keyboardHoldRightSpeed: playerSettings.keyboardHoldRightSpeed,
mouseLeftClickAction: playerSettings.mouseLeftClickAction,
mouseLeftDoubleClickAction:
playerSettings.mouseLeftDoubleClickAction,
mouseRightClickAction: playerSettings.mouseRightClickAction,
mouseRightDoubleClickAction:
playerSettings.mouseRightDoubleClickAction,
);
final gestureBindings = _isDesktopWindow
? desktopBindings
: GestureBindings(
keyboardHoldLeftSpeed: desktopBindings.keyboardHoldLeftSpeed,
keyboardHoldRightSpeed: desktopBindings.keyboardHoldRightSpeed,
mouseLeftClickAction: GestureAction.toggleControls,
mouseLeftDoubleClickAction: GestureAction.playPause,
mouseRightClickAction: desktopBindings.mouseRightClickAction,
mouseRightDoubleClickAction:
desktopBindings.mouseRightDoubleClickAction,
);
return Stack(
fit: StackFit.expand,
children: [
AnimatedBuilder(
animation: _drawer,
builder: (context, _) {
final panelOpen = _drawer.isOpen;
return PlayerVideoWidget(
key: ValueKey<String>(
'mpv:${session.context.itemId}:'
'${session.context.mediaSourceId ?? ''}',
),
player: session.engine,
fit: _videoFit,
topBar: Platform.isAndroid
? (_isAndroidPip
? const <Widget>[]
: _buildAndroidTopBar(session))
: _buildTopBar(session),
topBarLeftInset: _topBarLeftInset,
topBarTopInset: _topBarTopInset,
bottomBar: Platform.isAndroid
? (_isAndroidPip
? const SizedBox.shrink()
: _buildAndroidBottomBar(session))
: _buildBottomBar(session),
centerBar: Platform.isAndroid
? null
: (_pip.isPip ? _buildPipCenterControls(session) : null),
leftBar: Platform.isAndroid && !_isAndroidLocked && !_isAndroidPip
? _buildAndroidLeftBar()
: null,
rightBar:
Platform.isAndroid && !_isAndroidLocked && !_isAndroidPip
? _buildAndroidRightBar(session)
: null,
mobileGestureBuilder:
Platform.isAndroid && !_isAndroidLocked && !_isAndroidPip
? (toggleControls, showControls) => _buildAndroidGestureLayer(
session,
toggleControls,
showControls,
)
: null,
gestureFeedback: Platform.isAndroid && !_isAndroidPip
? _buildAndroidGestureFeedback()
: null,
overlays: _isAndroidPip
? _buildPipOverlays(session)
: _buildVideoOverlays(session),
onControlsAreaTap: panelOpen ? _drawer.close : null,
isFullscreen: _fullscreen.isFullscreen,
onToggleFullscreen: _toggleFullscreen,
isPip: _pip.isPip,
onTogglePip: _isDesktopWindow ? _togglePip : null,
lockControls: panelOpen,
suppressControlsChrome:
Platform.isAndroid && (_isAndroidLocked || _isAndroidPip),
keyboardEnabled: !_isDanmakuSearchOpen && !_drawer.isOpen,
seekForwardSeconds: seekFwd,
seekBackwardSeconds: seekBwd,
volumeBoost: volumeBoost,
gestureBindings: gestureBindings,
currentRate: _playbackRate,
onSetRate: _setPlaybackRate,
onPrevItem: () {
final pl = _playlist;
if (pl != null && pl.hasPrev) unawaited(pl.prev());
},
onNextItem: () {
final pl = _playlist;
if (pl != null && pl.hasNext) unawaited(pl.next());
},
onExit: _goBack,
onSecondaryContextMenu: kDebugMode
? _showPlayerContextMenu
: null,
);
},
),
_buildVersionBarLayer(session),
if (!Platform.isAndroid)
PlayerPanelPopover(
controller: _drawer,
anchorLinks: _panelLinks,
bodyBuilder: (c) => _panelBody(session, c),
),
if (Platform.isAndroid && !_isAndroidPip)
_buildAndroidDrawerLayer(session),
if (Platform.isAndroid && _isAndroidLocked && !_isAndroidPip)
AndroidLockedOverlay(onUnlock: () => _setAndroidLocked(false)),
if (_switchInFlight)
const Positioned.fill(
child: IgnorePointer(
child: ColoredBox(
color: Colors.black54,
child: Center(
child: AppLoadingRing(size: 44, color: Colors.white),
),
),
),
),
],
);
}
}