1277 lines
42 KiB
Dart
1277 lines
42 KiB
Dart
|
|
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),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|