forked from Foxyz/sync_replay_project
Player: sharp video and subtitles at screen size; dev tools: danmaku preview, subtitle lift with a stand-in chat line
- "Sharp video and subtitles" (Settings → Player and the player settings card, on by default): the video output renders at the view's physical size (debounced, GPU rendering only), so libass, mpv's scaler and the shaders work at screen resolution; re-sent after media_kit's own reset on new video params, redrawn while paused. - Dev builds only (Settings → About → Developer): a danmaku preview (DanmakuLayer: free lanes in an upper band, density cap with a queue, equal crossing time, outlined text, avatars, own-line frame) over the real player or a stand-in picture, with samples, a random stream, live toasts, every knob and mock subtitles; and "Lift subtitles above the chat line and controls": Enter opens a stand-in chat line, bottom subtitles move up via mpv sub-pos while it or the control bar is up. - tool/make_sub_test.sh: a test video with a top \an8 sign, bottom dialogue and an SRT track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
638a30e16c
commit
5bc252bf02
18 files changed
+2253
-16
No files matched your search
@@ -7,6 +7,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../controls/frame.dart' show DoubleClickListener;
|
||||
import '../controls/session_controls.dart' show controlsBottomHeight;
|
||||
import '../danmaku/danmaku_preview.dart';
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
@@ -248,6 +249,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
for (final stop in _stops) {
|
||||
stop();
|
||||
}
|
||||
_danmaku?.dispose();
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_whatsNewSource?.due.removeListener(_maybeWhatsNew);
|
||||
_updateTimer?.cancel();
|
||||
@@ -358,6 +360,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
|
||||
void _expand() => _show(PlayerMode.full);
|
||||
|
||||
/// Dev builds: the danmaku preview, made on first use.
|
||||
DanmakuPreview? _danmaku;
|
||||
|
||||
/// Over the full player when a player is up, else on its own page with
|
||||
/// a stand-in picture.
|
||||
void _openDanmakuPreview() {
|
||||
final d = _danmaku ??= DanmakuPreview();
|
||||
if (_online.peek() && _graph.coordinator.playback.alive.peek()) {
|
||||
setState(() => d.active.value = true);
|
||||
_show(PlayerMode.full);
|
||||
} else {
|
||||
d.active.value = false;
|
||||
showDanmakuPreviewPage(context, d);
|
||||
}
|
||||
}
|
||||
|
||||
void _collapse() {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
_show(PlayerMode.mini);
|
||||
@@ -765,6 +783,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
updates: widget.updates,
|
||||
jellyfin: _jellyfin,
|
||||
onDanmakuPreview: _openDanmakuPreview,
|
||||
),
|
||||
};
|
||||
final full = _shown == PlayerMode.full;
|
||||
@@ -908,6 +927,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
videoBuilder: widget.videoBuilder,
|
||||
windowControls: widget.windowControls,
|
||||
controlsShown: _controlsShown,
|
||||
danmaku: _danmaku,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,9 +13,11 @@ import '../controls/model/controls_layout.dart';
|
||||
import '../controls/model/controls_model.dart';
|
||||
import '../controls/session_controls.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../danmaku/danmaku_preview.dart';
|
||||
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/home/ui.dart' show devTools;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
import '../session/app_events.dart' show ShaderMessage;
|
||||
@@ -24,6 +26,7 @@ import '../shaders/shader_presets.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
@@ -32,6 +35,7 @@ import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
import 'sit_out_card.dart';
|
||||
import 'start_banner.dart';
|
||||
import 'video_view.dart';
|
||||
|
||||
/// Where the session's video is shown: nowhere, the floating mini player,
|
||||
/// or filling the body (and, with fullscreen, the window).
|
||||
@@ -69,6 +73,9 @@ class SessionPlayer extends StatefulWidget {
|
||||
/// Follows whether the full player's controls are up (the shell lifts
|
||||
/// its toasts above them); the player keeps its own when null.
|
||||
final ValueNotifier<bool>? controlsShown;
|
||||
/// Dev builds: the danmaku preview, shown over the full player while
|
||||
/// it is active.
|
||||
final DanmakuPreview? danmaku;
|
||||
const SessionPlayer({
|
||||
super.key,
|
||||
required this.mode,
|
||||
@@ -86,6 +93,7 @@ class SessionPlayer extends StatefulWidget {
|
||||
this.videoBuilder,
|
||||
this.windowControls = true,
|
||||
this.controlsShown,
|
||||
this.danmaku,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -104,6 +112,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
bool _miniHover = false;
|
||||
// Whether the controls are up (their frame reports it).
|
||||
final _ownShown = ValueNotifier(true);
|
||||
// Dev builds with "Lift subtitles": Enter opens a stand-in for the
|
||||
// planned chat line at the bottom, in place of the control bar.
|
||||
final _chatLine = ValueNotifier(false);
|
||||
final _chatLineText = TextEditingController();
|
||||
final _chatLineFocus = FocusNode();
|
||||
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
|
||||
// The mouse on a floating element, or the Up next popover open, keeps
|
||||
// the controls up.
|
||||
@@ -166,6 +179,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_stopPopoverWatch();
|
||||
_noFocus.dispose();
|
||||
_ownShown.dispose();
|
||||
_chatLine.dispose();
|
||||
_chatLineText.dispose();
|
||||
_chatLineFocus.dispose();
|
||||
_hold.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
@@ -197,6 +213,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
return false;
|
||||
}
|
||||
if (textInputFocused()) return false;
|
||||
if (devTools &&
|
||||
_coord.settings.liftSubtitles.peek() &&
|
||||
(event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter)) {
|
||||
_openChatLine();
|
||||
return true;
|
||||
}
|
||||
final now = DateTime.now().toUtc();
|
||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||
return false;
|
||||
@@ -306,11 +328,15 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
video,
|
||||
// Dev experiment: bottom subtitles above the controls.
|
||||
if (devTools) _subtitleLifter(),
|
||||
if (_full) ...[
|
||||
_blockedCard(),
|
||||
_actionArea(),
|
||||
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
||||
_volumeReadout(),
|
||||
if (devTools) _chatLineOverlay(),
|
||||
if (widget.danmaku case final d?) _danmakuPanel(d),
|
||||
] else
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
@@ -324,11 +350,111 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||
key: _videoKey,
|
||||
controller: videoControllerOf(_coord.playback.video),
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: controls,
|
||||
Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
|
||||
playback: _coord.playback,
|
||||
sharp: _coord.settings.screenResVideo,
|
||||
child: Video(
|
||||
key: _videoKey,
|
||||
controller: videoControllerOf(_coord.playback.video),
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: controls,
|
||||
),
|
||||
);
|
||||
|
||||
/// What covers the bottom of the full player, for the subtitle lift:
|
||||
/// the chat line, else the control bar while it is up.
|
||||
Widget _subtitleLifter() => SignalBuilder(builder: (context) {
|
||||
final controls = _bottomControlsHeight();
|
||||
return SubtitleLifter(
|
||||
playback: _coord.playback,
|
||||
changes: Listenable.merge([_controlsShown, _chatLine]),
|
||||
lift: _coord.settings.liftSubtitles,
|
||||
cover: () => _chatLine.value
|
||||
? _chatLineHeight + _edge
|
||||
: _controlsShown.value
|
||||
? controls
|
||||
: 0,
|
||||
active: _full,
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------- chat line (stand-in)
|
||||
|
||||
static const _chatLineHeight = 44.0;
|
||||
|
||||
void _openChatLine() {
|
||||
_chatLine.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _chatLineFocus.requestFocus());
|
||||
}
|
||||
|
||||
void _closeChatLine() {
|
||||
_chatLine.value = false;
|
||||
_chatLineText.clear();
|
||||
_chatLineFocus.unfocus();
|
||||
}
|
||||
|
||||
void _sendChatLine(String text) {
|
||||
final t = text.trim();
|
||||
if (t.isNotEmpty) {
|
||||
_coord.sendChat(t);
|
||||
// The preview, when it is up, shows it as your own line.
|
||||
final d = widget.danmaku;
|
||||
if (d != null && d.active.peek()) d.controller.add(d.own(t));
|
||||
}
|
||||
_closeChatLine();
|
||||
}
|
||||
|
||||
/// A one-line chat box at the bottom of the full player (Enter opens
|
||||
/// it, Enter sends, Esc closes): the planned chat line, to try the
|
||||
/// subtitle lift with.
|
||||
Widget _chatLineOverlay() => Positioned(
|
||||
left: _edge,
|
||||
right: _edge,
|
||||
bottom: _edge,
|
||||
height: _chatLineHeight,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _chatLine,
|
||||
builder: (context, open, child) => IgnorePointer(
|
||||
ignoring: !open,
|
||||
child: AnimatedSlide(
|
||||
offset: open ? Offset.zero : const Offset(0, 0.4),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: AnimatedOpacity(opacity: open ? 1 : 0, duration: easeTime, curve: ease, child: child),
|
||||
),
|
||||
),
|
||||
child: CallbackShortcuts(
|
||||
bindings: {const SingleActivator(LogicalKeyboardKey.escape): _closeChatLine},
|
||||
child: AppGlass.pill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 18, color: Colors.white.withValues(alpha: 0.7)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('chat-line'),
|
||||
controller: _chatLineText,
|
||||
focusNode: _chatLineFocus,
|
||||
onSubmitted: _sendChatLine,
|
||||
onTapOutside: (_) => _closeChatLine(),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
cursorColor: Colors.white,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: 'Say something · Enter sends, Esc closes',
|
||||
hintStyle: TextStyle(color: Colors.white.withValues(alpha: 0.45), fontSize: 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||
@@ -355,12 +481,25 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const StartBanner(),
|
||||
SessionControls(
|
||||
model: _model,
|
||||
shown: _controlsShown,
|
||||
hold: _hold,
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
windowButtons: _fullscreenWindowButtons(),
|
||||
// Under the controls, over the picture.
|
||||
if (widget.danmaku case final d?)
|
||||
SignalBuilder(
|
||||
builder: (context) => d.active.value ? DanmakuPreviewLayers(preview: d) : const SizedBox.shrink(),
|
||||
),
|
||||
// The chat line takes the control bar's place while it is open.
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _chatLine,
|
||||
builder: (context, chatLine, child) => IgnorePointer(
|
||||
ignoring: chatLine,
|
||||
child: AnimatedOpacity(opacity: chatLine ? 0 : 1, duration: easeTime, curve: ease, child: child),
|
||||
),
|
||||
child: SessionControls(
|
||||
model: _model,
|
||||
shown: _controlsShown,
|
||||
hold: _hold,
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
windowButtons: _fullscreenWindowButtons(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -378,6 +517,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
|
||||
|
||||
/// The danmaku preview's panel, top right under the top bar; the mouse
|
||||
/// on it keeps the controls up.
|
||||
Widget _danmakuPanel(DanmakuPreview d) => Positioned(
|
||||
right: _edge,
|
||||
top: _topBarHeight + 8,
|
||||
child: SignalBuilder(
|
||||
builder: (context) => fadeThrough(
|
||||
expand: false,
|
||||
alignment: Alignment.topRight,
|
||||
d.active.value
|
||||
? ControlsHoldRegion(
|
||||
key: const ValueKey('danmaku-panel'),
|
||||
hold: _hold,
|
||||
child: DanmakuSessionPanel(preview: d, coord: _coord),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('danmaku-none')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// The volume flash after a wheel or key step: a pill under the top bar.
|
||||
Widget _volumeReadout() => Positioned(
|
||||
left: 0,
|
||||
|
||||
@@ -13,7 +13,7 @@ import '../jellyfin/accounts_panel.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../screens/home/ui.dart' show appName, devTools, displayVersion;
|
||||
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
@@ -52,6 +52,8 @@ class SettingsTab extends StatelessWidget {
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
|
||||
final Future<AnimeFlowMe> Function()? animeFlowMe;
|
||||
/// Dev builds: About → Developer → Danmaku preview.
|
||||
final VoidCallback? onDanmakuPreview;
|
||||
const SettingsTab({
|
||||
super.key,
|
||||
required this.section,
|
||||
@@ -60,6 +62,7 @@ class SettingsTab extends StatelessWidget {
|
||||
this.updates,
|
||||
this.jellyfin,
|
||||
this.animeFlowMe,
|
||||
this.onDanmakuPreview,
|
||||
});
|
||||
|
||||
List<Widget> _page(BuildContext context) {
|
||||
@@ -162,7 +165,10 @@ class SettingsTab extends StatelessWidget {
|
||||
case SettingsSection.shortcuts:
|
||||
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
|
||||
case SettingsSection.about:
|
||||
return [AboutPanel(updates: updates)];
|
||||
return [
|
||||
AboutPanel(updates: updates),
|
||||
if (devTools) DeveloperPanel(onDanmakuPreview: onDanmakuPreview),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../playback/playback_controller.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
// What the session's video view tells mpv about where it is shown: the
|
||||
// size its output renders at ("Sharp video and subtitles") and, as a dev
|
||||
// experiment, how far bottom subtitles move up while the controls are up.
|
||||
|
||||
/// The video output's size for a view of [logical] size at [dpr]: its
|
||||
/// physical pixels when [on] and video renders on the GPU, else null (the
|
||||
/// video's own size). Software rendering keeps the video's size: every
|
||||
/// pixel there is CPU work.
|
||||
(int, int)? videoOutputSize(Size logical, double dpr, {required bool on, required RenderMode mode}) {
|
||||
if (!on || mode != RenderMode.gpu) return null;
|
||||
final w = (logical.width * dpr).round(), h = (logical.height * dpr).round();
|
||||
if (w < 16 || h < 16) return null;
|
||||
return (w, h);
|
||||
}
|
||||
|
||||
/// How long a view size must hold before the output follows it (a
|
||||
/// window drag resizes many times a second; each resize reallocates the
|
||||
/// output's texture).
|
||||
const outputResizeDelay = Duration(milliseconds: 180);
|
||||
|
||||
/// Sends the size of the view around [child] to the player host (see
|
||||
/// [videoOutputSize]): at once the first time, then once a resize has
|
||||
/// settled for [outputResizeDelay]. Follows the setting and the render
|
||||
/// mode live.
|
||||
class VideoOutputSizer extends StatefulWidget {
|
||||
final PlaybackController playback;
|
||||
|
||||
/// The setting ([AppSettings.screenResVideo]).
|
||||
final ReadonlySignal<bool> sharp;
|
||||
final Widget child;
|
||||
const VideoOutputSizer({super.key, required this.playback, required this.sharp, required this.child});
|
||||
|
||||
@override
|
||||
State<VideoOutputSizer> createState() => _VideoOutputSizerState();
|
||||
}
|
||||
|
||||
class _VideoOutputSizerState extends State<VideoOutputSizer> {
|
||||
Size? _view;
|
||||
double _dpr = 1;
|
||||
Timer? _debounce;
|
||||
late final void Function() _stop;
|
||||
var _first = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stop = effect(() {
|
||||
widget.sharp.value;
|
||||
widget.playback.renderMode.value;
|
||||
untracked(_send);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final view = _view;
|
||||
if (view == null) return;
|
||||
final size = videoOutputSize(view, _dpr,
|
||||
on: widget.sharp.peek(), mode: widget.playback.renderMode.peek());
|
||||
widget.playback.host.setOutputSize(size?.$1, size?.$2);
|
||||
}
|
||||
|
||||
void _resized(Size view, double dpr) {
|
||||
if (view == _view && dpr == _dpr) return;
|
||||
_view = view;
|
||||
_dpr = dpr;
|
||||
_debounce?.cancel();
|
||||
if (_first) {
|
||||
_first = false;
|
||||
_send();
|
||||
return;
|
||||
}
|
||||
_debounce = Timer(outputResizeDelay, _send);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
_resized(box.biggest, dpr);
|
||||
return widget.child;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// mpv `sub-pos` (100 = where the file puts them) that lifts bottom
|
||||
/// subtitles clear of what covers the bottom of the view ([controls]
|
||||
/// logical px tall: the control bar or the chat line, plus [gap]) in a [view] showing a [videoW] x [videoH] video fitted
|
||||
/// inside it: only as far as the letterbox under the picture does not
|
||||
/// already make room. `sub-pos` moves bottom-aligned lines by a share of
|
||||
/// the picture's height; top-aligned ASS signs stay put.
|
||||
double subtitleLiftPos({required Size view, int? videoW, int? videoH, required double controls, double gap = 8}) {
|
||||
if (view.height <= 0 || view.width <= 0) return 100;
|
||||
var picH = view.height;
|
||||
if (videoW != null && videoH != null && videoW > 0 && videoH > 0) {
|
||||
picH = math.min(view.height, view.width * videoH / videoW);
|
||||
}
|
||||
final bar = (view.height - picH) / 2;
|
||||
final shift = math.max(0.0, controls + gap - bar);
|
||||
return (100 - shift / picH * 100).clamp(50.0, 100.0);
|
||||
}
|
||||
|
||||
/// Dev experiment ([AppSettings.liftSubtitles]): while something covers
|
||||
/// the bottom of the full player ([cover]: its height, 0 for nothing; the
|
||||
/// chat line, else the control bar while it is up), bottom subtitles of the
|
||||
/// current player move above it, and back down when it goes, animated over
|
||||
/// the shell's [easeTime] in mpv `sub-pos` steps. Top-aligned signs stay
|
||||
/// put. Draws nothing.
|
||||
class SubtitleLifter extends StatefulWidget {
|
||||
final PlaybackController playback;
|
||||
|
||||
/// Fires when [cover] may have changed.
|
||||
final Listenable changes;
|
||||
final double Function() cover;
|
||||
|
||||
/// The dev setting ([AppSettings.liftSubtitles]).
|
||||
final ReadonlySignal<bool> lift;
|
||||
|
||||
/// Only the full player lifts.
|
||||
final bool active;
|
||||
const SubtitleLifter({
|
||||
super.key,
|
||||
required this.playback,
|
||||
required this.changes,
|
||||
required this.cover,
|
||||
required this.lift,
|
||||
required this.active,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SubtitleLifter> createState() => _SubtitleLifterState();
|
||||
}
|
||||
|
||||
class _SubtitleLifterState extends State<SubtitleLifter> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _anim = AnimationController(vsync: this, duration: easeTime)
|
||||
..addListener(_tick);
|
||||
double _from = 100, _to = 100;
|
||||
double _sent = 100;
|
||||
HostedPlayer? _player;
|
||||
Size _view = Size.zero;
|
||||
late final void Function() _stop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.changes.addListener(_retarget);
|
||||
_stop = effect(() {
|
||||
widget.lift.value;
|
||||
widget.playback.generation.value;
|
||||
untracked(_retarget);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SubtitleLifter old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.changes != widget.changes) {
|
||||
old.changes.removeListener(_retarget);
|
||||
widget.changes.addListener(_retarget);
|
||||
}
|
||||
_retarget();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.changes.removeListener(_retarget);
|
||||
_stop();
|
||||
_anim.dispose();
|
||||
// Leave the player as the file wants it.
|
||||
if (_sent != 100) _send(100);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _target {
|
||||
final cover = widget.cover();
|
||||
if (!widget.active || !widget.lift.peek() || cover <= 0) return 100;
|
||||
final st = widget.playback.current?.state;
|
||||
return subtitleLiftPos(view: _view, videoW: st?.width, videoH: st?.height, controls: cover);
|
||||
}
|
||||
|
||||
void _retarget() {
|
||||
if (!mounted) return;
|
||||
final p = widget.playback.current;
|
||||
if (!identical(p, _player)) {
|
||||
// A new player starts where the file wants its subtitles.
|
||||
_player = p;
|
||||
_sent = 100;
|
||||
_from = _to = 100;
|
||||
_anim.value = 1;
|
||||
}
|
||||
final t = _target;
|
||||
if ((t - _to).abs() < 0.1) return;
|
||||
_from = _sent;
|
||||
_to = t;
|
||||
_anim.forward(from: 0);
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
final v = _from + (_to - _from) * ease.transform(_anim.value);
|
||||
// mpv redraws on every change: whole tenths are enough.
|
||||
final r = (v * 10).round() / 10;
|
||||
if (r != _sent) _send(r);
|
||||
}
|
||||
|
||||
void _send(double pos) {
|
||||
_sent = pos;
|
||||
final p = widget.playback.current;
|
||||
if (p == null || p.disposed) return;
|
||||
unawaited(p.setProperty('sub-pos', pos.toStringAsFixed(1)).catchError((Object _) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
||||
if (box.biggest != _view) {
|
||||
_view = box.biggest;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _retarget());
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user