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 createState() => _PlayerPageState(); } class _PlayerPageState extends ConsumerState with WidgetsBindingObserver { int _maxVolumeLevel = 15; double _volumeFraction = 0.0; PlaybackSession? _session; PlaybackRequest? _lastRequest; bool _videoVisible = false; PlaybackSessionBuilder? _builder; Playlist? _playlist; ({int startTimeTicks, Map info})? _prefetchedPlaybackInfo; bool _switchInFlight = false; bool _engineFallbackInFlight = false; final Map _forcedEngineBySource = {}; 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? _androidPipPlayingSub; bool get _isAndroidPip => Platform.isAndroid && _androidPip.isInPip; final ValueNotifier _videoFit = ValueNotifier(BoxFit.contain); final ValueNotifier _barVersion = ValueNotifier(0); final ValueNotifier _debugHudVisible = ValueNotifier(false); final PlayerPanelController _drawer = PlayerPanelController(); final Map _panelLinks = { for (final c in PlayerPanelCategory.values) c: LayerLink(), }; final GlobalKey _episodeBodyKey = GlobalKey(); final ValueNotifier _versionBarOpen = ValueNotifier(false); final ValueNotifier _seekPreview = ValueNotifier(null); final ValueNotifier _danmakuOverlayVersion = ValueNotifier(0); final ValueNotifier _subtitleOverlayVersion = ValueNotifier(0); final ValueNotifier _bufferingOverlayVisible = ValueNotifier( false, ); List _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? _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 _playbackActiveNotifier; late final PlayerSettingsNotifier _playerSettingsNotifier; late final AudioSettingsNotifier _audioSettingsNotifier; late final SubtitleSettingsNotifier _subtitleSettingsNotifier; late final DanmakuSettingsNotifier _danmakuSettingsNotifier; late final LastPlayedVersionStore _lastPlayedVersionStore; StreamSubscription? _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 _beginAndroidPlayerStartup() async { try { await SystemChrome.setPreferredOrientations(const [ 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 _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( 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 _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.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( 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( 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 _toggleFullscreen() async { if (_pip.isPip) { await _pip.leave(); } await _fullscreen.toggle(); } Future _togglePip() async { if (!_isDesktopWindow) return; if (_fullscreen.isFullscreen) { await _fullscreen.leave(); } await _pip.toggle(); } void _goBack() { if (_backNavigationInFlight) return; _backNavigationInFlight = true; unawaited(_goBackAfterLeavingPip()); } Future _goBackAfterLeavingPip() async { try { try { unawaited(_session?.pause() ?? Future.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 _showPlayerContextMenu(Offset globalPosition) async { final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; if (overlay == null) return; final selected = await showMenu( context: context, position: RelativeRect.fromRect( globalPosition & const Size(40, 40), Offset.zero & overlay.size, ), items: [ CheckedPopupMenuItem( 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 _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 []); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); unawaited( ScreenBrightness.instance.resetApplicationScreenBrightness().catchError( (_) {}, ), ); } _flushDanmakuPanelSettingsPersist(); _relink.dispose(); _frameJankMonitor.dispose(); unawaited(_androidPipPlayingSub?.cancel()); _androidPipPlayingSub = null; Future.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( 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 [], ), 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( 'mpv:${session.context.itemId}:' '${session.context.mediaSourceId ?? ''}', ), player: session.engine, fit: _videoFit, topBar: Platform.isAndroid ? (_isAndroidPip ? const [] : _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), ), ), ), ), ], ); } }