part of 'player_page.dart'; extension _AndroidChrome on _PlayerPageState { static const _androidSpeeds = [ 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, ]; void _seekBy(int seconds) { final session = _session; if (session == null) return; final target = session.engine.state.position + Duration(seconds: seconds); unawaited( session.engine.seek(target < Duration.zero ? Duration.zero : target), ); } void _onAndroidSpeedIncrement() { final idx = _androidSpeeds.indexWhere((s) => s > _playbackRate); if (idx >= 0) _setPlaybackRate(_androidSpeeds[idx]); } void _onAndroidSpeedDecrement() { final idx = _androidSpeeds.lastIndexWhere((s) => s < _playbackRate); if (idx >= 0) _setPlaybackRate(_androidSpeeds[idx]); } List _buildAndroidTopBar(PlaybackSession session) { return [ IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: _goBack, ), const SizedBox(width: 4), Expanded( child: Text( _buildTitleText(session), style: const TextStyle(color: Colors.white, fontSize: 15), overflow: TextOverflow.ellipsis, ), ), Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ QualityBadge(quality: _activeQualityInfo(session)), const SizedBox(width: 8), NetworkSpeedIndicator(monitor: session.speedMonitor), ], ), const SizedBox(height: 6), const PlayerClockText(), ], ), const SizedBox(width: 4), PopupMenuButton( icon: const Icon(Icons.more_vert, color: Colors.white), onSelected: (value) { switch (value) { case 'fit:contain': _setVideoFit(BoxFit.contain); case 'fit:fill': _setVideoFit(BoxFit.fill); case 'fit:cover': _setVideoFit(BoxFit.cover); case 'pip': unawaited(_enterAndroidPip()); case 'debug_hud': _debugHudVisible.value = !_debugHudVisible.value; } }, itemBuilder: (context) => [ CheckedPopupMenuItem( value: 'fit:contain', checked: _videoFit.value == BoxFit.contain, child: const Text('画面比例 - 适应'), ), CheckedPopupMenuItem( value: 'fit:fill', checked: _videoFit.value == BoxFit.fill, child: const Text('画面比例 - 拉伸'), ), CheckedPopupMenuItem( value: 'fit:cover', checked: _videoFit.value == BoxFit.cover, child: const Text('画面比例 - 裁切'), ), if (_androidPip.isSupported && !_isAndroidLocked) const PopupMenuItem(value: 'pip', child: Text('画中画')), if (kDebugMode) CheckedPopupMenuItem( value: 'debug_hud', checked: _debugHudVisible.value, child: const Text('调试 HUD'), ), ], ), ]; } Widget _buildAndroidBottomBar(PlaybackSession session) { final seekSettings = ref.read(playerSettingsProvider).value; final seekFwd = seekSettings?.seekForwardSeconds ?? 15; final seekBwd = seekSettings?.seekBackwardSeconds ?? 15; return Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ _AndroidTimeLabel( player: session.engine, showPosition: true, seekPreview: _seekPreview, ), const SizedBox(width: 8), Expanded( child: DebouncedSeekBar( player: session.engine, onDragChanged: (t) => _seekPreview.value = t, ), ), const SizedBox(width: 8), _AndroidTimeLabel( player: session.engine, showPosition: false, seekPreview: _seekPreview, ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), child: LayoutBuilder( builder: (context, constraints) { final hasVersionSwitch = session.context.allMediaSources.length > 1 || _crossServerCards.isNotEmpty; final hasEpisode = session.context.seriesId != null; const alwaysVisibleWidth = 120.0 + 96.0 + 96.0; final secondaryMenuEntries = >[ if (hasVersionSwitch) PopupMenuItem( value: _toggleVersionBar, child: const ListTile( leading: Icon(Icons.switch_video), title: Text('切换版本'), dense: true, ), ), PopupMenuItem( value: () => _togglePanel(PlayerPanelCategory.danmaku), child: const ListTile( leading: Icon(Icons.comment_outlined), title: Text('弹幕'), dense: true, ), ), PopupMenuItem( value: () => _togglePanel(PlayerPanelCategory.speed), child: const ListTile( leading: Icon(Icons.speed), title: Text('倍速'), dense: true, ), ), if (hasEpisode) PopupMenuItem( value: () => _togglePanel(PlayerPanelCategory.episode), child: const ListTile( leading: Icon(Icons.playlist_play), title: Text('选集'), dense: true, ), ), ]; final secondaryInlineCount = (hasVersionSwitch ? 1 : 0) + 2 + (hasEpisode ? 1 : 0); const iconButtonWidth = 48.0; final overflowMenuButtonWidth = secondaryMenuEntries.isNotEmpty ? iconButtonWidth : 0.0; final showAllInline = constraints.maxWidth >= alwaysVisibleWidth + secondaryInlineCount * iconButtonWidth + overflowMenuButtonWidth; return Row( children: [ AndroidBottomTransportControls( player: session.engine, hasPrev: _playlist?.hasPrev ?? false, hasNext: _playlist?.hasNext ?? false, onPrevItem: () => unawaited(_playlist!.prev()), onNextItem: () => unawaited(_playlist!.next()), ), IconButton( tooltip: '快退', icon: const Icon( Icons.fast_rewind, color: Colors.white, size: 24, ), onPressed: () => _seekBy(-seekBwd), ), IconButton( tooltip: '快进', icon: const Icon( Icons.fast_forward, color: Colors.white, size: 24, ), onPressed: () => _seekBy(seekFwd), ), const Spacer(), if (showAllInline) ...[ if (hasVersionSwitch) IconButton( icon: const Icon( Icons.switch_video, color: Colors.white, size: 24, ), onPressed: _toggleVersionBar, tooltip: '切换版本', ), ], IconButton( icon: const Icon( Icons.subtitles_outlined, color: Colors.white, size: 24, ), onPressed: () => _togglePanel(PlayerPanelCategory.subtitle), tooltip: '字幕', ), IconButton( icon: const Icon( Icons.audiotrack, color: Colors.white, size: 24, ), onPressed: () => _togglePanel(PlayerPanelCategory.audio), tooltip: '音轨', ), if (showAllInline) ...[ IconButton( icon: const Icon( Icons.comment_outlined, color: Colors.white, size: 24, ), onPressed: () => _togglePanel(PlayerPanelCategory.danmaku), tooltip: '弹幕', ), IconButton( icon: const Icon( Icons.speed, color: Colors.white, size: 24, ), onPressed: () => _togglePanel(PlayerPanelCategory.speed), tooltip: '倍速', ), if (hasEpisode) IconButton( icon: const Icon( Icons.playlist_play, color: Colors.white, size: 24, ), onPressed: () => _togglePanel(PlayerPanelCategory.episode), tooltip: '选集', ), ] else if (secondaryMenuEntries.isNotEmpty) PopupMenuButton( icon: const Icon( Icons.more_vert, color: Colors.white, size: 24, ), tooltip: '更多', onSelected: (callback) => callback(), itemBuilder: (_) => secondaryMenuEntries, ), ], ); }, ), ), ], ); } Widget _buildAndroidLeftBar() { return AndroidLockButton(onLock: () => _setAndroidLocked(true)); } Widget _buildAndroidRightBar(PlaybackSession session) { return AndroidSpeedStrip( currentRate: _playbackRate, onIncrement: _onAndroidSpeedIncrement, onDecrement: _onAndroidSpeedDecrement, onTapRate: () => _togglePanel(PlayerPanelCategory.speed), ); } Widget _buildAndroidGestureLayer( PlaybackSession session, VoidCallback toggleControls, VoidCallback showControls, ) { return AndroidGestureLayer( enabled: !_isAndroidLocked, player: session.engine, onToggleControls: toggleControls, onDoubleTapCenter: () { unawaited( session.engine.state.playing ? session.engine.pause() : session.engine.play(), ); showControls(); }, onDoubleTapLeft: () { final secs = ref.read(playerSettingsProvider).value?.seekBackwardSeconds ?? 15; _seekBy(-secs); setState( () => _androidFeedbackData = GestureFeedbackData( kind: GestureFeedbackKind.doubleTapSeek, doubleTapSeconds: secs, doubleTapIsLeft: true, ), ); Future.delayed(const Duration(milliseconds: 600), () { if (mounted) { setState( () => _androidFeedbackData = const GestureFeedbackData.none(), ); } }); }, onDoubleTapRight: () { final secs = ref.read(playerSettingsProvider).value?.seekForwardSeconds ?? 15; _seekBy(secs); setState( () => _androidFeedbackData = GestureFeedbackData( kind: GestureFeedbackKind.doubleTapSeek, doubleTapSeconds: secs, doubleTapIsLeft: false, ), ); Future.delayed(const Duration(milliseconds: 600), () { if (mounted) { setState( () => _androidFeedbackData = const GestureFeedbackData.none(), ); } }); }, onBrightnessChange: (delta) async { final gen = _brightnessGen; try { final current = await ScreenBrightness.instance.application; if (!mounted || gen != _brightnessGen) return; final next = (current + delta).clamp(0.0, 1.0); await ScreenBrightness.instance.setApplicationScreenBrightness(next); if (!mounted || gen != _brightnessGen) return; _lastSetBrightness = next; setState( () => _androidFeedbackData = GestureFeedbackData( kind: GestureFeedbackKind.brightness, value: next, ), ); } catch (_) {} }, onVolumeChange: (delta) { final oldStep = (_volumeFraction * _maxVolumeLevel).round(); _volumeFraction = (_volumeFraction + delta).clamp(0.0, 1.0); final newStep = (_volumeFraction * _maxVolumeLevel).round(); if (newStep != oldStep) { unawaited( SystemAudioController.instance.adjustVolume(newStep - oldStep), ); } _systemVolumeOsdTimer?.cancel(); setState( () => _androidFeedbackData = GestureFeedbackData( kind: GestureFeedbackKind.volume, value: _volumeFraction, ), ); }, onVerticalDragEnd: () { _brightnessGen++; if (_lastSetBrightness != null) { unawaited( _playerSettingsNotifier.setAndroidBrightness(_lastSetBrightness!), ); _lastSetBrightness = null; } setState(() => _androidFeedbackData = const GestureFeedbackData.none()); }, onHorizontalSeekStart: () {}, onHorizontalSeekUpdate: (seekDelta) { final current = session.engine.state.position; final duration = session.engine.state.duration; final target = current + seekDelta; final clamped = target < Duration.zero ? Duration.zero : (target > duration ? duration : target); _seekPreview.value = clamped; setState( () => _androidFeedbackData = GestureFeedbackData( kind: GestureFeedbackKind.seek, seekTarget: clamped, totalDuration: duration, seekIsForward: !seekDelta.isNegative, ), ); }, onHorizontalSeekEnd: () { final target = _seekPreview.value; if (target != null) { unawaited(session.engine.seek(target)); } _seekPreview.value = null; setState(() => _androidFeedbackData = const GestureFeedbackData.none()); }, onLongPressStart: (isLeftSide) { final settings = ref.read(playerSettingsProvider).value; final holdSpeed = isLeftSide ? (settings?.keyboardHoldLeftSpeed ?? 0.5) : (settings?.keyboardHoldRightSpeed ?? 3.0); _savedRateBeforeLongPress = _playbackRate; _setPlaybackRate(holdSpeed); setState( () => _androidFeedbackData = GestureFeedbackData( kind: GestureFeedbackKind.longPressSpeed, speedRate: holdSpeed, ), ); }, onLongPressEnd: () { final restore = _savedRateBeforeLongPress ?? 1.0; _savedRateBeforeLongPress = null; _setPlaybackRate(restore); if (_androidFeedbackData.kind == GestureFeedbackKind.longPressSpeed) { setState( () => _androidFeedbackData = const GestureFeedbackData.none(), ); } }, ); } Widget _buildAndroidGestureFeedback() { return AndroidGestureFeedback(data: _androidFeedbackData); } Widget _buildAndroidDrawerLayer(PlaybackSession session) { return AnimatedBuilder( animation: _drawer, builder: (context, _) { final category = _drawer.category; if (category == null) return const SizedBox.shrink(); return Stack( children: [ GestureDetector( behavior: HitTestBehavior.opaque, onTap: _drawer.close, child: const ColoredBox( color: Colors.black38, child: SizedBox.expand(), ), ), Positioned( right: 0, top: 0, bottom: 0, width: 320, child: Material( color: const Color(0xF0181818), child: PlayerPanelShell( icon: panelCategoryIcon(category), title: panelCategoryLabel(category), width: double.infinity, onClose: _drawer.close, child: _panelBody(session, category), ), ), ), ], ); }, ); } } class _AndroidTimeLabel extends StatefulWidget { const _AndroidTimeLabel({ required this.player, required this.showPosition, required this.seekPreview, }); final PlayerEngine player; final bool showPosition; final ValueNotifier seekPreview; @override State<_AndroidTimeLabel> createState() => _AndroidTimeLabelState(); } class _AndroidTimeLabelState extends State<_AndroidTimeLabel> { late Duration _position; late Duration _duration; StreamSubscription? _posSub; StreamSubscription? _durSub; @override void initState() { super.initState(); _position = widget.player.state.position; _duration = widget.player.state.duration; _posSub = widget.player.stream.position.listen((p) { if (mounted) setState(() => _position = p); }); _durSub = widget.player.stream.duration.listen((d) { if (mounted) setState(() => _duration = d); }); if (widget.showPosition) { widget.seekPreview.addListener(_onSeekPreviewChanged); } } @override void didUpdateWidget(covariant _AndroidTimeLabel oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.player != widget.player) { unawaited(_posSub?.cancel()); unawaited(_durSub?.cancel()); _position = widget.player.state.position; _duration = widget.player.state.duration; _posSub = widget.player.stream.position.listen((p) { if (mounted) setState(() => _position = p); }); _durSub = widget.player.stream.duration.listen((d) { if (mounted) setState(() => _duration = d); }); } if (oldWidget.seekPreview != widget.seekPreview) { oldWidget.seekPreview.removeListener(_onSeekPreviewChanged); if (widget.showPosition) { widget.seekPreview.addListener(_onSeekPreviewChanged); } } } void _onSeekPreviewChanged() { if (mounted) setState(() {}); } @override void dispose() { unawaited(_posSub?.cancel()); unawaited(_durSub?.cancel()); widget.seekPreview.removeListener(_onSeekPreviewChanged); super.dispose(); } @override Widget build(BuildContext context) { final Duration display; if (widget.showPosition) { display = widget.seekPreview.value ?? _position; } else { display = _duration; } return Text( formatDurationClock(display), style: const TextStyle( color: Colors.white70, fontSize: 12, fontFeatures: [FontFeature.tabularFigures()], ), ); } }