forked from Foxyz/sync_replay_project
Player: quick chat box, danmaku notifications, the player panel, smooth subtitle lift
- Quick chat (Enter in the full player, whatever plays): a box at the bottom centre in the control bar's place, compact and widening with the text, sharing its draft with the chat panel; Enter sends, Esc or an empty Enter closes, it closes by itself after 8 s without typing (the draft stays). Keys go to it; the Enter that opened it no longer reaches a focused button. Typing never shows the cursor or the controls: the frame wakes only on real mouse movement and holds the controls hidden while the box or its echo is up. Your sent line shows as an echo (or flies as your own danmaku line); your own chat toast is not repeated. - Subtitle lift: always for the box, for the control bar as a setting (off); 4 eased-out sub-pos steps 42 ms apart instead of one per frame. - Notifications: Toasts / Danmaku (off by default): chat and short notices fly over the full player, problems stay toasts; every knob of the look plus boldness, persisted, with a live preview strip in Settings. - Player panel: ⚙ → Player settings docks a foldable glass panel at the right (tracks, subtitle size and lift, picture, volume, notifications, controls) in place of the card; Esc closes it. - Danmaku preview: the mock dialogue follows the lift; the stand-in page has the chat box too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6048fbe7d1
commit
69fae18fdc
29 files changed
+2755
-581
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_notices.dart' show fliesAsDanmaku;
|
||||
import '../danmaku/danmaku_preview.dart';
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
@@ -38,6 +39,7 @@ import 'profile_card.dart';
|
||||
import '../schedule/ui/schedule_page.dart';
|
||||
import 'session_player.dart';
|
||||
import 'settings_tab.dart';
|
||||
import 'quick_chat.dart';
|
||||
import 'toasts.dart';
|
||||
import 'welcome.dart';
|
||||
|
||||
@@ -112,6 +114,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// Whether the full player's controls are up (the player's frame
|
||||
/// reports it): the toasts sit above them.
|
||||
final _controlsShown = ValueNotifier(true);
|
||||
/// The player's quick chat box (Enter); its draft is the chat panel's.
|
||||
late final _quickChat = QuickChat(draft: _chat);
|
||||
/// The player flies notifications as danmaku: those stay out of the
|
||||
/// toasts.
|
||||
final _danmakuActive = ValueNotifier(false);
|
||||
|
||||
Alignment _corner = Alignment.bottomRight;
|
||||
Offset? _drag; // mini top-left while dragging
|
||||
@@ -262,6 +269,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_picker.dispose();
|
||||
_quickChat.dispose();
|
||||
_danmakuActive.dispose();
|
||||
_chat.dispose();
|
||||
_chatFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
@@ -367,7 +376,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// 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();
|
||||
final d = _danmaku ??= DanmakuPreview(style: _graph.coordinator.settings.danmakuStyle.peek());
|
||||
if (_online.peek() && _graph.coordinator.playback.alive.peek()) {
|
||||
setState(() => d.active.value = true);
|
||||
_show(PlayerMode.full);
|
||||
@@ -855,6 +864,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onOpenChat: () {
|
||||
if (!_chatOpen) _toggleChat();
|
||||
},
|
||||
skip: (t) => (_danmakuActive.value && fliesAsDanmaku(t)) || _quickChat.echoed(t),
|
||||
);
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
@@ -950,6 +960,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
videoBuilder: widget.videoBuilder,
|
||||
windowControls: widget.windowControls,
|
||||
controlsShown: _controlsShown,
|
||||
quickChat: _quickChat,
|
||||
danmakuActive: _danmakuActive,
|
||||
danmaku: _danmaku,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../session/notifier.dart' show ToastItem;
|
||||
import '../widgets/app_menu.dart' show AppMenuStyle;
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
// The quick chat box: Enter in the full player opens a small box at the
|
||||
// bottom centre, in the control bar's place, without opening the chat
|
||||
// panel. It types into the same draft as the chat panel's field.
|
||||
|
||||
/// The box's state, owned by the shell (it outlives the player's modes):
|
||||
/// open or not, the draft ([draft], the chat panel's controller), its
|
||||
/// focus, the echo of what was just sent, and the sends whose own chat
|
||||
/// toast should not show again ([echoed]).
|
||||
class QuickChat {
|
||||
/// Closes by itself after this long without typing; the draft stays.
|
||||
static const idleClose = Duration(seconds: 8);
|
||||
|
||||
/// How long the echo of a sent line stays where the box was.
|
||||
static const echoTime = Duration(milliseconds: 2600);
|
||||
|
||||
/// The draft, shared with the chat panel's field.
|
||||
final TextEditingController draft;
|
||||
final bool _ownsDraft;
|
||||
final focus = FocusNode(debugLabel: 'quick chat');
|
||||
final open = ValueNotifier(false);
|
||||
|
||||
/// The line just sent, shown briefly in the box's place (null: none).
|
||||
final echo = ValueNotifier<String?>(null);
|
||||
|
||||
/// Bumped on each send ([lastSent]): the player flies your own line
|
||||
/// when danmaku is the notification style (and drops the echo).
|
||||
final sends = ValueNotifier(0);
|
||||
String lastSent = '';
|
||||
|
||||
/// Without typing this long, the box closes ([idleClose]).
|
||||
final Duration idle;
|
||||
Timer? _idleTimer;
|
||||
Timer? _echoTimer;
|
||||
final _recent = <(String, DateTime)>[];
|
||||
|
||||
QuickChat({TextEditingController? draft, this.idle = idleClose})
|
||||
: draft = draft ?? TextEditingController(),
|
||||
_ownsDraft = draft == null {
|
||||
this.draft.addListener(_typed);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
draft.removeListener(_typed);
|
||||
if (_ownsDraft) draft.dispose();
|
||||
_idleTimer?.cancel();
|
||||
_echoTimer?.cancel();
|
||||
focus.dispose();
|
||||
open.dispose();
|
||||
echo.dispose();
|
||||
sends.dispose();
|
||||
}
|
||||
|
||||
/// Opens the box with the draft as it was, caret at its end, and gives
|
||||
/// it the keyboard at once (keys typed right after go to it).
|
||||
void show() {
|
||||
_echoTimer?.cancel();
|
||||
echo.value = null;
|
||||
open.value = true;
|
||||
draft.selection = TextSelection.collapsed(offset: draft.text.length);
|
||||
focus.requestFocus();
|
||||
_armIdle();
|
||||
}
|
||||
|
||||
/// Closes the box; the draft stays for the next Enter (or the panel).
|
||||
void close() {
|
||||
_idleTimer?.cancel();
|
||||
if (!open.value) return;
|
||||
open.value = false;
|
||||
focus.unfocus();
|
||||
}
|
||||
|
||||
void _typed() {
|
||||
if (open.value) _armIdle();
|
||||
}
|
||||
|
||||
void _armIdle() {
|
||||
_idleTimer?.cancel();
|
||||
_idleTimer = Timer(idle, close);
|
||||
}
|
||||
|
||||
/// Enter: the trimmed draft to send (cleared, the box closes and the
|
||||
/// echo shows it), or null when blank (the box just closes).
|
||||
String? submit() {
|
||||
final text = draft.text.trim();
|
||||
close();
|
||||
if (text.isEmpty) return null;
|
||||
draft.clear();
|
||||
final now = DateTime.now();
|
||||
_recent.removeWhere((r) => now.difference(r.$2) > const Duration(seconds: 20));
|
||||
_recent.add((text, now));
|
||||
lastSent = text;
|
||||
echo.value = text;
|
||||
_echoTimer?.cancel();
|
||||
_echoTimer = Timer(echoTime, () => echo.value = null);
|
||||
sends.value++;
|
||||
return text;
|
||||
}
|
||||
|
||||
/// [t] is your own chat toast for a line this box already showed (as
|
||||
/// its echo or your own danmaku line): it should not pop up again.
|
||||
bool echoed(ToastItem t) {
|
||||
if (!t.chat || !t.own) return false;
|
||||
final now = DateTime.now();
|
||||
return _recent.any((r) => now.difference(r.$2) < const Duration(seconds: 20) && t.text.endsWith(': ${r.$1}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// The box: a glass pill at the bottom centre that starts compact
|
||||
/// ([minWidth]) and widens with the text up to [maxWidth], then wraps to
|
||||
/// up to four lines, growing upward. Enter sends ([onSend], empty Enter
|
||||
/// closes), Esc closes. Your avatar leads it. While closed it stays
|
||||
/// mounted, invisible and out of the way, so [QuickChat.show] focuses it
|
||||
/// at once. The pointer over it stays hidden until the mouse moves.
|
||||
class QuickChatBox extends StatefulWidget {
|
||||
static const minWidth = 300.0;
|
||||
static const height = 44.0;
|
||||
final QuickChat chat;
|
||||
final double maxWidth;
|
||||
final void Function(String text) onSend;
|
||||
|
||||
/// Its height whenever it changes (the subtitle lift follows it).
|
||||
final ValueChanged<double>? onHeight;
|
||||
const QuickChatBox({super.key, required this.chat, required this.onSend, this.maxWidth = 640, this.onHeight});
|
||||
|
||||
@override
|
||||
State<QuickChatBox> createState() => _QuickChatBoxState();
|
||||
}
|
||||
|
||||
class _QuickChatBoxState extends State<QuickChatBox> {
|
||||
static const _text = TextStyle(color: Colors.white, fontSize: 15, height: 1.3);
|
||||
bool _pointerMoved = false;
|
||||
Offset? _pointerAt;
|
||||
|
||||
QuickChat get c => widget.chat;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
c.draft.addListener(_changed);
|
||||
c.open.addListener(_openChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(QuickChatBox old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.chat != widget.chat) {
|
||||
old.chat.draft.removeListener(_changed);
|
||||
old.chat.open.removeListener(_openChanged);
|
||||
c.draft.addListener(_changed);
|
||||
c.open.addListener(_openChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
c.draft.removeListener(_changed);
|
||||
c.open.removeListener(_openChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() => setState(() {});
|
||||
|
||||
void _openChanged() {
|
||||
if (c.open.value) _pointerMoved = false;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
KeyEventResult _key(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
final k = e.logicalKey;
|
||||
if (k == LogicalKeyboardKey.escape) {
|
||||
c.close();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
|
||||
// An input method composing a word takes Enter itself.
|
||||
if (c.draft.value.composing.isValid) return KeyEventResult.ignored;
|
||||
if (e is KeyRepeatEvent) return KeyEventResult.handled;
|
||||
final text = c.submit();
|
||||
if (text != null) widget.onSend(text);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Wide enough for the draft on one line, between [QuickChatBox.minWidth]
|
||||
/// and the room there is.
|
||||
double _width(double room) {
|
||||
final max = math.max(QuickChatBox.minWidth, math.min(widget.maxWidth, room));
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: c.draft.text.isEmpty ? ' ' : c.draft.text, style: _text),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
// Avatar, gaps and padding around the text, plus room for the caret.
|
||||
final w = tp.width + 22 + 12 + 18 + 18 + 40;
|
||||
tp.dispose();
|
||||
return w.clamp(QuickChatBox.minWidth, max);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final open = c.open.value;
|
||||
final me = selfAvatarInfo();
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final width = _width(box.maxWidth);
|
||||
final field = Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: _key,
|
||||
child: TextField(
|
||||
key: const ValueKey('quick-chat-field'),
|
||||
controller: c.draft,
|
||||
focusNode: c.focus,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.send,
|
||||
maxLength: 500,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
// Enter is ours (above); a platform send action lands here.
|
||||
onSubmitted: (_) {
|
||||
final text = c.submit();
|
||||
if (text != null) widget.onSend(text);
|
||||
},
|
||||
mouseCursor: _pointerMoved ? SystemMouseCursors.text : SystemMouseCursors.none,
|
||||
style: _text,
|
||||
cursorColor: Colors.white,
|
||||
cursorWidth: 1.6,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 4),
|
||||
hintText: 'Say something',
|
||||
hintStyle: _text.copyWith(color: Colors.white.withValues(alpha: 0.42)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IgnorePointer(
|
||||
ignoring: !open,
|
||||
child: AnimatedSlide(
|
||||
offset: open ? Offset.zero : const Offset(0, 0.35),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: AnimatedOpacity(
|
||||
opacity: open ? 1 : 0,
|
||||
duration: open ? easeTime : const Duration(milliseconds: 160),
|
||||
curve: ease,
|
||||
child: MouseRegion(
|
||||
onHover: (e) {
|
||||
final at = _pointerAt;
|
||||
_pointerAt = e.position;
|
||||
if (!_pointerMoved && at != null && (e.position - at).distance > 0.5) {
|
||||
setState(() => _pointerMoved = true);
|
||||
}
|
||||
},
|
||||
child: _ReportHeight(
|
||||
onHeight: widget.onHeight,
|
||||
child: AnimatedContainer(
|
||||
key: const ValueKey('quick-chat'),
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: width,
|
||||
constraints: const BoxConstraints(minHeight: QuickChatBox.height),
|
||||
padding: const EdgeInsets.fromLTRB(11, 6, 16, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppMenuStyle.glass,
|
||||
borderRadius: BorderRadius.circular(QuickChatBox.height / 2),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: open ? 0.22 : 0.1), width: 0.8),
|
||||
boxShadow: const [BoxShadow(color: Color(0x55000000), blurRadius: 18, offset: Offset(0, 6))],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
StatusAvatar(info: me, size: 22, badges: false, ring: false),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: field),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.keyboard_return_rounded, size: 16, color: Colors.white.withValues(alpha: 0.38)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What you just sent, in the box's place for [QuickChat.echoTime]: your
|
||||
/// avatar and the line on the same glass, then it fades. Shown when the
|
||||
/// notifications are toasts (with danmaku your own line flies instead).
|
||||
class QuickChatEcho extends StatelessWidget {
|
||||
final QuickChat chat;
|
||||
const QuickChatEcho({super.key, required this.chat});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return IgnorePointer(
|
||||
child: ValueListenableBuilder<String?>(
|
||||
valueListenable: chat.echo,
|
||||
builder: (context, text, _) => AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
switchInCurve: ease,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, a) => FadeTransition(
|
||||
opacity: a,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: text == null
|
||||
? const SizedBox.shrink(key: ValueKey('no-echo'))
|
||||
: Align(
|
||||
key: ValueKey('echo-$text'),
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Container(
|
||||
key: const ValueKey('quick-chat-echo'),
|
||||
padding: const EdgeInsets.fromLTRB(11, 10, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppMenuStyle.glass,
|
||||
borderRadius: BorderRadius.circular(QuickChatBox.height / 2),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.7), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StatusAvatar(info: selfAvatarInfo(), size: 22, badges: false, ring: false),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, height: 1.3)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.check_rounded, size: 16, color: accent),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports its child's height after layout when it changes.
|
||||
class _ReportHeight extends SingleChildRenderObjectWidget {
|
||||
final ValueChanged<double>? onHeight;
|
||||
const _ReportHeight({required this.onHeight, required super.child});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) => _RenderReportHeight(onHeight);
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _RenderReportHeight r) => r.onHeight = onHeight;
|
||||
}
|
||||
|
||||
class _RenderReportHeight extends RenderProxyBox {
|
||||
ValueChanged<double>? onHeight;
|
||||
double? _last;
|
||||
_RenderReportHeight(this.onHeight);
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
final h = size.height;
|
||||
if (h == _last) return;
|
||||
_last = h;
|
||||
final f = onHeight;
|
||||
if (f != null) WidgetsBinding.instance.addPostFrameCallback((_) => f(h));
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,15 @@ import '../controls/controls_probe.dart';
|
||||
import '../controls/minimal/style.dart';
|
||||
import '../controls/model/controls_layout.dart';
|
||||
import '../controls/model/controls_model.dart';
|
||||
import '../controls/player_panel.dart';
|
||||
import '../controls/session_controls.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../danmaku/danmaku.dart' show DanmakuController, DanmakuItem, DanmakuKind;
|
||||
import '../danmaku/danmaku_notices.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;
|
||||
@@ -26,13 +28,13 @@ 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';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
import 'quick_chat.dart';
|
||||
import 'sit_out_card.dart';
|
||||
import 'start_banner.dart';
|
||||
import 'video_view.dart';
|
||||
@@ -48,9 +50,10 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu
|
||||
|
||||
/// The session's player: the one `Video` of the current player and
|
||||
/// everything over it. Full: the controls (one model, the skin picked in
|
||||
/// Settings), the start card, the status pill, Jellyfin overlays and
|
||||
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
||||
/// widget mounted at one place in the tree while the mode changes, so the
|
||||
/// Settings), the start card, the status pill, Jellyfin overlays, the
|
||||
/// quick chat box, the danmaku, the player panel and the player keys.
|
||||
/// Mini: hover controls and a progress line. The shell keeps this widget
|
||||
/// mounted at one place in the tree while the mode changes, so the
|
||||
/// `Video` is never mounted twice nor rebuilt for a move.
|
||||
class SessionPlayer extends StatefulWidget {
|
||||
final PlayerMode mode;
|
||||
@@ -73,6 +76,12 @@ 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;
|
||||
/// The quick chat box's state (the shell's: its draft is the chat
|
||||
/// panel's); the player keeps its own when null.
|
||||
final QuickChat? quickChat;
|
||||
/// Set while the session's notifications fly as danmaku here (the
|
||||
/// shell then leaves those out of its toasts).
|
||||
final ValueNotifier<bool>? danmakuActive;
|
||||
/// Dev builds: the danmaku preview, shown over the full player while
|
||||
/// it is active.
|
||||
final DanmakuPreview? danmaku;
|
||||
@@ -93,6 +102,8 @@ class SessionPlayer extends StatefulWidget {
|
||||
this.videoBuilder,
|
||||
this.windowControls = true,
|
||||
this.controlsShown,
|
||||
this.quickChat,
|
||||
this.danmakuActive,
|
||||
this.danmaku,
|
||||
});
|
||||
|
||||
@@ -108,16 +119,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
DateTime? _lastKeyAt;
|
||||
late final void Function() _stopShaderWatch;
|
||||
late final void Function() _stopSubScale;
|
||||
late final void Function() _stopDanmakuWatch;
|
||||
late final void Function() _stopLiftWatch;
|
||||
int _shaderNoticeSeen = 0;
|
||||
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 quick chat box (Enter): the shell's, else one of our own.
|
||||
QuickChat? _ownChat;
|
||||
QuickChat get _quick => widget.quickChat ?? (_ownChat ??= QuickChat());
|
||||
// The box or its echo sits in the control bar's place: the controls
|
||||
// stay hidden meanwhile.
|
||||
final _boxUp = ValueNotifier(false);
|
||||
double _boxHeight = QuickChatBox.height;
|
||||
// What covers the bottom, as bottom subtitles move above it.
|
||||
final _lift = SubtitleLift();
|
||||
// The Enter that opened the box: kept from the focused widget.
|
||||
KeyEvent? _swallow;
|
||||
// The session's danmaku lines (and your own sent ones).
|
||||
final _lines = DanmakuController();
|
||||
final _danmakuOn = signal(false);
|
||||
late final _full = signal(widget.mode == PlayerMode.full);
|
||||
// The mouse on a floating element, or the Up next popover open, keeps
|
||||
// the controls up.
|
||||
final _hold = ControlsHold();
|
||||
@@ -139,7 +163,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
|
||||
late final SessionCoordinator _coord = AppScope.of(context).coordinator;
|
||||
bool get _full => widget.mode == PlayerMode.full;
|
||||
AppSettings get _settings => _coord.settings;
|
||||
bool get _isFull => widget.mode == PlayerMode.full;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -148,6 +173,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_model;
|
||||
_coord.jellyfin.ensureLoaded();
|
||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||
FocusManager.instance.addEarlyKeyEventHandler(_earlyKey);
|
||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||
AppKeys.addChatToggle(_toggleChat);
|
||||
// Shader messages (preset changes, load failures) become toasts.
|
||||
@@ -161,6 +187,32 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_stopPopoverWatch = effect(() {
|
||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||
});
|
||||
// Subtitle size on every player, live.
|
||||
_stopSubScale = effect(() {
|
||||
final scale = _settings.subScale.value;
|
||||
_coord.playback.generation.value;
|
||||
final p = untracked(() => _coord.playback.current);
|
||||
if (p == null || p.disposed) return;
|
||||
unawaited(p.setProperty('sub-scale', scale.toStringAsFixed(2)).catchError((Object _) {}));
|
||||
});
|
||||
// Danmaku is the style and the full player is up (not sitting out).
|
||||
_stopDanmakuWatch = effect(() {
|
||||
final on = _full.value && _settings.notifyStyle.value == NotifyStyle.danmaku && !_coord.selfAway.value;
|
||||
untracked(() {
|
||||
_danmakuOn.value = on;
|
||||
widget.danmakuActive?.value = on;
|
||||
});
|
||||
});
|
||||
_stopLiftWatch = effect(() {
|
||||
_settings.liftSubtitles.value;
|
||||
_settings.controlsSkin.value;
|
||||
_settings.controlsSize.value;
|
||||
untracked(_retargetLift);
|
||||
});
|
||||
_quick.open.addListener(_boxChanged);
|
||||
_quick.echo.addListener(_boxChanged);
|
||||
_quick.sends.addListener(_sent);
|
||||
_controlsShown.addListener(_retargetLift);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -169,19 +221,48 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_fullscreen.value = widget.fullscreen;
|
||||
_chatOpen.value = widget.chatOpen;
|
||||
_unread.value = widget.unread;
|
||||
_full.value = _isFull;
|
||||
if (!_isFull) {
|
||||
_quick.close();
|
||||
_model.panelOpen.value = false;
|
||||
}
|
||||
if (oldWidget.quickChat != widget.quickChat) {
|
||||
final old = oldWidget.quickChat ?? _ownChat;
|
||||
old?.open.removeListener(_boxChanged);
|
||||
old?.echo.removeListener(_boxChanged);
|
||||
old?.sends.removeListener(_sent);
|
||||
_quick.open.addListener(_boxChanged);
|
||||
_quick.echo.addListener(_boxChanged);
|
||||
_quick.sends.addListener(_sent);
|
||||
}
|
||||
if (oldWidget.controlsShown != widget.controlsShown) {
|
||||
(oldWidget.controlsShown ?? _ownShown).removeListener(_retargetLift);
|
||||
_controlsShown.addListener(_retargetLift);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
FocusManager.instance.removeEarlyKeyEventHandler(_earlyKey);
|
||||
AppKeys.removeChatToggle(_toggleChat);
|
||||
_stopShaderWatch();
|
||||
_stopPopoverWatch();
|
||||
_stopSubScale();
|
||||
_stopDanmakuWatch();
|
||||
_stopLiftWatch();
|
||||
widget.danmakuActive?.value = false;
|
||||
_quick.open.removeListener(_boxChanged);
|
||||
_quick.echo.removeListener(_boxChanged);
|
||||
_quick.sends.removeListener(_sent);
|
||||
_controlsShown.removeListener(_retargetLift);
|
||||
_quick.close();
|
||||
_ownChat?.dispose();
|
||||
_noFocus.dispose();
|
||||
_ownShown.dispose();
|
||||
_chatLine.dispose();
|
||||
_chatLineText.dispose();
|
||||
_chatLineFocus.dispose();
|
||||
_boxUp.dispose();
|
||||
_lift.dispose();
|
||||
_lines.dispose();
|
||||
_hold.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
@@ -189,6 +270,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
void _toggleChat() => _model.toggleChat();
|
||||
|
||||
// ------------------------------------------------------- quick chat box
|
||||
|
||||
void _boxChanged() {
|
||||
_boxUp.value = _quick.open.value || _quick.echo.value != null;
|
||||
_retargetLift();
|
||||
}
|
||||
|
||||
/// What covers the bottom of the full player, for the subtitle lift:
|
||||
/// the box (or its echo) from 16 px up, else the control bar while it is
|
||||
/// up when "Subtitles above the controls" is on; 8 px above it.
|
||||
void _retargetLift() {
|
||||
if (!mounted) return;
|
||||
final box = _boxUp.value ? _edge + _boxHeight + 8 : 0.0;
|
||||
final bar = _settings.liftSubtitles.peek() && _controlsShown.value ? _bottomControlsHeight() + 8 : 0.0;
|
||||
_lift.animateTo(math.max(box, bar));
|
||||
}
|
||||
|
||||
void _boxHeightChanged(double h) {
|
||||
if (h == _boxHeight) return;
|
||||
_boxHeight = h;
|
||||
_retargetLift();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// Enter in the box with text: to the chat; your own line flies right
|
||||
/// away when danmaku is the style (the echo is for toasts).
|
||||
void _send(String text) => _coord.sendChat(text);
|
||||
|
||||
void _sent() {
|
||||
if (!_danmakuOn.peek()) return;
|
||||
_quick.echo.value = null;
|
||||
final me = selfAvatarInfo();
|
||||
_lines.add(DanmakuItem(
|
||||
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: _quick.lastSent, own: true, avatar: me));
|
||||
}
|
||||
|
||||
/// Swallows the Enter that opened the box before the focused widget
|
||||
/// (a button keeps focus after a click) acts on it too.
|
||||
KeyEventResult _earlyKey(KeyEvent event) {
|
||||
final s = _swallow;
|
||||
if (s == null || event.logicalKey != s.logicalKey) return KeyEventResult.ignored;
|
||||
if (event is KeyDownEvent && !identical(event, s)) {
|
||||
_swallow = null;
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event is KeyUpEvent) _swallow = null;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// The player keys, while the player fills the body and nothing (a
|
||||
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||
/// app-wide ([AppKeys]).
|
||||
@@ -197,7 +327,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
if (!AppScope.of(context).navigation.online.peek()) return false;
|
||||
// Mini: Esc is the last step out (a friend's server: asking to leave
|
||||
// it; yours: the player closes).
|
||||
if (!_full) {
|
||||
if (!_isFull) {
|
||||
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
|
||||
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
|
||||
widget.onLeave();
|
||||
@@ -213,10 +343,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();
|
||||
final key = event.logicalKey;
|
||||
// Enter: the quick chat box (the chat panel's draft), whatever plays.
|
||||
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
|
||||
if (_coord.selfAway.peek()) return false;
|
||||
_swallow = event;
|
||||
_quick.show();
|
||||
return true;
|
||||
}
|
||||
final now = DateTime.now().toUtc();
|
||||
@@ -224,7 +356,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
return false;
|
||||
}
|
||||
final model = _model;
|
||||
final key = event.logicalKey;
|
||||
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
||||
final digit = _shaderKeys.indexOf(key);
|
||||
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
||||
@@ -276,7 +407,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
_lastKeyAt = now;
|
||||
if (widget.chatOpen) {
|
||||
if (_model.panelOpen.peek()) {
|
||||
_model.panelOpen.value = false;
|
||||
} else if (widget.chatOpen) {
|
||||
widget.onToggleChat();
|
||||
} else if (widget.fullscreen) {
|
||||
widget.onToggleFullscreen();
|
||||
@@ -308,7 +441,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
if (!playback.alive.value) {
|
||||
return Center(
|
||||
child: Text('player starting…',
|
||||
style: TextStyle(color: Colors.white70, fontSize: _full ? 14 : 12)),
|
||||
style: TextStyle(color: Colors.white70, fontSize: _isFull ? 14 : 12)),
|
||||
);
|
||||
}
|
||||
if (gen != _videoGen) {
|
||||
@@ -317,25 +450,25 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
// Never null for the mini: media_kit keeps the last controls
|
||||
// when given null, so the full player's would stay under it.
|
||||
final VideoControlsBuilder controls = _full ? _sessionControls : _noControls;
|
||||
final VideoControlsBuilder controls = _isFull ? _sessionControls : _noControls;
|
||||
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||
// The wheel anywhere on the full player steps the volume
|
||||
// (lists, menus and popovers under the mouse scroll instead).
|
||||
return VolumeWheel(
|
||||
enabled: _full,
|
||||
enabled: _isFull,
|
||||
onStep: _model.stepVolume,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
video,
|
||||
// Dev experiment: bottom subtitles above the controls.
|
||||
if (devTools) _subtitleLifter(),
|
||||
if (_full) ...[
|
||||
SubtitleLifter(playback: _coord.playback, lift: _lift, active: _isFull),
|
||||
if (_isFull) ...[
|
||||
_blockedCard(),
|
||||
_actionArea(),
|
||||
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
||||
_volumeReadout(),
|
||||
if (devTools) _chatLineOverlay(),
|
||||
_quickChatBox(),
|
||||
_playerPanel(),
|
||||
if (widget.danmaku case final d?) _danmakuPanel(d),
|
||||
] else
|
||||
_miniOverlay(),
|
||||
@@ -352,7 +485,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
|
||||
playback: _coord.playback,
|
||||
sharp: _coord.settings.screenResVideo,
|
||||
sharp: _settings.screenResVideo,
|
||||
child: Video(
|
||||
key: _videoKey,
|
||||
controller: videoControllerOf(_coord.playback.video),
|
||||
@@ -361,100 +494,56 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
),
|
||||
);
|
||||
|
||||
/// 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(
|
||||
/// The quick chat box in the control bar's place, centred (Enter opens
|
||||
/// it), and the echo of what you sent.
|
||||
Widget _quickChatBox() => 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
QuickChatEcho(chat: _quick),
|
||||
QuickChatBox(chat: _quick, onSend: _send, onHeight: _boxHeightChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// The player panel docked at the right (⚙ → Player settings), between
|
||||
/// the top bar and the control bar; it stays while they hide.
|
||||
Widget _playerPanel() => Positioned(
|
||||
right: _edge,
|
||||
top: _topBarHeight + 8,
|
||||
bottom: _bottomControlsHeightNow() + _edge,
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final open = _model.panelOpen.value;
|
||||
return AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
switchInCurve: ease,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
layoutBuilder: (current, previous) =>
|
||||
Stack(alignment: Alignment.topRight, children: [...previous, ?current]),
|
||||
transitionBuilder: (child, a) => FadeTransition(
|
||||
opacity: a,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0.12, 0), end: Offset.zero).animate(a),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: !open
|
||||
? const SizedBox.shrink(key: ValueKey('no-player-panel'))
|
||||
: ControlsHoldRegion(
|
||||
key: const ValueKey('player-panel-hold'),
|
||||
hold: _hold,
|
||||
child: PlayerPanel(
|
||||
onClose: () => _model.panelOpen.value = false,
|
||||
renderMode: _model.renderMode,
|
||||
volume: _model.volume,
|
||||
onVolume: _model.setVolume,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||
@@ -482,24 +571,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
children: [
|
||||
const StartBanner(),
|
||||
// Under the controls, over the picture.
|
||||
SignalBuilder(
|
||||
builder: (context) => _danmakuOn.value
|
||||
? DanmakuNotices(
|
||||
toasts: _coord.toasts,
|
||||
style: _settings.danmakuStyle,
|
||||
controller: _lines,
|
||||
avatar: (name, id) => avatarForName(name, _coord, id),
|
||||
skip: _quick.echoed,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
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(),
|
||||
builder: (context) =>
|
||||
d.active.value ? DanmakuPreviewLayers(preview: d, lift: _lift) : const SizedBox.shrink(),
|
||||
),
|
||||
SessionControls(
|
||||
model: _model,
|
||||
shown: _controlsShown,
|
||||
hold: _hold,
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
windowButtons: _fullscreenWindowButtons(),
|
||||
suppress: _boxUp,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -510,12 +604,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
static const _edge = 16.0;
|
||||
|
||||
/// The bottom controls' height in the skin and size in use.
|
||||
static double _bottomControlsHeight() {
|
||||
final s = AppSettings.instance;
|
||||
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||
}
|
||||
double _bottomControlsHeight() =>
|
||||
controlsBottomHeight(_settings.controlsSkin.value, _settings.controlsSize.value);
|
||||
|
||||
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
|
||||
double _bottomControlsHeightNow() =>
|
||||
controlsBottomHeight(_settings.controlsSkin.peek(), _settings.controlsSize.peek());
|
||||
|
||||
double get _topBarHeight => controlsTopHeight(_settings.controlsSkin.peek());
|
||||
|
||||
/// The danmaku preview's panel, top right under the top bar; the mouse
|
||||
/// on it keeps the controls up.
|
||||
@@ -560,15 +655,20 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
Widget _actionArea() {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final controls = _bottomControlsHeight();
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, child) => AnimatedPositioned(
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([_controlsShown, _boxUp]),
|
||||
builder: (context, child) => AnimatedPositioned(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
left: _edge,
|
||||
right: _edge,
|
||||
top: 0,
|
||||
bottom: shown ? controls + _edge : _edge,
|
||||
// Above the quick chat box while it (or its echo) is up.
|
||||
bottom: _boxUp.value
|
||||
? _boxHeight + _edge * 2
|
||||
: _controlsShown.value
|
||||
? controls + _edge
|
||||
: _edge,
|
||||
child: child!,
|
||||
),
|
||||
child: CustomMultiChildLayout(
|
||||
@@ -705,7 +805,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
|
||||
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_isFull),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
@@ -714,9 +814,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
children: [
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
onPressed: _full ? widget.onCollapse : widget.onExpand,
|
||||
tooltip: _full ? 'Small player' : 'Expand',
|
||||
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||
onPressed: _isFull ? widget.onCollapse : widget.onExpand,
|
||||
tooltip: _isFull ? 'Small player' : 'Expand',
|
||||
icon: Icon(_isFull ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart'
|
||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||
|
||||
import '../controls/player_settings.dart';
|
||||
import '../danmaku/danmaku_settings.dart' show DanmakuSettingsPanel;
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/accounts_panel.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
@@ -124,7 +125,8 @@ class SettingsTab extends StatelessWidget {
|
||||
return [
|
||||
const SettingsHintCard(
|
||||
icon: Icons.tune,
|
||||
text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
|
||||
text: "Player settings are also in the player: ⚙ → Player settings opens a panel at its right edge "
|
||||
"that stays while you watch.",
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Player',
|
||||
@@ -778,7 +780,8 @@ class NotificationsInfo extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
line('Joins, leaves, lost connections and video changes',
|
||||
"Pop up bottom left for 5 s with the person's picture, above the player's controls"),
|
||||
"Pop up bottom left for 5 s with the person's picture, above the player's controls "
|
||||
'(or fly across the picture: Danmaku, below)'),
|
||||
line('Added to Up next', 'With the title, its picture and who added it'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
@@ -811,6 +814,11 @@ class NotificationsInfo extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
key: const ValueKey('danmaku-settings'),
|
||||
title: 'Over the player',
|
||||
child: DanmakuSettingsPanel(settings: AppScope.of(context).coordinator.settings),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Sitting out and in the background',
|
||||
child: Column(
|
||||
|
||||
@@ -58,7 +58,11 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
|
||||
class PartyToasts extends StatefulWidget {
|
||||
final VoidCallback? onOpenChat;
|
||||
const PartyToasts({super.key, this.onOpenChat});
|
||||
|
||||
/// Toasts shown another way (flying as danmaku, your quick chat echo):
|
||||
/// left out when they come.
|
||||
final bool Function(ToastItem t)? skip;
|
||||
const PartyToasts({super.key, this.onOpenChat, this.skip});
|
||||
|
||||
@override
|
||||
State<PartyToasts> createState() => _PartyToastsState();
|
||||
@@ -86,6 +90,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
// Only new toasts re-run this, not the party they mention.
|
||||
untracked(() {
|
||||
for (final t in fresh) {
|
||||
if (widget.skip?.call(t) ?? false) continue;
|
||||
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
|
||||
}
|
||||
if (gone.isNotEmpty) _leave(gone);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
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.
|
||||
// size its output renders at ("Sharp video and subtitles") and how far
|
||||
// bottom subtitles move up while something covers the bottom of the
|
||||
// picture (the quick chat box; the control bar when chosen).
|
||||
|
||||
/// 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
|
||||
@@ -115,42 +116,94 @@ double subtitleLiftPos({required Size view, int? videoW, int? videoH, required d
|
||||
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.
|
||||
/// The subtitle lift moves in [liftStepCount] steps [liftStepGap] apart
|
||||
/// (about 130 ms): each `sub-pos` change makes mpv re-render the
|
||||
/// subtitles, and a playing video shows a change only with its next
|
||||
/// frame, so a change per screen frame (60 a second against a video's 24)
|
||||
/// landed unevenly and looked choppy. One step per frame of a 24 fps
|
||||
/// video, eased out (most of the way in the first step), reads as one
|
||||
/// quick, smooth move.
|
||||
const liftStepCount = 4;
|
||||
const liftStepGap = Duration(milliseconds: 42);
|
||||
|
||||
/// The covered heights a lift from [from] to [to] passes through, one per
|
||||
/// step, eased out; the last is exactly [to]. Empty when there is nothing
|
||||
/// to move.
|
||||
List<double> liftSteps(double from, double to, {int count = liftStepCount}) {
|
||||
if ((to - from).abs() < 0.5) return const [];
|
||||
return [
|
||||
for (var i = 1; i <= count; i++) i == count ? to : from + (to - from) * Curves.easeOutQuad.transform(i / count),
|
||||
];
|
||||
}
|
||||
|
||||
/// How much of the bottom of the view is covered, as it moves: [value]
|
||||
/// the covered height in logical px (0: nothing), stepping toward the
|
||||
/// latest [animateTo] target ([liftSteps], [liftStepGap] apart; a new
|
||||
/// target starts from where it is). mpv's `sub-pos` follows it
|
||||
/// ([SubtitleLifter]), and so do the danmaku preview's mock subtitles.
|
||||
class SubtitleLift extends ChangeNotifier implements ValueListenable<double> {
|
||||
double _value = 0;
|
||||
double _target = 0;
|
||||
List<double> _steps = const [];
|
||||
int _next = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
double get value => _value;
|
||||
double get target => _target;
|
||||
|
||||
/// Moves toward [target] (px); the first step at once.
|
||||
void animateTo(double target) {
|
||||
target = math.max(0, target);
|
||||
if ((target - _target).abs() < 0.5) return;
|
||||
_target = target;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_steps = liftSteps(_value, target);
|
||||
_next = 0;
|
||||
if (_steps.isEmpty) return;
|
||||
_step();
|
||||
if (_next < _steps.length) _timer = Timer.periodic(liftStepGap, (_) => _step());
|
||||
}
|
||||
|
||||
void _step() {
|
||||
if (_next >= _steps.length) return;
|
||||
_value = _steps[_next++];
|
||||
if (_next >= _steps.length) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom subtitles of the current player sit above what covers the
|
||||
/// bottom of the full player ([lift]: the quick chat box, and the control
|
||||
/// bar while it is up when [AppSettings.liftSubtitles] is on): mpv
|
||||
/// `sub-pos` follows each step of [lift], only as far as the letterbox
|
||||
/// under the picture leaves no room ([subtitleLiftPos]). 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;
|
||||
final SubtitleLift 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,
|
||||
});
|
||||
const SubtitleLifter({super.key, required this.playback, 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;
|
||||
class _SubtitleLifterState extends State<SubtitleLifter> {
|
||||
static const _home = '100.0';
|
||||
String _sent = _home;
|
||||
HostedPlayer? _player;
|
||||
Size _view = Size.zero;
|
||||
late final void Function() _stop;
|
||||
@@ -158,77 +211,61 @@ class _SubtitleLifterState extends State<SubtitleLifter> with SingleTickerProvid
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.changes.addListener(_retarget);
|
||||
widget.lift.addListener(_apply);
|
||||
// A new player starts where the file wants its subtitles.
|
||||
_stop = effect(() {
|
||||
widget.lift.value;
|
||||
widget.playback.generation.value;
|
||||
untracked(_retarget);
|
||||
untracked(_apply);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SubtitleLifter old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.changes != widget.changes) {
|
||||
old.changes.removeListener(_retarget);
|
||||
widget.changes.addListener(_retarget);
|
||||
if (old.lift != widget.lift) {
|
||||
old.lift.removeListener(_apply);
|
||||
widget.lift.addListener(_apply);
|
||||
}
|
||||
_retarget();
|
||||
if (old.active != widget.active) _apply();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.changes.removeListener(_retarget);
|
||||
widget.lift.removeListener(_apply);
|
||||
_stop();
|
||||
_anim.dispose();
|
||||
// Leave the player as the file wants it.
|
||||
if (_sent != 100) _send(100);
|
||||
if (_sent != _home) _send(_home);
|
||||
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() {
|
||||
void _apply() {
|
||||
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;
|
||||
_sent = _home;
|
||||
}
|
||||
final t = _target;
|
||||
if ((t - _to).abs() < 0.1) return;
|
||||
_from = _sent;
|
||||
_to = t;
|
||||
_anim.forward(from: 0);
|
||||
final cover = widget.active ? widget.lift.value : 0.0;
|
||||
final st = p?.state;
|
||||
final pos = cover <= 0
|
||||
? 100.0
|
||||
: subtitleLiftPos(view: _view, videoW: st?.width, videoH: st?.height, controls: cover, gap: 0);
|
||||
final v = pos.toStringAsFixed(1);
|
||||
if (v != _sent) _send(v);
|
||||
}
|
||||
|
||||
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) {
|
||||
void _send(String pos) {
|
||||
_sent = pos;
|
||||
final p = widget.playback.current;
|
||||
if (p == null || p.disposed) return;
|
||||
unawaited(p.setProperty('sub-pos', pos.toStringAsFixed(1)).catchError((Object _) {}));
|
||||
unawaited(p.setProperty('sub-pos', pos).catchError((Object _) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
||||
if (box.biggest != _view) {
|
||||
_view = box.biggest;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _retarget());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _apply());
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user