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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
import '../services/network_speed_monitor.dart';
|
||||
|
||||
class BufferingOverlay extends StatefulWidget {
|
||||
final PlayerEngine player;
|
||||
final NetworkSpeedMonitor? speedMonitor;
|
||||
final ValueChanged<bool>? onBufferingChanged;
|
||||
|
||||
|
||||
final bool suppress;
|
||||
|
||||
const BufferingOverlay({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.speedMonitor,
|
||||
this.onBufferingChanged,
|
||||
this.suppress = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BufferingOverlay> createState() => _BufferingOverlayState();
|
||||
}
|
||||
|
||||
class _BufferingOverlayState extends State<BufferingOverlay> {
|
||||
bool _isBuffering = false;
|
||||
Timer? _debounceTimer;
|
||||
|
||||
Duration _position = Duration.zero;
|
||||
Duration _buffer = Duration.zero;
|
||||
bool _playing = false;
|
||||
|
||||
StreamSubscription<Duration>? _bufferSub;
|
||||
StreamSubscription<Duration>? _positionSub;
|
||||
StreamSubscription<bool>? _playingSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_readState(widget.player);
|
||||
_subscribe(widget.player);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BufferingOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
_cancelSubscriptions();
|
||||
_setBuffering(false);
|
||||
_readState(widget.player);
|
||||
_subscribe(widget.player);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_cancelSubscriptions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _readState(PlayerEngine player) {
|
||||
_position = player.state.position;
|
||||
_buffer = player.state.buffer;
|
||||
_playing = player.state.playing;
|
||||
}
|
||||
|
||||
void _subscribe(PlayerEngine player) {
|
||||
_positionSub = player.stream.position.listen((p) {
|
||||
_position = p;
|
||||
_evaluate();
|
||||
});
|
||||
_bufferSub = player.stream.buffer.listen((b) {
|
||||
_buffer = b;
|
||||
_evaluate();
|
||||
});
|
||||
_playingSub = player.stream.playing.listen((playing) {
|
||||
_playing = playing;
|
||||
_evaluate();
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelSubscriptions() {
|
||||
unawaited(_positionSub?.cancel());
|
||||
unawaited(_bufferSub?.cancel());
|
||||
unawaited(_playingSub?.cancel());
|
||||
_positionSub = null;
|
||||
_bufferSub = null;
|
||||
_playingSub = null;
|
||||
}
|
||||
|
||||
void _setBuffering(bool value) {
|
||||
if (_isBuffering == value) return;
|
||||
setState(() => _isBuffering = value);
|
||||
widget.onBufferingChanged?.call(value);
|
||||
}
|
||||
|
||||
void _evaluate() {
|
||||
final shouldBuffer =
|
||||
_playing && _buffer <= _position && _position > Duration.zero;
|
||||
|
||||
if (shouldBuffer) {
|
||||
if (_isBuffering || _debounceTimer != null) return;
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
_debounceTimer = null;
|
||||
if (!mounted) return;
|
||||
if (_playing && _buffer <= _position && _position > Duration.zero) {
|
||||
_setBuffering(true);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = null;
|
||||
_setBuffering(false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final monitor = widget.speedMonitor;
|
||||
return IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: widget.suppress ? 0.0 : 1.0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _isBuffering ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppLoadingRing(size: 40, color: Colors.white),
|
||||
if (monitor != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: monitor.available,
|
||||
builder: (_, available, _) {
|
||||
if (!available) return const SizedBox.shrink();
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: monitor.speed,
|
||||
builder: (_, speed, _) {
|
||||
return Text(
|
||||
formatDownloadSpeed(speed),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/contracts/danmaku.dart';
|
||||
import 'danmaku_tokens.dart';
|
||||
|
||||
|
||||
class DanmakuMatchTile extends StatelessWidget {
|
||||
const DanmakuMatchTile({
|
||||
super.key,
|
||||
required this.candidate,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final DanmakuMatchCandidate candidate;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DanmakuTokens.selectedFill : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(DanmakuTokens.radius),
|
||||
border: selected
|
||||
? Border.all(color: DanmakuTokens.accent.withValues(alpha: 0.5))
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
size: 15,
|
||||
color: selected ? DanmakuTokens.accent : DanmakuTokens.textHint,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
candidate.animeTitle,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? DanmakuTokens.textPrimary
|
||||
: DanmakuTokens.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (candidate.episodeTitle.isNotEmpty)
|
||||
Text(
|
||||
candidate.episodeTitle,
|
||||
style: const TextStyle(
|
||||
color: DanmakuTokens.textHint,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
candidate.source.displayName,
|
||||
style: const TextStyle(
|
||||
color: DanmakuTokens.textFaint,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class DanmakuTokens {
|
||||
DanmakuTokens._();
|
||||
|
||||
|
||||
static const Color accent = Colors.lightBlueAccent;
|
||||
|
||||
|
||||
static const Color selectedFill = Colors.white12;
|
||||
|
||||
static const Color textPrimary = Colors.white;
|
||||
static const Color textSecondary = Colors.white70;
|
||||
static const Color textHint = Colors.white38;
|
||||
static const Color textFaint = Colors.white30;
|
||||
|
||||
static const double radius = 6.0;
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../core/contracts/danmaku.dart';
|
||||
import '../../../core/media/danmaku_episode_parser.dart';
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
import '../services/danmaku/danmaku_session_notifier.dart';
|
||||
|
||||
part 'danmaku_search_dialog_view.dart';
|
||||
|
||||
const _kBg = Color(0xE8181818);
|
||||
const _kDivider = Color(0x33FFFFFF);
|
||||
const _kAccent = Color(0xFF4FA8FF);
|
||||
|
||||
enum _Phase {
|
||||
idle,
|
||||
searching,
|
||||
results,
|
||||
empty,
|
||||
error,
|
||||
episodesLoading,
|
||||
episodes,
|
||||
applying,
|
||||
}
|
||||
|
||||
class DanmakuSearchDialog extends ConsumerStatefulWidget {
|
||||
final String currentItemId;
|
||||
final bool Function() isItemStillCurrent;
|
||||
final String? initialQuery;
|
||||
final List<DanmakuMatchCandidate> initialResults;
|
||||
final int initialSelectedIndex;
|
||||
final int? targetEpisode;
|
||||
|
||||
const DanmakuSearchDialog({
|
||||
super.key,
|
||||
required this.currentItemId,
|
||||
required this.isItemStillCurrent,
|
||||
this.initialQuery,
|
||||
this.initialResults = const [],
|
||||
this.initialSelectedIndex = -1,
|
||||
this.targetEpisode,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DanmakuSearchDialog> createState() =>
|
||||
_DanmakuSearchDialogState();
|
||||
}
|
||||
|
||||
class _DanmakuSearchDialogState extends ConsumerState<DanmakuSearchDialog> {
|
||||
DanmakuSessionNotifier get _notifier =>
|
||||
ref.read(danmakuSessionProvider.notifier);
|
||||
|
||||
final TextEditingController _queryCtrl = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final ScrollController _episodesScrollCtrl = ScrollController();
|
||||
final Map<String, List<DanmakuBangumiEpisode>> _episodeCache = {};
|
||||
|
||||
static const double _kEpisodeItemExtent = 68.0;
|
||||
|
||||
_Phase _phase = _Phase.idle;
|
||||
List<DanmakuMatchCandidate> _results = const [];
|
||||
DanmakuMatchCandidate? _activeMatch;
|
||||
List<DanmakuBangumiEpisode> _episodes = const [];
|
||||
String? _messageText;
|
||||
bool _messageIsError = false;
|
||||
int? _flashEpisodeId;
|
||||
DanmakuSource? _flashSource;
|
||||
int _searchSeq = 0;
|
||||
int _episodeSeq = 0;
|
||||
int _applySeq = 0;
|
||||
Offset _transitionOffset = Offset.zero;
|
||||
|
||||
bool get _busy =>
|
||||
_phase == _Phase.searching ||
|
||||
_phase == _Phase.episodesLoading ||
|
||||
_phase == _Phase.applying;
|
||||
|
||||
String _episodeCacheKey(DanmakuMatchCandidate match) =>
|
||||
'${match.source.id}:${match.animeId}';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final q = widget.initialQuery;
|
||||
if (q != null && q.isNotEmpty) {
|
||||
_queryCtrl.text = q;
|
||||
_queryCtrl.selection = TextSelection.collapsed(offset: q.length);
|
||||
}
|
||||
if (widget.initialResults.isNotEmpty) {
|
||||
_results = widget.initialResults;
|
||||
_phase = _Phase.results;
|
||||
final idx = widget.initialSelectedIndex;
|
||||
if (idx >= 0 && idx < widget.initialResults.length) {
|
||||
_flashEpisodeId = widget.initialResults[idx].episodeId;
|
||||
_flashSource = widget.initialResults[idx].source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_queryCtrl.dispose();
|
||||
_focusNode.dispose();
|
||||
_episodesScrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int? _findTargetEpisodeIndex(List<DanmakuBangumiEpisode> episodes) {
|
||||
final target = widget.targetEpisode;
|
||||
if (target == null) return null;
|
||||
for (var i = 0; i < episodes.length; i++) {
|
||||
final n = extractEpisodeNumberFromTitle(episodes[i].episodeTitle);
|
||||
if (n == target) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _scheduleEpisodeScroll(int targetIdx) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_episodesScrollCtrl.hasClients) return;
|
||||
final position = _episodesScrollCtrl.position;
|
||||
final viewport = position.viewportDimension;
|
||||
final desired =
|
||||
targetIdx * _kEpisodeItemExtent -
|
||||
(viewport - _kEpisodeItemExtent) / 2;
|
||||
final clamped = desired.clamp(0.0, position.maxScrollExtent);
|
||||
_episodesScrollCtrl.jumpTo(clamped);
|
||||
});
|
||||
}
|
||||
|
||||
void _dismiss() {
|
||||
Navigator.of(context, rootNavigator: true).maybePop();
|
||||
}
|
||||
|
||||
bool _ensureItemStillCurrent() {
|
||||
if (widget.isItemStillCurrent()) return true;
|
||||
_dismiss();
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_ensureItemStillCurrent()) return;
|
||||
final query = _queryCtrl.text.trim();
|
||||
if (query.isEmpty) {
|
||||
setState(() {
|
||||
_phase = _Phase.idle;
|
||||
_messageText = '请输入番剧名';
|
||||
_messageIsError = true;
|
||||
_flashEpisodeId = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final seq = ++_searchSeq;
|
||||
_episodeSeq++;
|
||||
_applySeq++;
|
||||
_transitionOffset = Offset.zero;
|
||||
|
||||
setState(() {
|
||||
_phase = _Phase.searching;
|
||||
_activeMatch = null;
|
||||
_episodes = const [];
|
||||
_messageText = null;
|
||||
_messageIsError = false;
|
||||
_flashEpisodeId = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final matches = await _notifier.manualSearch(query);
|
||||
if (!mounted || seq != _searchSeq) return;
|
||||
if (matches == null) {
|
||||
_dismiss();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_results = matches;
|
||||
_phase = matches.isEmpty ? _Phase.empty : _Phase.results;
|
||||
_messageText = null;
|
||||
_messageIsError = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted || seq != _searchSeq) return;
|
||||
setState(() {
|
||||
_phase = _Phase.error;
|
||||
_messageText = '搜索失败,请检查弹幕服务地址';
|
||||
_messageIsError = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openAnime(DanmakuMatchCandidate match) async {
|
||||
if (_busy) return;
|
||||
if (!_ensureItemStillCurrent()) return;
|
||||
if (match.animeId <= 0) {
|
||||
await _applyDirect(match);
|
||||
return;
|
||||
}
|
||||
|
||||
final cacheKey = _episodeCacheKey(match);
|
||||
final cached = _episodeCache[cacheKey];
|
||||
if (cached != null) {
|
||||
if (cached.isEmpty) {
|
||||
_transitionOffset = const Offset(-0.08, 0);
|
||||
setState(() {
|
||||
_phase = _Phase.results;
|
||||
_activeMatch = null;
|
||||
_episodes = const [];
|
||||
_messageText = '该番剧未返回分集列表,可直接使用搜索结果"直接加载"';
|
||||
_messageIsError = false;
|
||||
_flashEpisodeId = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (cached.length == 1) {
|
||||
_activeMatch = match;
|
||||
_episodes = cached;
|
||||
await _applyEpisode(match, cached.single);
|
||||
return;
|
||||
}
|
||||
_transitionOffset = const Offset(0.08, 0);
|
||||
final targetIdx = _findTargetEpisodeIndex(cached);
|
||||
setState(() {
|
||||
_activeMatch = match;
|
||||
_episodes = cached;
|
||||
_phase = _Phase.episodes;
|
||||
_messageText = null;
|
||||
_messageIsError = false;
|
||||
_flashEpisodeId = targetIdx != null
|
||||
? cached[targetIdx].episodeId
|
||||
: null;
|
||||
});
|
||||
if (targetIdx != null) _scheduleEpisodeScroll(targetIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
final seq = ++_episodeSeq;
|
||||
_applySeq++;
|
||||
_transitionOffset = const Offset(0.08, 0);
|
||||
|
||||
setState(() {
|
||||
_activeMatch = match;
|
||||
_episodes = const [];
|
||||
_phase = _Phase.episodesLoading;
|
||||
_messageText = null;
|
||||
_messageIsError = false;
|
||||
_flashEpisodeId = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final episodes = await _notifier.listEpisodesForAnime(
|
||||
match.source,
|
||||
match.animeId,
|
||||
);
|
||||
if (!mounted || seq != _episodeSeq) return;
|
||||
if (episodes == null) {
|
||||
_dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
_episodeCache[cacheKey] = episodes;
|
||||
|
||||
if (episodes.isEmpty) {
|
||||
_transitionOffset = const Offset(-0.08, 0);
|
||||
setState(() {
|
||||
_phase = _Phase.results;
|
||||
_activeMatch = null;
|
||||
_episodes = const [];
|
||||
_messageText = '该番剧未返回分集列表,可直接使用搜索结果"直接加载"';
|
||||
_messageIsError = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (episodes.length == 1) {
|
||||
_activeMatch = match;
|
||||
_episodes = episodes;
|
||||
await _applyEpisode(match, episodes.single);
|
||||
return;
|
||||
}
|
||||
|
||||
_transitionOffset = const Offset(0.08, 0);
|
||||
final targetIdx = _findTargetEpisodeIndex(episodes);
|
||||
setState(() {
|
||||
_episodes = episodes;
|
||||
_phase = _Phase.episodes;
|
||||
_messageText = null;
|
||||
_messageIsError = false;
|
||||
_flashEpisodeId = targetIdx != null
|
||||
? episodes[targetIdx].episodeId
|
||||
: null;
|
||||
});
|
||||
if (targetIdx != null) _scheduleEpisodeScroll(targetIdx);
|
||||
} catch (_) {
|
||||
if (!mounted || seq != _episodeSeq) return;
|
||||
_transitionOffset = const Offset(-0.08, 0);
|
||||
setState(() {
|
||||
_phase = _Phase.results;
|
||||
_activeMatch = null;
|
||||
_episodes = const [];
|
||||
_messageText = '加载分集失败,请重试';
|
||||
_messageIsError = true;
|
||||
_flashEpisodeId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _backToResults() {
|
||||
_episodeSeq++;
|
||||
_applySeq++;
|
||||
_transitionOffset = const Offset(-0.08, 0);
|
||||
setState(() {
|
||||
_phase = _Phase.results;
|
||||
_activeMatch = null;
|
||||
_episodes = const [];
|
||||
_flashEpisodeId = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _applyEpisode(
|
||||
DanmakuMatchCandidate match,
|
||||
DanmakuBangumiEpisode episode,
|
||||
) async {
|
||||
if (!_ensureItemStillCurrent()) return;
|
||||
final seq = ++_applySeq;
|
||||
_searchSeq++;
|
||||
_episodeSeq++;
|
||||
_transitionOffset = Offset.zero;
|
||||
|
||||
setState(() {
|
||||
_flashEpisodeId = episode.episodeId;
|
||||
_phase = _Phase.applying;
|
||||
_messageText = null;
|
||||
_messageIsError = false;
|
||||
});
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted || seq != _applySeq) return;
|
||||
if (!_ensureItemStillCurrent()) return;
|
||||
|
||||
try {
|
||||
await _notifier.applyManualSelection(
|
||||
source: match.source,
|
||||
episodeId: episode.episodeId,
|
||||
animeId: match.animeId,
|
||||
animeTitle: match.animeTitle,
|
||||
episodeTitle: episode.episodeTitle,
|
||||
);
|
||||
if (!mounted || seq != _applySeq) return;
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
} catch (_) {
|
||||
if (!mounted || seq != _applySeq) return;
|
||||
setState(() {
|
||||
_phase = _episodes.isEmpty ? _Phase.results : _Phase.episodes;
|
||||
_messageText = '加载弹幕失败,请重试';
|
||||
_messageIsError = true;
|
||||
_flashEpisodeId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyDirect(DanmakuMatchCandidate match) async {
|
||||
if (!_ensureItemStillCurrent()) return;
|
||||
final seq = ++_applySeq;
|
||||
_searchSeq++;
|
||||
_episodeSeq++;
|
||||
_transitionOffset = Offset.zero;
|
||||
|
||||
setState(() {
|
||||
_flashEpisodeId = match.episodeId;
|
||||
_flashSource = match.source;
|
||||
_phase = _Phase.applying;
|
||||
_messageText = null;
|
||||
_messageIsError = false;
|
||||
});
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted || seq != _applySeq) return;
|
||||
if (!_ensureItemStillCurrent()) return;
|
||||
|
||||
try {
|
||||
await _notifier.applyManualSelection(
|
||||
source: match.source,
|
||||
episodeId: match.episodeId,
|
||||
animeId: match.animeId,
|
||||
animeTitle: match.animeTitle,
|
||||
episodeTitle: match.episodeTitle,
|
||||
);
|
||||
if (!mounted || seq != _applySeq) return;
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
} catch (_) {
|
||||
if (!mounted || seq != _applySeq) return;
|
||||
setState(() {
|
||||
_phase = _Phase.results;
|
||||
_messageText = '加载弹幕失败,请重试';
|
||||
_messageIsError = true;
|
||||
_flashEpisodeId = null;
|
||||
_flashSource = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Shortcuts(
|
||||
shortcuts: const <ShortcutActivator, Intent>{
|
||||
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
DismissIntent: CallbackAction<DismissIntent>(
|
||||
onInvoke: (_) {
|
||||
_dismiss();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: _kBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _kDivider),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black54,
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const FDivider(style: .delta(color: _kDivider, padding: .value(EdgeInsets.zero))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: _buildSearchField(),
|
||||
),
|
||||
const FDivider(style: .delta(color: _kDivider, padding: .value(EdgeInsets.zero))),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: _slideFade,
|
||||
child: _buildBody(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
part of 'danmaku_search_dialog.dart';
|
||||
|
||||
extension _DanmakuSearchDialogView on _DanmakuSearchDialogState {
|
||||
Widget _slideFade(Widget child, Animation<double> animation) {
|
||||
final curved = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: curved,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: _transitionOffset,
|
||||
end: Offset.zero,
|
||||
).animate(curved),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'搜索弹幕源',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: _dismiss,
|
||||
icon: const Icon(Icons.close, color: Colors.white70),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: TextField(
|
||||
controller: _queryCtrl,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
enabled: !_busy,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
cursorColor: Colors.lightBlueAccent,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.08),
|
||||
hintText: '输入番剧名后按 Enter 搜索',
|
||||
hintStyle: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||
prefixIcon: const Icon(Icons.search, color: Colors.white54, size: 18),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: _busy ? null : _submit,
|
||||
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||
color: Colors.white70,
|
||||
tooltip: '搜索',
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 0,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: _kDivider),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: _kDivider),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: _kAccent),
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
switch (_phase) {
|
||||
case _Phase.idle:
|
||||
return _buildStateBody(
|
||||
key: const ValueKey('idle'),
|
||||
icon: Icons.search,
|
||||
title: '输入番剧名,按 Enter 搜索',
|
||||
subtitle: '可手动选择具体集数',
|
||||
showMessage: true,
|
||||
);
|
||||
case _Phase.searching:
|
||||
return _buildLoadingBody(
|
||||
key: const ValueKey('searching'),
|
||||
text: '正在搜索…',
|
||||
);
|
||||
case _Phase.results:
|
||||
return _buildResultsBody(
|
||||
key: const ValueKey('results'),
|
||||
disabled: false,
|
||||
);
|
||||
case _Phase.empty:
|
||||
return _buildStateBody(
|
||||
key: const ValueKey('empty'),
|
||||
icon: Icons.search_off,
|
||||
title: '未找到匹配的弹幕源',
|
||||
subtitle: '换个剧名试试',
|
||||
);
|
||||
case _Phase.error:
|
||||
return _buildStateBody(
|
||||
key: const ValueKey('error'),
|
||||
icon: Icons.error_outline,
|
||||
title: _messageText ?? '搜索失败',
|
||||
subtitle: '请检查弹幕服务地址',
|
||||
);
|
||||
case _Phase.episodesLoading:
|
||||
return _buildEpisodesLoadingBody();
|
||||
case _Phase.episodes:
|
||||
return _buildEpisodesBody(
|
||||
key: const ValueKey('episodes'),
|
||||
disabled: false,
|
||||
);
|
||||
case _Phase.applying:
|
||||
return _buildApplyingBody();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStateBody({
|
||||
required Key key,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
bool showMessage = false,
|
||||
}) {
|
||||
return Center(
|
||||
key: key,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: Colors.white24, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
if (showMessage && _messageText != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildMessageBanner(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingBody({required Key key, required String text}) {
|
||||
return Center(
|
||||
key: key,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppLoadingRing(size: 22, color: Colors.white),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsBody({required Key key, required bool disabled}) {
|
||||
return Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'搜索结果 ${_results.length} 条',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (_messageText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildMessageBanner(),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _results.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (_, i) => _buildResultTile(_results[i], disabled),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodesLoadingBody() {
|
||||
return Column(
|
||||
key: const ValueKey('episodes-loading'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildEpisodesHeader(disabled: false),
|
||||
const SizedBox(height: 16),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppLoadingRing(size: 22, color: Colors.white),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'正在加载分集…',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodesBody({
|
||||
required Key key,
|
||||
required bool disabled,
|
||||
bool useScrollController = true,
|
||||
}) {
|
||||
return Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildEpisodesHeader(disabled: disabled),
|
||||
if (_messageText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildMessageBanner(),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
controller: useScrollController ? _episodesScrollCtrl : null,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _episodes.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (_, i) => _buildEpisodeTile(_episodes[i], i, disabled),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildApplyingBody() {
|
||||
final base = _activeMatch != null && _episodes.isNotEmpty
|
||||
? _buildEpisodesBody(
|
||||
key: const ValueKey('applying-episodes'),
|
||||
disabled: true,
|
||||
useScrollController: false,
|
||||
)
|
||||
: _buildResultsBody(
|
||||
key: const ValueKey('applying-results'),
|
||||
disabled: true,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
key: const ValueKey('applying'),
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
base,
|
||||
Positioned.fill(
|
||||
child: ColoredBox(color: Colors.black.withValues(alpha: 0.18)),
|
||||
),
|
||||
const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppLoadingRing(size: 22, color: Colors.white),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'正在加载弹幕…',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodesHeader({required bool disabled}) {
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: disabled ? null : _backToResults,
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white70),
|
||||
tooltip: '返回',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_activeMatch?.animeTitle ?? '选择分集',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'选择要加载的集数',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBanner() {
|
||||
final color = _messageIsError ? Colors.redAccent : _kAccent;
|
||||
final icon = _messageIsError ? Icons.error_outline : Icons.info_outline;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_messageText ?? '',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultTile(DanmakuMatchCandidate match, bool disabled) {
|
||||
final highlighted =
|
||||
_flashEpisodeId == match.episodeId &&
|
||||
_flashSource?.id == match.source.id;
|
||||
final canDrill = match.animeId > 0;
|
||||
final subtitle = match.episodeTitle.isNotEmpty
|
||||
? match.episodeTitle
|
||||
: canDrill
|
||||
? '进入分集列表后选择具体集数'
|
||||
: '直接加载当前搜索结果';
|
||||
final showTargetHint = widget.targetEpisode != null && canDrill;
|
||||
|
||||
return InkWell(
|
||||
onTap: disabled
|
||||
? null
|
||||
: () {
|
||||
if (canDrill) {
|
||||
_openAnime(match);
|
||||
} else {
|
||||
_applyDirect(match);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: highlighted
|
||||
? Colors.white12
|
||||
: Colors.white.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: highlighted
|
||||
? Colors.lightBlueAccent.withValues(alpha: 0.7)
|
||||
: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
match.animeTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
match.source.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
if (showTargetHint) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.adjust,
|
||||
size: 12,
|
||||
color: Colors.white38,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'目标:${formatEpisodeNumber(widget.targetEpisode)}(点击选择)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildResultAffordance(canDrill: canDrill),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultAffordance({required bool canDrill}) {
|
||||
if (canDrill) {
|
||||
return const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('选择集数', style: TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
SizedBox(width: 2),
|
||||
Icon(Icons.chevron_right, size: 16, color: Colors.white54),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _kAccent.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: _kAccent.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: const Text(
|
||||
'直接加载',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodeTile(
|
||||
DanmakuBangumiEpisode episode,
|
||||
int index,
|
||||
bool disabled,
|
||||
) {
|
||||
final highlighted = _flashEpisodeId == episode.episodeId;
|
||||
final epNum = episode.episodeNumber.trim();
|
||||
final episodeLabel = epNum.isEmpty
|
||||
? formatEpisodeNumber(index + 1)!
|
||||
: '第$epNum集';
|
||||
|
||||
return InkWell(
|
||||
onTap: disabled || _activeMatch == null
|
||||
? null
|
||||
: () => _applyEpisode(_activeMatch!, episode),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: highlighted
|
||||
? Colors.white12
|
||||
: Colors.white.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: highlighted
|
||||
? Colors.lightBlueAccent.withValues(alpha: 0.7)
|
||||
: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
episodeLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (episode.episodeTitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
episode.episodeTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'点击加载',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/contracts/danmaku.dart';
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
import '../../../core/media/danmaku_heatmap.dart';
|
||||
import '../../../providers/danmaku_settings_provider.dart';
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
import '../services/danmaku/danmaku_session_notifier.dart';
|
||||
|
||||
|
||||
const double _kSeekBarHeight = 28.0;
|
||||
|
||||
|
||||
class DebouncedSeekBar extends ConsumerStatefulWidget {
|
||||
final PlayerEngine player;
|
||||
|
||||
|
||||
final void Function(Duration?)? onDragChanged;
|
||||
|
||||
const DebouncedSeekBar({super.key, required this.player, this.onDragChanged});
|
||||
|
||||
@override
|
||||
ConsumerState<DebouncedSeekBar> createState() => _DebouncedSeekBarState();
|
||||
}
|
||||
|
||||
class _DebouncedSeekBarState extends ConsumerState<DebouncedSeekBar> {
|
||||
StreamSubscription<Duration>? _posSub;
|
||||
StreamSubscription<Duration>? _durSub;
|
||||
StreamSubscription<Duration>? _bufSub;
|
||||
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
Duration _buffer = Duration.zero;
|
||||
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0.0;
|
||||
bool _hovering = false;
|
||||
double? _hoverFraction;
|
||||
|
||||
List<DanmakuComment>? _cachedComments;
|
||||
double? _cachedDurationSec;
|
||||
DanmakuHeatmapData? _cachedHeatmap;
|
||||
|
||||
PlayerEngine get _player => widget.player;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_position = _player.state.position;
|
||||
_duration = _player.state.duration;
|
||||
_buffer = _player.state.buffer;
|
||||
_subscribeStreams();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DebouncedSeekBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
unawaited(_posSub?.cancel());
|
||||
unawaited(_durSub?.cancel());
|
||||
unawaited(_bufSub?.cancel());
|
||||
_position = _player.state.position;
|
||||
_duration = _player.state.duration;
|
||||
_buffer = _player.state.buffer;
|
||||
_subscribeStreams();
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeStreams() {
|
||||
_posSub = _player.stream.position.listen((p) {
|
||||
if (!_dragging && mounted) setState(() => _position = p);
|
||||
});
|
||||
_durSub = _player.stream.duration.listen((d) {
|
||||
if (mounted) setState(() => _duration = d);
|
||||
});
|
||||
_bufSub = _player.stream.buffer.listen((b) {
|
||||
if (mounted) setState(() => _buffer = b);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_posSub?.cancel();
|
||||
_durSub?.cancel();
|
||||
_bufSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _fraction(Duration d) {
|
||||
final total = _duration.inMilliseconds;
|
||||
if (total <= 0) return 0.0;
|
||||
return (d.inMilliseconds / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
void _onDown(PointerDownEvent e, double width) {
|
||||
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
setState(() {
|
||||
_dragging = true;
|
||||
_dragFraction = fraction;
|
||||
});
|
||||
widget.onDragChanged?.call(_duration * fraction);
|
||||
}
|
||||
|
||||
void _onMove(PointerMoveEvent e, double width) {
|
||||
if (!_dragging) return;
|
||||
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
setState(() {
|
||||
_dragFraction = fraction;
|
||||
});
|
||||
widget.onDragChanged?.call(_duration * fraction);
|
||||
}
|
||||
|
||||
void _onUp(PointerUpEvent e, double width) {
|
||||
if (!_dragging) return;
|
||||
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
final target = _duration * fraction;
|
||||
unawaited(_player.seek(target));
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
_position = target;
|
||||
_hovering = false;
|
||||
_hoverFraction = null;
|
||||
});
|
||||
widget.onDragChanged?.call(null);
|
||||
}
|
||||
|
||||
void _onCancel(PointerCancelEvent e) {
|
||||
if (!_dragging) return;
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
_hovering = false;
|
||||
_hoverFraction = null;
|
||||
});
|
||||
widget.onDragChanged?.call(null);
|
||||
}
|
||||
|
||||
|
||||
List<DanmakuComment>? _watchDanmakuComments() {
|
||||
final state = ref.watch(danmakuSessionProvider).value;
|
||||
if (state is DanmakuSessionMatched &&
|
||||
state.commentStatus == DanmakuCommentStatus.ready) {
|
||||
return state.comments;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
DanmakuHeatmapData? _resolveHeatmap(
|
||||
List<DanmakuComment>? comments,
|
||||
double durationSec,
|
||||
bool enabled,
|
||||
) {
|
||||
if (!enabled || comments == null) {
|
||||
_cachedComments = null;
|
||||
_cachedDurationSec = null;
|
||||
_cachedHeatmap = null;
|
||||
return null;
|
||||
}
|
||||
if (identical(comments, _cachedComments) &&
|
||||
_cachedDurationSec == durationSec) {
|
||||
return _cachedHeatmap;
|
||||
}
|
||||
final h = computeHeatmap(comments: comments, durationSec: durationSec);
|
||||
_cachedComments = comments;
|
||||
_cachedDurationSec = durationSec;
|
||||
_cachedHeatmap = h;
|
||||
return h;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final posFrac = _dragging ? _dragFraction : _fraction(_position);
|
||||
final bufFrac = _fraction(_buffer);
|
||||
final active = _hovering || _dragging;
|
||||
|
||||
final showBubble = _dragging || (_hovering && _hoverFraction != null);
|
||||
final bubbleFrac = _dragging ? _dragFraction : (_hoverFraction ?? 0.0);
|
||||
final bubbleTime = _duration * bubbleFrac;
|
||||
|
||||
final heatmapEnabled = ref.watch(
|
||||
danmakuSettingsProvider.select(
|
||||
(v) => v.value?.panelSettings.heatmapEnabled ?? true,
|
||||
),
|
||||
);
|
||||
final comments = heatmapEnabled ? _watchDanmakuComments() : null;
|
||||
final durationSec = _duration.inMilliseconds / 1000.0;
|
||||
final heatmap = _resolveHeatmap(comments, durationSec, heatmapEnabled);
|
||||
final heatmapVisible = heatmap?.visible == true;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
const bubbleWidth = 60.0;
|
||||
const bubbleHalfWidth = bubbleWidth / 2;
|
||||
final bubbleLeft = showBubble
|
||||
? (w * bubbleFrac - bubbleHalfWidth).clamp(0.0, w - bubbleWidth)
|
||||
: 0.0;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onHover: (event) {
|
||||
final frac = (event.localPosition.dx / w).clamp(0.0, 1.0);
|
||||
setState(() => _hoverFraction = frac);
|
||||
},
|
||||
onExit: (_) {
|
||||
if (!_dragging) {
|
||||
setState(() {
|
||||
_hovering = false;
|
||||
_hoverFraction = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (e) => _onDown(e, w),
|
||||
onPointerMove: (e) => _onMove(e, w),
|
||||
onPointerUp: (e) => _onUp(e, w),
|
||||
onPointerCancel: _onCancel,
|
||||
child: SizedBox(
|
||||
height: _kSeekBarHeight,
|
||||
child: CustomPaint(
|
||||
size: Size(w, _kSeekBarHeight),
|
||||
painter: _SeekBarPainter(
|
||||
position: posFrac,
|
||||
buffer: bufFrac,
|
||||
active: active,
|
||||
heatmap: heatmapVisible ? heatmap : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showBubble)
|
||||
Positioned(
|
||||
bottom: _kSeekBarHeight + 6,
|
||||
left: bubbleLeft,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
width: bubbleWidth,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC000000),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
formatDurationClock(bubbleTime),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekBarPainter extends CustomPainter {
|
||||
final double position;
|
||||
final double buffer;
|
||||
final bool active;
|
||||
final DanmakuHeatmapData? heatmap;
|
||||
|
||||
const _SeekBarPainter({
|
||||
required this.position,
|
||||
required this.buffer,
|
||||
required this.active,
|
||||
this.heatmap,
|
||||
});
|
||||
|
||||
|
||||
static const double _heatmapBaselineY = 11.0;
|
||||
static const double _heatmapMaxHeight = 10.0;
|
||||
static const double _heatmapPixelsPerBucket = 4.0;
|
||||
|
||||
|
||||
static const double _heatmapGamma = 0.6;
|
||||
|
||||
|
||||
static const Color _heatmapLowColor = Color(0xCC82B1FF);
|
||||
static const Color _heatmapHighColor = Color(0xFF2962FF);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
_paintHeatmap(canvas, size);
|
||||
_paintSeekBar(canvas, size);
|
||||
}
|
||||
|
||||
void _paintHeatmap(Canvas canvas, Size size) {
|
||||
final h = heatmap;
|
||||
if (h == null || h.buckets.isEmpty || size.width <= 0) return;
|
||||
|
||||
final targetCount = (size.width / _heatmapPixelsPerBucket).floor().clamp(
|
||||
1,
|
||||
h.buckets.length,
|
||||
);
|
||||
final samples = downsampleHeatmap(h.buckets, targetCount);
|
||||
if (samples.isEmpty) return;
|
||||
|
||||
final points = <Offset>[];
|
||||
var peak = 0.0;
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
final shaped = math
|
||||
.pow(samples[i].clamp(0.0, 1.0), _heatmapGamma)
|
||||
.toDouble();
|
||||
if (shaped > peak) peak = shaped;
|
||||
final cx = samples.length == 1
|
||||
? size.width / 2
|
||||
: i / (samples.length - 1) * size.width;
|
||||
points.add(Offset(cx, _heatmapBaselineY - _heatmapMaxHeight * shaped));
|
||||
}
|
||||
|
||||
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
|
||||
for (var i = 1; i < points.length; i++) {
|
||||
linePath.lineTo(points[i].dx, points[i].dy);
|
||||
}
|
||||
|
||||
final color = Color.lerp(_heatmapLowColor, _heatmapHighColor, peak)!;
|
||||
|
||||
final fillPath = Path.from(linePath)
|
||||
..lineTo(points.last.dx, _heatmapBaselineY)
|
||||
..lineTo(points.first.dx, _heatmapBaselineY)
|
||||
..close();
|
||||
canvas.drawPath(
|
||||
fillPath,
|
||||
Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = color.withValues(alpha: color.a * 0.18),
|
||||
);
|
||||
|
||||
canvas.drawPath(
|
||||
linePath,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.4
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
void _paintSeekBar(Canvas canvas, Size size) {
|
||||
final trackH = active ? 5.0 : 3.0;
|
||||
final cy = size.height / 2;
|
||||
final top = cy - trackH / 2;
|
||||
final bottom = cy + trackH / 2;
|
||||
final r = Radius.circular(trackH / 2);
|
||||
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(Rect.fromLTRB(0, top, size.width, bottom), r),
|
||||
Paint()..color = const Color(0x3DFFFFFF),
|
||||
);
|
||||
|
||||
if (buffer > 0) {
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTRB(0, top, size.width * buffer, bottom),
|
||||
r,
|
||||
),
|
||||
Paint()..color = const Color(0x3DFFFFFF),
|
||||
);
|
||||
}
|
||||
|
||||
if (position > 0) {
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTRB(0, top, size.width * position, bottom),
|
||||
r,
|
||||
),
|
||||
Paint()..color = const Color(0xFFFF0000),
|
||||
);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
canvas.drawCircle(
|
||||
Offset(size.width * position, cy),
|
||||
6.4,
|
||||
Paint()..color = const Color(0xFFFF0000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SeekBarPainter old) =>
|
||||
position != old.position ||
|
||||
buffer != old.buffer ||
|
||||
active != old.active ||
|
||||
!identical(heatmap, old.heatmap);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'panel_toggle_affordance.dart';
|
||||
|
||||
class FitButton extends StatelessWidget {
|
||||
final BoxFit currentFit;
|
||||
final bool isPanelOpen;
|
||||
final VoidCallback onTogglePanel;
|
||||
|
||||
const FitButton({
|
||||
super.key,
|
||||
required this.currentFit,
|
||||
required this.isPanelOpen,
|
||||
required this.onTogglePanel,
|
||||
});
|
||||
|
||||
static const options = [
|
||||
(fit: BoxFit.contain, label: '适应屏幕', subtitle: 'Contain'),
|
||||
(fit: BoxFit.fill, label: '拉伸', subtitle: 'Stretch'),
|
||||
(fit: BoxFit.cover, label: '裁剪', subtitle: 'Crop'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PanelToggleAffordance(
|
||||
isOpen: isPanelOpen,
|
||||
onTap: onTogglePanel,
|
||||
tooltip: '画面比例',
|
||||
child: const Icon(Icons.aspect_ratio, color: Colors.white, size: 28),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
import '../services/network_speed_monitor.dart';
|
||||
|
||||
class NetworkSpeedIndicator extends StatelessWidget {
|
||||
const NetworkSpeedIndicator({super.key, required this.monitor});
|
||||
|
||||
final NetworkSpeedMonitor monitor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: monitor.available,
|
||||
builder: (context, available, _) {
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: monitor.speed,
|
||||
builder: (context, speed, _) {
|
||||
final speedText = available ? formatDownloadSpeed(speed) : '--';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
speedText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class PanelToggleAffordance extends StatefulWidget {
|
||||
static const double _radius = 8;
|
||||
static const Color _activeFill = Color(0x2EFFFFFF);
|
||||
static const Duration _openDuration = Duration(milliseconds: 220);
|
||||
static const Duration _closeDuration = Duration(milliseconds: 130);
|
||||
|
||||
final bool isOpen;
|
||||
final VoidCallback onTap;
|
||||
final String? tooltip;
|
||||
final EdgeInsets padding;
|
||||
final Widget child;
|
||||
|
||||
const PanelToggleAffordance({
|
||||
super.key,
|
||||
required this.isOpen,
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
this.tooltip,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
});
|
||||
|
||||
@override
|
||||
State<PanelToggleAffordance> createState() => _PanelToggleAffordanceState();
|
||||
}
|
||||
|
||||
class _PanelToggleAffordanceState extends State<PanelToggleAffordance>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<double> _t;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: PanelToggleAffordance._openDuration,
|
||||
reverseDuration: PanelToggleAffordance._closeDuration,
|
||||
value: widget.isOpen ? 1.0 : 0.0,
|
||||
);
|
||||
_t = CurvedAnimation(
|
||||
parent: _ctrl,
|
||||
curve: Curves.easeInOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PanelToggleAffordance oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isOpen != widget.isOpen) {
|
||||
if (widget.isOpen) {
|
||||
_ctrl.forward();
|
||||
} else {
|
||||
_ctrl.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inkWell = Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(PanelToggleAffordance._radius),
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedBuilder(
|
||||
animation: _t,
|
||||
builder: (context, child) {
|
||||
final fill = Color.lerp(
|
||||
Colors.transparent,
|
||||
PanelToggleAffordance._activeFill,
|
||||
_t.value,
|
||||
)!;
|
||||
return Container(
|
||||
padding: widget.padding,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
PanelToggleAffordance._radius,
|
||||
),
|
||||
color: fill,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final t = widget.tooltip;
|
||||
if (t == null) return inkWell;
|
||||
return Tooltip(message: t, child: inkWell);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:canvas_danmaku/canvas_danmaku.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../../core/contracts/danmaku.dart';
|
||||
import '../../../../providers/danmaku_settings_provider.dart';
|
||||
import '../../../../shared/widgets/app_loading_ring.dart';
|
||||
import '../danmaku/danmaku_match_tile.dart';
|
||||
import '../player_drawer_panel.dart';
|
||||
import 'panel_setting_rows.dart';
|
||||
|
||||
part 'danmaku_panel_body_view.dart';
|
||||
|
||||
|
||||
extension DanmakuPanelSettingsCanvas on DanmakuPanelSettings {
|
||||
DanmakuOption toDanmakuOption({double? screenHeight}) {
|
||||
final double effectiveArea;
|
||||
if (maxRows > 0 && screenHeight != null && screenHeight > 0) {
|
||||
final lineHeightPx = _measureDanmakuLineHeight(
|
||||
fontSize: fontSize,
|
||||
lineHeight: lineHeight,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
final computed = (maxRows + 0.5) * lineHeightPx / screenHeight;
|
||||
effectiveArea = computed.clamp(0.05, 1.0);
|
||||
} else {
|
||||
effectiveArea = area;
|
||||
}
|
||||
return DanmakuOption(
|
||||
|
||||
|
||||
opacity: 1.0,
|
||||
area: effectiveArea,
|
||||
fontSize: fontSize,
|
||||
fontWeight: bold ? 7 : 4,
|
||||
fontFamily: fontFamily,
|
||||
duration: 10.0 / speed.clamp(0.1, 5.0),
|
||||
lineHeight: lineHeight,
|
||||
hideTop: fixedToScroll ? true : !showTop,
|
||||
hideScroll: !showScroll,
|
||||
hideBottom: fixedToScroll ? true : !showBottom,
|
||||
strokeWidth: strokeWidth,
|
||||
safeArea: true,
|
||||
massiveMode: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
double _measureDanmakuLineHeight({
|
||||
required double fontSize,
|
||||
required double lineHeight,
|
||||
String? fontFamily,
|
||||
}) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '弹幕',
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
height: lineHeight,
|
||||
fontFamily: fontFamily,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return painter.height;
|
||||
}
|
||||
|
||||
enum _DanmakuView { main, settings, filter, search }
|
||||
|
||||
class DanmakuPanelBody extends StatefulWidget {
|
||||
final DanmakuPanelSettings settings;
|
||||
final ValueChanged<DanmakuPanelSettings> onChanged;
|
||||
|
||||
final DanmakuSessionState sessionState;
|
||||
final bool danmakuGlobalEnabled;
|
||||
final bool danmakuSourceConfigured;
|
||||
|
||||
final List<DanmakuMatchCandidate> matches;
|
||||
final int selectedMatchIndex;
|
||||
final ValueChanged<int> onMatchSelected;
|
||||
final VoidCallback onOpenSearch;
|
||||
final bool isLoading;
|
||||
final int commentCount;
|
||||
|
||||
final List<String> blockedKeywords;
|
||||
final ValueChanged<String> onAddKeyword;
|
||||
final ValueChanged<String> onRemoveKeyword;
|
||||
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onGoToSettings;
|
||||
final VoidCallback? onToggleDanmakuEnabled;
|
||||
|
||||
const DanmakuPanelBody({
|
||||
super.key,
|
||||
required this.settings,
|
||||
required this.onChanged,
|
||||
required this.sessionState,
|
||||
this.danmakuGlobalEnabled = true,
|
||||
this.danmakuSourceConfigured = true,
|
||||
this.matches = const [],
|
||||
this.selectedMatchIndex = -1,
|
||||
required this.onMatchSelected,
|
||||
required this.onOpenSearch,
|
||||
this.isLoading = false,
|
||||
this.commentCount = 0,
|
||||
this.blockedKeywords = const [],
|
||||
required this.onAddKeyword,
|
||||
required this.onRemoveKeyword,
|
||||
this.onRetry,
|
||||
this.onGoToSettings,
|
||||
this.onToggleDanmakuEnabled,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DanmakuPanelBody> createState() => _DanmakuPanelBodyState();
|
||||
}
|
||||
|
||||
class _DanmakuPanelBodyState extends State<DanmakuPanelBody> {
|
||||
_DanmakuView _view = _DanmakuView.main;
|
||||
bool _navForward = true;
|
||||
|
||||
DanmakuPanelSettings get s => widget.settings;
|
||||
|
||||
void _emit(DanmakuPanelSettings next) => widget.onChanged(next);
|
||||
|
||||
void _navigateTo(_DanmakuView view) {
|
||||
if (view == _view) return;
|
||||
setState(() {
|
||||
_navForward = view != _DanmakuView.main;
|
||||
_view = view;
|
||||
});
|
||||
}
|
||||
|
||||
void _back() => _navigateTo(_DanmakuView.main);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) {
|
||||
final isEntering =
|
||||
(child.key as ValueKey<_DanmakuView>).value == _view;
|
||||
final enterOffset = _navForward
|
||||
? const Offset(0.2, 0.0)
|
||||
: const Offset(-0.2, 0.0);
|
||||
final exitOffset = _navForward
|
||||
? const Offset(-0.15, 0.0)
|
||||
: const Offset(0.15, 0.0);
|
||||
final slide = isEntering
|
||||
? Tween<Offset>(
|
||||
begin: enterOffset,
|
||||
end: Offset.zero,
|
||||
).animate(animation)
|
||||
: Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: exitOffset,
|
||||
).animate(animation);
|
||||
return ClipRect(
|
||||
child: SlideTransition(
|
||||
position: slide,
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<_DanmakuView>(_view),
|
||||
child: _buildView(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildView() {
|
||||
if (_view == _DanmakuView.main) {
|
||||
final emptyState = _buildEmptyState();
|
||||
if (emptyState != null) return emptyState;
|
||||
}
|
||||
return switch (_view) {
|
||||
_DanmakuView.main => _buildMainMenu(),
|
||||
_DanmakuView.settings => _buildSettingsDrawer(),
|
||||
_DanmakuView.filter => _buildFilterDrawer(),
|
||||
_DanmakuView.search => _buildSearchDrawer(),
|
||||
};
|
||||
}
|
||||
|
||||
final TextEditingController _keywordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keywordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
part of 'danmaku_panel_body.dart';
|
||||
|
||||
extension _DanmakuPanelBodyView on _DanmakuPanelBodyState {
|
||||
Widget? _buildEmptyState() {
|
||||
final state = widget.sessionState;
|
||||
if (state is DanmakuSessionIdle) {
|
||||
if (!widget.danmakuSourceConfigured) {
|
||||
return _emptyStatePage(
|
||||
icon: Icons.subtitles,
|
||||
title: '弹幕未配置',
|
||||
description: '请先在设置中配置弹幕数据源',
|
||||
actions: [
|
||||
if (widget.onGoToSettings != null)
|
||||
_emptyStateCta('前往设置', widget.onGoToSettings!),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (widget.danmakuGlobalEnabled) {
|
||||
return _emptyStatePage(
|
||||
icon: Icons.subtitles,
|
||||
title: '正在搜索弹幕…',
|
||||
showSpinner: true,
|
||||
);
|
||||
}
|
||||
return _emptyStatePage(
|
||||
icon: Icons.subtitles,
|
||||
title: '弹幕已关闭',
|
||||
description: '开启后将自动匹配弹幕',
|
||||
actions: [
|
||||
if (widget.onToggleDanmakuEnabled != null)
|
||||
_emptyStateCta('开启弹幕', widget.onToggleDanmakuEnabled!),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (state is DanmakuSessionMatching ||
|
||||
(state is DanmakuSessionMatched &&
|
||||
state.commentStatus == DanmakuCommentStatus.loading)) {
|
||||
return _emptyStatePage(
|
||||
icon: Icons.subtitles,
|
||||
title: '正在搜索弹幕…',
|
||||
showSpinner: true,
|
||||
);
|
||||
}
|
||||
if (state is DanmakuSessionEmpty) {
|
||||
return _emptyStatePage(
|
||||
icon: Icons.search_off,
|
||||
title: '未找到匹配弹幕',
|
||||
description: '可尝试手动搜索匹配',
|
||||
actions: [_emptyStateCta('搜索弹幕', widget.onOpenSearch)],
|
||||
);
|
||||
}
|
||||
if (state is DanmakuSessionFailed) {
|
||||
return _emptyStatePage(
|
||||
icon: Icons.error_outline,
|
||||
title: '加载失败',
|
||||
description: '弹幕匹配请求出错',
|
||||
actions: [
|
||||
if (widget.onRetry != null) _emptyStateCta('重试', widget.onRetry!),
|
||||
_emptyStateCta('搜索弹幕', widget.onOpenSearch),
|
||||
],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _emptyStatePage({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
String? description,
|
||||
List<Widget> actions = const [],
|
||||
bool showSpinner = false,
|
||||
}) {
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showSpinner)
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Icon(icon, color: const Color(0x61FFFFFF), size: 40),
|
||||
const AppLoadingRing(size: 52, color: Color(0x99FFFFFF)),
|
||||
],
|
||||
)
|
||||
else
|
||||
Icon(icon, color: const Color(0x61FFFFFF), size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < actions.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
actions[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyStateCta(String label, VoidCallback onTap) {
|
||||
return TextButton(
|
||||
onPressed: onTap,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.white12,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
child: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainMenu() {
|
||||
final hasMatch =
|
||||
widget.selectedMatchIndex >= 0 &&
|
||||
widget.selectedMatchIndex < widget.matches.length;
|
||||
final matchTitle = hasMatch
|
||||
? widget.matches[widget.selectedMatchIndex].animeTitle
|
||||
: null;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionHeader('弹幕数据'),
|
||||
_menuItem(
|
||||
icon: Icons.search,
|
||||
label: '搜索弹幕',
|
||||
hasCheck: hasMatch,
|
||||
subtitle: matchTitle,
|
||||
onTap: widget.onOpenSearch,
|
||||
),
|
||||
const FDivider(
|
||||
style: .delta(
|
||||
color: Color(0x1AFFFFFF),
|
||||
padding: .value(EdgeInsetsDirectional.only(start: 16)),
|
||||
),
|
||||
),
|
||||
_sectionHeader('操作'),
|
||||
_menuItem(
|
||||
icon: Icons.tune,
|
||||
label: '显示设置',
|
||||
onTap: () => _navigateTo(_DanmakuView.settings),
|
||||
),
|
||||
_menuItem(
|
||||
icon: Icons.filter_alt_outlined,
|
||||
label: '过滤关键词',
|
||||
badge: widget.blockedKeywords.isNotEmpty
|
||||
? '${widget.blockedKeywords.length}'
|
||||
: null,
|
||||
onTap: () => _navigateTo(_DanmakuView.filter),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: PanelToggleRow(
|
||||
label: '高能进度条',
|
||||
value: s.heatmapEnabled,
|
||||
onChanged: (v) => _emit(s.copyWith(heatmapEnabled: v)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: PanelToggleRow(
|
||||
label: '弹幕',
|
||||
value: s.enabled,
|
||||
onChanged: (v) => _emit(s.copyWith(enabled: v)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
String? subtitle,
|
||||
String? badge,
|
||||
bool hasCheck = false,
|
||||
bool showArrow = true,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasCheck)
|
||||
const Icon(Icons.check, color: Colors.white70, size: 16)
|
||||
else
|
||||
Icon(icon, color: Colors.white54, size: 16),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
badge,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (showArrow)
|
||||
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsDrawer() {
|
||||
return PlayerDrawerPanel(
|
||||
title: '显示设置',
|
||||
onBack: _back,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PanelToggleRow(
|
||||
label: '固定弹幕转滚动',
|
||||
value: s.fixedToScroll,
|
||||
onChanged: (v) => _emit(s.copyWith(fixedToScroll: v)),
|
||||
),
|
||||
PanelStepperRow(
|
||||
label: '弹幕行数',
|
||||
display: s.maxRows == 0 ? '自动' : '${s.maxRows} 行',
|
||||
onDecrement: s.maxRows > 0
|
||||
? () => _emit(s.copyWith(maxRows: s.maxRows - 1))
|
||||
: null,
|
||||
onIncrement: s.maxRows < 10
|
||||
? () => _emit(s.copyWith(maxRows: s.maxRows + 1))
|
||||
: null,
|
||||
),
|
||||
const FDivider(
|
||||
style: .delta(
|
||||
color: Color(0x1AFFFFFF),
|
||||
padding: .value(EdgeInsets.zero),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
PanelSliderRow(
|
||||
label: '延迟显示',
|
||||
value: s.offset,
|
||||
min: -10.0,
|
||||
max: 10.0,
|
||||
step: 0.5,
|
||||
display:
|
||||
'${s.offset >= 0 ? '+' : ''}${s.offset.toStringAsFixed(1)}s',
|
||||
onChanged: (v) => _emit(s.copyWith(offset: _round(v, -10.0, 10.0))),
|
||||
),
|
||||
PanelSliderRow(
|
||||
label: '行间距',
|
||||
value: s.lineHeight,
|
||||
min: 1.0,
|
||||
max: 3.0,
|
||||
step: 0.1,
|
||||
display: s.lineHeight.toStringAsFixed(1),
|
||||
onChanged: (v) =>
|
||||
_emit(s.copyWith(lineHeight: _round(v, 1.0, 3.0))),
|
||||
),
|
||||
PanelSliderRow(
|
||||
label: '基础速度',
|
||||
value: s.speed,
|
||||
min: 0.25,
|
||||
max: 2.0,
|
||||
step: 0.25,
|
||||
display: '${s.speed.toStringAsFixed(2)}x',
|
||||
onChanged: (v) => _emit(s.copyWith(speed: v.clamp(0.25, 2.0))),
|
||||
),
|
||||
PanelSliderRow(
|
||||
label: '字号大小',
|
||||
value: s.fontSize,
|
||||
min: 12,
|
||||
max: 40,
|
||||
step: 2,
|
||||
display: s.fontSize.round().toString(),
|
||||
onChanged: (v) => _emit(s.copyWith(fontSize: _round(v, 12, 40))),
|
||||
),
|
||||
PanelToggleRow(
|
||||
label: '粗体',
|
||||
value: s.bold,
|
||||
onChanged: (v) => _emit(s.copyWith(bold: v)),
|
||||
),
|
||||
const FDivider(
|
||||
style: .delta(
|
||||
color: Color(0x1AFFFFFF),
|
||||
padding: .value(EdgeInsets.zero),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
PanelSliderRow(
|
||||
label: '不透明度',
|
||||
value: s.opacity,
|
||||
min: 0.1,
|
||||
max: 1.0,
|
||||
step: 0.1,
|
||||
display: '${(s.opacity * 100).round()}%',
|
||||
onChanged: (v) => _emit(s.copyWith(opacity: _round(v, 0.1, 1.0))),
|
||||
),
|
||||
PanelSliderRow(
|
||||
label: '描边大小',
|
||||
value: s.strokeWidth,
|
||||
min: 0.0,
|
||||
max: 3.0,
|
||||
step: 0.5,
|
||||
display: s.strokeWidth.toStringAsFixed(1),
|
||||
onChanged: (v) =>
|
||||
_emit(s.copyWith(strokeWidth: _round(v, 0.0, 3.0))),
|
||||
),
|
||||
const FDivider(
|
||||
style: .delta(
|
||||
color: Color(0x1AFFFFFF),
|
||||
padding: .value(EdgeInsets.zero),
|
||||
),
|
||||
),
|
||||
_fontFamilyRow(),
|
||||
const SizedBox(height: 8),
|
||||
_resetButton(),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
static double _round(double v, double min, double max) =>
|
||||
(v.clamp(min, max) * 10).round() / 10;
|
||||
|
||||
static const _fontPresets = [
|
||||
(label: '系统默认', value: null),
|
||||
(label: 'PingFang SC', value: 'PingFang SC'),
|
||||
(label: 'Heiti SC', value: 'Heiti SC'),
|
||||
(label: 'Arial', value: 'Arial'),
|
||||
];
|
||||
|
||||
Widget _fontFamilyRow() {
|
||||
final current = s.fontFamily;
|
||||
final label =
|
||||
_fontPresets.where((p) => p.value == current).firstOrNull?.label ??
|
||||
'系统默认';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'字体',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _showFontPicker,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.lightBlueAccent,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.unfold_more,
|
||||
color: Colors.lightBlueAccent,
|
||||
size: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFontPicker() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF252525),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(14)),
|
||||
),
|
||||
builder: (_) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Text(
|
||||
'选择字体',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final p in _fontPresets)
|
||||
ListTile(
|
||||
title: Text(
|
||||
p.label,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: p.value,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
trailing: s.fontFamily == p.value
|
||||
? const Icon(Icons.check, color: Colors.white70, size: 18)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_emit(s.copyWith(fontFamily: p.value));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resetButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: () => _emit(defaultDanmakuPanelSettings()),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white54,
|
||||
backgroundColor: Colors.white10,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
child: const Text('重置', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterDrawer() {
|
||||
return PlayerDrawerPanel(
|
||||
title: '弹幕过滤',
|
||||
onBack: _back,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_keywordInput(),
|
||||
const SizedBox(height: 8),
|
||||
const FDivider(
|
||||
style: .delta(
|
||||
color: Color(0x1AFFFFFF),
|
||||
padding: .value(EdgeInsets.zero),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_keywordList(),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _keywordInput() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 10),
|
||||
child: Icon(
|
||||
Icons.filter_alt_outlined,
|
||||
color: Colors.white38,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _keywordController,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入要过滤的关键词',
|
||||
hintStyle: TextStyle(color: Colors.white38, fontSize: 13),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 10,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: _addKeyword,
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(30)],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline, size: 18),
|
||||
color: Colors.white54,
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: () => _addKeyword(_keywordController.text),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addKeyword(String raw) {
|
||||
final kw = raw.trim();
|
||||
if (kw.isEmpty) return;
|
||||
if (widget.blockedKeywords.contains(kw)) {
|
||||
_keywordController.clear();
|
||||
return;
|
||||
}
|
||||
widget.onAddKeyword(kw);
|
||||
_keywordController.clear();
|
||||
}
|
||||
|
||||
Widget _keywordList() {
|
||||
final keywords = widget.blockedKeywords;
|
||||
if (keywords.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'暂无屏蔽关键词',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final kw in keywords)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.label_outline,
|
||||
color: Colors.white38,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
kw,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 14),
|
||||
color: Colors.white38,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 28,
|
||||
minHeight: 28,
|
||||
),
|
||||
onPressed: () => widget.onRemoveKeyword(kw),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchDrawer() {
|
||||
return PlayerDrawerPanel(
|
||||
title: '搜索弹幕',
|
||||
onBack: _back,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_matchesContent(),
|
||||
const SizedBox(height: 8),
|
||||
_manualSearchTrigger(),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _matchesContent() {
|
||||
if (widget.isLoading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppLoadingRing(size: 14, color: Colors.white54),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'正在匹配弹幕源…',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final matches = widget.matches;
|
||||
if (matches.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.white24, size: 14),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'未匹配到弹幕源,可手动搜索',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
'弹幕源 (${matches.length} 条)',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
),
|
||||
...List.generate(matches.length, (i) {
|
||||
final m = matches[i];
|
||||
final selected = i == widget.selectedMatchIndex;
|
||||
return DanmakuMatchTile(
|
||||
candidate: m,
|
||||
selected: selected,
|
||||
onTap: () => widget.onMatchSelected(i),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _manualSearchTrigger() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: widget.onOpenSearch,
|
||||
style: OutlinedButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Color(0x664FA8FF)),
|
||||
backgroundColor: const Color(0x10FFFFFF),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
icon: const Icon(Icons.manage_search, size: 16),
|
||||
label: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'搜索并手动选择分集',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, size: 14, color: Colors.white38),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/contracts/library.dart';
|
||||
import '../../../../providers/di_providers.dart';
|
||||
import '../../../../providers/tmdb_settings_provider.dart';
|
||||
import '../../../../shared/utils/emby_ticks.dart';
|
||||
import '../../../../shared/utils/format_utils.dart';
|
||||
import '../../../../shared/utils/tmdb_episode_helpers.dart';
|
||||
import '../../../../shared/utils/tmdb_key_utils.dart';
|
||||
import '../../../../shared/widgets/app_inline_alert.dart';
|
||||
import '../../../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../../../shared/widgets/episode_thumb.dart';
|
||||
import '../../../../shared/widgets/season_selector.dart';
|
||||
import '../player_panel_shell.dart';
|
||||
|
||||
const Color _kActiveColor = Color(0xFF4FA8FF);
|
||||
|
||||
class EpisodePanelBody extends ConsumerStatefulWidget {
|
||||
final String seriesId;
|
||||
final String? initialSeasonId;
|
||||
final String currentEpisodeId;
|
||||
final ValueChanged<EmbyRawItem> onEpisodeSelected;
|
||||
|
||||
|
||||
final String serverId;
|
||||
final String baseUrl;
|
||||
final String token;
|
||||
|
||||
const EpisodePanelBody({
|
||||
super.key,
|
||||
required this.seriesId,
|
||||
required this.currentEpisodeId,
|
||||
required this.onEpisodeSelected,
|
||||
required this.serverId,
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
this.initialSeasonId,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<EpisodePanelBody> createState() => EpisodePanelBodyState();
|
||||
}
|
||||
|
||||
|
||||
class EpisodePanelBodyState extends ConsumerState<EpisodePanelBody> {
|
||||
String? _selectedSeasonId;
|
||||
bool _loadStarted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
|
||||
void ensureLoaded() {
|
||||
if (_loadStarted) return;
|
||||
_loadStarted = true;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loadStarted) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final serverId = widget.serverId;
|
||||
if (serverId.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final seasonsAsync = ref.watch(
|
||||
seriesSeasonsDataProvider((
|
||||
serverId: serverId,
|
||||
seriesId: widget.seriesId,
|
||||
)),
|
||||
);
|
||||
|
||||
return seasonsAsync.when(
|
||||
loading: _loading,
|
||||
error: (_, _) => _error(),
|
||||
data: (seasons) {
|
||||
if (seasons.isEmpty) return _empty('暂无可用季');
|
||||
final effectiveSeasonId = _resolveSeasonId(seasons);
|
||||
final effectiveSeason = seasons.firstWhere(
|
||||
(s) => s.Id == effectiveSeasonId,
|
||||
orElse: () => seasons.first,
|
||||
);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (seasons.length > 1) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SeasonSelector(
|
||||
seasons: seasons,
|
||||
selectedId: effectiveSeasonId,
|
||||
onSelect: (seasonId) {
|
||||
setState(() => _selectedSeasonId = seasonId);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(color: kPanelDivider, height: 1),
|
||||
],
|
||||
Flexible(
|
||||
child: _EpisodeList(
|
||||
seriesId: widget.seriesId,
|
||||
seasonId: effectiveSeasonId,
|
||||
seasonNumber: seasonNumberFrom(effectiveSeason),
|
||||
currentEpisodeId: widget.currentEpisodeId,
|
||||
serverId: widget.serverId,
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
onSelected: widget.onEpisodeSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveSeasonId(List<EmbyRawSeason> seasons) {
|
||||
final selected = _selectedSeasonId;
|
||||
if (selected != null && seasons.any((s) => s.Id == selected)) {
|
||||
return selected;
|
||||
}
|
||||
final initial = widget.initialSeasonId;
|
||||
if (initial != null && seasons.any((s) => s.Id == initial)) {
|
||||
return initial;
|
||||
}
|
||||
return seasons.first.Id;
|
||||
}
|
||||
|
||||
Widget _loading() => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: AppLoadingRing(size: 22, color: Colors.white)),
|
||||
);
|
||||
|
||||
Widget _empty(String text) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _error() => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: AppInlineAlert(message: '选集加载失败'),
|
||||
);
|
||||
}
|
||||
|
||||
class _EpisodeList extends ConsumerStatefulWidget {
|
||||
final String seriesId;
|
||||
final String seasonId;
|
||||
final int? seasonNumber;
|
||||
final String? currentEpisodeId;
|
||||
final String serverId;
|
||||
final String baseUrl;
|
||||
final String token;
|
||||
final ValueChanged<EmbyRawItem> onSelected;
|
||||
|
||||
const _EpisodeList({
|
||||
required this.seriesId,
|
||||
required this.seasonId,
|
||||
required this.seasonNumber,
|
||||
required this.currentEpisodeId,
|
||||
required this.serverId,
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_EpisodeList> createState() => _EpisodeListState();
|
||||
}
|
||||
|
||||
class _EpisodeListState extends ConsumerState<_EpisodeList> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _hasScrolledToActive = false;
|
||||
|
||||
static const _itemHeight = 76.0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _EpisodeList oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.seasonId != widget.seasonId ||
|
||||
oldWidget.currentEpisodeId != widget.currentEpisodeId) {
|
||||
_hasScrolledToActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToActive(List<EmbyRawItem> episodes) {
|
||||
if (_hasScrolledToActive || widget.currentEpisodeId == null) return;
|
||||
final idx = episodes.indexWhere((e) => e.Id == widget.currentEpisodeId);
|
||||
if (idx < 0) return;
|
||||
_hasScrolledToActive = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
final viewport = _scrollController.position.viewportDimension;
|
||||
final target = idx * _itemHeight - (viewport - _itemHeight) / 2;
|
||||
final clamped = target.clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
_scrollController.jumpTo(clamped);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final serverId = widget.serverId;
|
||||
final episodesAsync = ref.watch(
|
||||
seriesEpisodesDataProvider((
|
||||
serverId: serverId,
|
||||
seriesId: widget.seriesId,
|
||||
seasonId: widget.seasonId,
|
||||
)),
|
||||
);
|
||||
|
||||
final tmdbSettings = ref.watch(tmdbSettingsProvider).value;
|
||||
final canRequest = tmdbSettings?.canRequest ?? false;
|
||||
String tmdbSeriesId = '';
|
||||
if (canRequest) {
|
||||
final seriesItem = ref
|
||||
.watch(
|
||||
mediaDetailDataProvider((
|
||||
serverId: serverId,
|
||||
itemId: widget.seriesId,
|
||||
)),
|
||||
)
|
||||
.value
|
||||
?.base;
|
||||
if (seriesItem != null) tmdbSeriesId = tmdbIdFromItem(seriesItem);
|
||||
}
|
||||
final canUseTmdbSeason =
|
||||
canRequest && tmdbSeriesId.isNotEmpty && widget.seasonNumber != null;
|
||||
final tmdbSeason = canUseTmdbSeason
|
||||
? ref
|
||||
.watch(
|
||||
tmdbSeasonDetailProvider((
|
||||
tmdbId: tmdbSeriesId,
|
||||
seasonNumber: widget.seasonNumber!,
|
||||
)),
|
||||
)
|
||||
.value
|
||||
: null;
|
||||
final tmdbStillUrls = tmdbStillUrlsByEpisode(
|
||||
tmdbSeason,
|
||||
tmdbSettings?.effectiveImageBaseUrl,
|
||||
);
|
||||
|
||||
return episodesAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: AppLoadingRing(size: 22, color: Colors.white)),
|
||||
),
|
||||
error: (_, _) => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: AppInlineAlert(message: '选集加载失败'),
|
||||
),
|
||||
data: (episodes) {
|
||||
if (episodes.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'暂无分集',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
_scrollToActive(episodes);
|
||||
return ListView.separated(
|
||||
controller: _scrollController,
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: episodes.length,
|
||||
separatorBuilder: (_, _) => const Divider(
|
||||
color: kPanelDivider,
|
||||
height: 1,
|
||||
indent: 12,
|
||||
endIndent: 12,
|
||||
),
|
||||
itemBuilder: (_, i) {
|
||||
final ep = episodes[i];
|
||||
final tmdbStillUrl = ep.IndexNumber == null
|
||||
? null
|
||||
: tmdbStillUrls[ep.IndexNumber];
|
||||
return _EpisodeTile(
|
||||
episode: ep,
|
||||
seriesId: widget.seriesId,
|
||||
tmdbStillUrl: tmdbStillUrl,
|
||||
isActive: ep.Id == widget.currentEpisodeId,
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
onTap: () => widget.onSelected(ep),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EpisodeTile extends StatelessWidget {
|
||||
final EmbyRawItem episode;
|
||||
final String seriesId;
|
||||
final String? tmdbStillUrl;
|
||||
final bool isActive;
|
||||
final String baseUrl;
|
||||
final String token;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _EpisodeTile({
|
||||
required this.episode,
|
||||
required this.seriesId,
|
||||
required this.tmdbStillUrl,
|
||||
required this.isActive,
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ep = formatEpisodeNumber(episode.IndexNumber) ?? '';
|
||||
final runtime = episode.RunTimeTicks != null
|
||||
? '${(episode.RunTimeTicks! / kTicksPerMinute).round()} 分钟'
|
||||
: '';
|
||||
final progress = playbackProgress(episode);
|
||||
|
||||
return InkWell(
|
||||
onTap: isActive ? null : onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
width: 96,
|
||||
height: 54,
|
||||
color: Colors.white12,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
EpisodeThumb(
|
||||
episode: episode,
|
||||
seriesId: seriesId,
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
tmdbStillUrl: tmdbStillUrl,
|
||||
maxWidth: 240,
|
||||
),
|
||||
if (isActive)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
if (progress > 0)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 2,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
_kActiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (ep.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? _kActiveColor : Colors.white12,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
ep,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
episode.Name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: isActive ? _kActiveColor : Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (runtime.isNotEmpty)
|
||||
Text(
|
||||
runtime,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../player_panel_shell.dart' show panelDivider;
|
||||
import 'selection_panel_entry.dart';
|
||||
|
||||
|
||||
class PanelSelectionList<T> extends StatelessWidget {
|
||||
final List<SelectionPanelEntry<T>> entries;
|
||||
final ValueChanged<T> onSelected;
|
||||
|
||||
const PanelSelectionList({
|
||||
super.key,
|
||||
required this.entries,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final entry in entries)
|
||||
if (entry.isDivider) panelDivider() else _itemTile(entry),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _itemTile(SelectionPanelEntry<T> entry) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (entry.value != null) onSelected(entry.value as T);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: entry.isActive
|
||||
? const Icon(Icons.check, size: 16, color: Color(0xFF2196F3))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
entry.label!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: entry.isActive
|
||||
? const Color(0xFF2196F3)
|
||||
: Colors.white,
|
||||
fontWeight: entry.isActive
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
if (entry.subtitle != null && entry.subtitle != entry.label)
|
||||
Text(
|
||||
entry.subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (entry.badge != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
entry.badge!,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (entry.isLoading) ...[
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox.square(
|
||||
dimension: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
|
||||
const _kRowLabelStyle = TextStyle(color: Colors.white70, fontSize: 13);
|
||||
const _kRowValueStyle = TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
const _kPanelSliderTheme = SliderThemeData(
|
||||
trackHeight: 3,
|
||||
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
overlayShape: RoundSliderOverlayShape(overlayRadius: 14),
|
||||
activeTrackColor: Colors.white,
|
||||
inactiveTrackColor: Colors.white24,
|
||||
thumbColor: Colors.white,
|
||||
overlayColor: Colors.white10,
|
||||
);
|
||||
|
||||
|
||||
class PanelGroupHeader extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const PanelGroupHeader({super.key, required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4, left: 2),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PanelSliderRow extends StatelessWidget {
|
||||
final String label;
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
|
||||
|
||||
final double step;
|
||||
|
||||
|
||||
final String display;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
const PanelSliderRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.step,
|
||||
required this.display,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final divisions = ((max - min) / step).round();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: _kRowLabelStyle)),
|
||||
Text(display, style: _kRowValueStyle),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: SliderTheme(
|
||||
data: _kPanelSliderTheme,
|
||||
child: Slider(
|
||||
value: value.clamp(min, max),
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
padding: EdgeInsets.zero,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PanelToggleRow extends StatelessWidget {
|
||||
final String label;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const PanelToggleRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: _kRowLabelStyle)),
|
||||
FSwitch(
|
||||
value: value,
|
||||
onChange: onChanged,
|
||||
style: FSwitchStyleDelta.delta(
|
||||
trackColor: FVariantsValueDelta.delta([
|
||||
FVariantValueDeltaOperation.all(Colors.white12),
|
||||
FVariantValueDeltaOperation.exact({
|
||||
FSwitchVariantConstraint.selected,
|
||||
}, Colors.white38),
|
||||
]),
|
||||
thumbColor: FVariantsValueDelta.delta([
|
||||
FVariantValueDeltaOperation.all(Colors.white38),
|
||||
FVariantValueDeltaOperation.exact({
|
||||
FSwitchVariantConstraint.selected,
|
||||
}, Colors.white),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PanelStepperRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String display;
|
||||
final VoidCallback? onDecrement;
|
||||
final VoidCallback? onIncrement;
|
||||
|
||||
const PanelStepperRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.display,
|
||||
this.onDecrement,
|
||||
this.onIncrement,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: _kRowLabelStyle)),
|
||||
_StepButton(icon: Icons.remove, onPressed: onDecrement),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
width: 46,
|
||||
child: Text(
|
||||
display,
|
||||
style: _kRowValueStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
_StepButton(icon: Icons.add, onPressed: onIncrement),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _StepButton({required this.icon, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: 28,
|
||||
child: FButton.icon(
|
||||
variant: FButtonVariant.ghost,
|
||||
size: FButtonSizeVariant.xs,
|
||||
onPress: onPressed,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: onPressed != null ? Colors.white70 : Colors.white24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
class SelectionPanelEntry<T> {
|
||||
final T? value;
|
||||
final String? label;
|
||||
final String? subtitle;
|
||||
final String? badge;
|
||||
final bool isActive;
|
||||
final bool isLoading;
|
||||
final bool isDivider;
|
||||
|
||||
const SelectionPanelEntry.item({
|
||||
required T this.value,
|
||||
required String this.label,
|
||||
this.subtitle,
|
||||
this.badge,
|
||||
this.isActive = false,
|
||||
this.isLoading = false,
|
||||
}) : isDivider = false;
|
||||
|
||||
const SelectionPanelEntry.divider()
|
||||
: value = null,
|
||||
label = null,
|
||||
subtitle = null,
|
||||
badge = null,
|
||||
isActive = false,
|
||||
isLoading = false,
|
||||
isDivider = true;
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../../core/contracts/playback.dart';
|
||||
import '../../../../core/infra/player_engine/player_engine.dart';
|
||||
import '../../../../providers/subtitle_settings_provider.dart';
|
||||
import '../player_drawer_panel.dart';
|
||||
import '../player_panel_shell.dart';
|
||||
import 'panel_selection_list.dart';
|
||||
import 'panel_setting_rows.dart';
|
||||
import 'selection_panel_entry.dart';
|
||||
|
||||
const _activeColor = Color(0xFF2196F3);
|
||||
|
||||
String? _translateLanguage(String? code) {
|
||||
if (code == null || code.isEmpty) return null;
|
||||
return kLanguageNames[code.toLowerCase()];
|
||||
}
|
||||
|
||||
enum _SubtitlePanelView { subtitles, style }
|
||||
|
||||
class SubtitlePanelBody extends StatefulWidget {
|
||||
final SubtitleStyleSettings subtitleSettings;
|
||||
final ValueChanged<SubtitleStyleSettings> onSubtitleSettingsChanged;
|
||||
|
||||
|
||||
final List<EmbySubtitleTrack> subtitles;
|
||||
|
||||
|
||||
final ValueListenable<int?> activeSubtitleStreamIndex;
|
||||
|
||||
|
||||
final ValueListenable<bool> subtitleLoading;
|
||||
|
||||
|
||||
final void Function(int? streamIndex)? onSubtitleSelected;
|
||||
|
||||
const SubtitlePanelBody({
|
||||
super.key,
|
||||
required this.subtitleSettings,
|
||||
required this.onSubtitleSettingsChanged,
|
||||
required this.subtitles,
|
||||
required this.activeSubtitleStreamIndex,
|
||||
required this.subtitleLoading,
|
||||
this.onSubtitleSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SubtitlePanelBody> createState() => _SubtitlePanelBodyState();
|
||||
}
|
||||
|
||||
class _SubtitlePanelBodyState extends State<SubtitlePanelBody> {
|
||||
_SubtitlePanelView _selectedView = _SubtitlePanelView.subtitles;
|
||||
|
||||
|
||||
int? _activeStreamIndex;
|
||||
bool _isSubtitleLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged);
|
||||
widget.subtitleLoading.addListener(_onLoadingChanged);
|
||||
_activeStreamIndex = widget.activeSubtitleStreamIndex.value;
|
||||
_isSubtitleLoading = widget.subtitleLoading.value;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SubtitlePanelBody oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.activeSubtitleStreamIndex !=
|
||||
widget.activeSubtitleStreamIndex) {
|
||||
oldWidget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged);
|
||||
widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged);
|
||||
_activeStreamIndex = widget.activeSubtitleStreamIndex.value;
|
||||
}
|
||||
if (oldWidget.subtitleLoading != widget.subtitleLoading) {
|
||||
oldWidget.subtitleLoading.removeListener(_onLoadingChanged);
|
||||
widget.subtitleLoading.addListener(_onLoadingChanged);
|
||||
_isSubtitleLoading = widget.subtitleLoading.value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged);
|
||||
widget.subtitleLoading.removeListener(_onLoadingChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onActiveIndexChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() => _activeStreamIndex = widget.activeSubtitleStreamIndex.value);
|
||||
}
|
||||
|
||||
void _onLoadingChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSubtitleLoading = widget.subtitleLoading.value);
|
||||
}
|
||||
|
||||
|
||||
void _selectSubtitle(int? streamIndex) {
|
||||
if (!mounted) return;
|
||||
setState(() => _activeStreamIndex = streamIndex);
|
||||
widget.onSubtitleSelected?.call(streamIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (Platform.isAndroid) return _buildAndroidBody();
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 150, child: _leftMenu()),
|
||||
const FDivider(
|
||||
axis: Axis.vertical,
|
||||
style: .delta(color: kPanelDivider, padding: .value(EdgeInsets.zero)),
|
||||
),
|
||||
Expanded(child: _content()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAndroidBody() {
|
||||
if (_selectedView == _SubtitlePanelView.style) {
|
||||
return PlayerDrawerPanel(
|
||||
title: '字幕样式',
|
||||
onBack: () =>
|
||||
setState(() => _selectedView = _SubtitlePanelView.subtitles),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _subtitleStyleRows(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_androidStyleEntry(),
|
||||
panelDivider(),
|
||||
Flexible(child: _subtitleList()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _androidStyleEntry() {
|
||||
return InkWell(
|
||||
onTap: () => setState(() => _selectedView = _SubtitlePanelView.style),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.closed_caption_outlined,
|
||||
color: Colors.white54,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'字幕样式',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_subtitleStyleLabel(),
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content() {
|
||||
switch (_selectedView) {
|
||||
case _SubtitlePanelView.subtitles:
|
||||
return _subtitleList();
|
||||
case _SubtitlePanelView.style:
|
||||
return _subtitleSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _leftMenu() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_menuItem(
|
||||
view: _SubtitlePanelView.subtitles,
|
||||
label: '字幕',
|
||||
subtitle: _activeSubtitleLabel(),
|
||||
icon: Icons.subtitles_outlined,
|
||||
),
|
||||
_menuItem(
|
||||
view: _SubtitlePanelView.style,
|
||||
label: '字幕样式',
|
||||
subtitle: _subtitleStyleLabel(),
|
||||
icon: Icons.closed_caption_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuItem({
|
||||
required _SubtitlePanelView view,
|
||||
required String label,
|
||||
required String subtitle,
|
||||
required IconData icon,
|
||||
}) {
|
||||
final selected = _selectedView == view;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedView = view),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: selected
|
||||
? BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: selected ? _activeColor : Colors.white54,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected ? _activeColor : Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? _activeColor.withOpacity(0.7)
|
||||
: Colors.white38,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: selected ? _activeColor : Colors.white24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _subtitleList() {
|
||||
final embedded = widget.subtitles
|
||||
.where((track) => !track.isExternal)
|
||||
.toList(growable: false);
|
||||
final external = widget.subtitles
|
||||
.where((track) => track.isExternal)
|
||||
.toList(growable: false);
|
||||
|
||||
const disableValue = -1;
|
||||
final entries = <SelectionPanelEntry<int>>[
|
||||
SelectionPanelEntry.item(
|
||||
value: disableValue,
|
||||
label: '禁用',
|
||||
isActive: _activeStreamIndex == null,
|
||||
),
|
||||
if (embedded.isNotEmpty) const SelectionPanelEntry.divider(),
|
||||
for (final track in embedded)
|
||||
SelectionPanelEntry.item(
|
||||
value: track.index,
|
||||
label: track.title ?? track.language ?? '字幕 ${track.index}',
|
||||
subtitle: _translateLanguage(track.language),
|
||||
isActive: _activeStreamIndex == track.index,
|
||||
isLoading: _isSubtitleLoading && _activeStreamIndex == track.index,
|
||||
),
|
||||
if (external.isNotEmpty) const SelectionPanelEntry.divider(),
|
||||
for (final track in external)
|
||||
SelectionPanelEntry.item(
|
||||
value: track.index,
|
||||
label: track.title ?? track.language ?? '外挂字幕 ${track.index}',
|
||||
subtitle: _translateLanguage(track.language),
|
||||
badge: '外挂',
|
||||
isActive: _activeStreamIndex == track.index,
|
||||
isLoading: _isSubtitleLoading && _activeStreamIndex == track.index,
|
||||
),
|
||||
];
|
||||
return PanelSelectionList<int>(
|
||||
entries: entries,
|
||||
onSelected: (value) =>
|
||||
_selectSubtitle(value == disableValue ? null : value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _subtitleSettings() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PanelGroupHeader(title: '主字幕样式'),
|
||||
..._subtitleStyleRows(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
List<Widget> _subtitleStyleRows() {
|
||||
final settings = widget.subtitleSettings;
|
||||
return [
|
||||
PanelSliderRow(
|
||||
label: '位置',
|
||||
value: settings.position.toDouble(),
|
||||
min: 0,
|
||||
max: 150,
|
||||
step: 5,
|
||||
display: settings.position.toString(),
|
||||
onChanged: (v) => widget.onSubtitleSettingsChanged(
|
||||
settings.copyWith(position: v.round()),
|
||||
),
|
||||
),
|
||||
PanelSliderRow(
|
||||
label: '大小',
|
||||
value: settings.fontSize,
|
||||
min: 10,
|
||||
max: 120,
|
||||
step: 2,
|
||||
display: settings.fontSize.round().toString(),
|
||||
onChanged: (v) =>
|
||||
widget.onSubtitleSettingsChanged(settings.copyWith(fontSize: v)),
|
||||
),
|
||||
PanelSliderRow(
|
||||
label: '延迟',
|
||||
value: settings.delay,
|
||||
min: -10.0,
|
||||
max: 10.0,
|
||||
step: 0.5,
|
||||
display:
|
||||
'${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s',
|
||||
onChanged: (v) =>
|
||||
widget.onSubtitleSettingsChanged(settings.copyWith(delay: v)),
|
||||
),
|
||||
PanelSliderRow(
|
||||
label: '背景',
|
||||
value: settings.bgOpacity,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.1,
|
||||
display: '${(settings.bgOpacity * 100).round()}%',
|
||||
onChanged: (v) =>
|
||||
widget.onSubtitleSettingsChanged(settings.copyWith(bgOpacity: v)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String _activeSubtitleLabel() {
|
||||
final index = _activeStreamIndex;
|
||||
if (index == null) return '禁用';
|
||||
final track = widget.subtitles
|
||||
.where((subtitle) => subtitle.index == index)
|
||||
.firstOrNull;
|
||||
if (track == null) return '字幕';
|
||||
return track.title ?? track.language ?? '字幕 ${track.index}';
|
||||
}
|
||||
|
||||
String _subtitleStyleLabel() {
|
||||
final settings = widget.subtitleSettings;
|
||||
return '${settings.fontSize.round()} / ${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s';
|
||||
}
|
||||
}
|
||||
|
||||
class AudioPanelBody extends StatefulWidget {
|
||||
final PlayerEngine player;
|
||||
final void Function(AudioTrack track)? onAudioTrackSelected;
|
||||
|
||||
const AudioPanelBody({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.onAudioTrackSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioPanelBody> createState() => _AudioPanelBodyState();
|
||||
}
|
||||
|
||||
class _AudioPanelBodyState extends State<AudioPanelBody> {
|
||||
AudioTrack _activeAudio = const AudioTrack.auto();
|
||||
List<AudioTrack> _audioTracks = const [];
|
||||
StreamSubscription<PlayerTrack>? _trackSub;
|
||||
StreamSubscription<PlayerTracks>? _tracksSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncFromPlayer(widget.player);
|
||||
_subscribePlayer(widget.player);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AudioPanelBody oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
_cancelSubscriptions();
|
||||
_syncFromPlayer(widget.player);
|
||||
_subscribePlayer(widget.player);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelSubscriptions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncFromPlayer(PlayerEngine player) {
|
||||
_activeAudio = player.state.track.audio;
|
||||
_audioTracks = player.state.tracks.embeddedAudio;
|
||||
}
|
||||
|
||||
void _subscribePlayer(PlayerEngine player) {
|
||||
_trackSub = player.stream.track.listen((track) {
|
||||
if (!mounted) return;
|
||||
setState(() => _activeAudio = track.audio);
|
||||
});
|
||||
_tracksSub = player.stream.tracks.listen((tracks) {
|
||||
if (!mounted) return;
|
||||
setState(() => _audioTracks = tracks.embeddedAudio);
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelSubscriptions() {
|
||||
unawaited(_trackSub?.cancel());
|
||||
unawaited(_tracksSub?.cancel());
|
||||
_trackSub = null;
|
||||
_tracksSub = null;
|
||||
}
|
||||
|
||||
Future<void> _selectAudio(AudioTrack track) async {
|
||||
if (!mounted) return;
|
||||
setState(() => _activeAudio = track);
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await widget.player.setAudioTrack(track);
|
||||
widget.onAudioTrackSelected?.call(track);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _activeAudio = widget.player.state.track.audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _audioList();
|
||||
}
|
||||
|
||||
Widget _audioList() {
|
||||
final entries = <SelectionPanelEntry<AudioTrack>>[
|
||||
for (final t in _audioTracks)
|
||||
SelectionPanelEntry.item(
|
||||
value: t,
|
||||
label: t.title ?? t.language ?? '音频 ${t.id}',
|
||||
subtitle: _translateLanguage(t.language) ?? t.language,
|
||||
isActive: _activeAudio.id == t.id,
|
||||
),
|
||||
if (_audioTracks.isNotEmpty) const SelectionPanelEntry.divider(),
|
||||
SelectionPanelEntry.item(
|
||||
value: const AudioTrack.no(),
|
||||
label: '关闭音频',
|
||||
isActive: _activeAudio.id == 'no',
|
||||
),
|
||||
];
|
||||
return PanelSelectionList<AudioTrack>(
|
||||
entries: entries,
|
||||
onSelected: (track) => unawaited(_selectAudio(track)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class PlayerClockText extends StatefulWidget {
|
||||
const PlayerClockText({super.key, this.style});
|
||||
|
||||
final TextStyle? style;
|
||||
|
||||
@override
|
||||
State<PlayerClockText> createState() => _PlayerClockTextState();
|
||||
}
|
||||
|
||||
class _PlayerClockTextState extends State<PlayerClockText> {
|
||||
Timer? _timer;
|
||||
late String _text;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_text = formatClock(DateTime.now());
|
||||
_scheduleNextTick();
|
||||
}
|
||||
|
||||
void _scheduleNextTick() {
|
||||
final now = DateTime.now();
|
||||
final next = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
).add(const Duration(minutes: 1));
|
||||
_timer = Timer(next.difference(now), _tick);
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
if (!mounted) return;
|
||||
setState(() => _text = formatClock(DateTime.now()));
|
||||
_scheduleNextTick();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
_text,
|
||||
style:
|
||||
widget.style ??
|
||||
const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
height: 1.0,
|
||||
letterSpacing: 0.5,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
String formatClock(DateTime t) {
|
||||
final h = t.hour.toString().padLeft(2, '0');
|
||||
final m = t.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/widgets/top_drag_area.dart';
|
||||
|
||||
class ControlsChrome extends StatelessWidget {
|
||||
final bool visible;
|
||||
final List<Widget> topBar;
|
||||
final double topBarLeftInset;
|
||||
final double topBarTopInset;
|
||||
final Widget bottomBar;
|
||||
final Widget? centerBar;
|
||||
final VoidCallback? onControlsAreaTap;
|
||||
final ValueChanged<bool>? onControlsHoverChanged;
|
||||
final Widget? leftBar;
|
||||
final Widget? rightBar;
|
||||
|
||||
|
||||
final bool isWindowed;
|
||||
|
||||
|
||||
final bool isPip;
|
||||
|
||||
const ControlsChrome({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.topBar,
|
||||
this.topBarLeftInset = 8,
|
||||
this.topBarTopInset = 8,
|
||||
required this.bottomBar,
|
||||
this.centerBar,
|
||||
this.leftBar,
|
||||
this.rightBar,
|
||||
required this.onControlsAreaTap,
|
||||
this.onControlsHoverChanged,
|
||||
this.isWindowed = false,
|
||||
this.isPip = false,
|
||||
});
|
||||
|
||||
Widget _withControlsAreaTap(Widget child) {
|
||||
final onTap = onControlsAreaTap;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap ?? () {},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _withTopDragHandle(Widget child) {
|
||||
if (isPip) {
|
||||
return DragToMoveArea(child: child);
|
||||
}
|
||||
return TopDragArea(enabled: isWindowed, child: child);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.appTokens;
|
||||
return IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => onControlsHoverChanged?.call(true),
|
||||
onExit: (_) => onControlsHoverChanged?.call(false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black87, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: _withTopDragHandle(
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
topBarLeftInset,
|
||||
topBarTopInset,
|
||||
tokens.chromeInset,
|
||||
isPip ? 12 : 28,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: topBar,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => onControlsHoverChanged?.call(true),
|
||||
onExit: (_) => onControlsHoverChanged?.call(false),
|
||||
child: _withControlsAreaTap(
|
||||
Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0,
|
||||
isPip ? 8 : 20,
|
||||
0,
|
||||
isPip ? 6 : 8,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black87, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: bottomBar,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (leftBar != null)
|
||||
Positioned(
|
||||
left: 28,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(child: leftBar!),
|
||||
),
|
||||
if (rightBar != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(child: rightBar!),
|
||||
),
|
||||
if (centerBar != null)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => onControlsHoverChanged?.call(true),
|
||||
onExit: (_) => onControlsHoverChanged?.call(false),
|
||||
child: centerBar!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
import '../services/frame_jank_monitor.dart';
|
||||
|
||||
|
||||
const int _kLowBufferLeadMs = 1000;
|
||||
|
||||
|
||||
class PlayerDebugHud extends StatelessWidget {
|
||||
const PlayerDebugHud({
|
||||
super.key,
|
||||
required this.engine,
|
||||
required this.frameMonitor,
|
||||
});
|
||||
|
||||
final PlayerEngine engine;
|
||||
final FrameJankMonitor frameMonitor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 56, left: 12),
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
fontFamily: 'monospace',
|
||||
fontFamilyFallback: ['Consolas', 'Menlo', 'monospace'],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'DEBUG',
|
||||
style: TextStyle(
|
||||
color: Colors.lightGreenAccent,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
Text('engine: ${engine.displayName}'),
|
||||
ValueListenableBuilder<PlaybackStats?>(
|
||||
valueListenable: engine.debugStats,
|
||||
builder: (context, stats, _) => _StatsBlock(stats: stats),
|
||||
),
|
||||
ValueListenableBuilder<FrameDiag>(
|
||||
valueListenable: frameMonitor.diag,
|
||||
builder: (context, diag, _) => _FrameBlock(diag: diag),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsBlock extends StatelessWidget {
|
||||
const _StatsBlock({required this.stats});
|
||||
|
||||
final PlaybackStats? stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = stats;
|
||||
if (s == null) {
|
||||
return const Text('engine: (no stats yet)');
|
||||
}
|
||||
final low = s.bufferLeadMs < _kLowBufferLeadMs;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('decoder: ${s.decoder} (hwdec=${s.hwdec})'),
|
||||
Text('isDV: ${s.isDolbyVision} rate: ${s.rate}x'),
|
||||
Text('bitrate: ${s.bitrateMbps.toStringAsFixed(1)} Mbps'),
|
||||
Text(
|
||||
'bufferLead: ${s.bufferLeadMs} ms',
|
||||
style: TextStyle(
|
||||
color: low ? Colors.orangeAccent : Colors.white,
|
||||
fontWeight: low ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FrameBlock extends StatelessWidget {
|
||||
const _FrameBlock({required this.diag});
|
||||
|
||||
final FrameDiag diag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rasterHot = diag.lastRasterMs >= 32;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text('fps: ${diag.fps.toStringAsFixed(0)}'),
|
||||
Text(
|
||||
'raster: ${diag.lastRasterMs.toStringAsFixed(1)} ms',
|
||||
style: TextStyle(
|
||||
color: rasterHot ? Colors.orangeAccent : Colors.white,
|
||||
fontWeight: rasterHot ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
Text('build: ${diag.lastBuildMs.toStringAsFixed(1)} ms'),
|
||||
Text('jank frames: ${diag.jankCount}'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'player_panel_shell.dart';
|
||||
|
||||
class PlayerDrawerPanel extends StatelessWidget {
|
||||
final String title;
|
||||
final VoidCallback onBack;
|
||||
final Widget child;
|
||||
|
||||
const PlayerDrawerPanel({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.onBack,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_header(),
|
||||
panelDivider(),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 12, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new,
|
||||
color: Colors.white70,
|
||||
size: 15,
|
||||
),
|
||||
onPressed: onBack,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
tooltip: '返回',
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'speed_button.dart';
|
||||
|
||||
const holdSpeedPromptBackgroundColor = Color(0x45181818);
|
||||
|
||||
class HoldSpeedPrompt extends StatelessWidget {
|
||||
final bool visible;
|
||||
final bool isLeft;
|
||||
final double rate;
|
||||
|
||||
const HoldSpeedPrompt({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.isLeft,
|
||||
required this.rate,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = isLeft
|
||||
? '${SpeedButton.formatRate(rate)} 慢放中'
|
||||
: '${SpeedButton.formatRate(rate)} 快进中';
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 64),
|
||||
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: [
|
||||
Icon(
|
||||
isLeft ? Icons.fast_rewind : Icons.fast_forward,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
const kPanelBg = Color(0xE8181818);
|
||||
const kPanelDivider = Color(0x33FFFFFF);
|
||||
|
||||
Widget panelDivider() => const FDivider(
|
||||
style: .delta(color: kPanelDivider, padding: .value(EdgeInsets.zero)),
|
||||
);
|
||||
|
||||
class PlayerPanelShell extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final double width;
|
||||
final VoidCallback onClose;
|
||||
final Widget child;
|
||||
|
||||
const PlayerPanelShell({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.width,
|
||||
required this.onClose,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: width,
|
||||
decoration: BoxDecoration(
|
||||
color: kPanelBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_header(),
|
||||
panelDivider(),
|
||||
Flexible(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: Colors.white70, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white54, size: 18),
|
||||
onPressed: onClose,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
|
||||
class PlayerPlayPauseButton extends StatefulWidget {
|
||||
final PlayerEngine player;
|
||||
|
||||
|
||||
final double? iconSize;
|
||||
|
||||
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
const PlayerPlayPauseButton({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.iconSize,
|
||||
this.constraints,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerPlayPauseButton> createState() => _PlayerPlayPauseButtonState();
|
||||
}
|
||||
|
||||
class _PlayerPlayPauseButtonState extends State<PlayerPlayPauseButton> {
|
||||
late bool _playing;
|
||||
StreamSubscription<bool>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_playing = widget.player.state.playing;
|
||||
_sub = widget.player.stream.playing.listen((playing) {
|
||||
if (mounted) setState(() => _playing = playing);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PlayerPlayPauseButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
unawaited(_sub?.cancel());
|
||||
_playing = widget.player.state.playing;
|
||||
_sub = widget.player.stream.playing.listen((playing) {
|
||||
if (mounted) setState(() => _playing = playing);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_sub?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: _playing ? '暂停' : '播放',
|
||||
constraints: widget.constraints,
|
||||
padding: widget.constraints != null ? EdgeInsets.zero : null,
|
||||
onPressed: () {
|
||||
if (_playing) {
|
||||
unawaited(widget.player.pause());
|
||||
} else {
|
||||
unawaited(widget.player.play());
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
_playing ? Icons.pause : Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: widget.iconSize ?? 30,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
|
||||
class PlayerPositionIndicator extends StatefulWidget {
|
||||
final PlayerEngine player;
|
||||
|
||||
|
||||
final ValueNotifier<Duration?>? seekPreview;
|
||||
|
||||
const PlayerPositionIndicator({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.seekPreview,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerPositionIndicator> createState() =>
|
||||
_PlayerPositionIndicatorState();
|
||||
}
|
||||
|
||||
class _PlayerPositionIndicatorState extends State<PlayerPositionIndicator> {
|
||||
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((position) {
|
||||
if (mounted) setState(() => _position = position);
|
||||
});
|
||||
_durSub = widget.player.stream.duration.listen((duration) {
|
||||
if (mounted) setState(() => _duration = duration);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PlayerPositionIndicator 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((position) {
|
||||
if (mounted) setState(() => _position = position);
|
||||
});
|
||||
_durSub = widget.player.stream.duration.listen((duration) {
|
||||
if (mounted) setState(() => _duration = duration);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_posSub?.cancel());
|
||||
unawaited(_durSub?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildText(Duration position, Duration duration) {
|
||||
return SizedBox(
|
||||
width: 126,
|
||||
child: Text(
|
||||
'${formatDurationClock(position)} / ${formatDurationClock(duration)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13,
|
||||
fontFeatures: [ui.FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final seekPreview = widget.seekPreview;
|
||||
if (seekPreview != null) {
|
||||
return ValueListenableBuilder<Duration?>(
|
||||
valueListenable: seekPreview,
|
||||
builder: (context, preview, _) {
|
||||
final displayPos = preview ?? _position;
|
||||
return _buildText(displayPos, _duration);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _buildText(_position, _duration);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
import '../../../shared/widgets/top_drag_area.dart';
|
||||
import '../../../shared/widgets/window_controls.dart';
|
||||
|
||||
|
||||
class PlayerLoadingBackdrop extends StatelessWidget {
|
||||
const PlayerLoadingBackdrop({super.key, this.backdropUrl});
|
||||
|
||||
final String? backdropUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = backdropUrl;
|
||||
if (url == null || url.isEmpty) {
|
||||
return const Positioned.fill(child: ColoredBox(color: Colors.black));
|
||||
}
|
||||
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
RepaintBoundary(
|
||||
child: Transform.scale(
|
||||
scale: 1.08,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 24, sigmaY: 24),
|
||||
child: SmartImage(
|
||||
url: url,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 0,
|
||||
fallbackIcon: null,
|
||||
memCacheWidth: 480,
|
||||
placeholder: const ColoredBox(color: Colors.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const ColoredBox(color: Colors.black54),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PlayerLoadingIndicator extends StatelessWidget {
|
||||
const PlayerLoadingIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: AppLoadingRing(size: 44, color: Colors.white));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PlayerStatusTopBar extends StatelessWidget {
|
||||
const PlayerStatusTopBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.onBack,
|
||||
required this.topInset,
|
||||
required this.leftInset,
|
||||
required this.showWindowControls,
|
||||
this.enableWindowDrag = false,
|
||||
});
|
||||
|
||||
|
||||
final String title;
|
||||
final VoidCallback onBack;
|
||||
final double topInset;
|
||||
final double leftInset;
|
||||
final bool showWindowControls;
|
||||
|
||||
|
||||
final bool enableWindowDrag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trimmed = title.trim();
|
||||
final shown = trimmed.isEmpty ? '播放器' : trimmed;
|
||||
|
||||
final Widget bar = SafeArea(
|
||||
bottom: false,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onBack,
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
tooltip: '返回',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
shown,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (showWindowControls) const WindowControls.forDarkSurface(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Positioned(
|
||||
top: topInset,
|
||||
left: leftInset,
|
||||
right: 12,
|
||||
child: enableWindowDrag ? TopDragArea(child: bar) : bar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PlayerErrorCard extends StatelessWidget {
|
||||
const PlayerErrorCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onBack,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Material(
|
||||
color: const Color(0xFF1C1F26),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 12, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(
|
||||
0xFFFFB020,
|
||||
).withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.error_outline,
|
||||
color: Color(0xFFFFC46B),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'无法播放此媒体',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onBack,
|
||||
icon: const Icon(Icons.close, color: Colors.white70),
|
||||
tooltip: '关闭播放器',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 140),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const FDivider(
|
||||
style: .delta(
|
||||
color: Colors.white12,
|
||||
padding: .value(EdgeInsets.only(top: 20)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: onBack,
|
||||
icon: const Icon(Icons.arrow_back, size: 18),
|
||||
label: const Text('返回详情'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('重试'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
import '../gestures/gesture_action.dart';
|
||||
import '../gestures/gesture_bindings.dart';
|
||||
import 'player_controls_chrome.dart';
|
||||
import 'player_hold_speed_prompt.dart';
|
||||
|
||||
class PlayerVideoWidget extends StatefulWidget {
|
||||
final PlayerEngine player;
|
||||
final ValueListenable<BoxFit> fit;
|
||||
final List<Widget> topBar;
|
||||
final Widget bottomBar;
|
||||
final Widget? centerBar;
|
||||
final List<Widget> overlays;
|
||||
final VoidCallback? onControlsAreaTap;
|
||||
final bool isFullscreen;
|
||||
final Future<void> Function() onToggleFullscreen;
|
||||
final bool isPip;
|
||||
final Future<void> Function()? onTogglePip;
|
||||
final double topBarLeftInset;
|
||||
final double topBarTopInset;
|
||||
final bool lockControls;
|
||||
final bool keyboardEnabled;
|
||||
final int seekForwardSeconds;
|
||||
final int seekBackwardSeconds;
|
||||
final bool volumeBoost;
|
||||
|
||||
final GestureBindings gestureBindings;
|
||||
|
||||
final double currentRate;
|
||||
final ValueChanged<double>? onSetRate;
|
||||
final VoidCallback? onPrevItem;
|
||||
final VoidCallback? onNextItem;
|
||||
final VoidCallback? onExit;
|
||||
|
||||
|
||||
final void Function(Offset globalPosition)? onSecondaryContextMenu;
|
||||
|
||||
|
||||
final Widget Function(VoidCallback toggleControls, VoidCallback showControls)?
|
||||
mobileGestureBuilder;
|
||||
|
||||
|
||||
final Widget? gestureFeedback;
|
||||
|
||||
|
||||
final bool suppressControlsChrome;
|
||||
|
||||
|
||||
final Widget? leftBar;
|
||||
|
||||
|
||||
final Widget? rightBar;
|
||||
|
||||
const PlayerVideoWidget({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.fit,
|
||||
required this.topBar,
|
||||
required this.bottomBar,
|
||||
this.centerBar,
|
||||
this.overlays = const <Widget>[],
|
||||
this.onControlsAreaTap,
|
||||
required this.isFullscreen,
|
||||
required this.onToggleFullscreen,
|
||||
this.isPip = false,
|
||||
this.onTogglePip,
|
||||
this.topBarLeftInset = 8,
|
||||
this.topBarTopInset = 8,
|
||||
this.lockControls = false,
|
||||
this.keyboardEnabled = true,
|
||||
this.seekForwardSeconds = 15,
|
||||
this.seekBackwardSeconds = 15,
|
||||
this.volumeBoost = false,
|
||||
this.gestureBindings = GestureBindings.defaults,
|
||||
this.currentRate = 1.0,
|
||||
this.onSetRate,
|
||||
this.onPrevItem,
|
||||
this.onNextItem,
|
||||
this.onExit,
|
||||
this.onSecondaryContextMenu,
|
||||
this.mobileGestureBuilder,
|
||||
this.gestureFeedback,
|
||||
this.suppressControlsChrome = false,
|
||||
this.leftBar,
|
||||
this.rightBar,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerVideoWidget> createState() => _PlayerVideoWidgetState();
|
||||
}
|
||||
|
||||
class _PlayerVideoWidgetState extends State<PlayerVideoWidget> {
|
||||
static const double _volumeStep = 5;
|
||||
static const double _pipResizeEdgeSize = 10;
|
||||
static const List<ResizeEdge> _pipResizeEdges = <ResizeEdge>[
|
||||
ResizeEdge.topLeft,
|
||||
ResizeEdge.top,
|
||||
ResizeEdge.topRight,
|
||||
ResizeEdge.left,
|
||||
ResizeEdge.right,
|
||||
ResizeEdge.bottomLeft,
|
||||
ResizeEdge.bottom,
|
||||
ResizeEdge.bottomRight,
|
||||
];
|
||||
|
||||
bool _controlsVisible = true;
|
||||
bool _cursorVisible = true;
|
||||
bool _hoveringControls = false;
|
||||
Timer? _controlsHideTimer;
|
||||
Timer? _cursorHideTimer;
|
||||
StreamSubscription<bool>? _playingSub;
|
||||
Offset? _lastMousePosition;
|
||||
|
||||
bool _holdingLeft = false;
|
||||
bool _holdingRight = false;
|
||||
double? _savedRateBeforeHold;
|
||||
|
||||
bool _holdSpeedPromptVisible = false;
|
||||
bool _holdSpeedPromptIsLeft = false;
|
||||
double _holdSpeedPromptRate = 1.0;
|
||||
|
||||
Timer? _pendingSeekLeft;
|
||||
Timer? _pendingSeekRight;
|
||||
static const _arrowHoldThreshold = Duration(milliseconds: 200);
|
||||
|
||||
|
||||
static const _topHoverTrigger = 120.0;
|
||||
static const _bottomHoverTrigger = 180.0;
|
||||
|
||||
Timer? _secondaryTapTimer;
|
||||
Offset? _secondaryTapDownPos;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_playingSub = widget.player.stream.playing.listen((playing) {
|
||||
if (!mounted) return;
|
||||
if (playing) {
|
||||
_scheduleHide();
|
||||
_scheduleHideCursor();
|
||||
} else {
|
||||
_showControls(lock: true);
|
||||
_showCursor(lock: true);
|
||||
}
|
||||
});
|
||||
if (widget.player.state.playing) {
|
||||
_scheduleHide();
|
||||
_scheduleHideCursor();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PlayerVideoWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
unawaited(_playingSub?.cancel());
|
||||
_playingSub = widget.player.stream.playing.listen((playing) {
|
||||
if (!mounted) return;
|
||||
if (playing) {
|
||||
_scheduleHide();
|
||||
_scheduleHideCursor();
|
||||
} else {
|
||||
_showControls(lock: true);
|
||||
_showCursor(lock: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (oldWidget.lockControls != widget.lockControls) {
|
||||
if (widget.lockControls) {
|
||||
_controlsHideTimer?.cancel();
|
||||
_cursorHideTimer?.cancel();
|
||||
_showControls(lock: true);
|
||||
_showCursor(lock: true);
|
||||
} else if (widget.player.state.playing) {
|
||||
_scheduleHide();
|
||||
_scheduleHideCursor();
|
||||
}
|
||||
}
|
||||
if (oldWidget.keyboardEnabled && !widget.keyboardEnabled) {
|
||||
_cancelPendingArrowSeeks();
|
||||
_restoreHoldRateIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controlsHideTimer?.cancel();
|
||||
_cursorHideTimer?.cancel();
|
||||
_secondaryTapTimer?.cancel();
|
||||
_cancelPendingArrowSeeks();
|
||||
_restoreHoldRateIfNeeded();
|
||||
unawaited(_playingSub?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _cancelPendingArrowSeeks() {
|
||||
_pendingSeekLeft?.cancel();
|
||||
_pendingSeekLeft = null;
|
||||
_pendingSeekRight?.cancel();
|
||||
_pendingSeekRight = null;
|
||||
}
|
||||
|
||||
void _showControls({bool lock = false}) {
|
||||
_controlsHideTimer?.cancel();
|
||||
if (!_controlsVisible && mounted) {
|
||||
setState(() => _controlsVisible = true);
|
||||
}
|
||||
if (!lock && widget.player.state.playing) {
|
||||
_scheduleHide();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleHide() {
|
||||
_controlsHideTimer?.cancel();
|
||||
if (_hoveringControls || widget.lockControls) return;
|
||||
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted &&
|
||||
widget.player.state.playing &&
|
||||
!_hoveringControls &&
|
||||
!widget.lockControls) {
|
||||
setState(() => _controlsVisible = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void _showCursor({bool lock = false}) {
|
||||
_cursorHideTimer?.cancel();
|
||||
if (!_cursorVisible && mounted) {
|
||||
setState(() => _cursorVisible = true);
|
||||
}
|
||||
if (!lock && widget.player.state.playing) {
|
||||
_scheduleHideCursor();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleHideCursor() {
|
||||
_cursorHideTimer?.cancel();
|
||||
if (_hoveringControls || widget.lockControls) return;
|
||||
_cursorHideTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted &&
|
||||
widget.player.state.playing &&
|
||||
!_hoveringControls &&
|
||||
!widget.lockControls) {
|
||||
setState(() => _cursorVisible = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _inEdgeZone(double localY, double viewHeight) =>
|
||||
localY <= _topHoverTrigger || localY >= viewHeight - _bottomHoverTrigger;
|
||||
|
||||
Future<void> _togglePlayPause() async {
|
||||
if (widget.player.state.playing) {
|
||||
await widget.player.pause();
|
||||
} else {
|
||||
await widget.player.play();
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (!widget.keyboardEnabled) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowLeft ||
|
||||
key == LogicalKeyboardKey.arrowRight) {
|
||||
return _handleArrowKey(event, key == LogicalKeyboardKey.arrowLeft);
|
||||
}
|
||||
|
||||
final isRepeat = event is KeyRepeatEvent;
|
||||
if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored;
|
||||
if (key == LogicalKeyboardKey.space) {
|
||||
if (isRepeat) return KeyEventResult.handled;
|
||||
unawaited(_togglePlayPause());
|
||||
_showControls();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
if (Platform.isAndroid) return KeyEventResult.ignored;
|
||||
final maxVol = widget.volumeBoost ? 150.0 : 100.0;
|
||||
final next = (widget.player.state.volume + _volumeStep).clamp(
|
||||
0.0,
|
||||
maxVol,
|
||||
);
|
||||
unawaited(widget.player.setVolume(next));
|
||||
_showControls();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
if (Platform.isAndroid) return KeyEventResult.ignored;
|
||||
final maxVol = widget.volumeBoost ? 150.0 : 100.0;
|
||||
final next = (widget.player.state.volume - _volumeStep).clamp(
|
||||
0.0,
|
||||
maxVol,
|
||||
);
|
||||
unawaited(widget.player.setVolume(next));
|
||||
_showControls();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.keyF ||
|
||||
(key == LogicalKeyboardKey.escape && widget.isFullscreen)) {
|
||||
if (isRepeat) return KeyEventResult.handled;
|
||||
unawaited(widget.onToggleFullscreen());
|
||||
_showControls();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.keyP) {
|
||||
if (isRepeat) return KeyEventResult.handled;
|
||||
final togglePip = widget.onTogglePip;
|
||||
if (togglePip != null) {
|
||||
unawaited(togglePip());
|
||||
_showControls();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape && widget.isPip) {
|
||||
if (isRepeat) return KeyEventResult.handled;
|
||||
final togglePip = widget.onTogglePip;
|
||||
if (togglePip != null) {
|
||||
unawaited(togglePip());
|
||||
_showControls();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
KeyEventResult _handleArrowKey(KeyEvent event, bool isLeft) {
|
||||
final seekSeconds = isLeft
|
||||
? -widget.seekBackwardSeconds
|
||||
: widget.seekForwardSeconds;
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
if (isLeft) {
|
||||
_pendingSeekLeft?.cancel();
|
||||
_pendingSeekLeft = Timer(_arrowHoldThreshold, () {
|
||||
_pendingSeekLeft = null;
|
||||
if (!mounted) return;
|
||||
_enterHoldSpeed(isLeft: true);
|
||||
});
|
||||
} else {
|
||||
_pendingSeekRight?.cancel();
|
||||
_pendingSeekRight = Timer(_arrowHoldThreshold, () {
|
||||
_pendingSeekRight = null;
|
||||
if (!mounted) return;
|
||||
_enterHoldSpeed(isLeft: false);
|
||||
});
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event is KeyRepeatEvent) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event is KeyUpEvent) {
|
||||
final pending = isLeft ? _pendingSeekLeft : _pendingSeekRight;
|
||||
if (pending != null) {
|
||||
pending.cancel();
|
||||
if (isLeft) {
|
||||
_pendingSeekLeft = null;
|
||||
} else {
|
||||
_pendingSeekRight = null;
|
||||
}
|
||||
_seekBy(seekSeconds);
|
||||
_showControls();
|
||||
}
|
||||
final wasHolding = isLeft ? _holdingLeft : _holdingRight;
|
||||
if (wasHolding) {
|
||||
if (isLeft) {
|
||||
_holdingLeft = false;
|
||||
} else {
|
||||
_holdingRight = false;
|
||||
}
|
||||
if (!_holdingLeft && !_holdingRight) {
|
||||
_hideHoldSpeedPrompt();
|
||||
final restore = _savedRateBeforeHold ?? widget.currentRate;
|
||||
_savedRateBeforeHold = null;
|
||||
widget.onSetRate?.call(restore);
|
||||
}
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _enterHoldSpeed({required bool isLeft}) {
|
||||
final holding = isLeft ? _holdingLeft : _holdingRight;
|
||||
if (holding) return;
|
||||
|
||||
final holdSpeed = isLeft
|
||||
? widget.gestureBindings.keyboardHoldLeftSpeed
|
||||
: widget.gestureBindings.keyboardHoldRightSpeed;
|
||||
_savedRateBeforeHold ??= widget.currentRate;
|
||||
widget.onSetRate?.call(holdSpeed);
|
||||
if (isLeft) {
|
||||
_holdingLeft = true;
|
||||
} else {
|
||||
_holdingRight = true;
|
||||
}
|
||||
_showHoldSpeedPrompt(isLeft: isLeft, rate: holdSpeed);
|
||||
}
|
||||
|
||||
void _showHoldSpeedPrompt({required bool isLeft, required double rate}) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_holdSpeedPromptVisible = true;
|
||||
_holdSpeedPromptIsLeft = isLeft;
|
||||
_holdSpeedPromptRate = rate;
|
||||
});
|
||||
}
|
||||
|
||||
void _hideHoldSpeedPrompt() {
|
||||
if (!_holdSpeedPromptVisible) return;
|
||||
if (!mounted) {
|
||||
_holdSpeedPromptVisible = false;
|
||||
return;
|
||||
}
|
||||
setState(() => _holdSpeedPromptVisible = false);
|
||||
}
|
||||
|
||||
void _restoreHoldRateIfNeeded() {
|
||||
if (!_holdingLeft && !_holdingRight) return;
|
||||
_holdingLeft = false;
|
||||
_holdingRight = false;
|
||||
_hideHoldSpeedPrompt();
|
||||
final restore = _savedRateBeforeHold;
|
||||
_savedRateBeforeHold = null;
|
||||
if (restore != null) widget.onSetRate?.call(restore);
|
||||
}
|
||||
|
||||
void _seekBy(int seconds) {
|
||||
final target = widget.player.state.position + Duration(seconds: seconds);
|
||||
unawaited(
|
||||
widget.player.seek(target < Duration.zero ? Duration.zero : target),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleControlsVisibility() {
|
||||
if (_controlsVisible) {
|
||||
setState(() => _controlsVisible = false);
|
||||
_controlsHideTimer?.cancel();
|
||||
} else {
|
||||
_showControls();
|
||||
_showCursor();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSecondaryTap() {
|
||||
final contextMenu = widget.onSecondaryContextMenu;
|
||||
if (contextMenu != null) {
|
||||
contextMenu(_secondaryTapDownPos ?? Offset.zero);
|
||||
return;
|
||||
}
|
||||
if (widget.gestureBindings.mouseRightDoubleClickAction ==
|
||||
GestureAction.none) {
|
||||
_dispatchGesture(GestureKind.rightClick);
|
||||
return;
|
||||
}
|
||||
final pending = _secondaryTapTimer;
|
||||
if (pending != null && pending.isActive) {
|
||||
pending.cancel();
|
||||
_secondaryTapTimer = null;
|
||||
_dispatchGesture(GestureKind.rightDoubleClick);
|
||||
return;
|
||||
}
|
||||
_secondaryTapTimer = Timer(kDoubleTapTimeout, () {
|
||||
_secondaryTapTimer = null;
|
||||
if (!mounted) return;
|
||||
_dispatchGesture(GestureKind.rightClick);
|
||||
});
|
||||
}
|
||||
|
||||
void _dispatchGesture(GestureKind kind) {
|
||||
final action = widget.gestureBindings.actionFor(kind);
|
||||
switch (action) {
|
||||
case GestureAction.none:
|
||||
return;
|
||||
case GestureAction.playPause:
|
||||
unawaited(_togglePlayPause());
|
||||
break;
|
||||
case GestureAction.toggleControls:
|
||||
_toggleControlsVisibility();
|
||||
return;
|
||||
case GestureAction.fullscreen:
|
||||
unawaited(widget.onToggleFullscreen());
|
||||
break;
|
||||
case GestureAction.seekBackward:
|
||||
_seekBy(-widget.seekBackwardSeconds);
|
||||
break;
|
||||
case GestureAction.seekForward:
|
||||
_seekBy(widget.seekForwardSeconds);
|
||||
break;
|
||||
case GestureAction.previousItem:
|
||||
widget.onPrevItem?.call();
|
||||
break;
|
||||
case GestureAction.nextItem:
|
||||
widget.onNextItem?.call();
|
||||
break;
|
||||
case GestureAction.exit:
|
||||
widget.onExit?.call();
|
||||
break;
|
||||
}
|
||||
_showControls();
|
||||
_showCursor();
|
||||
}
|
||||
|
||||
Widget _withPipResizeAffordance(Widget child) {
|
||||
if (!widget.isPip) return child;
|
||||
return DragToResizeArea(
|
||||
resizeEdgeSize: _pipResizeEdgeSize,
|
||||
resizeEdgeColor: Colors.transparent,
|
||||
enableResizeEdges: _pipResizeEdges,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWindowed = !widget.isFullscreen && !widget.isPip;
|
||||
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewHeight = constraints.maxHeight;
|
||||
|
||||
if (widget.mobileGestureBuilder != null) {
|
||||
return _withPipResizeAffordance(
|
||||
Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
PlayerVideoSurface(
|
||||
player: widget.player,
|
||||
fit: widget.fit,
|
||||
),
|
||||
widget.mobileGestureBuilder!(
|
||||
_toggleControlsVisibility,
|
||||
_showControls,
|
||||
),
|
||||
...widget.overlays,
|
||||
if (widget.gestureFeedback != null)
|
||||
widget.gestureFeedback!,
|
||||
HoldSpeedPrompt(
|
||||
visible: _holdSpeedPromptVisible,
|
||||
isLeft: _holdSpeedPromptIsLeft,
|
||||
rate: _holdSpeedPromptRate,
|
||||
),
|
||||
ControlsChrome(
|
||||
visible:
|
||||
_controlsVisible &&
|
||||
!widget.suppressControlsChrome,
|
||||
topBar: widget.topBar,
|
||||
topBarLeftInset: widget.topBarLeftInset,
|
||||
topBarTopInset: widget.topBarTopInset,
|
||||
bottomBar: widget.bottomBar,
|
||||
centerBar: widget.centerBar,
|
||||
leftBar: widget.leftBar,
|
||||
rightBar: widget.rightBar,
|
||||
onControlsAreaTap: widget.onControlsAreaTap,
|
||||
isWindowed: isWindowed,
|
||||
isPip: widget.isPip,
|
||||
onControlsHoverChanged: (hovering) {
|
||||
_hoveringControls = hovering;
|
||||
if (!hovering && widget.player.state.playing) {
|
||||
_scheduleHide();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return MouseRegion(
|
||||
cursor: _cursorVisible
|
||||
? SystemMouseCursors.basic
|
||||
: SystemMouseCursors.none,
|
||||
onEnter: (event) {
|
||||
_lastMousePosition = event.position;
|
||||
_showCursor();
|
||||
if (widget.isPip ||
|
||||
_inEdgeZone(event.localPosition.dy, viewHeight)) {
|
||||
_showControls();
|
||||
}
|
||||
},
|
||||
onExit: (_) {
|
||||
_lastMousePosition = null;
|
||||
},
|
||||
onHover: (event) {
|
||||
final pos = event.position;
|
||||
final last = _lastMousePosition;
|
||||
_lastMousePosition = pos;
|
||||
if (last != null &&
|
||||
_cursorVisible &&
|
||||
_controlsVisible &&
|
||||
(pos - last).distanceSquared < 9) {
|
||||
return;
|
||||
}
|
||||
_showCursor();
|
||||
if (widget.isPip ||
|
||||
_inEdgeZone(event.localPosition.dy, viewHeight)) {
|
||||
_showControls();
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.lockControls
|
||||
? null
|
||||
: () => _dispatchGesture(GestureKind.leftClick),
|
||||
onDoubleTap:
|
||||
widget.lockControls ||
|
||||
widget
|
||||
.gestureBindings
|
||||
.mouseLeftDoubleClickAction ==
|
||||
GestureAction.none
|
||||
? null
|
||||
: () => _dispatchGesture(GestureKind.leftDoubleClick),
|
||||
onSecondaryTap: widget.lockControls
|
||||
? null
|
||||
: _handleSecondaryTap,
|
||||
onSecondaryTapDown: widget.lockControls
|
||||
? null
|
||||
: (d) => _secondaryTapDownPos = d.globalPosition,
|
||||
child: _withPipResizeAffordance(
|
||||
Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
PlayerVideoSurface(
|
||||
player: widget.player,
|
||||
fit: widget.fit,
|
||||
),
|
||||
...widget.overlays,
|
||||
HoldSpeedPrompt(
|
||||
visible: _holdSpeedPromptVisible,
|
||||
isLeft: _holdSpeedPromptIsLeft,
|
||||
rate: _holdSpeedPromptRate,
|
||||
),
|
||||
ControlsChrome(
|
||||
visible:
|
||||
_controlsVisible &&
|
||||
!widget.suppressControlsChrome,
|
||||
topBar: widget.topBar,
|
||||
topBarLeftInset: widget.topBarLeftInset,
|
||||
topBarTopInset: widget.topBarTopInset,
|
||||
bottomBar: widget.bottomBar,
|
||||
centerBar: widget.centerBar,
|
||||
leftBar: widget.leftBar,
|
||||
rightBar: widget.rightBar,
|
||||
onControlsAreaTap: widget.onControlsAreaTap,
|
||||
isWindowed: isWindowed,
|
||||
isPip: widget.isPip,
|
||||
onControlsHoverChanged: (hovering) {
|
||||
_hoveringControls = hovering;
|
||||
if (!hovering && widget.player.state.playing) {
|
||||
_scheduleHide();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerVideoSurface extends StatelessWidget {
|
||||
final PlayerEngine player;
|
||||
final ValueListenable<BoxFit> fit;
|
||||
|
||||
const PlayerVideoSurface({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.fit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = player.buildVideoView(context, fit);
|
||||
if (player.usesPlatformSurface) return child;
|
||||
return ColoredBox(color: Colors.black, child: child);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
|
||||
class PlayerVolumeButton extends StatefulWidget {
|
||||
final PlayerEngine player;
|
||||
final bool volumeBoost;
|
||||
|
||||
const PlayerVolumeButton({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.volumeBoost = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerVolumeButton> createState() => _PlayerVolumeButtonState();
|
||||
}
|
||||
|
||||
class _PlayerVolumeButtonState extends State<PlayerVolumeButton> {
|
||||
late double _volume;
|
||||
StreamSubscription<double>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_volume = widget.player.state.volume;
|
||||
_sub = widget.player.stream.volume.listen((volume) {
|
||||
if (mounted) setState(() => _volume = volume);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PlayerVolumeButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
unawaited(_sub?.cancel());
|
||||
_volume = widget.player.state.volume;
|
||||
_sub = widget.player.stream.volume.listen((volume) {
|
||||
if (mounted) setState(() => _volume = volume);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_sub?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = _volume <= 0;
|
||||
final max = widget.volumeBoost ? 150.0 : 100.0;
|
||||
return SizedBox(
|
||||
width: 122,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: muted ? '取消静音' : '静音',
|
||||
onPressed: () => unawaited(widget.player.toggleMute()),
|
||||
icon: Icon(
|
||||
muted
|
||||
? Icons.volume_off
|
||||
: (_volume < 45 ? Icons.volume_down : Icons.volume_up),
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: const SliderThemeData(
|
||||
trackHeight: 3,
|
||||
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: RoundSliderOverlayShape(overlayRadius: 12),
|
||||
activeTrackColor: Colors.white,
|
||||
inactiveTrackColor: Colors.white24,
|
||||
thumbColor: Colors.white,
|
||||
overlayColor: Colors.white10,
|
||||
showValueIndicator: ShowValueIndicator.onlyForContinuous,
|
||||
valueIndicatorShape: PaddleSliderValueIndicatorShape(),
|
||||
valueIndicatorColor: Color(0xCC1E1E1E),
|
||||
valueIndicatorTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
value: _volume.clamp(0.0, max),
|
||||
min: 0,
|
||||
max: max,
|
||||
label: '${_volume.round()}',
|
||||
onChanged: (value) => unawaited(widget.player.setVolume(value)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'player_panel_controller.dart';
|
||||
|
||||
|
||||
String panelCategoryLabel(PlayerPanelCategory c) {
|
||||
switch (c) {
|
||||
case PlayerPanelCategory.subtitle:
|
||||
return '字幕';
|
||||
case PlayerPanelCategory.audio:
|
||||
return '音轨';
|
||||
case PlayerPanelCategory.speed:
|
||||
return '播放速度';
|
||||
case PlayerPanelCategory.fit:
|
||||
return '画面比例';
|
||||
case PlayerPanelCategory.episode:
|
||||
return '选集';
|
||||
case PlayerPanelCategory.danmaku:
|
||||
return '弹幕';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
IconData panelCategoryIcon(PlayerPanelCategory c) {
|
||||
switch (c) {
|
||||
case PlayerPanelCategory.subtitle:
|
||||
return Icons.subtitles_outlined;
|
||||
case PlayerPanelCategory.audio:
|
||||
return Icons.audiotrack;
|
||||
case PlayerPanelCategory.speed:
|
||||
return Icons.speed;
|
||||
case PlayerPanelCategory.fit:
|
||||
return Icons.aspect_ratio;
|
||||
case PlayerPanelCategory.episode:
|
||||
return Icons.format_list_numbered;
|
||||
case PlayerPanelCategory.danmaku:
|
||||
return Icons.forum_outlined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
||||
enum PlayerPanelCategory { subtitle, audio, speed, fit, episode, danmaku }
|
||||
|
||||
|
||||
class PlayerPanelController extends ValueNotifier<PlayerPanelCategory?> {
|
||||
PlayerPanelController() : super(null);
|
||||
|
||||
bool get isOpen => value != null;
|
||||
PlayerPanelCategory? get category => value;
|
||||
|
||||
void open(PlayerPanelCategory category) => value = category;
|
||||
|
||||
|
||||
void toggle(PlayerPanelCategory category) =>
|
||||
value = (value == category) ? null : category;
|
||||
|
||||
void close() => value = null;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../player_panel_shell.dart';
|
||||
import 'panel_category_meta.dart';
|
||||
import 'player_panel_controller.dart';
|
||||
|
||||
|
||||
class PlayerPanelPopover extends StatefulWidget {
|
||||
|
||||
final PlayerPanelController controller;
|
||||
|
||||
|
||||
final Map<PlayerPanelCategory, LayerLink> anchorLinks;
|
||||
|
||||
|
||||
final Set<PlayerPanelCategory> downwardCategories;
|
||||
|
||||
|
||||
final Widget Function(PlayerPanelCategory) bodyBuilder;
|
||||
|
||||
|
||||
final double width;
|
||||
|
||||
const PlayerPanelPopover({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.anchorLinks,
|
||||
this.downwardCategories = const {},
|
||||
required this.bodyBuilder,
|
||||
this.width = 340,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerPanelPopover> createState() => _PlayerPanelPopoverState();
|
||||
}
|
||||
|
||||
class _PlayerPanelPopoverState extends State<PlayerPanelPopover>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _anim;
|
||||
late final Animation<double> _t;
|
||||
late final Animation<double> _scale;
|
||||
|
||||
|
||||
PlayerPanelCategory? _visibleCategory;
|
||||
|
||||
|
||||
bool _showing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_anim = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
reverseDuration: const Duration(milliseconds: 130),
|
||||
value: widget.controller.value != null ? 1.0 : 0.0,
|
||||
);
|
||||
_t = CurvedAnimation(
|
||||
parent: _anim,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
_scale = Tween<double>(begin: 0.96, end: 1.0).animate(_t);
|
||||
_visibleCategory = widget.controller.value;
|
||||
_showing = widget.controller.value != null;
|
||||
_anim.addStatusListener(_onStatus);
|
||||
widget.controller.addListener(_onController);
|
||||
}
|
||||
|
||||
void _onController() {
|
||||
final c = widget.controller.value;
|
||||
if (c != null) {
|
||||
setState(() {
|
||||
_visibleCategory = c;
|
||||
_showing = true;
|
||||
});
|
||||
_anim.forward();
|
||||
} else {
|
||||
_anim.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _onStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.dismissed && mounted && _showing) {
|
||||
setState(() => _showing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onController);
|
||||
_anim.removeStatusListener(_onStatus);
|
||||
_anim.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final category = _visibleCategory;
|
||||
if (!_showing || category == null) return const SizedBox.shrink();
|
||||
final link = widget.anchorLinks[category];
|
||||
if (link == null) return const SizedBox.shrink();
|
||||
final downward = widget.downwardCategories.contains(category);
|
||||
final maxH = (MediaQuery.of(context).size.height * 0.55).clamp(0.0, 360.0);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.controller.close,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
child: CompositedTransformFollower(
|
||||
link: link,
|
||||
targetAnchor: downward ? Alignment.bottomRight : Alignment.topRight,
|
||||
followerAnchor: downward
|
||||
? Alignment.topRight
|
||||
: Alignment.bottomRight,
|
||||
offset: Offset(0, downward ? 8 : -8),
|
||||
showWhenUnlinked: false,
|
||||
child: FadeTransition(
|
||||
opacity: _t,
|
||||
child: ScaleTransition(
|
||||
scale: _scale,
|
||||
alignment: downward
|
||||
? Alignment.topRight
|
||||
: Alignment.bottomRight,
|
||||
child: _bubble(category, maxH),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bubble(PlayerPanelCategory category, double maxHeight) {
|
||||
return CallbackShortcuts(
|
||||
bindings: <ShortcutActivator, VoidCallback>{
|
||||
const SingleActivator(LogicalKeyboardKey.escape):
|
||||
widget.controller.close,
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: PlayerPanelShell(
|
||||
icon: panelCategoryIcon(category),
|
||||
title: panelCategoryLabel(category),
|
||||
width: widget.width,
|
||||
onClose: widget.controller.close,
|
||||
child: widget.bodyBuilder(category),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
|
||||
class QualityBadge extends StatelessWidget {
|
||||
const QualityBadge({super.key, required this.quality});
|
||||
|
||||
final MediaQualityInfo? quality;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = _buildLabel();
|
||||
if (label == null) return const SizedBox.shrink();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _buildLabel() {
|
||||
final q = quality;
|
||||
if (q == null) return null;
|
||||
final res = q.resolutionLabel;
|
||||
if (res.isEmpty) return null;
|
||||
final range = q.dynamicRangeLabel;
|
||||
if (range == 'SDR' || range.isEmpty) return res;
|
||||
final shortRange = range == 'Dolby Vision' ? 'DV' : range;
|
||||
return '$res $shortRange';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'panel_toggle_affordance.dart';
|
||||
|
||||
class SpeedButton extends StatelessWidget {
|
||||
final double currentRate;
|
||||
final bool isPanelOpen;
|
||||
final VoidCallback onTogglePanel;
|
||||
|
||||
const SpeedButton({
|
||||
super.key,
|
||||
required this.currentRate,
|
||||
required this.isPanelOpen,
|
||||
required this.onTogglePanel,
|
||||
});
|
||||
|
||||
static const speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
|
||||
|
||||
static String formatRate(double rate) {
|
||||
return rate == rate.roundToDouble() ? '${rate.toInt()}x' : '${rate}x';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PanelToggleAffordance(
|
||||
isOpen: isPanelOpen,
|
||||
onTap: onTogglePanel,
|
||||
tooltip: '播放速度',
|
||||
child: Text(
|
||||
formatRate(currentRate),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/playback.dart';
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
import '../../../providers/subtitle_settings_provider.dart';
|
||||
|
||||
|
||||
class StyledSubtitleOverlay extends StatefulWidget {
|
||||
final PlayerEngine player;
|
||||
final SubtitleStyleSettings settings;
|
||||
|
||||
|
||||
final ValueListenable<List<String>>? externalLines;
|
||||
|
||||
|
||||
final ValueListenable<EmbySubtitleTrack?>? externalActive;
|
||||
|
||||
const StyledSubtitleOverlay({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.settings,
|
||||
this.externalLines,
|
||||
this.externalActive,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StyledSubtitleOverlay> createState() => _StyledSubtitleOverlayState();
|
||||
}
|
||||
|
||||
class _StyledSubtitleOverlayState extends State<StyledSubtitleOverlay> {
|
||||
static const _mpvReferenceHeight = 720.0;
|
||||
static const _baseEdgeInset = 24.0;
|
||||
|
||||
List<String> _subtitle = const [];
|
||||
StreamSubscription<List<String>>? _subscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bindPlayer(widget.player);
|
||||
widget.externalLines?.addListener(_onExternalChanged);
|
||||
widget.externalActive?.addListener(_onExternalChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant StyledSubtitleOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
unawaited(_subscription?.cancel());
|
||||
_bindPlayer(widget.player);
|
||||
}
|
||||
if (oldWidget.externalLines != widget.externalLines) {
|
||||
oldWidget.externalLines?.removeListener(_onExternalChanged);
|
||||
widget.externalLines?.addListener(_onExternalChanged);
|
||||
}
|
||||
if (oldWidget.externalActive != widget.externalActive) {
|
||||
oldWidget.externalActive?.removeListener(_onExternalChanged);
|
||||
widget.externalActive?.addListener(_onExternalChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_subscription?.cancel());
|
||||
widget.externalLines?.removeListener(_onExternalChanged);
|
||||
widget.externalActive?.removeListener(_onExternalChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _bindPlayer(PlayerEngine player) {
|
||||
_subtitle = player.state.subtitle;
|
||||
_subscription = player.stream.subtitle.listen((value) {
|
||||
if (mounted) setState(() => _subtitle = value);
|
||||
});
|
||||
}
|
||||
|
||||
void _onExternalChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
bool get _externalIsActive => widget.externalActive?.value != null;
|
||||
|
||||
List<String> get _activeLines =>
|
||||
_externalIsActive ? (widget.externalLines?.value ?? const []) : _subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primaryText = _joinLines(_activeLines);
|
||||
if (primaryText.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = constraints.maxHeight;
|
||||
if (!width.isFinite ||
|
||||
!height.isFinite ||
|
||||
width <= 0 ||
|
||||
height <= 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final fontSize = _scaleFontSize(widget.settings.fontSize, height);
|
||||
|
||||
final position = widget.settings.position.clamp(0, 150).toDouble();
|
||||
final verticalProgress = math.min(position, 100.0) / 100.0;
|
||||
final extraLowering = math.max(0.0, position - 100.0) / 50.0;
|
||||
final bottomInset =
|
||||
_baseEdgeInset * (1.0 - extraLowering.clamp(0.0, 1.0));
|
||||
|
||||
final lineCount = math.max(1, primaryText.split('\n').length);
|
||||
final estimatedBlockHeight = math.min(
|
||||
height * 0.5,
|
||||
lineCount * fontSize * 1.32 + 12.0,
|
||||
);
|
||||
|
||||
final verticalRange = math.max(
|
||||
0.0,
|
||||
height - _baseEdgeInset - bottomInset - estimatedBlockHeight,
|
||||
);
|
||||
final top = _baseEdgeInset + verticalRange * verticalProgress;
|
||||
final horizontalInset = math.min(_baseEdgeInset, width * 0.06);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned(
|
||||
left: horizontalInset,
|
||||
right: horizontalInset,
|
||||
top: top,
|
||||
child: _buildLine(
|
||||
text: primaryText,
|
||||
fontSize: fontSize,
|
||||
bgOpacity: widget.settings.bgOpacity.clamp(0.0, 1.0),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _joinLines(List<String> lines) => lines
|
||||
.map((line) => line.trim())
|
||||
.where((line) => line.isNotEmpty)
|
||||
.join('\n');
|
||||
|
||||
double _scaleFontSize(double settingsFontSize, double height) =>
|
||||
(settingsFontSize * height / _mpvReferenceHeight).clamp(8.0, 200.0);
|
||||
|
||||
Widget _buildLine({
|
||||
required String text,
|
||||
required double fontSize,
|
||||
required double bgOpacity,
|
||||
}) {
|
||||
final child = Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
softWrap: true,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize,
|
||||
height: 1.32,
|
||||
letterSpacing: 0.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 2, offset: Offset(0, 1)),
|
||||
Shadow(color: Colors.black87, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (bgOpacity > 0) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(bgOpacity),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/playback.dart';
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
import 'panel_toggle_affordance.dart';
|
||||
|
||||
class SubtitleTracksButton extends StatelessWidget {
|
||||
final PlayerEngine player;
|
||||
final List<EmbySubtitleTrack> embySubtitles;
|
||||
final bool isPanelOpen;
|
||||
final VoidCallback onTogglePanel;
|
||||
|
||||
const SubtitleTracksButton({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.embySubtitles,
|
||||
required this.isPanelOpen,
|
||||
required this.onTogglePanel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<PlayerTracks>(
|
||||
key: ObjectKey(player),
|
||||
stream: player.stream.tracks,
|
||||
initialData: player.state.tracks,
|
||||
builder: (_, snapshot) {
|
||||
final hasSubtitles =
|
||||
(snapshot.data?.embeddedSubtitles.isNotEmpty ?? false) ||
|
||||
embySubtitles.isNotEmpty;
|
||||
if (!hasSubtitles) return const SizedBox.shrink();
|
||||
return PanelToggleAffordance(
|
||||
isOpen: isPanelOpen,
|
||||
onTap: onTogglePanel,
|
||||
tooltip: '字幕',
|
||||
child: const Icon(
|
||||
Icons.subtitles_outlined,
|
||||
color: Color(0xFFFFFFFF),
|
||||
size: 28,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AudioTracksButton extends StatelessWidget {
|
||||
final PlayerEngine player;
|
||||
final bool isPanelOpen;
|
||||
final VoidCallback onTogglePanel;
|
||||
|
||||
const AudioTracksButton({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.isPanelOpen,
|
||||
required this.onTogglePanel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<PlayerTracks>(
|
||||
key: ObjectKey(player),
|
||||
stream: player.stream.tracks,
|
||||
initialData: player.state.tracks,
|
||||
builder: (_, snapshot) {
|
||||
if ((snapshot.data?.embeddedAudio.length ?? 0) <= 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return PanelToggleAffordance(
|
||||
isOpen: isPanelOpen,
|
||||
onTap: onTogglePanel,
|
||||
tooltip: '音轨',
|
||||
child: const Icon(
|
||||
Icons.multitrack_audio,
|
||||
color: Color(0xFFFFFFFF),
|
||||
size: 28,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart' as forui;
|
||||
|
||||
import '../../../shared/widgets/all_versions_sheet.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../../shared/widgets/version_filter.dart';
|
||||
import '../../../shared/widgets/version_grouping.dart';
|
||||
import '../../../shared/widgets/version_source_card.dart';
|
||||
import 'player_panel_shell.dart';
|
||||
|
||||
|
||||
class VersionPanel extends StatefulWidget {
|
||||
final List<VersionSourceCardData> entries;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const VersionPanel({super.key, required this.entries, required this.onClose});
|
||||
|
||||
@override
|
||||
State<VersionPanel> createState() => _VersionPanelState();
|
||||
}
|
||||
|
||||
class _VersionPanelState extends State<VersionPanel> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
String? _selectedBucket;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entries = widget.entries;
|
||||
final allGroups = groupVersionEntries(entries);
|
||||
|
||||
final bucketsPresent = <String>{
|
||||
for (final e in entries) versionBucketOf(e.resolutionLabel),
|
||||
};
|
||||
final buckets = kVersionBucketOrder
|
||||
.where(bucketsPresent.contains)
|
||||
.toList(growable: false);
|
||||
|
||||
final activeEntry = entries
|
||||
.where((entry) => entry.isCurrent || entry.selected)
|
||||
.firstOrNull;
|
||||
final defaultBucket = activeEntry == null
|
||||
? (buckets.isNotEmpty ? buckets.first : null)
|
||||
: versionBucketOf(activeEntry.resolutionLabel);
|
||||
final selected =
|
||||
_selectedBucket != null && bucketsPresent.contains(_selectedBucket)
|
||||
? _selectedBucket!
|
||||
: defaultBucket;
|
||||
if (selected != _selectedBucket) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedBucket = selected);
|
||||
});
|
||||
}
|
||||
|
||||
final filteredGroups = selected == null
|
||||
? allGroups
|
||||
: allGroups
|
||||
.where(
|
||||
(group) =>
|
||||
versionBucketOf(group.representative.resolutionLabel) ==
|
||||
selected,
|
||||
)
|
||||
.toList(growable: false);
|
||||
final inlineLimit = inlineVersionLimit();
|
||||
final inlineGroups = filteredGroups
|
||||
.take(inlineLimit)
|
||||
.toList(growable: true);
|
||||
final activeGroupIndex = filteredGroups.indexWhere(
|
||||
(group) => group.hasActiveMember,
|
||||
);
|
||||
if (activeGroupIndex >= inlineLimit && inlineGroups.isNotEmpty) {
|
||||
inlineGroups[inlineGroups.length - 1] = filteredGroups[activeGroupIndex];
|
||||
}
|
||||
final hasMergedGroup = allGroups.any((group) => group.members.length > 1);
|
||||
final showAllVersionsButton =
|
||||
filteredGroups.length > inlineLimit || hasMergedGroup;
|
||||
final itemCount = inlineGroups.length;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: kPanelBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_header(
|
||||
buckets,
|
||||
selected,
|
||||
allGroups: allGroups,
|
||||
sourceCount: entries.length,
|
||||
showAllVersionsButton: showAllVersionsButton,
|
||||
),
|
||||
panelDivider(),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: Platform.isAndroid ? 8 : 12,
|
||||
bottom: Platform.isAndroid ? 10 : 14,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: Platform.isAndroid ? 56 : 120,
|
||||
child: HoverScrollableRow(
|
||||
controller: _scrollController,
|
||||
builder: (_, controller) => ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: Platform.isAndroid ? 10 : 16,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (_, i) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i < itemCount - 1
|
||||
? (Platform.isAndroid ? 6 : 12)
|
||||
: 0,
|
||||
),
|
||||
child: VersionSourceCard(
|
||||
data: inlineGroups[i].displayData,
|
||||
compact: Platform.isAndroid,
|
||||
onMergedServersTap: inlineGroups[i].members.length > 1
|
||||
? () => showAllVersionsSheet(
|
||||
context: context,
|
||||
groups: [inlineGroups[i]],
|
||||
title: '选择服务器',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(
|
||||
List<String> buckets,
|
||||
String? selected, {
|
||||
required List<VersionSourceGroup> allGroups,
|
||||
required int sourceCount,
|
||||
required bool showAllVersionsButton,
|
||||
}) {
|
||||
final isAndroid = Platform.isAndroid;
|
||||
return Padding(
|
||||
padding: isAndroid
|
||||
? const EdgeInsets.fromLTRB(16, 10, 8, 8)
|
||||
: const EdgeInsets.fromLTRB(16, 12, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.video_file_outlined,
|
||||
color: Colors.white70,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'切换版本',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: isAndroid ? 14 : 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (showAllVersionsButton) ...[
|
||||
const SizedBox(width: 10),
|
||||
forui.FButton(
|
||||
variant: forui.FButtonVariant.secondary,
|
||||
size: forui.FButtonSizeVariant.xs,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
onPress: () =>
|
||||
showAllVersionsSheet(context: context, groups: allGroups),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('全部 $sourceCount 个版本'),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.chevron_right_rounded, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < buckets.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
VersionFilterChip(
|
||||
label: buckets[i],
|
||||
selected: selected == buckets[i],
|
||||
showDot: true,
|
||||
onTap: () => setState(() => _selectedBucket = buckets[i]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white54, size: 18),
|
||||
onPressed: widget.onClose,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user