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,57 @@
import 'package:flutter/material.dart';
import '../../../../core/infra/player_engine/player_engine.dart';
import '../player_play_pause_button.dart';
class AndroidBottomTransportControls extends StatelessWidget {
const AndroidBottomTransportControls({
super.key,
required this.player,
required this.hasPrev,
required this.hasNext,
required this.onPrevItem,
required this.onNextItem,
});
final PlayerEngine player;
final bool hasPrev;
final bool hasNext;
final VoidCallback onPrevItem;
final VoidCallback onNextItem;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: '上一集',
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
icon: Icon(
Icons.skip_previous,
color: hasPrev ? Colors.white : Colors.white38,
size: 28,
),
onPressed: hasPrev ? onPrevItem : null,
),
PlayerPlayPauseButton(
player: player,
iconSize: 30,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
IconButton(
tooltip: '下一集',
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
icon: Icon(
Icons.skip_next,
color: hasNext ? Colors.white : Colors.white38,
size: 28,
),
onPressed: hasNext ? onNextItem : null,
),
],
);
}
}
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import '../../../../shared/utils/format_utils.dart';
import '../player_hold_speed_prompt.dart';
enum GestureFeedbackKind {
none,
brightness,
volume,
seek,
doubleTapSeek,
longPressSpeed,
}
class GestureFeedbackData {
final GestureFeedbackKind kind;
final double value;
final Duration seekTarget;
final Duration totalDuration;
final bool seekIsForward;
final int doubleTapSeconds;
final bool doubleTapIsLeft;
final double speedRate;
const GestureFeedbackData({
this.kind = GestureFeedbackKind.none,
this.value = 0,
this.seekTarget = Duration.zero,
this.totalDuration = Duration.zero,
this.seekIsForward = true,
this.doubleTapSeconds = 0,
this.doubleTapIsLeft = true,
this.speedRate = 1.0,
});
const GestureFeedbackData.none() : this();
}
class AndroidGestureFeedback extends StatelessWidget {
final GestureFeedbackData data;
const AndroidGestureFeedback({super.key, required this.data});
@override
Widget build(BuildContext context) {
if (data.kind == GestureFeedbackKind.none) {
return const IgnorePointer(child: SizedBox.shrink());
}
return IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final screenHeight = constraints.maxHeight;
return Stack(children: [_buildContent(screenWidth, screenHeight)]);
},
),
);
}
Widget _buildContent(double screenWidth, double screenHeight) {
switch (data.kind) {
case GestureFeedbackKind.brightness:
case GestureFeedbackKind.volume:
return _buildTopIndicator();
case GestureFeedbackKind.seek:
return _buildSeekPreview();
case GestureFeedbackKind.longPressSpeed:
return _buildLongPressSpeed();
case GestureFeedbackKind.doubleTapSeek:
return _buildDoubleTapSeek(screenWidth, screenHeight);
case GestureFeedbackKind.none:
return const SizedBox.shrink();
}
}
Widget _buildTopIndicator() {
final isBrightness = data.kind == GestureFeedbackKind.brightness;
final icon = isBrightness
? Icons.brightness_6
: (data.value == 0
? Icons.volume_off
: (data.value < 0.5 ? Icons.volume_down : Icons.volume_up));
final clamped = data.value.clamp(0.0, 1.0);
return Positioned(
top: 64,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xE8181818),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 10),
_buildHorizontalProgress(clamped),
const SizedBox(width: 10),
SizedBox(
width: 36,
child: Text(
'${(clamped * 100).round()}%',
textAlign: TextAlign.right,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
],
),
),
),
);
}
Widget _buildHorizontalProgress(double value) {
return SizedBox(
width: 120,
height: 4,
child: LinearProgressIndicator(
value: value,
color: Colors.white,
backgroundColor: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
);
}
Widget _buildSeekPreview() {
final isForward = data.seekIsForward;
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xE8181818),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isForward ? Icons.fast_forward : Icons.fast_rewind,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
formatDurationClock(
data.seekTarget < Duration.zero
? Duration.zero
: data.seekTarget,
),
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
const Text(
' / ',
style: TextStyle(color: Colors.white54, fontSize: 15),
),
Text(
formatDurationClock(data.totalDuration),
style: const TextStyle(
color: Colors.white54,
fontSize: 15,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
),
);
}
Widget _buildLongPressSpeed() {
return Positioned(
top: 64,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: holdSpeedPromptBackgroundColor,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.fast_forward, color: Colors.white, size: 18),
const SizedBox(width: 6),
Text(
'${data.speedRate}x',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
Widget _buildDoubleTapSeek(double screenWidth, double screenHeight) {
return Positioned(
top: 0,
bottom: 0,
left: data.doubleTapIsLeft ? screenWidth * 0.15 : null,
right: !data.doubleTapIsLeft ? screenWidth * 0.15 : null,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xE8181818),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
data.doubleTapIsLeft ? Icons.fast_rewind : Icons.fast_forward,
color: Colors.white,
size: 20,
),
const SizedBox(width: 6),
Text(
'${data.doubleTapSeconds}s',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../core/infra/player_engine/player_engine.dart';
enum _DragMode { none, brightness, volume, seek }
class AndroidGestureLayer extends StatefulWidget {
const AndroidGestureLayer({
super.key,
required this.enabled,
required this.player,
required this.onToggleControls,
required this.onDoubleTapCenter,
required this.onDoubleTapLeft,
required this.onDoubleTapRight,
required this.onBrightnessChange,
required this.onVolumeChange,
required this.onVerticalDragEnd,
required this.onHorizontalSeekUpdate,
required this.onHorizontalSeekStart,
required this.onHorizontalSeekEnd,
required this.onLongPressStart,
required this.onLongPressEnd,
});
final bool enabled;
final PlayerEngine player;
final VoidCallback onToggleControls;
final VoidCallback onDoubleTapCenter;
final VoidCallback onDoubleTapLeft;
final VoidCallback onDoubleTapRight;
final ValueChanged<double> onBrightnessChange;
final ValueChanged<double> onVolumeChange;
final VoidCallback onVerticalDragEnd;
final ValueChanged<Duration> onHorizontalSeekUpdate;
final VoidCallback onHorizontalSeekStart;
final VoidCallback onHorizontalSeekEnd;
final ValueChanged<bool> onLongPressStart;
final VoidCallback onLongPressEnd;
@override
State<AndroidGestureLayer> createState() => _AndroidGestureLayerState();
}
class _AndroidGestureLayerState extends State<AndroidGestureLayer> {
_DragMode _dragMode = _DragMode.none;
Offset _panStartPos = Offset.zero;
double _cumulativeDx = 0;
double _cumulativeDy = 0;
Timer? _doubleTapTimer;
Offset _lastTapDownPos = Offset.zero;
void _handleTapDown(TapDownDetails details) {
_lastTapDownPos = details.localPosition;
}
void _handleTapUp(TapUpDetails details) {
final pos = _lastTapDownPos;
if (_doubleTapTimer != null) {
_doubleTapTimer!.cancel();
_doubleTapTimer = null;
widget.onToggleControls();
_executeDoubleTap(pos);
} else {
widget.onToggleControls();
_doubleTapTimer = Timer(const Duration(milliseconds: 300), () {
_doubleTapTimer = null;
});
}
}
void _executeDoubleTap(Offset pos) {
final width = context.size?.width ?? 0;
final third = width / 3;
if (pos.dx < third) {
widget.onDoubleTapLeft();
} else if (pos.dx > third * 2) {
widget.onDoubleTapRight();
} else {
widget.onDoubleTapCenter();
}
}
void _handlePanStart(DragStartDetails details) {
_panStartPos = details.localPosition;
_dragMode = _DragMode.none;
_cumulativeDx = 0;
_cumulativeDy = 0;
}
void _handlePanUpdate(DragUpdateDetails details) {
_cumulativeDx += details.delta.dx;
_cumulativeDy += details.delta.dy;
if (_dragMode == _DragMode.none) {
final absDx = _cumulativeDx.abs();
final absDy = _cumulativeDy.abs();
if (absDx < 20 && absDy < 20) return;
if (absDx > absDy * 1.5) {
_dragMode = _DragMode.seek;
widget.onHorizontalSeekStart();
} else if (absDy > absDx * 1.5) {
final screenWidth = context.size?.width ?? 1;
_dragMode = _panStartPos.dx < screenWidth / 2
? _DragMode.brightness
: _DragMode.volume;
} else {
return;
}
}
if (_dragMode == _DragMode.seek) {
final screenWidth = context.size?.width ?? 1;
final totalDuration = widget.player.state.duration;
final seekDelta = totalDuration * (_cumulativeDx / screenWidth) * 0.5;
widget.onHorizontalSeekUpdate(seekDelta);
} else if (_dragMode == _DragMode.brightness) {
final screenHeight = context.size?.height ?? 1;
final delta = -details.delta.dy / (screenHeight * 0.7);
widget.onBrightnessChange(delta);
} else if (_dragMode == _DragMode.volume) {
final screenHeight = context.size?.height ?? 1;
final delta = -details.delta.dy / (screenHeight * 1.5);
widget.onVolumeChange(delta);
}
}
void _handlePanEnd(DragEndDetails details) {
if (_dragMode == _DragMode.seek) {
widget.onHorizontalSeekEnd();
} else if (_dragMode == _DragMode.brightness ||
_dragMode == _DragMode.volume) {
widget.onVerticalDragEnd();
}
_dragMode = _DragMode.none;
}
@override
void dispose() {
_doubleTapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.enabled) return const SizedBox.expand();
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onPanStart: _handlePanStart,
onPanUpdate: _handlePanUpdate,
onPanEnd: _handlePanEnd,
onLongPressStart: (details) {
final screenWidth = context.size?.width ?? 1;
final isLeftSide = details.localPosition.dx < screenWidth / 2;
widget.onLongPressStart(isLeftSide);
},
onLongPressEnd: (_) => widget.onLongPressEnd(),
child: const SizedBox.expand(),
);
}
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/material.dart';
class AndroidLockButton extends StatelessWidget {
const AndroidLockButton({super.key, required this.onLock});
final VoidCallback onLock;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xB3000000),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onLock,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.lock_open, color: Colors.white, size: 22),
),
),
);
}
}
class AndroidLockedOverlay extends StatefulWidget {
const AndroidLockedOverlay({super.key, required this.onUnlock});
final VoidCallback onUnlock;
@override
State<AndroidLockedOverlay> createState() => _AndroidLockedOverlayState();
}
class _AndroidLockedOverlayState extends State<AndroidLockedOverlay> {
bool _iconVisible = true;
Timer? _hideTimer;
@override
void initState() {
super.initState();
_startTimer();
}
void _startTimer() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _iconVisible = false);
});
}
void _onTapBackground() {
if (!_iconVisible) {
setState(() => _iconVisible = true);
_startTimer();
}
}
@override
void dispose() {
_hideTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _onTapBackground,
child: Stack(
children: [
Positioned(
left: 56,
top: 0,
bottom: 0,
child: Center(
child: AnimatedOpacity(
opacity: _iconVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 160),
child: Material(
color: const Color(0xB3000000),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _iconVisible ? widget.onUnlock : null,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.lock, color: Colors.white, size: 28),
),
),
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import '../speed_button.dart';
class AndroidSpeedStrip extends StatelessWidget {
const AndroidSpeedStrip({
super.key,
required this.currentRate,
required this.onIncrement,
required this.onDecrement,
required this.onTapRate,
});
final double currentRate;
final VoidCallback onIncrement;
final VoidCallback onDecrement;
final VoidCallback onTapRate;
@override
Widget build(BuildContext context) {
const buttonConstraints = BoxConstraints(minWidth: 36, minHeight: 36);
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
decoration: BoxDecoration(
color: const Color(0xB3000000),
borderRadius: BorderRadius.circular(24),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onIncrement,
icon: const Icon(Icons.add, size: 20, color: Colors.white),
constraints: buttonConstraints,
padding: EdgeInsets.zero,
),
GestureDetector(
onTap: onTapRate,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
SpeedButton.formatRate(currentRate),
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
),
IconButton(
onPressed: onDecrement,
icon: const Icon(Icons.remove, size: 20, color: Colors.white),
constraints: buttonConstraints,
padding: EdgeInsets.zero,
),
],
),
);
}
}