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