Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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()],
),
);
}
}