Initial commit
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
|
||||
|
||||
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()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user