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