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
+2709
-535
No files matched your search
+5
-1
@@ -7,7 +7,7 @@ cut into a release: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#changelog).
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
Links you put up are downloaded once for everyone, plus What's new and fixes for Windows, the relay and chat.
|
Links you put up are downloaded once for everyone, quick chat and danmaku in the player, plus What's new and fixes for Windows, the relay and chat.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -17,6 +17,10 @@ Links you put up are downloaded once for everyone, plus What's new and fixes for
|
|||||||
- Invite finds your internet address, lets you pick it or your home-network address, and says when your router needs the port forwarded
|
- Invite finds your internet address, lets you pick it or your home-network address, and says when your router needs the port forwarded
|
||||||
- The password goes into an invite link only when you tick "Include the password in the link"
|
- The password goes into an invite link only when you tick "Include the password in the link"
|
||||||
- Starsync runs once: a link clicked while it is open goes to the open window
|
- Starsync runs once: a link clicked while it is open goes to the open window
|
||||||
|
- Quick chat: press Enter in the big player to type a message in a small box at the bottom, without opening the chat panel and without the mouse pointer or the controls popping up; it shares its draft with the chat panel, closes by itself when you stop typing, and subtitles move up out of its way
|
||||||
|
- Danmaku notifications: chat, joins, leaves and Up next can fly across the top of the picture instead of popping up (Settings → Notifications & sounds → Notifications: Danmaku, off by default), with speed, how many at once, where, opacity, size, boldness, outline and avatars all adjustable and a live preview
|
||||||
|
- Player panel: ⚙ → Player settings opens a panel at the right of the player that stays open while you watch, with tracks, subtitle size, picture, volume and notification settings
|
||||||
|
- Subtitles can move up above the controls while they show (Settings → Player → Subtitles above the controls, off by default), and their size can be changed
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,26 @@ by default, or Only when paused, or Off; messages always reach the chat,
|
|||||||
with their sound). Watching alone, nothing pops up or sounds about the
|
with their sound). Watching alone, nothing pops up or sounds about the
|
||||||
party (there is nobody); the app's own notices still show.
|
party (there is nobody); the app's own notices still show.
|
||||||
|
|
||||||
|
**Quick chat.** Enter in the big player opens a small chat box at the
|
||||||
|
bottom centre without opening the chat panel, also while the video
|
||||||
|
plays: type (the player's keys wait meanwhile), Enter sends, Esc or an
|
||||||
|
empty Enter closes it, and it closes by itself after a few seconds
|
||||||
|
without typing. It is the same draft as the chat panel's field, so a
|
||||||
|
message started in one is there in the other. What you sent shows for a
|
||||||
|
moment where the box was (or flies as your own line with danmaku, below);
|
||||||
|
bottom subtitles move up out of its way and back, top signs stay. The
|
||||||
|
mouse pointer and the controls stay hidden: typing is not moving the
|
||||||
|
mouse.
|
||||||
|
|
||||||
|
**Danmaku.** Settings → Notifications & sounds → Over the player →
|
||||||
|
Notifications: Toasts (default) or Danmaku. With Danmaku, over the big
|
||||||
|
player chat, joins, leaves, Up next and "starting now" fly right to left
|
||||||
|
across the top of the picture instead of popping up (problems and errors
|
||||||
|
still pop up, and everything does while the player is small). The look
|
||||||
|
is yours to tune there, with a live preview: crossing time, how many at
|
||||||
|
once, which band of the picture they use, opacity, size, boldness,
|
||||||
|
outline, avatars, and a frame round your own lines.
|
||||||
|
|
||||||
**While sitting out or in the background.** Sitting out, only chat,
|
**While sitting out or in the background.** Sitting out, only chat,
|
||||||
nudges, "starting now" (the video starts for the others) and Up next
|
nudges, "starting now" (the video starts for the others) and Up next
|
||||||
reach you; joins and leaves too if you turn on Settings → Notifications &
|
reach you; joins and leaves too if you turn on Settings → Notifications &
|
||||||
@@ -390,15 +410,21 @@ volume and sound set, and every sound with a preview, its own set and a switch);
|
|||||||
(the version, also at the bottom of every Settings page and in the
|
(the version, also at the bottom of every Settings page and in the
|
||||||
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
|
Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc).
|
||||||
|
|
||||||
Player settings live in the player too: ⚙ → Player settings, at the
|
Player settings live in the player too: ⚙ → Player settings opens a
|
||||||
right of its controls, and Settings → Player has the same ones (plus the
|
narrow panel at the right edge of the player that stays open while you
|
||||||
controls' layout and the custom shader files). Player design: Standard or
|
watch (the controls hide and show as usual) until its × or Esc. Its
|
||||||
Minimal (below); progress line when hidden (on by default: a thin line at
|
sections fold: tracks (subtitles, audio), subtitles (size, above the
|
||||||
the bottom of the video while the controls are hidden); upscaling shader
|
controls), picture (upscaling shader, sharp video, hardware decoding),
|
||||||
(see below); hardware decoding (auto, off or custom; auto is mpv's
|
sound (volume), notifications (toasts or danmaku and the danmaku look)
|
||||||
`auto-copy` on Windows, `auto` elsewhere; the env var
|
and controls (design, progress line). Settings → Player has the same
|
||||||
`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
|
ones (plus the controls' layout and the custom shader files). Player
|
||||||
audio track. Settings → Player → Controls: time display (elapsed / total
|
design: Standard or Minimal (below); progress line when hidden (on by
|
||||||
|
default: a thin line at the bottom of the video while the controls are
|
||||||
|
hidden); upscaling shader (see below); hardware decoding (auto, off or
|
||||||
|
custom; auto is mpv's `auto-copy` on Windows, `auto` elsewhere; the env
|
||||||
|
var `SYNC_RELAY_HWDEC` overrides it); subtitle size; subtitles above the
|
||||||
|
controls (off by default: bottom lines move up while the control bar
|
||||||
|
shows; the quick chat box always moves them). Settings → Player → Controls: time display (elapsed / total
|
||||||
or remaining), controls size (normal or large), hide the controls after
|
or remaining), controls size (normal or large), hide the controls after
|
||||||
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
|
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
|
||||||
subtitles, audio, chat; hidden ones are still in ⚙). Every change
|
subtitles, audio, chat; hidden ones are still in ⚙). Every change
|
||||||
@@ -408,8 +434,9 @@ Anywhere in a server (unless you are typing): Space = ready / unready,
|
|||||||
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
||||||
Ctrl+Shift+B = mark a problem in the session log. Keys while the
|
Ctrl+Shift+B = mark a problem in the session log. Keys while the
|
||||||
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
|
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
|
||||||
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
|
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Enter =
|
||||||
close the chat, then fullscreen, then shrink the player to mini; from the
|
quick chat, Esc = close the player panel, then
|
||||||
|
the chat, then fullscreen, then shrink the player to mini; from the
|
||||||
mini it asks to leave, Ctrl+R = reload video,
|
mini it asks to leave, Ctrl+R = reload video,
|
||||||
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
||||||
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import '../widgets/copy_open.dart';
|
|||||||
import 'frame.dart';
|
import 'frame.dart';
|
||||||
import 'model/controls_layout.dart';
|
import 'model/controls_layout.dart';
|
||||||
import 'model/controls_model.dart';
|
import 'model/controls_model.dart';
|
||||||
import 'player_settings.dart';
|
|
||||||
|
|
||||||
// The controls' menus, shared by both skins: the track and chapter lists
|
// The controls' menus, shared by both skins: the track and chapter lists
|
||||||
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
|
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
|
||||||
@@ -129,7 +128,7 @@ Future<void> runControlAction(BuildContext context, PlayerControlsModel model, C
|
|||||||
case ControlAction.markProblem:
|
case ControlAction.markProblem:
|
||||||
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
|
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
|
||||||
case ControlAction.playerSettings:
|
case ControlAction.playerSettings:
|
||||||
await showPlayerSettings(context, renderMode: model.renderMode);
|
model.togglePanel();
|
||||||
case ControlAction.leave:
|
case ControlAction.leave:
|
||||||
model.leave();
|
model.leave();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -10,7 +11,10 @@ import 'controls_probe.dart' show ControlsHold;
|
|||||||
// without any (or the mouse leaving) hides them and the cursor. Holds keep
|
// without any (or the mouse leaving) hides them and the cursor. Holds keep
|
||||||
// them up: a seek drag (no hover events arrive), an open menu (a route
|
// them up: a seek drag (no hover events arrive), an open menu (a route
|
||||||
// takes the mouse), and the session's [ControlsHold] (the mouse on a
|
// takes the mouse), and the session's [ControlsHold] (the mouse on a
|
||||||
// floating pill or card, the Up next popover).
|
// floating pill or card, the Up next popover). Only real mouse movement
|
||||||
|
// wakes them: a widget appearing or going under a still pointer (the
|
||||||
|
// quick chat box) does not, nor does typing. [ControlsFrame.suppress]
|
||||||
|
// hides them while something takes their place (the quick chat box).
|
||||||
|
|
||||||
class ControlsFrame extends StatefulWidget {
|
class ControlsFrame extends StatefulWidget {
|
||||||
/// Draws the controls; [visible] false while they are hidden.
|
/// Draws the controls; [visible] false while they are hidden.
|
||||||
@@ -28,6 +32,11 @@ class ControlsFrame extends StatefulWidget {
|
|||||||
|
|
||||||
/// Keeps them up while held.
|
/// Keeps them up while held.
|
||||||
final ControlsHold? holdBy;
|
final ControlsHold? holdBy;
|
||||||
|
|
||||||
|
/// While true the controls are drawn hidden and [shown] says so; the
|
||||||
|
/// cursor still follows the mouse. They come back as they would have
|
||||||
|
/// been once it is false again.
|
||||||
|
final ValueListenable<bool>? suppress;
|
||||||
const ControlsFrame({
|
const ControlsFrame({
|
||||||
super.key,
|
super.key,
|
||||||
required this.builder,
|
required this.builder,
|
||||||
@@ -37,6 +46,7 @@ class ControlsFrame extends StatefulWidget {
|
|||||||
this.hideAfter = const Duration(seconds: 3),
|
this.hideAfter = const Duration(seconds: 3),
|
||||||
this.shown,
|
this.shown,
|
||||||
this.holdBy,
|
this.holdBy,
|
||||||
|
this.suppress,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The frame around [context], for widgets that keep it up (a drag, a
|
/// The frame around [context], for widgets that keep it up (a drag, a
|
||||||
@@ -62,16 +72,23 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
int _holds = 0;
|
int _holds = 0;
|
||||||
bool _heldBy = false;
|
bool _heldBy = false;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
// Where the mouse was last seen, to tell real movement from a widget
|
||||||
|
// appearing or going under it.
|
||||||
|
Offset? _lastPos;
|
||||||
|
|
||||||
bool get visible => _visible;
|
bool get _suppressed => widget.suppress?.value ?? false;
|
||||||
|
|
||||||
|
/// The controls are drawn (up and not suppressed).
|
||||||
|
bool get visible => _visible && !_suppressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
widget.holdBy?.addListener(_holdByChanged);
|
widget.holdBy?.addListener(_holdByChanged);
|
||||||
|
widget.suppress?.addListener(_suppressChanged);
|
||||||
// Not during this build: an overlay listening to [shown] may be built.
|
// Not during this build: an overlay listening to [shown] may be built.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) widget.shown?.value = true;
|
if (mounted) widget.shown?.value = visible;
|
||||||
});
|
});
|
||||||
_hideLater();
|
_hideLater();
|
||||||
}
|
}
|
||||||
@@ -84,12 +101,17 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
widget.holdBy?.addListener(_holdByChanged);
|
widget.holdBy?.addListener(_holdByChanged);
|
||||||
_holdByChanged();
|
_holdByChanged();
|
||||||
}
|
}
|
||||||
|
if (oldWidget.suppress != widget.suppress) {
|
||||||
|
oldWidget.suppress?.removeListener(_suppressChanged);
|
||||||
|
widget.suppress?.addListener(_suppressChanged);
|
||||||
|
}
|
||||||
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.holdBy?.removeListener(_holdByChanged);
|
widget.holdBy?.removeListener(_holdByChanged);
|
||||||
|
widget.suppress?.removeListener(_suppressChanged);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -101,15 +123,29 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
hold(on);
|
hold(on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _suppressChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
widget.shown?.value = visible;
|
||||||
|
}
|
||||||
|
|
||||||
/// Show the controls and restart the hide countdown.
|
/// Show the controls and restart the hide countdown.
|
||||||
void wake() {
|
void wake() {
|
||||||
if (!_visible && mounted) {
|
if (!_visible && mounted) {
|
||||||
setState(() => _visible = true);
|
setState(() => _visible = true);
|
||||||
widget.shown?.value = true;
|
widget.shown?.value = visible;
|
||||||
}
|
}
|
||||||
_hideLater();
|
_hideLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A hover or enter at [pos]: wakes only when the mouse really moved.
|
||||||
|
void _moved(Offset pos) {
|
||||||
|
final last = _lastPos;
|
||||||
|
_lastPos = pos;
|
||||||
|
if (last != null && (pos - last).distance < 0.5) return;
|
||||||
|
wake();
|
||||||
|
}
|
||||||
|
|
||||||
/// Keep the controls up while [on] (paired calls).
|
/// Keep the controls up while [on] (paired calls).
|
||||||
void hold(bool on) {
|
void hold(bool on) {
|
||||||
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
||||||
@@ -141,9 +177,10 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||||
onEnter: (_) => wake(),
|
onEnter: (e) => _moved(e.position),
|
||||||
onHover: (_) => wake(),
|
onHover: (e) => _moved(e.position),
|
||||||
onExit: (_) {
|
onExit: (e) {
|
||||||
|
_lastPos = e.position;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_hide();
|
_hide();
|
||||||
},
|
},
|
||||||
@@ -154,12 +191,12 @@ class ControlsFrameState extends State<ControlsFrame> {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: DoubleClickListener(
|
child: DoubleClickListener(
|
||||||
onDoubleClick: widget.onDoubleClick!,
|
onDoubleClick: widget.onDoubleClick!,
|
||||||
skipTop: _visible ? widget.barTop : 0,
|
skipTop: visible ? widget.barTop : 0,
|
||||||
skipBottom: _visible ? widget.barBottom : 0,
|
skipBottom: visible ? widget.barBottom : 0,
|
||||||
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
widget.builder(context, _visible),
|
widget.builder(context, visible),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ class PlayerControlsModel {
|
|||||||
/// A menu of the controls is open (the player's Esc closes it).
|
/// A menu of the controls is open (the player's Esc closes it).
|
||||||
int menusOpen = 0;
|
int menusOpen = 0;
|
||||||
|
|
||||||
|
/// The player panel docked at the right (⚙ → Player settings; Esc or
|
||||||
|
/// its × closes it). Stays open while the controls hide and show.
|
||||||
|
final panelOpen = signal(false);
|
||||||
|
|
||||||
|
void togglePanel() => panelOpen.value = !panelOpen.peek();
|
||||||
|
|
||||||
void dispose() => volumeFlash.dispose();
|
void dispose() => volumeFlash.dispose();
|
||||||
|
|
||||||
// -------------------------------------------------------------- actions
|
// -------------------------------------------------------------- actions
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||||
|
|
||||||
|
import '../app/app_graph.dart';
|
||||||
|
import '../danmaku/danmaku_settings.dart' show DanmakuKnobs, NotifyStyleSelect;
|
||||||
|
import '../playback/playback_controller.dart' show RenderMode;
|
||||||
|
import '../shaders/shader_presets.dart';
|
||||||
|
import '../shell/mark_problem.dart' show MarkProblemButton;
|
||||||
|
import '../shell/motion.dart';
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import 'model/controls_prefs.dart';
|
||||||
|
import 'player_settings.dart' show applyHwdecLive, applyShaderChoice;
|
||||||
|
import 'volume.dart' as vol;
|
||||||
|
|
||||||
|
// The player panel (⚙ → Player settings, both skins): a narrow glass
|
||||||
|
// column docked at the right edge of the full player that stays open
|
||||||
|
// while the controls hide and show, until its × or Esc. What is useful
|
||||||
|
// while watching, in collapsible sections: tracks, subtitles, picture,
|
||||||
|
// sound, notifications (toasts or danmaku, the danmaku look) and the
|
||||||
|
// controls. Everything applies live and is saved; the rest of Settings →
|
||||||
|
// Player stays there.
|
||||||
|
|
||||||
|
/// Sections folded by the user, kept for the app's run.
|
||||||
|
final _folded = <String>{'Notifications', 'Controls'};
|
||||||
|
|
||||||
|
class PlayerPanel extends StatelessWidget {
|
||||||
|
static const width = 312.0;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
/// The running player's render mode (shader and sharp video notes).
|
||||||
|
final ReadonlySignal<RenderMode>? renderMode;
|
||||||
|
|
||||||
|
/// The player's volume (0..[vol.maxVolume]) and how to set it; the
|
||||||
|
/// Sound section only with both.
|
||||||
|
final ReadonlySignal<double>? volume;
|
||||||
|
final ValueChanged<double>? onVolume;
|
||||||
|
const PlayerPanel({super.key, required this.onClose, this.renderMode, this.volume, this.onVolume});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final graph = AppScope.of(context);
|
||||||
|
final s = graph.coordinator.settings;
|
||||||
|
return AppGlass(
|
||||||
|
key: const ValueKey('player-panel'),
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 6, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.tune_rounded, size: 18, color: Colors.white70),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(
|
||||||
|
child: Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('player-panel-close'),
|
||||||
|
tooltip: 'Close (Esc)',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
iconSize: 18,
|
||||||
|
onPressed: onClose,
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const _Section(title: 'Tracks', children: [
|
||||||
|
_TrackRow(type: JellyfinStreamType.subtitle),
|
||||||
|
_TrackRow(type: JellyfinStreamType.audio),
|
||||||
|
]),
|
||||||
|
_Section(title: 'Subtitles', children: [
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _SliderRow(
|
||||||
|
key: const ValueKey('panel-sub-scale'),
|
||||||
|
label: 'Size',
|
||||||
|
value: s.subScale.value,
|
||||||
|
min: 0.5,
|
||||||
|
max: 2,
|
||||||
|
divisions: 30,
|
||||||
|
readout: '${(s.subScale.value * 100).round()} %',
|
||||||
|
onChanged: s.setSubScale,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _SwitchRow(
|
||||||
|
key: const ValueKey('panel-lift'),
|
||||||
|
label: 'Above the controls',
|
||||||
|
help: 'Bottom lines move up while the control bar shows (the chat box always lifts them)',
|
||||||
|
value: s.liftSubtitles.value,
|
||||||
|
onChanged: s.setLiftSubtitles,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
_Section(title: 'Picture', children: [
|
||||||
|
_ShaderRow(renderMode: renderMode),
|
||||||
|
SignalBuilder(builder: (context) {
|
||||||
|
final m = renderMode?.value;
|
||||||
|
final software = m == RenderMode.software || m == RenderMode.softwareGl;
|
||||||
|
return _SwitchRow(
|
||||||
|
key: const ValueKey('panel-sharp'),
|
||||||
|
label: 'Sharp video and subtitles',
|
||||||
|
help: software ? 'No effect: software rendering' : "At your screen's resolution",
|
||||||
|
value: s.screenResVideo.value,
|
||||||
|
onChanged: s.setScreenResVideo,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _Row(
|
||||||
|
label: 'Hardware decoding',
|
||||||
|
control: AppSelect<HwdecMode>(
|
||||||
|
key: const ValueKey('panel-hwdec'),
|
||||||
|
value: s.hwdecMode.value,
|
||||||
|
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
|
||||||
|
onChanged: (m) {
|
||||||
|
s.setHwdecMode(m);
|
||||||
|
applyHwdecLive(graph.coordinator.playback);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
if (volume case final v?)
|
||||||
|
_Section(title: 'Sound', children: [
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _SliderRow(
|
||||||
|
key: const ValueKey('panel-volume'),
|
||||||
|
label: 'Volume',
|
||||||
|
value: v.value,
|
||||||
|
min: 0,
|
||||||
|
max: vol.maxVolume,
|
||||||
|
readout: '${v.value.round()} %',
|
||||||
|
onChanged: onVolume,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
_Section(title: 'Notifications', children: [
|
||||||
|
_Row(label: 'Show as', control: NotifyStyleSelect(settings: s)),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: s.notifyStyle.value == NotifyStyle.danmaku
|
||||||
|
? DanmakuKnobs(settings: s, dense: true)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
_Section(title: 'Controls', children: [
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _Row(
|
||||||
|
label: 'Design',
|
||||||
|
control: AppSelect<ControlsSkin>(
|
||||||
|
key: const ValueKey('panel-design'),
|
||||||
|
value: s.controlsSkin.value,
|
||||||
|
items: [for (final k in ControlsSkin.values) (k, k.label)],
|
||||||
|
onChanged: s.setControlsSkin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => _SwitchRow(
|
||||||
|
key: const ValueKey('panel-progress-line'),
|
||||||
|
label: 'Progress line when hidden',
|
||||||
|
value: s.progressLine.value,
|
||||||
|
onChanged: s.setProgressLine,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'More in Settings → Player',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const MarkProblemButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A titled group that folds (its title is the toggle).
|
||||||
|
class _Section extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Widget> children;
|
||||||
|
const _Section({required this.title, required this.children});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Section> createState() => _SectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionState extends State<_Section> {
|
||||||
|
late bool _open = !_folded.contains(widget.title);
|
||||||
|
|
||||||
|
void _toggle() {
|
||||||
|
setState(() => _open = !_open);
|
||||||
|
_open ? _folded.remove(widget.title) : _folded.add(widget.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final muted = Colors.white.withValues(alpha: 0.55);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
key: ValueKey('panel-section-${widget.title}'),
|
||||||
|
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
||||||
|
onTap: _toggle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(widget.title.toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: muted)),
|
||||||
|
),
|
||||||
|
AnimatedRotation(
|
||||||
|
turns: _open ? 0 : -0.25,
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
child: Icon(Icons.expand_more_rounded, size: 18, color: muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: _open
|
||||||
|
? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: widget.children)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Row extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Widget control;
|
||||||
|
const _Row({required this.label, required this.control});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||||
|
Flexible(flex: 2, child: Align(alignment: Alignment.centerRight, child: control)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwitchRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String? help;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
const _SwitchRow({super.key, required this.label, this.help, required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 13)),
|
||||||
|
if (help != null)
|
||||||
|
Text(help!, style: TextStyle(fontSize: 11.5, color: Colors.white.withValues(alpha: 0.5))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Transform.scale(
|
||||||
|
scale: 0.8,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Switch(value: value, onChanged: onChanged),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SliderRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double value, min, max;
|
||||||
|
final int? divisions;
|
||||||
|
final String readout;
|
||||||
|
final ValueChanged<double>? onChanged;
|
||||||
|
const _SliderRow({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.min,
|
||||||
|
required this.max,
|
||||||
|
this.divisions,
|
||||||
|
required this.readout,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
height: 38,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 84, child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||||
|
Expanded(
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: 3,
|
||||||
|
overlayShape: SliderComponentShape.noOverlay,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||||
|
tickMarkShape: SliderTickMarkShape.noTickMark,
|
||||||
|
),
|
||||||
|
child: Slider(value: value.clamp(min, max), min: min, max: max, divisions: divisions, onChanged: onChanged),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 52,
|
||||||
|
child: Text(readout,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtitle or audio track of the running player; remembered for the
|
||||||
|
/// next episode.
|
||||||
|
class _TrackRow extends StatelessWidget {
|
||||||
|
final JellyfinStreamType type;
|
||||||
|
const _TrackRow({required this.type});
|
||||||
|
|
||||||
|
bool get _audio => type == JellyfinStreamType.audio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final coord = AppScope.of(context).coordinator;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final list = [for (final t in coord.playback.tracks.value) if (t.type == type) t];
|
||||||
|
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
|
||||||
|
final items = <(int, String)>[
|
||||||
|
if (!_audio) (-1, 'Off'),
|
||||||
|
for (final t in list) (t.id, t.label),
|
||||||
|
];
|
||||||
|
final key = ValueKey(_audio ? 'panel-audio' : 'panel-subtitles');
|
||||||
|
return _Row(
|
||||||
|
label: _audio ? 'Audio' : 'Subtitles',
|
||||||
|
control: items.isEmpty || selected == null
|
||||||
|
? Text(items.isEmpty ? 'None' : 'Pick one…', key: key, style: const TextStyle(fontSize: 13))
|
||||||
|
: AppSelect<int>(
|
||||||
|
key: key,
|
||||||
|
value: selected,
|
||||||
|
expanded: true,
|
||||||
|
items: items,
|
||||||
|
onChanged: (id) {
|
||||||
|
coord.markInput();
|
||||||
|
if (id == -1) {
|
||||||
|
coord.selectTrack(subtitlesOff: true);
|
||||||
|
} else if (_audio) {
|
||||||
|
coord.selectTrack(aid: id);
|
||||||
|
} else {
|
||||||
|
coord.selectTrack(sid: id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The upscaling shader: Anime4K mode (or custom files) and HQ / Fast.
|
||||||
|
class _ShaderRow extends StatelessWidget {
|
||||||
|
final ReadonlySignal<RenderMode>? renderMode;
|
||||||
|
const _ShaderRow({this.renderMode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final coord = AppScope.of(context).coordinator;
|
||||||
|
final s = coord.settings;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final preset = s.shaderPreset.value;
|
||||||
|
final hasCustom = s.customShaders.value.isNotEmpty;
|
||||||
|
final software = switch (renderMode?.value) {
|
||||||
|
RenderMode.software || RenderMode.softwareGl => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_Row(
|
||||||
|
label: 'Upscaling',
|
||||||
|
control: AppSelect<ShaderPreset>(
|
||||||
|
key: const ValueKey('panel-shader'),
|
||||||
|
value: preset,
|
||||||
|
items: [
|
||||||
|
for (final p in ShaderPreset.values)
|
||||||
|
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
|
||||||
|
],
|
||||||
|
onChanged: (p) => applyShaderChoice(coord, p, null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (preset.isAnime4k)
|
||||||
|
_Row(
|
||||||
|
label: 'Shader quality',
|
||||||
|
control: AppSelect<ShaderQuality>(
|
||||||
|
key: const ValueKey('panel-shader-quality'),
|
||||||
|
value: s.shaderQuality.value,
|
||||||
|
items: [for (final q in ShaderQuality.values) (q, q.label)],
|
||||||
|
onChanged: (q) => applyShaderChoice(coord, preset, q),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (software && preset != ShaderPreset.off)
|
||||||
|
const Text('Software rendering: shaders have little or no effect',
|
||||||
|
style: TextStyle(fontSize: 11.5, color: Colors.orange)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,19 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
|
||||||
|
|
||||||
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
|
||||||
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
||||||
import '../session/session_coordinator.dart' show SessionCoordinator;
|
import '../session/session_coordinator.dart' show SessionCoordinator;
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../app/app_graph.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../shell/mark_problem.dart';
|
|
||||||
import '../shell/motion.dart';
|
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import 'frame.dart';
|
|
||||||
import 'model/controls_prefs.dart';
|
import 'model/controls_prefs.dart';
|
||||||
|
|
||||||
// Everything about the player, defined once: the rows of the player
|
// Everything about the player, defined once: the rows of Settings →
|
||||||
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
|
// Player (the player panel, `player_panel.dart`, has compact rows for the
|
||||||
// writes [AppSettings] and applies to a running player right away.
|
// same settings). Each row reads and writes [AppSettings] and applies to
|
||||||
|
// a running player right away.
|
||||||
|
|
||||||
/// One setting: a label, one line of help under it, the control at the
|
/// One setting: a label, one line of help under it, the control at the
|
||||||
/// right ([below]: an extra line under the row, e.g. a text field).
|
/// right ([below]: an extra line under the row, e.g. a text field).
|
||||||
@@ -362,58 +358,6 @@ class _HwdecSettingState extends State<HwdecSetting> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio or subtitle track of the running player (in the player only).
|
|
||||||
/// The pick is remembered for the next Jellyfin item.
|
|
||||||
class TrackSetting extends StatelessWidget {
|
|
||||||
final JellyfinStreamType type;
|
|
||||||
const TrackSetting({super.key, required this.type});
|
|
||||||
|
|
||||||
bool get _audio => type == JellyfinStreamType.audio;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final coord = AppScope.of(context).coordinator;
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
final list = [
|
|
||||||
for (final t in coord.playback.tracks.value)
|
|
||||||
if (t.type == type) t,
|
|
||||||
];
|
|
||||||
// -1: subtitles off.
|
|
||||||
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
|
|
||||||
final items = <(int, String)>[
|
|
||||||
if (!_audio) (-1, 'Off'),
|
|
||||||
for (final MpvTrack t in list) (t.id, t.label),
|
|
||||||
];
|
|
||||||
return SettingRow(
|
|
||||||
label: _audio ? 'Audio' : 'Subtitles',
|
|
||||||
help: 'Remembered for the next episode',
|
|
||||||
control: SizedBox(
|
|
||||||
width: 190,
|
|
||||||
child: items.isEmpty || selected == null
|
|
||||||
? Text(items.isEmpty ? 'None' : 'Pick one…',
|
|
||||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end)
|
|
||||||
: _dropdown<int>(
|
|
||||||
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'),
|
|
||||||
value: selected,
|
|
||||||
expanded: true,
|
|
||||||
items: items,
|
|
||||||
onChanged: (id) {
|
|
||||||
coord.markInput();
|
|
||||||
if (id == -1) {
|
|
||||||
coord.selectTrack(subtitlesOff: true);
|
|
||||||
} else if (_audio) {
|
|
||||||
coord.selectTrack(aid: id);
|
|
||||||
} else {
|
|
||||||
coord.selectTrack(sid: id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The controls' own choices (Settings → Player → Controls).
|
/// The controls' own choices (Settings → Player → Controls).
|
||||||
List<Widget> controlsSettingRows() => [
|
List<Widget> controlsSettingRows() => [
|
||||||
const TimeDisplaySetting(),
|
const TimeDisplaySetting(),
|
||||||
@@ -426,58 +370,38 @@ List<Widget> controlsSettingRows() => [
|
|||||||
for (final c in OptionalControl.values) BarButtonSetting(control: c),
|
for (final c in OptionalControl.values) BarButtonSetting(control: c),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The player rows of Settings → Player and the player settings card, in
|
/// The player rows of Settings → Player, in order (the player panel has
|
||||||
/// order.
|
/// its own compact rows for the same settings).
|
||||||
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||||
const PlayerDesignSetting(),
|
const PlayerDesignSetting(),
|
||||||
const ProgressLineSetting(),
|
const ProgressLineSetting(),
|
||||||
ShaderPresetSetting(renderMode: renderMode),
|
ShaderPresetSetting(renderMode: renderMode),
|
||||||
ScreenResSetting(renderMode: renderMode),
|
ScreenResSetting(renderMode: renderMode),
|
||||||
const HwdecSetting(),
|
const HwdecSetting(),
|
||||||
|
const SubScaleSetting(),
|
||||||
|
const LiftSubtitlesSetting(),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The player settings card: every player setting, plus the running
|
/// Subtitle size (mpv `sub-scale`, [AppSettings.subScale]). Live.
|
||||||
/// player's tracks.
|
class SubScaleSetting extends StatelessWidget {
|
||||||
class PlayerSettingsCard extends StatelessWidget {
|
const SubScaleSetting({super.key});
|
||||||
final ReadonlySignal<RenderMode>? renderMode;
|
|
||||||
/// Show the audio / subtitle pickers (a player is running).
|
|
||||||
final bool tracks;
|
|
||||||
const PlayerSettingsCard({super.key, this.renderMode, this.tracks = true});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// The app's glass, like the Up next popover and the menus.
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
return AppGlass(
|
return SignalBuilder(
|
||||||
key: const ValueKey('player-settings-card'),
|
builder: (context) => SettingRow(
|
||||||
child: SizedBox(
|
label: 'Subtitle size',
|
||||||
width: 420,
|
help: '${(s.subScale.value * 100).round()} % of what the file sets',
|
||||||
child: Padding(
|
control: SizedBox(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
width: 220,
|
||||||
child: Column(
|
child: Slider(
|
||||||
mainAxisSize: MainAxisSize.min,
|
key: const ValueKey('sub-scale'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
value: s.subScale.value,
|
||||||
children: [
|
min: 0.5,
|
||||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
max: 2,
|
||||||
const SizedBox(height: 4),
|
divisions: 30,
|
||||||
...playerSettingRows(renderMode: renderMode),
|
onChanged: s.setSubScale,
|
||||||
if (tracks) ...[
|
|
||||||
const Divider(),
|
|
||||||
const TrackSetting(type: JellyfinStreamType.subtitle),
|
|
||||||
const TrackSetting(type: JellyfinStreamType.audio),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Controls layout and custom shaders: Settings → Player',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const MarkProblemButton(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -485,14 +409,25 @@ class PlayerSettingsCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the player settings card over the player, near the controls'
|
/// Bottom subtitles above the control bar while it is up
|
||||||
/// right end; the controls stay up while it is open.
|
/// ([AppSettings.liftSubtitles]). Live.
|
||||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
|
class LiftSubtitlesSetting extends StatelessWidget {
|
||||||
ControlsFrame.holdWhile(
|
const LiftSubtitlesSetting({super.key});
|
||||||
context,
|
|
||||||
() => showPopover<void>(
|
@override
|
||||||
context,
|
Widget build(BuildContext context) {
|
||||||
at: const Alignment(0.96, 0.5),
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
child: PlayerSettingsCard(renderMode: renderMode),
|
return SignalBuilder(
|
||||||
|
builder: (context) => SettingRow(
|
||||||
|
label: 'Subtitles above the controls',
|
||||||
|
help: 'Bottom lines move up while the control bar shows; signs at the top stay. '
|
||||||
|
'The chat box always moves them',
|
||||||
|
control: Switch(
|
||||||
|
key: const ValueKey('lift-subtitles'),
|
||||||
|
value: s.liftSubtitles.value,
|
||||||
|
onChanged: s.setLiftSubtitles,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@ class SessionControls extends StatelessWidget {
|
|||||||
final ControlsHold hold;
|
final ControlsHold hold;
|
||||||
final VoidCallback onDoubleClick;
|
final VoidCallback onDoubleClick;
|
||||||
final Widget? windowButtons;
|
final Widget? windowButtons;
|
||||||
|
|
||||||
|
/// Hides them while something takes their place ([ControlsFrame.suppress]).
|
||||||
|
final ValueListenable<bool>? suppress;
|
||||||
const SessionControls({
|
const SessionControls({
|
||||||
super.key,
|
super.key,
|
||||||
required this.model,
|
required this.model,
|
||||||
@@ -41,6 +45,7 @@ class SessionControls extends StatelessWidget {
|
|||||||
required this.hold,
|
required this.hold,
|
||||||
required this.onDoubleClick,
|
required this.onDoubleClick,
|
||||||
this.windowButtons,
|
this.windowButtons,
|
||||||
|
this.suppress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,6 +61,7 @@ class SessionControls extends StatelessWidget {
|
|||||||
key: ValueKey(skin),
|
key: ValueKey(skin),
|
||||||
shown: shown,
|
shown: shown,
|
||||||
holdBy: hold,
|
holdBy: hold,
|
||||||
|
suppress: suppress,
|
||||||
hideAfter: Duration(seconds: s.controlsHideSecs.value),
|
hideAfter: Duration(seconds: s.controlsHideSecs.value),
|
||||||
onDoubleClick: onDoubleClick,
|
onDoubleClick: onDoubleClick,
|
||||||
barTop: controlsTopHeight(skin),
|
barTop: controlsTopHeight(skin),
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import '../shell/avatar.dart' show AvatarInfo, StatusAvatar;
|
|||||||
// two ever touch), at most [DanmakuStyle.maxOnScreen] fly at once and the
|
// two ever touch), at most [DanmakuStyle.maxOnScreen] fly at once and the
|
||||||
// rest wait their turn; every line crosses in the same time, so a long
|
// rest wait their turn; every line crosses in the same time, so a long
|
||||||
// one moves faster. The clock is the layer's own: lines keep flying while
|
// one moves faster. The clock is the layer's own: lines keep flying while
|
||||||
// the video is paused. Dev preview for now (DEVELOPMENT.md "Dev tools").
|
// the video is paused. The session's notifications use it when the user
|
||||||
|
// picks "Notifications as danmaku" (`danmaku_notices.dart`); the dev
|
||||||
|
// preview tries it out (DEVELOPMENT.md "Dev tools").
|
||||||
|
|
||||||
/// What a line is: a chat message or a short notice.
|
/// What a line is: a chat message or a short notice.
|
||||||
enum DanmakuKind { chat, join, leave, upNext, starting }
|
enum DanmakuKind { chat, join, leave, upNext, starting }
|
||||||
@@ -32,7 +34,9 @@ class DanmakuItem {
|
|||||||
{required this.kind, required this.text, this.sender, this.senderColor, this.own = false, this.avatar});
|
{required this.kind, required this.text, this.sender, this.senderColor, this.own = false, this.avatar});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How lines look and move.
|
/// How lines look and move. Persisted as [toJson] (Settings →
|
||||||
|
/// Notifications & sounds); [DanmakuStyle.fromJson] clamps every value to
|
||||||
|
/// its range ([limits]), so a hand-edited or older box never breaks it.
|
||||||
@immutable
|
@immutable
|
||||||
class DanmakuStyle {
|
class DanmakuStyle {
|
||||||
/// Seconds a line takes from the right edge until it has left on the
|
/// Seconds a line takes from the right edge until it has left on the
|
||||||
@@ -51,6 +55,10 @@ class DanmakuStyle {
|
|||||||
/// Text size relative to the picture's height ([fontSizeFor]).
|
/// Text size relative to the picture's height ([fontSizeFor]).
|
||||||
final double size;
|
final double size;
|
||||||
|
|
||||||
|
/// Font weight of the text, 400 (regular) to 900 (black); the sender's
|
||||||
|
/// name is a step bolder.
|
||||||
|
final int weight;
|
||||||
|
|
||||||
/// Width of the dark outline round the white text, logical px.
|
/// Width of the dark outline round the white text, logical px.
|
||||||
final double outline;
|
final double outline;
|
||||||
final bool highlightOwn;
|
final bool highlightOwn;
|
||||||
@@ -65,11 +73,24 @@ class DanmakuStyle {
|
|||||||
this.bandBottom = 0.45,
|
this.bandBottom = 0.45,
|
||||||
this.opacity = 0.92,
|
this.opacity = 0.92,
|
||||||
this.size = 1,
|
this.size = 1,
|
||||||
|
this.weight = 600,
|
||||||
this.outline = 1.4,
|
this.outline = 1.4,
|
||||||
this.highlightOwn = true,
|
this.highlightOwn = true,
|
||||||
this.avatars = true,
|
this.avatars = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The range of each knob (the settings' sliders use them too).
|
||||||
|
static const limits = (
|
||||||
|
crossSecs: (4.0, 16.0),
|
||||||
|
maxOnScreen: (1, 40),
|
||||||
|
band: (0.0, 0.9),
|
||||||
|
minBand: 0.05,
|
||||||
|
opacity: (0.3, 1.0),
|
||||||
|
size: (0.6, 1.8),
|
||||||
|
weight: (400, 900),
|
||||||
|
outline: (0.0, 4.0),
|
||||||
|
);
|
||||||
|
|
||||||
DanmakuStyle copyWith({
|
DanmakuStyle copyWith({
|
||||||
double? crossSecs,
|
double? crossSecs,
|
||||||
int? maxOnScreen,
|
int? maxOnScreen,
|
||||||
@@ -77,6 +98,7 @@ class DanmakuStyle {
|
|||||||
double? bandBottom,
|
double? bandBottom,
|
||||||
double? opacity,
|
double? opacity,
|
||||||
double? size,
|
double? size,
|
||||||
|
int? weight,
|
||||||
double? outline,
|
double? outline,
|
||||||
bool? highlightOwn,
|
bool? highlightOwn,
|
||||||
bool? avatars,
|
bool? avatars,
|
||||||
@@ -88,11 +110,69 @@ class DanmakuStyle {
|
|||||||
bandBottom: bandBottom ?? this.bandBottom,
|
bandBottom: bandBottom ?? this.bandBottom,
|
||||||
opacity: opacity ?? this.opacity,
|
opacity: opacity ?? this.opacity,
|
||||||
size: size ?? this.size,
|
size: size ?? this.size,
|
||||||
|
weight: weight ?? this.weight,
|
||||||
outline: outline ?? this.outline,
|
outline: outline ?? this.outline,
|
||||||
highlightOwn: highlightOwn ?? this.highlightOwn,
|
highlightOwn: highlightOwn ?? this.highlightOwn,
|
||||||
avatars: avatars ?? this.avatars,
|
avatars: avatars ?? this.avatars,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Map<String, Object> toJson() => {
|
||||||
|
'crossSecs': crossSecs,
|
||||||
|
'maxOnScreen': maxOnScreen,
|
||||||
|
'bandTop': bandTop,
|
||||||
|
'bandBottom': bandBottom,
|
||||||
|
'opacity': opacity,
|
||||||
|
'size': size,
|
||||||
|
'weight': weight,
|
||||||
|
'outline': outline,
|
||||||
|
'highlightOwn': highlightOwn,
|
||||||
|
'avatars': avatars,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [json] as stored ([toJson]); missing or wrong values take the
|
||||||
|
/// default, out-of-range ones the nearest end of their range.
|
||||||
|
factory DanmakuStyle.fromJson(Map<Object?, Object?> json) {
|
||||||
|
const d = DanmakuStyle();
|
||||||
|
const l = limits;
|
||||||
|
double num_(String k, double def, (double, double) r) {
|
||||||
|
final v = json[k];
|
||||||
|
return v is num && v.isFinite ? v.toDouble().clamp(r.$1, r.$2) : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
int int_(String k, int def, (int, int) r) {
|
||||||
|
final v = json[k];
|
||||||
|
return v is num && v.isFinite ? v.round().clamp(r.$1, r.$2) : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bool_(String k, bool def) {
|
||||||
|
final v = json[k];
|
||||||
|
return v is bool ? v : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
var top = num_('bandTop', d.bandTop, l.band);
|
||||||
|
var bottom = num_('bandBottom', d.bandBottom, l.band);
|
||||||
|
if (bottom - top < l.minBand) (top, bottom) = (d.bandTop, d.bandBottom);
|
||||||
|
return DanmakuStyle(
|
||||||
|
crossSecs: num_('crossSecs', d.crossSecs, l.crossSecs),
|
||||||
|
maxOnScreen: int_('maxOnScreen', d.maxOnScreen, l.maxOnScreen),
|
||||||
|
bandTop: top,
|
||||||
|
bandBottom: bottom,
|
||||||
|
opacity: num_('opacity', d.opacity, l.opacity),
|
||||||
|
size: num_('size', d.size, l.size),
|
||||||
|
weight: (int_('weight', d.weight, l.weight) / 100).round() * 100,
|
||||||
|
outline: num_('outline', d.outline, l.outline),
|
||||||
|
highlightOwn: bool_('highlightOwn', d.highlightOwn),
|
||||||
|
avatars: bool_('avatars', d.avatars),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [weight] as a [FontWeight] (400..900 in steps of 100).
|
||||||
|
FontWeight get fontWeight => FontWeight.values[((weight.clamp(100, 900) / 100).round() - 1)];
|
||||||
|
|
||||||
|
/// The sender's name: a step bolder than the text.
|
||||||
|
FontWeight get nameWeight =>
|
||||||
|
FontWeight.values[math.min(FontWeight.values.length - 1, (weight.clamp(100, 900) / 100).round())];
|
||||||
|
|
||||||
/// The text size in a picture [height] px tall: 4.2 % of it (about 30 px
|
/// The text size in a picture [height] px tall: 4.2 % of it (about 30 px
|
||||||
/// on a 720 px player), 13 to 44 px, times [size].
|
/// on a 720 px player), 13 to 44 px, times [size].
|
||||||
double fontSizeFor(double height) => (height * 0.042).clamp(13.0, 44.0) * size;
|
double fontSizeFor(double height) => (height * 0.042).clamp(13.0, 44.0) * size;
|
||||||
@@ -105,6 +185,24 @@ class DanmakuStyle {
|
|||||||
final band = (bandBottom - bandTop).clamp(0.0, 1.0) * height;
|
final band = (bandBottom - bandTop).clamp(0.0, 1.0) * height;
|
||||||
return math.max(1, (band / laneHeightFor(height)).floor());
|
return math.max(1, (band / laneHeightFor(height)).floor());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is DanmakuStyle &&
|
||||||
|
other.crossSecs == crossSecs &&
|
||||||
|
other.maxOnScreen == maxOnScreen &&
|
||||||
|
other.bandTop == bandTop &&
|
||||||
|
other.bandBottom == bandBottom &&
|
||||||
|
other.opacity == opacity &&
|
||||||
|
other.size == size &&
|
||||||
|
other.weight == weight &&
|
||||||
|
other.outline == outline &&
|
||||||
|
other.highlightOwn == highlightOwn &&
|
||||||
|
other.avatars == avatars;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(crossSecs, maxOnScreen, bandTop, bandBottom, opacity, size, weight, outline, highlightOwn, avatars);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line in flight in [lane], from [start] (seconds on the layer's
|
/// One line in flight in [lane], from [start] (seconds on the layer's
|
||||||
@@ -286,18 +384,21 @@ class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderSt
|
|||||||
late final Ticker _ticker = createTicker(_onTick);
|
late final Ticker _ticker = createTicker(_onTick);
|
||||||
final _lanes = DanmakuLanes<_Line>();
|
final _lanes = DanmakuLanes<_Line>();
|
||||||
final _repaint = ValueNotifier(0);
|
final _repaint = ValueNotifier(0);
|
||||||
|
// The layer's clock: [_base] from earlier runs of the ticker, which
|
||||||
|
// only runs while lines fly or wait (an idle layer asks for no frames).
|
||||||
|
Duration _base = Duration.zero;
|
||||||
Duration _elapsed = Duration.zero;
|
Duration _elapsed = Duration.zero;
|
||||||
Size _size = Size.zero;
|
Size _size = Size.zero;
|
||||||
var _pending = <DanmakuItem>[];
|
var _pending = <DanmakuItem>[];
|
||||||
var _shown = <DanmakuFlight<_Line>>[];
|
var _shown = <DanmakuFlight<_Line>>[];
|
||||||
|
|
||||||
double get _t => _elapsed.inMicroseconds / 1e6;
|
double get _t => (_base + _elapsed).inMicroseconds / 1e6;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
widget.controller.addListener(_onAdd);
|
widget.controller.addListener(_onAdd);
|
||||||
_ticker.start();
|
_onAdd();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -325,6 +426,7 @@ class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderSt
|
|||||||
_pending = [];
|
_pending = [];
|
||||||
}
|
}
|
||||||
_pending.addAll(c._take());
|
_pending.addAll(c._take());
|
||||||
|
if (!_ticker.isActive) _ticker.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
_Line _build(DanmakuItem item) {
|
_Line _build(DanmakuItem item) {
|
||||||
@@ -333,7 +435,7 @@ class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderSt
|
|||||||
final base = TextStyle(
|
final base = TextStyle(
|
||||||
fontSize: size,
|
fontSize: size,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: s.fontWeight,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.2,
|
||||||
shadows: const [],
|
shadows: const [],
|
||||||
@@ -346,7 +448,7 @@ class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderSt
|
|||||||
TextSpan(
|
TextSpan(
|
||||||
text: item.kind == DanmakuKind.chat ? '$name ' : '$name ',
|
text: item.kind == DanmakuKind.chat ? '$name ' : '$name ',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: s.nameWeight,
|
||||||
color: stroke == null ? (item.senderColor ?? Colors.white) : null,
|
color: stroke == null ? (item.senderColor ?? Colors.white) : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -421,6 +523,11 @@ class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderSt
|
|||||||
} else if (now.isNotEmpty) {
|
} else if (now.isNotEmpty) {
|
||||||
_repaint.value++;
|
_repaint.value++;
|
||||||
}
|
}
|
||||||
|
if (now.isEmpty && _lanes.waiting == 0 && _pending.isEmpty) {
|
||||||
|
_base += _elapsed;
|
||||||
|
_elapsed = Duration.zero;
|
||||||
|
_ticker.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _sameFlights(List<DanmakuFlight<_Line>> a, List<DanmakuFlight<_Line>> b) {
|
static bool _sameFlights(List<DanmakuFlight<_Line>> a, List<DanmakuFlight<_Line>> b) {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../session/notifier.dart' show NoticeCategory, ToastItem;
|
||||||
|
import '../shell/avatar.dart' show AvatarInfo, colorForName;
|
||||||
|
import 'danmaku.dart';
|
||||||
|
|
||||||
|
// "Notifications: Danmaku" (Settings → Notifications & sounds): over the
|
||||||
|
// full player, chat and short notices fly across the picture instead of
|
||||||
|
// popping up as toasts. Problems, errors and the app's own notices stay
|
||||||
|
// toasts, and so does everything while the player is small or closed.
|
||||||
|
|
||||||
|
/// Whether [t] flies as a line (when danmaku is the style in use): chat,
|
||||||
|
/// joins / leaves / lost connection / back, "starting now", and Up next
|
||||||
|
/// news someone caused (added, changed the video). Nudges, problems, your
|
||||||
|
/// own state and the app's notices stay toasts.
|
||||||
|
bool fliesAsDanmaku(ToastItem t) => switch (t.category) {
|
||||||
|
NoticeCategory.chat || NoticeCategory.people || NoticeCategory.start => true,
|
||||||
|
NoticeCategory.upNext => t.actor != null && t.actor!.isNotEmpty,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [t] as a line: the sender (their avatar from [avatar], their colour)
|
||||||
|
/// before what they said or did; "starting now" without one. Null for
|
||||||
|
/// what does not fly ([fliesAsDanmaku]).
|
||||||
|
DanmakuItem? danmakuForToast(ToastItem t, {AvatarInfo Function(String name, String id)? avatar}) {
|
||||||
|
if (!fliesAsDanmaku(t)) return null;
|
||||||
|
final who = t.actor;
|
||||||
|
if (who == null || who.isEmpty) {
|
||||||
|
return DanmakuItem(kind: DanmakuKind.starting, text: '▶ ${t.text}');
|
||||||
|
}
|
||||||
|
final pic = avatar?.call(who, t.actorId) ?? AvatarInfo(name: who, color: colorForName(who));
|
||||||
|
final color = pic.color;
|
||||||
|
if (t.chat && t.text.startsWith('$who: ')) {
|
||||||
|
return DanmakuItem(
|
||||||
|
kind: DanmakuKind.chat,
|
||||||
|
sender: who,
|
||||||
|
senderColor: color,
|
||||||
|
text: t.text.substring(who.length + 2),
|
||||||
|
own: t.own,
|
||||||
|
avatar: pic);
|
||||||
|
}
|
||||||
|
final rest = t.text.startsWith('$who ') ? t.text.substring(who.length + 1) : t.text;
|
||||||
|
final kind = switch (t.category) {
|
||||||
|
NoticeCategory.upNext => DanmakuKind.upNext,
|
||||||
|
_ when rest.startsWith('left') || rest.startsWith('lost') => DanmakuKind.leave,
|
||||||
|
_ => DanmakuKind.join,
|
||||||
|
};
|
||||||
|
return DanmakuItem(kind: kind, sender: who, senderColor: color, text: rest, avatar: pic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session's lines over the picture: fresh toasts that fly
|
||||||
|
/// ([fliesAsDanmaku], and not [skip]) go to [controller] while this is
|
||||||
|
/// mounted (the player mounts it only while danmaku is the style and the
|
||||||
|
/// full player is up); what was there before it mounted is old news.
|
||||||
|
class DanmakuNotices extends StatefulWidget {
|
||||||
|
final ReadonlySignal<List<ToastItem>> toasts;
|
||||||
|
final ReadonlySignal<DanmakuStyle> style;
|
||||||
|
final DanmakuController controller;
|
||||||
|
final AvatarInfo Function(String name, String id)? avatar;
|
||||||
|
|
||||||
|
/// Toasts shown another way already (your own quick chat echo).
|
||||||
|
final bool Function(ToastItem t)? skip;
|
||||||
|
const DanmakuNotices({
|
||||||
|
super.key,
|
||||||
|
required this.toasts,
|
||||||
|
required this.style,
|
||||||
|
required this.controller,
|
||||||
|
this.avatar,
|
||||||
|
this.skip,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuNotices> createState() => _DanmakuNoticesState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuNoticesState extends State<DanmakuNotices> {
|
||||||
|
late Set<int> _seen = {for (final t in widget.toasts.peek()) t.id};
|
||||||
|
late final void Function() _stop;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_stop = effect(() {
|
||||||
|
final items = widget.toasts.value;
|
||||||
|
untracked(() {
|
||||||
|
for (final t in items) {
|
||||||
|
if (_seen.contains(t.id) || (widget.skip?.call(t) ?? false)) continue;
|
||||||
|
final line = danmakuForToast(t, avatar: widget.avatar);
|
||||||
|
if (line != null) widget.controller.add(line);
|
||||||
|
}
|
||||||
|
_seen = {for (final t in items) t.id};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stop();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SignalBuilder(
|
||||||
|
builder: (context) => DanmakuLayer(
|
||||||
|
controller: widget.controller,
|
||||||
|
style: widget.style.value,
|
||||||
|
accent: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../controls/minimal/style.dart';
|
import '../controls/minimal/style.dart';
|
||||||
@@ -10,9 +12,12 @@ import '../session/notifier.dart' show ToastItem;
|
|||||||
import '../session/session_coordinator.dart' show SessionCoordinator;
|
import '../session/session_coordinator.dart' show SessionCoordinator;
|
||||||
import '../shell/avatar.dart' show AvatarInfo, avatarForName, colorForName, memberAvatarInfo, selfAvatarInfo;
|
import '../shell/avatar.dart' show AvatarInfo, avatarForName, colorForName, memberAvatarInfo, selfAvatarInfo;
|
||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
|
import '../shell/quick_chat.dart';
|
||||||
|
import '../shell/video_view.dart' show SubtitleLift;
|
||||||
import '../app/app_graph.dart' show AppScope;
|
import '../app/app_graph.dart' show AppScope;
|
||||||
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
|
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
|
||||||
import 'danmaku.dart';
|
import 'danmaku.dart';
|
||||||
|
import 'danmaku_notices.dart' show danmakuForToast;
|
||||||
|
|
||||||
// Dev builds only (Settings → About → Developer): try the danmaku look on
|
// Dev builds only (Settings → About → Developer): try the danmaku look on
|
||||||
// the real player (when something is loaded) or on a stand-in picture,
|
// the real player (when something is loaded) or on a stand-in picture,
|
||||||
@@ -25,7 +30,7 @@ class DanmakuPreview {
|
|||||||
|
|
||||||
/// Shown over the full session player.
|
/// Shown over the full session player.
|
||||||
final active = signal(false);
|
final active = signal(false);
|
||||||
final style = signal(const DanmakuStyle());
|
final Signal<DanmakuStyle> style;
|
||||||
|
|
||||||
/// A random stream: lines per minute, lengths from one word to a long
|
/// A random stream: lines per minute, lengths from one word to a long
|
||||||
/// sentence, CJK and emoji among them.
|
/// sentence, CJK and emoji among them.
|
||||||
@@ -50,7 +55,9 @@ class DanmakuPreview {
|
|||||||
Timer? _autoTimer;
|
Timer? _autoTimer;
|
||||||
late final void Function() _stopAuto;
|
late final void Function() _stopAuto;
|
||||||
|
|
||||||
DanmakuPreview() {
|
/// Starts from [style] (the user's look, Settings → Notifications &
|
||||||
|
/// sounds); the preview's knobs change only the preview.
|
||||||
|
DanmakuPreview({DanmakuStyle style = const DanmakuStyle()}) : style = signal(style) {
|
||||||
_stopAuto = effect(() {
|
_stopAuto = effect(() {
|
||||||
final on = auto.value;
|
final on = auto.value;
|
||||||
autoRate.value;
|
autoRate.value;
|
||||||
@@ -127,41 +134,15 @@ class DanmakuPreview {
|
|||||||
return sample(DanmakuKind.upNext);
|
return sample(DanmakuKind.upNext);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A toast of this session as a line: chat (name, then the message),
|
/// A toast of this session as a line, as "Notifications: Danmaku" maps
|
||||||
/// joins, leaves, Up next adds; the rest (problems) stay toasts.
|
/// it ([danmakuForToast]); the rest (problems) stay toasts.
|
||||||
DanmakuItem? fromToast(ToastItem t, {String? selfName}) {
|
DanmakuItem? fromToast(ToastItem t) {
|
||||||
final who = t.actor;
|
|
||||||
if (who == null || who.isEmpty) {
|
|
||||||
return t.text.toLowerCase().startsWith('starting')
|
|
||||||
? DanmakuItem(kind: DanmakuKind.starting, text: '▶ ${t.text}')
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
final color = colorForName(who);
|
|
||||||
final c = coord;
|
final c = coord;
|
||||||
final avatar = c == null ? AvatarInfo(name: who, color: color) : avatarForName(who, c, t.actorId);
|
return danmakuForToast(t, avatar: c == null ? null : (name, id) => avatarForName(name, c, id));
|
||||||
if (t.chat && t.text.startsWith('$who: ')) {
|
|
||||||
return DanmakuItem(
|
|
||||||
kind: DanmakuKind.chat,
|
|
||||||
sender: who,
|
|
||||||
senderColor: color,
|
|
||||||
text: t.text.substring(who.length + 2),
|
|
||||||
own: who == selfName,
|
|
||||||
avatar: avatar);
|
|
||||||
}
|
|
||||||
final rest = t.text.startsWith('$who ') ? t.text.substring(who.length + 1) : t.text;
|
|
||||||
final kind = rest.startsWith('joined')
|
|
||||||
? DanmakuKind.join
|
|
||||||
: rest.startsWith('left')
|
|
||||||
? DanmakuKind.leave
|
|
||||||
: rest.startsWith('added')
|
|
||||||
? DanmakuKind.upNext
|
|
||||||
: null;
|
|
||||||
if (kind == null) return null;
|
|
||||||
return DanmakuItem(kind: kind, sender: who, senderColor: color, text: rest, avatar: avatar);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follows [toasts] while [live] is on; returns the stop.
|
/// Follows [toasts] while [live] is on; returns the stop.
|
||||||
void Function() watchToasts(ReadonlySignal<List<ToastItem>> toasts, {String? selfName}) {
|
void Function() watchToasts(ReadonlySignal<List<ToastItem>> toasts) {
|
||||||
var seen = {for (final t in toasts.peek()) t.id};
|
var seen = {for (final t in toasts.peek()) t.id};
|
||||||
return effect(() {
|
return effect(() {
|
||||||
final items = toasts.value;
|
final items = toasts.value;
|
||||||
@@ -169,7 +150,7 @@ class DanmakuPreview {
|
|||||||
untracked(() {
|
untracked(() {
|
||||||
for (final t in items) {
|
for (final t in items) {
|
||||||
if (!seen.add(t.id) || !on) continue;
|
if (!seen.add(t.id) || !on) continue;
|
||||||
final line = fromToast(t, selfName: selfName);
|
final line = fromToast(t);
|
||||||
if (line != null) controller.add(line);
|
if (line != null) controller.add(line);
|
||||||
}
|
}
|
||||||
seen = {for (final t in items) t.id};
|
seen = {for (final t in items) t.id};
|
||||||
@@ -209,7 +190,11 @@ class DanmakuPreview {
|
|||||||
/// picture; lets clicks through.
|
/// picture; lets clicks through.
|
||||||
class DanmakuPreviewLayers extends StatelessWidget {
|
class DanmakuPreviewLayers extends StatelessWidget {
|
||||||
final DanmakuPreview preview;
|
final DanmakuPreview preview;
|
||||||
const DanmakuPreviewLayers({super.key, required this.preview});
|
|
||||||
|
/// The covered bottom the mock dialogue moves above, as the real
|
||||||
|
/// subtitles do ([SubtitleLift]).
|
||||||
|
final ValueListenable<double>? lift;
|
||||||
|
const DanmakuPreviewLayers({super.key, required this.preview, this.lift});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -218,7 +203,7 @@ class DanmakuPreviewLayers extends StatelessWidget {
|
|||||||
builder: (context) => Stack(
|
builder: (context) => Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value),
|
MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value, lift: lift),
|
||||||
DanmakuLayer(controller: preview.controller, style: preview.style.value, accent: accent),
|
DanmakuLayer(controller: preview.controller, style: preview.style.value, accent: accent),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -227,11 +212,14 @@ class DanmakuPreviewLayers extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stand-ins for subtitles where real ones sit: an ASS sign at the top
|
/// Stand-ins for subtitles where real ones sit: an ASS sign at the top
|
||||||
/// (`\an8`, yellow serif) and two lines of dialogue at the bottom.
|
/// (`\an8`, yellow serif) and two lines of dialogue at the bottom. The
|
||||||
|
/// dialogue moves up by [lift] (px covered at the bottom) in the same
|
||||||
|
/// steps as mpv's `sub-pos` moves the real ones; the sign stays.
|
||||||
class MockSubtitles extends StatelessWidget {
|
class MockSubtitles extends StatelessWidget {
|
||||||
final bool top;
|
final bool top;
|
||||||
final bool bottom;
|
final bool bottom;
|
||||||
const MockSubtitles({super.key, required this.top, required this.bottom});
|
final ValueListenable<double>? lift;
|
||||||
|
const MockSubtitles({super.key, required this.top, required this.bottom, this.lift});
|
||||||
|
|
||||||
static Widget _outlined(String text, TextStyle style, double outline) => Stack(
|
static Widget _outlined(String text, TextStyle style, double outline) => Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -276,10 +264,15 @@ class MockSubtitles extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (bottom)
|
if (bottom)
|
||||||
Positioned(
|
ValueListenableBuilder<double>(
|
||||||
|
valueListenable: lift ?? const AlwaysStoppedAnimation(0.0),
|
||||||
|
builder: (context, up, child) => Positioned(
|
||||||
|
key: const ValueKey('mock-dialogue'),
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: h * 0.05,
|
bottom: h * 0.05 + up,
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: _outlined(
|
child: _outlined(
|
||||||
'I told you the controls would cover this line.\nThey never do now — or do they?',
|
'I told you the controls would cover this line.\nThey never do now — or do they?',
|
||||||
@@ -299,15 +292,29 @@ class MockSubtitles extends StatelessWidget {
|
|||||||
/// fields drifting over a dusk gradient, so motion and contrast behind
|
/// fields drifting over a dusk gradient, so motion and contrast behind
|
||||||
/// the lines are realistic.
|
/// the lines are realistic.
|
||||||
class DanmakuStandIn extends StatefulWidget {
|
class DanmakuStandIn extends StatefulWidget {
|
||||||
const DanmakuStandIn({super.key});
|
/// False: a still picture (the settings' strip while it rests).
|
||||||
|
final bool animate;
|
||||||
|
const DanmakuStandIn({super.key, this.animate = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DanmakuStandIn> createState() => _DanmakuStandInState();
|
State<DanmakuStandIn> createState() => _DanmakuStandInState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DanmakuStandInState extends State<DanmakuStandIn> with SingleTickerProviderStateMixin {
|
class _DanmakuStandInState extends State<DanmakuStandIn> with SingleTickerProviderStateMixin {
|
||||||
late final AnimationController _clock =
|
late final AnimationController _clock = AnimationController(vsync: this, duration: const Duration(seconds: 60));
|
||||||
AnimationController(vsync: this, duration: const Duration(seconds: 60))..repeat();
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.animate) _clock.repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(DanmakuStandIn old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.animate && !_clock.isAnimating) _clock.repeat();
|
||||||
|
if (!widget.animate && _clock.isAnimating) _clock.stop();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -521,6 +528,8 @@ class _DanmakuPanelState extends State<DanmakuPanel> {
|
|||||||
_Slider('Opacity', s.opacity, 0.3, 1, '${(s.opacity * 100).round()} %',
|
_Slider('Opacity', s.opacity, 0.3, 1, '${(s.opacity * 100).round()} %',
|
||||||
(v) => _style((s) => s.copyWith(opacity: v))),
|
(v) => _style((s) => s.copyWith(opacity: v))),
|
||||||
_Slider('Size', s.size, 0.6, 1.8, '×${s.size.toStringAsFixed(2)}', (v) => _style((s) => s.copyWith(size: v))),
|
_Slider('Size', s.size, 0.6, 1.8, '×${s.size.toStringAsFixed(2)}', (v) => _style((s) => s.copyWith(size: v))),
|
||||||
|
_Slider('Bold', s.weight.toDouble(), 400, 900, '${s.weight}',
|
||||||
|
(v) => _style((s) => s.copyWith(weight: (v / 100).round() * 100))),
|
||||||
_Slider('Outline', s.outline, 0, 4, '${s.outline.toStringAsFixed(1)} px',
|
_Slider('Outline', s.outline, 0, 4, '${s.outline.toStringAsFixed(1)} px',
|
||||||
(v) => _style((s) => s.copyWith(outline: v))),
|
(v) => _style((s) => s.copyWith(outline: v))),
|
||||||
_Toggle('Avatars', s.avatars, (v) => _style((s) => s.copyWith(avatars: v))),
|
_Toggle('Avatars', s.avatars, (v) => _style((s) => s.copyWith(avatars: v))),
|
||||||
@@ -679,19 +688,72 @@ Future<void> showDanmakuPreviewPage(BuildContext context, DanmakuPreview preview
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class DanmakuPreviewPage extends StatelessWidget {
|
class DanmakuPreviewPage extends StatefulWidget {
|
||||||
final DanmakuPreview preview;
|
final DanmakuPreview preview;
|
||||||
const DanmakuPreviewPage({super.key, required this.preview});
|
const DanmakuPreviewPage({super.key, required this.preview});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuPreviewPage> createState() => _DanmakuPreviewPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page also has the quick chat box (Enter): what you send flies as
|
||||||
|
/// your own line, and the mock dialogue moves above the box in the lift's
|
||||||
|
/// steps, as the real subtitles do over the player.
|
||||||
|
class _DanmakuPreviewPageState extends State<DanmakuPreviewPage> {
|
||||||
|
final _chat = QuickChat();
|
||||||
|
final _lift = SubtitleLift();
|
||||||
|
final _pageFocus = FocusNode(debugLabel: 'danmaku preview');
|
||||||
|
double _boxH = QuickChatBox.height;
|
||||||
|
|
||||||
|
DanmakuPreview get preview => widget.preview;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_chat.open.addListener(_retarget);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _pageFocus.requestFocus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_chat.open.removeListener(_retarget);
|
||||||
|
_chat.dispose();
|
||||||
|
_lift.dispose();
|
||||||
|
_pageFocus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _retarget() {
|
||||||
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
|
final bar = preview.controlsBand.peek() && s.liftSubtitles.peek()
|
||||||
|
? controlsBottomHeight(s.controlsSkin.peek(), s.controlsSize.peek()) + 8
|
||||||
|
: 0.0;
|
||||||
|
final box = _chat.open.value ? 16 + _boxH + 8 : 0.0;
|
||||||
|
_lift.animateTo(box > bar ? box : bar);
|
||||||
|
if (!_chat.open.value) _pageFocus.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _key(FocusNode node, KeyEvent e) {
|
||||||
|
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||||
|
if (e.logicalKey == LogicalKeyboardKey.enter || e.logicalKey == LogicalKeyboardKey.numpadEnter) {
|
||||||
|
_chat.show();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Focus(
|
||||||
|
focusNode: _pageFocus,
|
||||||
|
onKeyEvent: _key,
|
||||||
|
child: Material(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
const DanmakuStandIn(),
|
const DanmakuStandIn(),
|
||||||
DanmakuPreviewLayers(preview: preview),
|
DanmakuPreviewLayers(preview: preview, lift: _lift),
|
||||||
SignalBuilder(builder: (context) {
|
SignalBuilder(builder: (context) {
|
||||||
if (!preview.controlsBand.value) return const SizedBox.shrink();
|
if (!preview.controlsBand.value) return const SizedBox.shrink();
|
||||||
final s = AppScope.of(context).coordinator.settings;
|
final s = AppScope.of(context).coordinator.settings;
|
||||||
@@ -699,6 +761,10 @@ class DanmakuPreviewPage extends StatelessWidget {
|
|||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _chat.open,
|
||||||
|
builder: (context, open, child) =>
|
||||||
|
AnimatedOpacity(opacity: open ? 0 : 1, duration: easeTime, curve: ease, child: child),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: h,
|
height: h,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
@@ -710,15 +776,30 @@ class DanmakuPreviewPage extends StatelessWidget {
|
|||||||
border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5)),
|
border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5)),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text('control bar', style: TextStyle(color: Colors.white.withValues(alpha: 0.35), fontSize: 12)),
|
child: Text('control bar',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.35), fontSize: 12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: QuickChatBox(
|
||||||
|
chat: _chat,
|
||||||
|
onSend: (text) => preview.controller.add(preview.own(text)),
|
||||||
|
onHeight: (h) {
|
||||||
|
_boxH = h;
|
||||||
|
if (_chat.open.value) _retarget();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
top: 14,
|
top: 14,
|
||||||
child: Text('Stand-in picture: load something to preview on the real player',
|
child: Text('Stand-in picture: load something to preview on the real player · Enter: chat box',
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 12)),
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 12)),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -728,6 +809,7 @@ class DanmakuPreviewPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../shell/avatar.dart' show AvatarInfo, colorForName, selfAvatarInfo;
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import '../widgets/app_menu.dart' show AppSelect;
|
||||||
|
import 'danmaku.dart';
|
||||||
|
import 'danmaku_preview.dart' show DanmakuPreview, DanmakuStandIn, MockSubtitles;
|
||||||
|
|
||||||
|
// Settings → Notifications & sounds: how notifications show over the full
|
||||||
|
// player (Toasts or Danmaku) and every knob of the danmaku look, with a
|
||||||
|
// live preview strip. The player's side panel shows the style and the
|
||||||
|
// knobs too ([DanmakuKnobs]).
|
||||||
|
|
||||||
|
/// Names of the weights the boldness slider steps through.
|
||||||
|
const danmakuWeightNames = {
|
||||||
|
400: 'Regular',
|
||||||
|
500: 'Medium',
|
||||||
|
600: 'Semibold',
|
||||||
|
700: 'Bold',
|
||||||
|
800: 'Extra bold',
|
||||||
|
900: 'Black',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// "Notifications: Toasts / Danmaku" ([AppSettings.notifyStyle]).
|
||||||
|
class NotifyStyleSelect extends StatelessWidget {
|
||||||
|
final AppSettings settings;
|
||||||
|
const NotifyStyleSelect({super.key, required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SignalBuilder(
|
||||||
|
builder: (context) => AppSelect<NotifyStyle>(
|
||||||
|
key: const ValueKey('notify-style'),
|
||||||
|
value: settings.notifyStyle.value,
|
||||||
|
onChanged: settings.setNotifyStyle,
|
||||||
|
items: [for (final m in NotifyStyle.values) (m, m.label)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card: the style picker, the preview strip and the knobs.
|
||||||
|
class DanmakuSettingsPanel extends StatelessWidget {
|
||||||
|
final AppSettings settings;
|
||||||
|
const DanmakuSettingsPanel({super.key, required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Notifications'),
|
||||||
|
Text(
|
||||||
|
'Danmaku: over the full player, chat, joins, leaves, Up next and "starting now" fly across '
|
||||||
|
'the top of the picture instead of popping up. Problems and errors stay pop-ups, and so does '
|
||||||
|
'everything with the player small',
|
||||||
|
style: muted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
NotifyStyleSelect(settings: settings),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DanmakuPreviewStrip(settings: settings),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
DanmakuKnobs(settings: settings),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 16:9-ish strip with a moving picture, a top sign and bottom
|
||||||
|
/// dialogue, and sample lines in the current look, a few a second.
|
||||||
|
class DanmakuPreviewStrip extends StatefulWidget {
|
||||||
|
final AppSettings settings;
|
||||||
|
final double height;
|
||||||
|
const DanmakuPreviewStrip({super.key, required this.settings, this.height = 190});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DanmakuPreviewStrip> createState() => _DanmakuPreviewStripState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuPreviewStripState extends State<DanmakuPreviewStrip> {
|
||||||
|
/// The strip plays this long after it opens or a knob moves, then
|
||||||
|
/// rests (a click plays it again): a settings page left open costs
|
||||||
|
/// nothing.
|
||||||
|
static const playFor = Duration(seconds: 16);
|
||||||
|
final _controller = DanmakuController();
|
||||||
|
final _rng = math.Random(3);
|
||||||
|
Timer? _timer;
|
||||||
|
Timer? _rest;
|
||||||
|
bool _playing = false;
|
||||||
|
int _n = 0;
|
||||||
|
late final void Function() _stop;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_stop = effect(() {
|
||||||
|
widget.settings.danmakuStyle.value;
|
||||||
|
untracked(_play);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stop();
|
||||||
|
_timer?.cancel();
|
||||||
|
_rest?.cancel();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _play() {
|
||||||
|
_rest?.cancel();
|
||||||
|
_rest = Timer(playFor, () {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
if (mounted) setState(() => _playing = false);
|
||||||
|
});
|
||||||
|
if (_timer != null) return;
|
||||||
|
_send();
|
||||||
|
_timer = Timer.periodic(const Duration(milliseconds: 1100), (_) => _send());
|
||||||
|
if (_playing) return;
|
||||||
|
_playing = true;
|
||||||
|
if (_built && mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _built = false;
|
||||||
|
|
||||||
|
void _send() {
|
||||||
|
final i = _n++;
|
||||||
|
if (i % 7 == 3) {
|
||||||
|
final me = selfAvatarInfo();
|
||||||
|
_controller.add(DanmakuItem(
|
||||||
|
kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: 'this is my line', own: true, avatar: me));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final name = DanmakuPreview.sampleNames[_rng.nextInt(DanmakuPreview.sampleNames.length)];
|
||||||
|
final who = AvatarInfo(name: name, color: colorForName(name));
|
||||||
|
if (i % 9 == 5) {
|
||||||
|
_controller.add(DanmakuItem(kind: DanmakuKind.join, sender: name, senderColor: who.color, text: 'joined', avatar: who));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Short and medium lines: the long ones crowd a small strip.
|
||||||
|
final pool = [for (final t in DanmakuPreview.sampleChat) if (t.length < 48) t];
|
||||||
|
_controller.add(DanmakuItem(
|
||||||
|
kind: DanmakuKind.chat,
|
||||||
|
sender: name,
|
||||||
|
senderColor: who.color,
|
||||||
|
text: pool[_rng.nextInt(pool.length)],
|
||||||
|
avatar: who));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_built = true;
|
||||||
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
|
return ClipRRect(
|
||||||
|
key: const ValueKey('danmaku-preview-strip'),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: SizedBox(
|
||||||
|
height: widget.height,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
DanmakuStandIn(animate: _playing),
|
||||||
|
const MockSubtitles(top: true, bottom: true),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) =>
|
||||||
|
DanmakuLayer(controller: _controller, style: widget.settings.danmakuStyle.value, accent: accent),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _playing ? 0 : 1,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: _playing,
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
key: const ValueKey('danmaku-preview-play'),
|
||||||
|
onPressed: _play,
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||||
|
label: const Text('Play preview'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every knob of the danmaku look, live and saved: crossing time, how
|
||||||
|
/// many at once, the lane band, opacity, size, boldness, outline,
|
||||||
|
/// avatars, your own lines framed; and a reset.
|
||||||
|
class DanmakuKnobs extends StatelessWidget {
|
||||||
|
final AppSettings settings;
|
||||||
|
|
||||||
|
/// Narrow (the player's side panel): shorter labels column.
|
||||||
|
final bool dense;
|
||||||
|
const DanmakuKnobs({super.key, required this.settings, this.dense = false});
|
||||||
|
|
||||||
|
void _set(DanmakuStyle Function(DanmakuStyle s) f) => settings.setDanmakuStyle(f(settings.danmakuStyle.peek()));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const l = DanmakuStyle.limits;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final s = settings.danmakuStyle.value;
|
||||||
|
final labelW = dense ? 84.0 : 150.0;
|
||||||
|
Widget slider(String key, String label, double v, (num, num) r, String readout, ValueChanged<double> f,
|
||||||
|
{int? divisions}) =>
|
||||||
|
_Knob(
|
||||||
|
label: label,
|
||||||
|
labelWidth: labelW,
|
||||||
|
readout: readout,
|
||||||
|
child: Slider(
|
||||||
|
key: ValueKey('danmaku-$key'),
|
||||||
|
value: v.clamp(r.$1.toDouble(), r.$2.toDouble()),
|
||||||
|
min: r.$1.toDouble(),
|
||||||
|
max: r.$2.toDouble(),
|
||||||
|
divisions: divisions,
|
||||||
|
onChanged: f,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Widget toggle(String key, String label, bool v, ValueChanged<bool> f) => _Knob(
|
||||||
|
label: label,
|
||||||
|
labelWidth: double.infinity,
|
||||||
|
child: Switch(key: ValueKey('danmaku-$key'), value: v, onChanged: f),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
slider('cross', dense ? 'Crossing' : 'Crossing time', s.crossSecs, l.crossSecs,
|
||||||
|
'${s.crossSecs.toStringAsFixed(1)} s', (v) => _set((s) => s.copyWith(crossSecs: v))),
|
||||||
|
slider('cap', dense ? 'At most' : 'At most on screen', s.maxOnScreen.toDouble(), l.maxOnScreen,
|
||||||
|
'${s.maxOnScreen} lines', (v) => _set((s) => s.copyWith(maxOnScreen: v.round())),
|
||||||
|
divisions: l.maxOnScreen.$2 - l.maxOnScreen.$1),
|
||||||
|
_Knob(
|
||||||
|
label: dense ? 'Lanes' : 'Lanes (from the top)',
|
||||||
|
labelWidth: labelW,
|
||||||
|
readout: '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
|
||||||
|
child: RangeSlider(
|
||||||
|
key: const ValueKey('danmaku-band'),
|
||||||
|
values: RangeValues(s.bandTop, s.bandBottom),
|
||||||
|
min: l.band.$1,
|
||||||
|
max: l.band.$2,
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v.end - v.start >= l.minBand) _set((s) => s.copyWith(bandTop: v.start, bandBottom: v.end));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
slider('opacity', 'Opacity', s.opacity, l.opacity, '${(s.opacity * 100).round()} %',
|
||||||
|
(v) => _set((s) => s.copyWith(opacity: v))),
|
||||||
|
slider('size', 'Size', s.size, l.size, '×${s.size.toStringAsFixed(2)}', (v) => _set((s) => s.copyWith(size: v))),
|
||||||
|
slider('weight', 'Boldness', s.weight.toDouble(), l.weight, danmakuWeightNames[s.weight] ?? '${s.weight}',
|
||||||
|
(v) => _set((s) => s.copyWith(weight: (v / 100).round() * 100)),
|
||||||
|
divisions: (l.weight.$2 - l.weight.$1) ~/ 100),
|
||||||
|
slider('outline', 'Outline', s.outline, l.outline,
|
||||||
|
s.outline < 0.05 ? 'none' : '${s.outline.toStringAsFixed(1)} px', (v) => _set((s) => s.copyWith(outline: v))),
|
||||||
|
toggle('avatars', 'Avatars before the lines', s.avatars, (v) => _set((s) => s.copyWith(avatars: v))),
|
||||||
|
toggle('own', 'Frame my own lines', s.highlightOwn, (v) => _set((s) => s.copyWith(highlightOwn: v))),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton.icon(
|
||||||
|
key: const ValueKey('danmaku-reset'),
|
||||||
|
onPressed: s == const DanmakuStyle() ? null : () => settings.setDanmakuStyle(const DanmakuStyle()),
|
||||||
|
icon: const Icon(Icons.restart_alt, size: 18),
|
||||||
|
label: const Text('Reset look'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Knob extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double labelWidth;
|
||||||
|
final String? readout;
|
||||||
|
final Widget child;
|
||||||
|
const _Knob({required this.label, required this.labelWidth, this.readout, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final text = Text(label, style: const TextStyle(fontSize: 13));
|
||||||
|
if (labelWidth == double.infinity) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 38,
|
||||||
|
child: Row(children: [Expanded(child: text), Transform.scale(scale: 0.8, alignment: Alignment.centerRight, child: child)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: labelWidth, child: text),
|
||||||
|
Expanded(
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: 3,
|
||||||
|
overlayShape: SliderComponentShape.noOverlay,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||||
|
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 7),
|
||||||
|
showValueIndicator: ShowValueIndicator.never,
|
||||||
|
tickMarkShape: SliderTickMarkShape.noTickMark,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 78,
|
||||||
|
child: Text(
|
||||||
|
readout ?? '',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@ class DeveloperPanel extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = Theme.of(context).textTheme;
|
final t = Theme.of(context).textTheme;
|
||||||
final s = AppScope.of(context).coordinator.settings;
|
|
||||||
return SettingsPanel(
|
return SettingsPanel(
|
||||||
title: 'Developer',
|
title: 'Developer',
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -41,19 +40,6 @@ class DeveloperPanel extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.subtitles_outlined, size: 18),
|
icon: const Icon(Icons.subtitles_outlined, size: 18),
|
||||||
label: const Text('Danmaku preview'),
|
label: const Text('Danmaku preview'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => SwitchListTile(
|
|
||||||
key: const ValueKey('lift-subtitles'),
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('Lift subtitles above the chat line and controls'),
|
|
||||||
subtitle: const Text('Enter in the player opens a stand-in chat line at the bottom. '
|
|
||||||
'While it or the control bar is up, bottom subtitles move above it (mpv sub-pos); '
|
|
||||||
'signs at the top stay where they are.'),
|
|
||||||
value: s.liftSubtitles.value,
|
|
||||||
onChanged: s.setLiftSubtitles,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const sessionShortcuts = [
|
|||||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||||
('PgUp / PgDn', 'Previous / next chapter'),
|
('PgUp / PgDn', 'Previous / next chapter'),
|
||||||
('F', 'Toggle fullscreen'),
|
('F', 'Toggle fullscreen'),
|
||||||
|
('Enter', 'Quick chat box (Enter sends, Esc closes)'),
|
||||||
('Tab', 'Open / close chat & activity'),
|
('Tab', 'Open / close chat & activity'),
|
||||||
('Ctrl+R', 'Reload the video'),
|
('Ctrl+R', 'Reload the video'),
|
||||||
('> or Shift+N', 'Put up the next Jellyfin episode'),
|
('> or Shift+N', 'Put up the next Jellyfin episode'),
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class ToastItem {
|
|||||||
final bool chat;
|
final bool chat;
|
||||||
/// A picture hash to show (an Up next item's), '' when none.
|
/// A picture hash to show (an Up next item's), '' when none.
|
||||||
final String thumb;
|
final String thumb;
|
||||||
|
/// What it is about (the shell may show some of it another way:
|
||||||
|
/// "Notifications as danmaku").
|
||||||
|
final NoticeCategory category;
|
||||||
|
/// This member's own (their chat message coming back from the server).
|
||||||
|
final bool own;
|
||||||
const ToastItem(
|
const ToastItem(
|
||||||
{required this.id,
|
{required this.id,
|
||||||
required this.text,
|
required this.text,
|
||||||
@@ -32,7 +37,9 @@ class ToastItem {
|
|||||||
this.actor,
|
this.actor,
|
||||||
this.actorId = '',
|
this.actorId = '',
|
||||||
this.chat = false,
|
this.chat = false,
|
||||||
this.thumb = ''});
|
this.thumb = '',
|
||||||
|
this.category = NoticeCategory.app,
|
||||||
|
this.own = false});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a notice is about; the policy filters by it ([noticeFor]).
|
/// What a notice is about; the policy filters by it ([noticeFor]).
|
||||||
@@ -135,6 +142,8 @@ class Notice {
|
|||||||
final bool chat;
|
final bool chat;
|
||||||
/// A picture hash for the toast (an Up next item's).
|
/// A picture hash for the toast (an Up next item's).
|
||||||
final String thumb;
|
final String thumb;
|
||||||
|
/// This member is the actor (a feed line of its own).
|
||||||
|
final bool own;
|
||||||
final SfxCue? sound;
|
final SfxCue? sound;
|
||||||
final bool attention;
|
final bool attention;
|
||||||
final SystemNote? system;
|
final SystemNote? system;
|
||||||
@@ -146,6 +155,7 @@ class Notice {
|
|||||||
this.actor,
|
this.actor,
|
||||||
this.chat = false,
|
this.chat = false,
|
||||||
this.thumb = '',
|
this.thumb = '',
|
||||||
|
this.own = false,
|
||||||
this.sound,
|
this.sound,
|
||||||
this.attention = false,
|
this.attention = false,
|
||||||
this.system,
|
this.system,
|
||||||
@@ -154,12 +164,13 @@ class Notice {
|
|||||||
|
|
||||||
static const none = Notice();
|
static const none = Notice();
|
||||||
|
|
||||||
Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice(
|
Notice _with({bool toast = true, bool sound = true, SystemNote? system, bool own = false}) => Notice(
|
||||||
category: category,
|
category: category,
|
||||||
toast: toast ? this.toast : null,
|
toast: toast ? this.toast : null,
|
||||||
actor: toast ? actor : null,
|
actor: toast ? actor : null,
|
||||||
chat: toast && chat,
|
chat: toast && chat,
|
||||||
thumb: toast ? thumb : '',
|
thumb: toast ? thumb : '',
|
||||||
|
own: own,
|
||||||
sound: sound ? this.sound : null,
|
sound: sound ? this.sound : null,
|
||||||
attention: attention,
|
attention: attention,
|
||||||
system: system,
|
system: system,
|
||||||
@@ -334,7 +345,7 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
|
|||||||
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
|
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return n._with(toast: toast, sound: sound, system: system);
|
return n._with(toast: toast, sound: sound, system: system, own: own);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||||
@@ -410,7 +421,9 @@ class Notifier {
|
|||||||
actor: n.actor,
|
actor: n.actor,
|
||||||
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
|
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
|
||||||
chat: n.chat,
|
chat: n.chat,
|
||||||
thumb: n.thumb),
|
thumb: n.thumb,
|
||||||
|
category: n.category,
|
||||||
|
own: n.own),
|
||||||
];
|
];
|
||||||
_timers[id] = Timer(toastLife, () => dismiss(id));
|
_timers[id] = Timer(toastLife, () => dismiss(id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
|
|
||||||
import '../controls/frame.dart' show DoubleClickListener;
|
import '../controls/frame.dart' show DoubleClickListener;
|
||||||
import '../controls/session_controls.dart' show controlsBottomHeight;
|
import '../controls/session_controls.dart' show controlsBottomHeight;
|
||||||
|
import '../danmaku/danmaku_notices.dart' show fliesAsDanmaku;
|
||||||
import '../danmaku/danmaku_preview.dart';
|
import '../danmaku/danmaku_preview.dart';
|
||||||
import '../files/folder_store.dart';
|
import '../files/folder_store.dart';
|
||||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||||
@@ -38,6 +39,7 @@ import 'profile_card.dart';
|
|||||||
import '../schedule/ui/schedule_page.dart';
|
import '../schedule/ui/schedule_page.dart';
|
||||||
import 'session_player.dart';
|
import 'session_player.dart';
|
||||||
import 'settings_tab.dart';
|
import 'settings_tab.dart';
|
||||||
|
import 'quick_chat.dart';
|
||||||
import 'toasts.dart';
|
import 'toasts.dart';
|
||||||
import 'welcome.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
|
/// Whether the full player's controls are up (the player's frame
|
||||||
/// reports it): the toasts sit above them.
|
/// reports it): the toasts sit above them.
|
||||||
final _controlsShown = ValueNotifier(true);
|
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;
|
Alignment _corner = Alignment.bottomRight;
|
||||||
Offset? _drag; // mini top-left while dragging
|
Offset? _drag; // mini top-left while dragging
|
||||||
@@ -262,6 +269,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_flow.dispose();
|
_flow.dispose();
|
||||||
_join.dispose();
|
_join.dispose();
|
||||||
_picker.dispose();
|
_picker.dispose();
|
||||||
|
_quickChat.dispose();
|
||||||
|
_danmakuActive.dispose();
|
||||||
_chat.dispose();
|
_chat.dispose();
|
||||||
_chatFocus.dispose();
|
_chatFocus.dispose();
|
||||||
_controlsShown.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
|
/// Over the full player when a player is up, else on its own page with
|
||||||
/// a stand-in picture.
|
/// a stand-in picture.
|
||||||
void _openDanmakuPreview() {
|
void _openDanmakuPreview() {
|
||||||
final d = _danmaku ??= DanmakuPreview();
|
final d = _danmaku ??= DanmakuPreview(style: _graph.coordinator.settings.danmakuStyle.peek());
|
||||||
if (_online.peek() && _graph.coordinator.playback.alive.peek()) {
|
if (_online.peek() && _graph.coordinator.playback.alive.peek()) {
|
||||||
setState(() => d.active.value = true);
|
setState(() => d.active.value = true);
|
||||||
_show(PlayerMode.full);
|
_show(PlayerMode.full);
|
||||||
@@ -855,6 +864,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
onOpenChat: () {
|
onOpenChat: () {
|
||||||
if (!_chatOpen) _toggleChat();
|
if (!_chatOpen) _toggleChat();
|
||||||
},
|
},
|
||||||
|
skip: (t) => (_danmakuActive.value && fliesAsDanmaku(t)) || _quickChat.echoed(t),
|
||||||
);
|
);
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: _controlsShown,
|
valueListenable: _controlsShown,
|
||||||
@@ -950,6 +960,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
videoBuilder: widget.videoBuilder,
|
videoBuilder: widget.videoBuilder,
|
||||||
windowControls: widget.windowControls,
|
windowControls: widget.windowControls,
|
||||||
controlsShown: _controlsShown,
|
controlsShown: _controlsShown,
|
||||||
|
quickChat: _quickChat,
|
||||||
|
danmakuActive: _danmakuActive,
|
||||||
danmaku: _danmaku,
|
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/minimal/style.dart';
|
||||||
import '../controls/model/controls_layout.dart';
|
import '../controls/model/controls_layout.dart';
|
||||||
import '../controls/model/controls_model.dart';
|
import '../controls/model/controls_model.dart';
|
||||||
|
import '../controls/player_panel.dart';
|
||||||
import '../controls/session_controls.dart';
|
import '../controls/session_controls.dart';
|
||||||
import '../controls/volume.dart';
|
import '../controls/volume.dart';
|
||||||
|
import '../danmaku/danmaku.dart' show DanmakuController, DanmakuItem, DanmakuKind;
|
||||||
|
import '../danmaku/danmaku_notices.dart';
|
||||||
import '../danmaku/danmaku_preview.dart';
|
import '../danmaku/danmaku_preview.dart';
|
||||||
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
|
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
|
||||||
import '../queue/queue_ui.dart';
|
import '../queue/queue_ui.dart';
|
||||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
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/jellyfin_widgets.dart';
|
||||||
import '../screens/session/player_controls.dart';
|
import '../screens/session/player_controls.dart';
|
||||||
import '../session/app_events.dart' show ShaderMessage;
|
import '../session/app_events.dart' show ShaderMessage;
|
||||||
@@ -26,13 +28,13 @@ import '../shaders/shader_presets.dart';
|
|||||||
import '../app/app_graph.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/app_menu.dart' show AppGlass;
|
|
||||||
import '../widgets/text_focus.dart';
|
import '../widgets/text_focus.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'now_playing_title.dart';
|
import 'now_playing_title.dart';
|
||||||
import 'player_status.dart';
|
import 'player_status.dart';
|
||||||
|
import 'quick_chat.dart';
|
||||||
import 'sit_out_card.dart';
|
import 'sit_out_card.dart';
|
||||||
import 'start_banner.dart';
|
import 'start_banner.dart';
|
||||||
import 'video_view.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
|
/// The session's player: the one `Video` of the current player and
|
||||||
/// everything over it. Full: the controls (one model, the skin picked in
|
/// everything over it. Full: the controls (one model, the skin picked in
|
||||||
/// Settings), the start card, the status pill, Jellyfin overlays and
|
/// Settings), the start card, the status pill, Jellyfin overlays, the
|
||||||
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
/// quick chat box, the danmaku, the player panel and the player keys.
|
||||||
/// widget mounted at one place in the tree while the mode changes, so the
|
/// 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.
|
/// `Video` is never mounted twice nor rebuilt for a move.
|
||||||
class SessionPlayer extends StatefulWidget {
|
class SessionPlayer extends StatefulWidget {
|
||||||
final PlayerMode mode;
|
final PlayerMode mode;
|
||||||
@@ -73,6 +76,12 @@ class SessionPlayer extends StatefulWidget {
|
|||||||
/// Follows whether the full player's controls are up (the shell lifts
|
/// Follows whether the full player's controls are up (the shell lifts
|
||||||
/// its toasts above them); the player keeps its own when null.
|
/// its toasts above them); the player keeps its own when null.
|
||||||
final ValueNotifier<bool>? controlsShown;
|
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
|
/// Dev builds: the danmaku preview, shown over the full player while
|
||||||
/// it is active.
|
/// it is active.
|
||||||
final DanmakuPreview? danmaku;
|
final DanmakuPreview? danmaku;
|
||||||
@@ -93,6 +102,8 @@ class SessionPlayer extends StatefulWidget {
|
|||||||
this.videoBuilder,
|
this.videoBuilder,
|
||||||
this.windowControls = true,
|
this.windowControls = true,
|
||||||
this.controlsShown,
|
this.controlsShown,
|
||||||
|
this.quickChat,
|
||||||
|
this.danmakuActive,
|
||||||
this.danmaku,
|
this.danmaku,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,16 +119,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
DateTime? _lastKeyAt;
|
DateTime? _lastKeyAt;
|
||||||
late final void Function() _stopShaderWatch;
|
late final void Function() _stopShaderWatch;
|
||||||
|
late final void Function() _stopSubScale;
|
||||||
|
late final void Function() _stopDanmakuWatch;
|
||||||
|
late final void Function() _stopLiftWatch;
|
||||||
int _shaderNoticeSeen = 0;
|
int _shaderNoticeSeen = 0;
|
||||||
bool _miniHover = false;
|
bool _miniHover = false;
|
||||||
// Whether the controls are up (their frame reports it).
|
// Whether the controls are up (their frame reports it).
|
||||||
final _ownShown = ValueNotifier(true);
|
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;
|
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 mouse on a floating element, or the Up next popover open, keeps
|
||||||
// the controls up.
|
// the controls up.
|
||||||
final _hold = ControlsHold();
|
final _hold = ControlsHold();
|
||||||
@@ -139,7 +163,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
late final SessionCoordinator _coord = AppScope.of(context).coordinator;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -148,6 +173,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_model;
|
_model;
|
||||||
_coord.jellyfin.ensureLoaded();
|
_coord.jellyfin.ensureLoaded();
|
||||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||||
|
FocusManager.instance.addEarlyKeyEventHandler(_earlyKey);
|
||||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||||
AppKeys.addChatToggle(_toggleChat);
|
AppKeys.addChatToggle(_toggleChat);
|
||||||
// Shader messages (preset changes, load failures) become toasts.
|
// Shader messages (preset changes, load failures) become toasts.
|
||||||
@@ -161,6 +187,32 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_stopPopoverWatch = effect(() {
|
_stopPopoverWatch = effect(() {
|
||||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
_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
|
@override
|
||||||
@@ -169,19 +221,48 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_fullscreen.value = widget.fullscreen;
|
_fullscreen.value = widget.fullscreen;
|
||||||
_chatOpen.value = widget.chatOpen;
|
_chatOpen.value = widget.chatOpen;
|
||||||
_unread.value = widget.unread;
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||||
|
FocusManager.instance.removeEarlyKeyEventHandler(_earlyKey);
|
||||||
AppKeys.removeChatToggle(_toggleChat);
|
AppKeys.removeChatToggle(_toggleChat);
|
||||||
_stopShaderWatch();
|
_stopShaderWatch();
|
||||||
_stopPopoverWatch();
|
_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();
|
_noFocus.dispose();
|
||||||
_ownShown.dispose();
|
_ownShown.dispose();
|
||||||
_chatLine.dispose();
|
_boxUp.dispose();
|
||||||
_chatLineText.dispose();
|
_lift.dispose();
|
||||||
_chatLineFocus.dispose();
|
_lines.dispose();
|
||||||
_hold.dispose();
|
_hold.dispose();
|
||||||
_model.dispose();
|
_model.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -189,6 +270,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
|
|
||||||
void _toggleChat() => _model.toggleChat();
|
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
|
/// 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
|
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||||
/// app-wide ([AppKeys]).
|
/// app-wide ([AppKeys]).
|
||||||
@@ -197,7 +327,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
if (!AppScope.of(context).navigation.online.peek()) return false;
|
if (!AppScope.of(context).navigation.online.peek()) return false;
|
||||||
// Mini: Esc is the last step out (a friend's server: asking to leave
|
// Mini: Esc is the last step out (a friend's server: asking to leave
|
||||||
// it; yours: the player closes).
|
// it; yours: the player closes).
|
||||||
if (!_full) {
|
if (!_isFull) {
|
||||||
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
|
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
|
||||||
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
|
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
|
||||||
widget.onLeave();
|
widget.onLeave();
|
||||||
@@ -213,10 +343,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (textInputFocused()) return false;
|
if (textInputFocused()) return false;
|
||||||
if (devTools &&
|
final key = event.logicalKey;
|
||||||
_coord.settings.liftSubtitles.peek() &&
|
// Enter: the quick chat box (the chat panel's draft), whatever plays.
|
||||||
(event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter)) {
|
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
|
||||||
_openChatLine();
|
if (_coord.selfAway.peek()) return false;
|
||||||
|
_swallow = event;
|
||||||
|
_quick.show();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
@@ -224,7 +356,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final model = _model;
|
final model = _model;
|
||||||
final key = event.logicalKey;
|
|
||||||
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
||||||
final digit = _shaderKeys.indexOf(key);
|
final digit = _shaderKeys.indexOf(key);
|
||||||
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
||||||
@@ -276,7 +407,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.escape) {
|
if (key == LogicalKeyboardKey.escape) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
if (widget.chatOpen) {
|
if (_model.panelOpen.peek()) {
|
||||||
|
_model.panelOpen.value = false;
|
||||||
|
} else if (widget.chatOpen) {
|
||||||
widget.onToggleChat();
|
widget.onToggleChat();
|
||||||
} else if (widget.fullscreen) {
|
} else if (widget.fullscreen) {
|
||||||
widget.onToggleFullscreen();
|
widget.onToggleFullscreen();
|
||||||
@@ -308,7 +441,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
if (!playback.alive.value) {
|
if (!playback.alive.value) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('player starting…',
|
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) {
|
if (gen != _videoGen) {
|
||||||
@@ -317,25 +450,25 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
// Never null for the mini: media_kit keeps the last controls
|
// Never null for the mini: media_kit keeps the last controls
|
||||||
// when given null, so the full player's would stay under it.
|
// 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);
|
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||||
// The wheel anywhere on the full player steps the volume
|
// The wheel anywhere on the full player steps the volume
|
||||||
// (lists, menus and popovers under the mouse scroll instead).
|
// (lists, menus and popovers under the mouse scroll instead).
|
||||||
return VolumeWheel(
|
return VolumeWheel(
|
||||||
enabled: _full,
|
enabled: _isFull,
|
||||||
onStep: _model.stepVolume,
|
onStep: _model.stepVolume,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
video,
|
video,
|
||||||
// Dev experiment: bottom subtitles above the controls.
|
SubtitleLifter(playback: _coord.playback, lift: _lift, active: _isFull),
|
||||||
if (devTools) _subtitleLifter(),
|
if (_isFull) ...[
|
||||||
if (_full) ...[
|
|
||||||
_blockedCard(),
|
_blockedCard(),
|
||||||
_actionArea(),
|
_actionArea(),
|
||||||
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
||||||
_volumeReadout(),
|
_volumeReadout(),
|
||||||
if (devTools) _chatLineOverlay(),
|
_quickChatBox(),
|
||||||
|
_playerPanel(),
|
||||||
if (widget.danmaku case final d?) _danmakuPanel(d),
|
if (widget.danmaku case final d?) _danmakuPanel(d),
|
||||||
] else
|
] else
|
||||||
_miniOverlay(),
|
_miniOverlay(),
|
||||||
@@ -352,7 +485,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
|
|
||||||
Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
|
Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
|
||||||
playback: _coord.playback,
|
playback: _coord.playback,
|
||||||
sharp: _coord.settings.screenResVideo,
|
sharp: _settings.screenResVideo,
|
||||||
child: Video(
|
child: Video(
|
||||||
key: _videoKey,
|
key: _videoKey,
|
||||||
controller: videoControllerOf(_coord.playback.video),
|
controller: videoControllerOf(_coord.playback.video),
|
||||||
@@ -361,101 +494,57 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// What covers the bottom of the full player, for the subtitle lift:
|
/// The quick chat box in the control bar's place, centred (Enter opens
|
||||||
/// the chat line, else the control bar while it is up.
|
/// it), and the echo of what you sent.
|
||||||
Widget _subtitleLifter() => SignalBuilder(builder: (context) {
|
Widget _quickChatBox() => Positioned(
|
||||||
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,
|
left: _edge,
|
||||||
right: _edge,
|
right: _edge,
|
||||||
bottom: _edge,
|
bottom: _edge,
|
||||||
height: _chatLineHeight,
|
child: Stack(
|
||||||
child: ValueListenableBuilder<bool>(
|
alignment: Alignment.bottomCenter,
|
||||||
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: [
|
children: [
|
||||||
Icon(Icons.chat_bubble_outline, size: 18, color: Colors.white.withValues(alpha: 0.7)),
|
QuickChatEcho(chat: _quick),
|
||||||
const SizedBox(width: 12),
|
QuickChatBox(chat: _quick, onSend: _send, onHeight: _boxHeightChanged),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 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
|
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||||
/// carries them while the controls are up (restore leaves fullscreen).
|
/// carries them while the controls are up (restore leaves fullscreen).
|
||||||
@@ -482,24 +571,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
children: [
|
children: [
|
||||||
const StartBanner(),
|
const StartBanner(),
|
||||||
// Under the controls, over the picture.
|
// 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?)
|
if (widget.danmaku case final d?)
|
||||||
SignalBuilder(
|
SignalBuilder(
|
||||||
builder: (context) => d.active.value ? DanmakuPreviewLayers(preview: d) : const SizedBox.shrink(),
|
builder: (context) =>
|
||||||
|
d.active.value ? DanmakuPreviewLayers(preview: d, lift: _lift) : const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
// The chat line takes the control bar's place while it is open.
|
SessionControls(
|
||||||
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,
|
model: _model,
|
||||||
shown: _controlsShown,
|
shown: _controlsShown,
|
||||||
hold: _hold,
|
hold: _hold,
|
||||||
onDoubleClick: widget.onToggleFullscreen,
|
onDoubleClick: widget.onToggleFullscreen,
|
||||||
windowButtons: _fullscreenWindowButtons(),
|
windowButtons: _fullscreenWindowButtons(),
|
||||||
),
|
suppress: _boxUp,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -510,12 +604,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
static const _edge = 16.0;
|
static const _edge = 16.0;
|
||||||
|
|
||||||
/// The bottom controls' height in the skin and size in use.
|
/// The bottom controls' height in the skin and size in use.
|
||||||
static double _bottomControlsHeight() {
|
double _bottomControlsHeight() =>
|
||||||
final s = AppSettings.instance;
|
controlsBottomHeight(_settings.controlsSkin.value, _settings.controlsSize.value);
|
||||||
return controlsBottomHeight(s.controlsSkin.value, s.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
|
/// The danmaku preview's panel, top right under the top bar; the mouse
|
||||||
/// on it keeps the controls up.
|
/// on it keeps the controls up.
|
||||||
@@ -560,15 +655,20 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
Widget _actionArea() {
|
Widget _actionArea() {
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final controls = _bottomControlsHeight();
|
final controls = _bottomControlsHeight();
|
||||||
return ValueListenableBuilder<bool>(
|
return ListenableBuilder(
|
||||||
valueListenable: _controlsShown,
|
listenable: Listenable.merge([_controlsShown, _boxUp]),
|
||||||
builder: (context, shown, child) => AnimatedPositioned(
|
builder: (context, child) => AnimatedPositioned(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
left: _edge,
|
left: _edge,
|
||||||
right: _edge,
|
right: _edge,
|
||||||
top: 0,
|
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: child!,
|
||||||
),
|
),
|
||||||
child: CustomMultiChildLayout(
|
child: CustomMultiChildLayout(
|
||||||
@@ -705,7 +805,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
|
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_isFull),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 4,
|
top: 4,
|
||||||
right: 4,
|
right: 4,
|
||||||
@@ -714,9 +814,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
onPressed: _full ? widget.onCollapse : widget.onExpand,
|
onPressed: _isFull ? widget.onCollapse : widget.onExpand,
|
||||||
tooltip: _full ? 'Small player' : 'Expand',
|
tooltip: _isFull ? 'Small player' : 'Expand',
|
||||||
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
icon: Icon(_isFull ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart'
|
|||||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||||
|
|
||||||
import '../controls/player_settings.dart';
|
import '../controls/player_settings.dart';
|
||||||
|
import '../danmaku/danmaku_settings.dart' show DanmakuSettingsPanel;
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/accounts_panel.dart';
|
import '../jellyfin/accounts_panel.dart';
|
||||||
import '../schedule/animeflow.dart';
|
import '../schedule/animeflow.dart';
|
||||||
@@ -124,7 +125,8 @@ class SettingsTab extends StatelessWidget {
|
|||||||
return [
|
return [
|
||||||
const SettingsHintCard(
|
const SettingsHintCard(
|
||||||
icon: Icons.tune,
|
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(
|
SettingsPanel(
|
||||||
title: 'Player',
|
title: 'Player',
|
||||||
@@ -778,7 +780,8 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
line('Joins, leaves, lost connections and video changes',
|
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'),
|
line('Added to Up next', 'With the title, its picture and who added it'),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
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(
|
SettingsPanel(
|
||||||
title: 'Sitting out and in the background',
|
title: 'Sitting out and in the background',
|
||||||
child: Column(
|
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.
|
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
|
||||||
class PartyToasts extends StatefulWidget {
|
class PartyToasts extends StatefulWidget {
|
||||||
final VoidCallback? onOpenChat;
|
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
|
@override
|
||||||
State<PartyToasts> createState() => _PartyToastsState();
|
State<PartyToasts> createState() => _PartyToastsState();
|
||||||
@@ -86,6 +90,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
|||||||
// Only new toasts re-run this, not the party they mention.
|
// Only new toasts re-run this, not the party they mention.
|
||||||
untracked(() {
|
untracked(() {
|
||||||
for (final t in fresh) {
|
for (final t in fresh) {
|
||||||
|
if (widget.skip?.call(t) ?? false) continue;
|
||||||
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
|
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
|
||||||
}
|
}
|
||||||
if (gone.isNotEmpty) _leave(gone);
|
if (gone.isNotEmpty) _leave(gone);
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../playback/playback_controller.dart';
|
import '../playback/playback_controller.dart';
|
||||||
import 'motion.dart';
|
|
||||||
|
|
||||||
// What the session's video view tells mpv about where it is shown: the
|
// 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
|
// size its output renders at ("Sharp video and subtitles") and how far
|
||||||
// experiment, how far bottom subtitles move up while the controls are up.
|
// 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
|
/// 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
|
/// 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);
|
return (100 - shift / picH * 100).clamp(50.0, 100.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dev experiment ([AppSettings.liftSubtitles]): while something covers
|
/// The subtitle lift moves in [liftStepCount] steps [liftStepGap] apart
|
||||||
/// the bottom of the full player ([cover]: its height, 0 for nothing; the
|
/// (about 130 ms): each `sub-pos` change makes mpv re-render the
|
||||||
/// chat line, else the control bar while it is up), bottom subtitles of the
|
/// subtitles, and a playing video shows a change only with its next
|
||||||
/// current player move above it, and back down when it goes, animated over
|
/// frame, so a change per screen frame (60 a second against a video's 24)
|
||||||
/// the shell's [easeTime] in mpv `sub-pos` steps. Top-aligned signs stay
|
/// landed unevenly and looked choppy. One step per frame of a 24 fps
|
||||||
/// put. Draws nothing.
|
/// 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 {
|
class SubtitleLifter extends StatefulWidget {
|
||||||
final PlaybackController playback;
|
final PlaybackController playback;
|
||||||
|
final SubtitleLift lift;
|
||||||
/// 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.
|
/// Only the full player lifts.
|
||||||
final bool active;
|
final bool active;
|
||||||
const SubtitleLifter({
|
const SubtitleLifter({super.key, required this.playback, required this.lift, required this.active});
|
||||||
super.key,
|
|
||||||
required this.playback,
|
|
||||||
required this.changes,
|
|
||||||
required this.cover,
|
|
||||||
required this.lift,
|
|
||||||
required this.active,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SubtitleLifter> createState() => _SubtitleLifterState();
|
State<SubtitleLifter> createState() => _SubtitleLifterState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SubtitleLifterState extends State<SubtitleLifter> with SingleTickerProviderStateMixin {
|
class _SubtitleLifterState extends State<SubtitleLifter> {
|
||||||
late final AnimationController _anim = AnimationController(vsync: this, duration: easeTime)
|
static const _home = '100.0';
|
||||||
..addListener(_tick);
|
String _sent = _home;
|
||||||
double _from = 100, _to = 100;
|
|
||||||
double _sent = 100;
|
|
||||||
HostedPlayer? _player;
|
HostedPlayer? _player;
|
||||||
Size _view = Size.zero;
|
Size _view = Size.zero;
|
||||||
late final void Function() _stop;
|
late final void Function() _stop;
|
||||||
@@ -158,77 +211,61 @@ class _SubtitleLifterState extends State<SubtitleLifter> with SingleTickerProvid
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
widget.changes.addListener(_retarget);
|
widget.lift.addListener(_apply);
|
||||||
|
// A new player starts where the file wants its subtitles.
|
||||||
_stop = effect(() {
|
_stop = effect(() {
|
||||||
widget.lift.value;
|
|
||||||
widget.playback.generation.value;
|
widget.playback.generation.value;
|
||||||
untracked(_retarget);
|
untracked(_apply);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(SubtitleLifter old) {
|
void didUpdateWidget(SubtitleLifter old) {
|
||||||
super.didUpdateWidget(old);
|
super.didUpdateWidget(old);
|
||||||
if (old.changes != widget.changes) {
|
if (old.lift != widget.lift) {
|
||||||
old.changes.removeListener(_retarget);
|
old.lift.removeListener(_apply);
|
||||||
widget.changes.addListener(_retarget);
|
widget.lift.addListener(_apply);
|
||||||
}
|
}
|
||||||
_retarget();
|
if (old.active != widget.active) _apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.changes.removeListener(_retarget);
|
widget.lift.removeListener(_apply);
|
||||||
_stop();
|
_stop();
|
||||||
_anim.dispose();
|
|
||||||
// Leave the player as the file wants it.
|
// Leave the player as the file wants it.
|
||||||
if (_sent != 100) _send(100);
|
if (_sent != _home) _send(_home);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
double get _target {
|
void _apply() {
|
||||||
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;
|
if (!mounted) return;
|
||||||
final p = widget.playback.current;
|
final p = widget.playback.current;
|
||||||
if (!identical(p, _player)) {
|
if (!identical(p, _player)) {
|
||||||
// A new player starts where the file wants its subtitles.
|
|
||||||
_player = p;
|
_player = p;
|
||||||
_sent = 100;
|
_sent = _home;
|
||||||
_from = _to = 100;
|
|
||||||
_anim.value = 1;
|
|
||||||
}
|
}
|
||||||
final t = _target;
|
final cover = widget.active ? widget.lift.value : 0.0;
|
||||||
if ((t - _to).abs() < 0.1) return;
|
final st = p?.state;
|
||||||
_from = _sent;
|
final pos = cover <= 0
|
||||||
_to = t;
|
? 100.0
|
||||||
_anim.forward(from: 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() {
|
void _send(String pos) {
|
||||||
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;
|
_sent = pos;
|
||||||
final p = widget.playback.current;
|
final p = widget.playback.current;
|
||||||
if (p == null || p.disposed) return;
|
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
|
@override
|
||||||
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
||||||
if (box.biggest != _view) {
|
if (box.biggest != _view) {
|
||||||
_view = box.biggest;
|
_view = box.biggest;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _retarget());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _apply());
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:hive_ce/hive.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../controls/model/controls_prefs.dart';
|
import '../controls/model/controls_prefs.dart';
|
||||||
|
import '../danmaku/danmaku.dart' show DanmakuStyle;
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../session/notice_prefs.dart';
|
import '../session/notice_prefs.dart';
|
||||||
import 'settings_schema.dart';
|
import 'settings_schema.dart';
|
||||||
@@ -48,9 +49,21 @@ const _keyWhatsNewSeen = 'whatsNewSeen';
|
|||||||
const _keyWhatsNewNotes = 'whatsNewNotes';
|
const _keyWhatsNewNotes = 'whatsNewNotes';
|
||||||
const _keyScreenResVideo = 'screenResVideo';
|
const _keyScreenResVideo = 'screenResVideo';
|
||||||
const _keyLiftSubtitles = 'liftSubtitles';
|
const _keyLiftSubtitles = 'liftSubtitles';
|
||||||
|
const _keyNotifyStyle = 'notifyStyle';
|
||||||
|
const _keySubScale = 'subScale';
|
||||||
|
const _keyDanmakuStyle = 'danmakuStyle';
|
||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
|
/// How notifications show over the full player ([AppSettings.notifyStyle]).
|
||||||
|
enum NotifyStyle {
|
||||||
|
toasts('Toasts'),
|
||||||
|
danmaku('Danmaku');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
const NotifyStyle(this.label);
|
||||||
|
}
|
||||||
|
|
||||||
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
|
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
|
||||||
/// the GPU, frames copied back): media_kit renders through ANGLE, where
|
/// the GPU, frames copied back): media_kit renders through ANGLE, where
|
||||||
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL
|
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL
|
||||||
@@ -96,9 +109,19 @@ class AppSettings {
|
|||||||
/// resolution; off: at the video's own size, scaled by Flutter. Applies
|
/// resolution; off: at the video's own size, scaled by Flutter. Applies
|
||||||
/// live (GPU rendering only).
|
/// live (GPU rendering only).
|
||||||
final screenResVideo = signal(true);
|
final screenResVideo = signal(true);
|
||||||
/// Dev builds only, an experiment: bottom subtitles move up while the
|
/// Bottom subtitles also move up above the control bar while it is up
|
||||||
/// controls are up (mpv `sub-pos`). Applies live.
|
/// (mpv `sub-pos`; the quick chat box always lifts them). Default off.
|
||||||
|
/// Applies live.
|
||||||
final liftSubtitles = signal(false);
|
final liftSubtitles = signal(false);
|
||||||
|
/// Subtitle size, mpv `sub-scale` (1: as the file has them), 0.5..2.
|
||||||
|
/// Applies live and to each new player.
|
||||||
|
final subScale = signal(1.0);
|
||||||
|
/// How notifications show over the full player: toasts (the default),
|
||||||
|
/// or chat and short notices flying across the picture as danmaku
|
||||||
|
/// (`danmaku/danmaku_notices.dart`; problems stay toasts). Applies live.
|
||||||
|
final notifyStyle = signal(NotifyStyle.toasts);
|
||||||
|
/// How those lines look and move (stored as one map, [DanmakuStyle.toJson]).
|
||||||
|
final danmakuStyle = signal(const DanmakuStyle());
|
||||||
/// Chat message toasts (default: all). Applies live.
|
/// Chat message toasts (default: all). Applies live.
|
||||||
final chatPopups = signal(ChatPopups.all);
|
final chatPopups = signal(ChatPopups.all);
|
||||||
/// Joins and leaves also notify while sitting out or with the window in
|
/// Joins and leaves also notify while sitting out or with the window in
|
||||||
@@ -189,6 +212,12 @@ class AppSettings {
|
|||||||
if (screenRes is bool) screenResVideo.value = screenRes;
|
if (screenRes is bool) screenResVideo.value = screenRes;
|
||||||
final lift = box.get(_keyLiftSubtitles);
|
final lift = box.get(_keyLiftSubtitles);
|
||||||
if (lift is bool) liftSubtitles.value = lift;
|
if (lift is bool) liftSubtitles.value = lift;
|
||||||
|
final scale = box.get(_keySubScale);
|
||||||
|
if (scale is num && scale.isFinite) subScale.value = scale.toDouble().clamp(0.5, 2.0);
|
||||||
|
final notifyAs = box.get(_keyNotifyStyle);
|
||||||
|
notifyStyle.value = NotifyStyle.values.where((m) => m.name == notifyAs).firstOrNull ?? NotifyStyle.toasts;
|
||||||
|
final style = box.get(_keyDanmakuStyle);
|
||||||
|
if (style is Map) danmakuStyle.value = DanmakuStyle.fromJson(style);
|
||||||
final share = box.get(_keyShareDiagnostics);
|
final share = box.get(_keyShareDiagnostics);
|
||||||
if (share is bool) shareDiagnostics.value = share;
|
if (share is bool) shareDiagnostics.value = share;
|
||||||
final popups = box.get(_keyChatPopups);
|
final popups = box.get(_keyChatPopups);
|
||||||
@@ -303,6 +332,22 @@ class AppSettings {
|
|||||||
_put(_keyLiftSubtitles, on);
|
_put(_keyLiftSubtitles, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setSubScale(double v) {
|
||||||
|
subScale.value = v.clamp(0.5, 2.0);
|
||||||
|
_put(_keySubScale, subScale.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setNotifyStyle(NotifyStyle style) {
|
||||||
|
notifyStyle.value = style;
|
||||||
|
_put(_keyNotifyStyle, style.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDanmakuStyle(DanmakuStyle style) {
|
||||||
|
if (style == danmakuStyle.peek()) return;
|
||||||
|
danmakuStyle.value = style;
|
||||||
|
_put(_keyDanmakuStyle, style.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
void setShareDiagnostics(bool on) {
|
void setShareDiagnostics(bool on) {
|
||||||
shareDiagnostics.value = on;
|
shareDiagnostics.value = on;
|
||||||
_put(_keyShareDiagnostics, on);
|
_put(_keyShareDiagnostics, on);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'dart:math' as math;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:starsync/src/danmaku/danmaku.dart';
|
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||||
|
import 'package:starsync/src/danmaku/danmaku_notices.dart';
|
||||||
|
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
|
||||||
import 'package:starsync/src/shell/avatar.dart' show AvatarInfo;
|
import 'package:starsync/src/shell/avatar.dart' show AvatarInfo;
|
||||||
|
|
||||||
// Danmaku lanes: free lanes only, no two lines in a lane ever touch, the
|
// Danmaku lanes: free lanes only, no two lines in a lane ever touch, the
|
||||||
@@ -173,4 +175,87 @@ void main() {
|
|||||||
expect(c.waiting.value, 0);
|
expect(c.waiting.value, 0);
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
group('DanmakuStyle as a setting', () {
|
||||||
|
test('round-trips through its map', () {
|
||||||
|
const s = DanmakuStyle(
|
||||||
|
crossSecs: 6, maxOnScreen: 5, bandTop: 0.2, bandBottom: 0.5, opacity: 0.7, size: 1.3, weight: 800,
|
||||||
|
outline: 2, highlightOwn: false, avatars: false);
|
||||||
|
expect(DanmakuStyle.fromJson(s.toJson()), s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('junk and out-of-range values: defaults and the nearest ends', () {
|
||||||
|
expect(DanmakuStyle.fromJson(const {}), const DanmakuStyle());
|
||||||
|
final s = DanmakuStyle.fromJson(const {
|
||||||
|
'crossSecs': 99,
|
||||||
|
'maxOnScreen': 0,
|
||||||
|
'opacity': 'x',
|
||||||
|
'weight': 1234,
|
||||||
|
'bandTop': 0.6,
|
||||||
|
'bandBottom': 0.61,
|
||||||
|
'avatars': 1,
|
||||||
|
});
|
||||||
|
expect(s.crossSecs, 16);
|
||||||
|
expect(s.maxOnScreen, 1);
|
||||||
|
expect(s.opacity, const DanmakuStyle().opacity);
|
||||||
|
expect(s.weight, 900);
|
||||||
|
expect((s.bandTop, s.bandBottom), (0.10, 0.45), reason: 'a band too thin takes the default');
|
||||||
|
expect(s.avatars, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boldness: the text at its weight, the name a step bolder', () {
|
||||||
|
expect(const DanmakuStyle(weight: 400).fontWeight, FontWeight.w400);
|
||||||
|
expect(const DanmakuStyle(weight: 400).nameWeight, FontWeight.w500);
|
||||||
|
expect(const DanmakuStyle(weight: 900).nameWeight, FontWeight.w900);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('notifications as danmaku', () {
|
||||||
|
ToastItem toast(String text,
|
||||||
|
{String? actor, NoticeCategory category = NoticeCategory.app, bool chat = false, bool own = false}) =>
|
||||||
|
ToastItem(id: 1, text: text, at: DateTime(2026), actor: actor, chat: chat, own: own, category: category);
|
||||||
|
|
||||||
|
test('chat and short notices fly; problems, nudges and the app stay toasts', () {
|
||||||
|
expect(fliesAsDanmaku(toast('Bob: hi', actor: 'Bob', chat: true, category: NoticeCategory.chat)), isTrue);
|
||||||
|
expect(fliesAsDanmaku(toast('Bob joined', actor: 'Bob', category: NoticeCategory.people)), isTrue);
|
||||||
|
expect(fliesAsDanmaku(toast('Starting now', category: NoticeCategory.start)), isTrue);
|
||||||
|
expect(fliesAsDanmaku(toast('Bob added X to Up next', actor: 'Bob', category: NoticeCategory.upNext)), isTrue);
|
||||||
|
expect(fliesAsDanmaku(toast('Skipped X: gone', category: NoticeCategory.upNext)), isFalse);
|
||||||
|
expect(fliesAsDanmaku(toast("Bob can't play this", actor: 'Bob', category: NoticeCategory.problem)), isFalse);
|
||||||
|
expect(fliesAsDanmaku(toast('Bob nudged you', actor: 'Bob', category: NoticeCategory.nudge)), isFalse);
|
||||||
|
expect(fliesAsDanmaku(toast('Link copied')), isFalse);
|
||||||
|
expect(fliesAsDanmaku(toast('The server restarted', category: NoticeCategory.self)), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lines: the sender before the message, own chat framed', () {
|
||||||
|
final chat = danmakuForToast(toast('Me: hello', actor: 'Me', chat: true, own: true, category: NoticeCategory.chat))!;
|
||||||
|
expect((chat.kind, chat.sender, chat.text, chat.own), (DanmakuKind.chat, 'Me', 'hello', true));
|
||||||
|
final left = danmakuForToast(toast('Bob lost connection', actor: 'Bob', category: NoticeCategory.people))!;
|
||||||
|
expect((left.kind, left.text), (DanmakuKind.leave, 'lost connection'));
|
||||||
|
final start = danmakuForToast(toast('Starting now: Frieren', category: NoticeCategory.start))!;
|
||||||
|
expect((start.kind, start.sender), (DanmakuKind.starting, null));
|
||||||
|
expect(danmakuForToast(toast('Copied')), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an idle layer asks for no frames', (tester) async {
|
||||||
|
final c = DanmakuController();
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
await tester.pumpWidget(Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: SizedBox(width: 800, height: 450, child: DanmakuLayer(controller: c, accent: Colors.purple)),
|
||||||
|
));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||||
|
c.add(const DanmakuItem(kind: DanmakuKind.chat, sender: 'Bob', text: 'hi'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(c.onScreen.value, 1);
|
||||||
|
expect(tester.binding.hasScheduledFrame, isTrue);
|
||||||
|
await tester.pump(const Duration(seconds: 9));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(c.onScreen.value, 0);
|
||||||
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart' show signal;
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:starsync/src/controls/frame.dart';
|
|
||||||
import 'package:starsync/src/controls/model/controls_prefs.dart';
|
import 'package:starsync/src/controls/model/controls_prefs.dart';
|
||||||
import 'package:starsync/src/controls/player_settings.dart';
|
import 'package:starsync/src/controls/player_panel.dart';
|
||||||
|
import 'package:starsync/src/danmaku/danmaku.dart' show DanmakuStyle;
|
||||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||||
import 'package:starsync/src/schedule/animeflow.dart';
|
import 'package:starsync/src/schedule/animeflow.dart';
|
||||||
import 'package:starsync/src/shaders/shader_presets.dart';
|
import 'package:starsync/src/shaders/shader_presets.dart';
|
||||||
@@ -37,6 +37,8 @@ void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pick(WidgetTester tester, String key, String item) async {
|
Future<void> pick(WidgetTester tester, String key, String item) async {
|
||||||
|
await tester.ensureVisible(find.byKey(ValueKey(key)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(ValueKey(key)));
|
await tester.tap(find.byKey(ValueKey(key)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text(item).last);
|
await tester.tap(find.text(item).last);
|
||||||
@@ -57,6 +59,8 @@ void main() {
|
|||||||
s.shaderPreset.value = ShaderPreset.off;
|
s.shaderPreset.value = ShaderPreset.off;
|
||||||
s.shaderQuality.value = ShaderQuality.hq;
|
s.shaderQuality.value = ShaderQuality.hq;
|
||||||
s.hwdecMode.value = HwdecMode.auto;
|
s.hwdecMode.value = HwdecMode.auto;
|
||||||
|
s.liftSubtitles.value = false;
|
||||||
|
s.notifyStyle.value = NotifyStyle.toasts;
|
||||||
coord.playback.tracks.value = const [];
|
coord.playback.tracks.value = const [];
|
||||||
coord.serverState.value = null;
|
coord.serverState.value = null;
|
||||||
coord.hostFile = null;
|
coord.hostFile = null;
|
||||||
@@ -64,76 +68,66 @@ void main() {
|
|||||||
_graph.forms.recents.value = const [];
|
_graph.forms.recents.value = const [];
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget opener() => Builder(
|
group('player panel', () {
|
||||||
builder: (context) => TextButton(
|
testWidgets('tracks, subtitles, picture, sound, notifications and controls; all live', (tester) async {
|
||||||
key: const ValueKey('open-settings'),
|
|
||||||
onPressed: () => showPlayerSettings(context),
|
|
||||||
child: const Text('settings'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
group('player settings card', () {
|
|
||||||
testWidgets('lists every player setting, changes apply live', (tester) async {
|
|
||||||
bigWindow(tester);
|
bigWindow(tester);
|
||||||
coord.playback.tracks.value = const [
|
coord.playback.tracks.value = const [
|
||||||
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
||||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||||
];
|
];
|
||||||
await tester.pumpWidget(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener()))));
|
var volume = 80.0;
|
||||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
var closed = 0;
|
||||||
|
final vol = signal(volume);
|
||||||
|
await tester.pumpWidget(scoped(
|
||||||
|
_graph,
|
||||||
|
app(Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 1000,
|
||||||
|
child: PlayerPanel(
|
||||||
|
onClose: () => closed++,
|
||||||
|
volume: vol,
|
||||||
|
onVolume: (v) => vol.value = volume = v,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final card = find.byKey(const ValueKey('player-settings-card'));
|
final panel = find.byKey(const ValueKey('player-panel'));
|
||||||
expect(card, findsOneWidget);
|
expect(panel, findsOneWidget);
|
||||||
for (final label in [
|
for (final label in ['Subtitles', 'Audio', 'Size', 'Upscaling', 'Hardware decoding', 'Volume']) {
|
||||||
'Player design',
|
expect(find.descendant(of: panel, matching: find.text(label)), findsWidgets, reason: label);
|
||||||
'Progress line when hidden',
|
|
||||||
'Upscaling shader',
|
|
||||||
'Hardware decoding',
|
|
||||||
'Subtitles',
|
|
||||||
'Audio',
|
|
||||||
]) {
|
|
||||||
expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label);
|
|
||||||
}
|
}
|
||||||
// Subtitles off (none selected), audio on the selected track.
|
// Subtitles off (none selected), audio on the selected track.
|
||||||
expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')),
|
expect(find.descendant(of: find.byKey(const ValueKey('panel-subtitles')), matching: find.text('Off')),
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
|
expect(find.descendant(of: find.byKey(const ValueKey('panel-audio')), matching: find.textContaining('Japanese')),
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
|
await pick(tester, 'panel-shader', 'Mode B');
|
||||||
await pick(tester, 'player-design', 'Minimal');
|
|
||||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
|
||||||
await tester.tap(find.byKey(const ValueKey('progress-line')));
|
|
||||||
await tester.pump();
|
|
||||||
expect(s.progressLine.value, isFalse);
|
|
||||||
await pick(tester, 'shader-preset', 'Mode B');
|
|
||||||
expect(s.shaderPreset.value, ShaderPreset.b);
|
expect(s.shaderPreset.value, ShaderPreset.b);
|
||||||
await pick(tester, 'shader-quality', 'Fast');
|
await pick(tester, 'panel-hwdec', 'Off');
|
||||||
expect(s.shaderQuality.value, ShaderQuality.fast);
|
|
||||||
await pick(tester, 'hwdec', 'Off');
|
|
||||||
expect(s.hwdec.value, 'no');
|
expect(s.hwdec.value, 'no');
|
||||||
// Esc closes it.
|
await tester.tap(find.descendant(of: find.byKey(const ValueKey('panel-lift')), matching: find.byType(Switch)));
|
||||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
await tester.pump();
|
||||||
|
expect(s.liftSubtitles.value, isTrue);
|
||||||
|
await tester.drag(find.descendant(of: find.byKey(const ValueKey('panel-volume')), matching: find.byType(Slider)),
|
||||||
|
const Offset(-60, 0));
|
||||||
|
expect(volume, lessThan(80));
|
||||||
|
// Folded sections open with a click: notifications, then danmaku's knobs.
|
||||||
|
expect(find.byKey(const ValueKey('notify-style')), findsNothing);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('panel-section-Notifications')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(card, findsNothing);
|
await pick(tester, 'notify-style', 'Danmaku');
|
||||||
});
|
expect(s.notifyStyle.value, NotifyStyle.danmaku);
|
||||||
|
expect(find.byKey(const ValueKey('danmaku-weight')), findsOneWidget);
|
||||||
testWidgets('keeps the controls up while open', (tester) async {
|
await tester.ensureVisible(find.byKey(const ValueKey('panel-section-Controls')));
|
||||||
bigWindow(tester);
|
await tester.tap(find.byKey(const ValueKey('panel-section-Controls')));
|
||||||
await tester.pumpWidget(scoped(_graph, app(ControlsFrame(
|
|
||||||
hideAfter: const Duration(seconds: 1),
|
|
||||||
builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
|
|
||||||
))));
|
|
||||||
final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
|
||||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
|
await pick(tester, 'panel-design', 'Minimal');
|
||||||
expect(find.text('Standard'), findsOneWidget, reason: 'the current skin');
|
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||||
await tester.pump(const Duration(seconds: 3));
|
await tester.ensureVisible(find.byKey(const ValueKey('player-panel-close')));
|
||||||
expect(controls.visible, isTrue, reason: 'held while the card is open');
|
|
||||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.pump(const Duration(seconds: 2));
|
await tester.tap(find.byKey(const ValueKey('player-panel-close')));
|
||||||
expect(controls.visible, isFalse);
|
expect(closed, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +163,37 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Notifications: Toasts / Danmaku, a preview strip and every knob of the look', (tester) async {
|
||||||
|
addTearDown(() => s.danmakuStyle.value = const DanmakuStyle());
|
||||||
|
await pumpSettings(tester, SettingsSection.notifications, size: const Size(1280, 2600));
|
||||||
|
final card = find.byKey(const ValueKey('danmaku-settings'));
|
||||||
|
expect(card, findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('danmaku-preview-strip')), findsOneWidget);
|
||||||
|
expect(s.notifyStyle.value, NotifyStyle.toasts, reason: 'toasts by default');
|
||||||
|
await pick(tester, 'notify-style', 'Danmaku');
|
||||||
|
expect(s.notifyStyle.value, NotifyStyle.danmaku);
|
||||||
|
for (final k in ['cross', 'cap', 'band', 'opacity', 'size', 'weight', 'outline', 'avatars', 'own']) {
|
||||||
|
expect(find.byKey(ValueKey('danmaku-$k')), findsOneWidget, reason: k);
|
||||||
|
}
|
||||||
|
// Boldness: one step right of Semibold is Bold.
|
||||||
|
final weight = find.byKey(const ValueKey('danmaku-weight'));
|
||||||
|
final box = tester.getRect(weight);
|
||||||
|
await tester.tapAt(Offset(box.left + box.width * 0.6, box.center.dy));
|
||||||
|
await tester.pump();
|
||||||
|
expect(s.danmakuStyle.value.weight, 700);
|
||||||
|
expect(find.text('Bold'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('danmaku-avatars')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(s.danmakuStyle.value.avatars, isFalse);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('danmaku-reset')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(s.danmakuStyle.value, const DanmakuStyle());
|
||||||
|
// The strip rests after a while and plays again on a click.
|
||||||
|
await tester.pump(const Duration(seconds: 20));
|
||||||
|
expect(find.byKey(const ValueKey('danmaku-preview-play')), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
|
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
|
||||||
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
|
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
|
||||||
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
|
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
|
||||||
@@ -177,6 +202,8 @@ void main() {
|
|||||||
'progress-line',
|
'progress-line',
|
||||||
'shader-preset',
|
'shader-preset',
|
||||||
'hwdec',
|
'hwdec',
|
||||||
|
'sub-scale',
|
||||||
|
'lift-subtitles',
|
||||||
'time-display',
|
'time-display',
|
||||||
'controls-size',
|
'controls-size',
|
||||||
'controls-hide',
|
'controls-hide',
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
|
||||||
|
import 'package:starsync/src/controls/frame.dart';
|
||||||
|
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||||
|
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||||
|
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
|
||||||
|
import 'package:starsync/src/shell/quick_chat.dart';
|
||||||
|
import 'package:starsync/src/shell/session_player.dart';
|
||||||
|
import 'package:starsync/src/state/settings.dart';
|
||||||
|
import 'package:starsync/src/widgets/app_keys.dart';
|
||||||
|
|
||||||
|
import 'test_graph.dart';
|
||||||
|
|
||||||
|
/// This file's composition root (see test_graph.dart).
|
||||||
|
final _graph = testGraph();
|
||||||
|
|
||||||
|
// The quick chat box over the full player: Enter opens it whatever plays,
|
||||||
|
// keys go to it (not to the player), the cursor and the controls stay as
|
||||||
|
// the mouse left them, a send shows your own line (echo, or danmaku), Esc
|
||||||
|
// and the idle time close it with the draft kept, the draft is the chat
|
||||||
|
// panel's. The picture is a black box with the session's controls on it
|
||||||
|
// (never a media_kit `Video`).
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final coord = _graph.coordinator;
|
||||||
|
final s = coord.settings;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
await coord.media.start();
|
||||||
|
_graph.navigation.place.value = Place.yours;
|
||||||
|
});
|
||||||
|
|
||||||
|
late TextEditingController draft;
|
||||||
|
late QuickChat quick;
|
||||||
|
var fullscreenToggles = 0;
|
||||||
|
var readyToggles = 0;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
draft = TextEditingController();
|
||||||
|
quick = QuickChat(draft: draft);
|
||||||
|
fullscreenToggles = 0;
|
||||||
|
readyToggles = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
s.notifyStyle.value = NotifyStyle.toasts;
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> mount(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1280, 720);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(scoped(
|
||||||
|
_graph,
|
||||||
|
MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: AppKeys(
|
||||||
|
onToggleReady: () => readyToggles++,
|
||||||
|
child: Scaffold(
|
||||||
|
body: SessionPlayer(
|
||||||
|
mode: PlayerMode.full,
|
||||||
|
fullscreen: false,
|
||||||
|
onExpand: () {},
|
||||||
|
onCollapse: () {},
|
||||||
|
onClose: () {},
|
||||||
|
onToggleFullscreen: () => fullscreenToggles++,
|
||||||
|
onToggleChat: () {},
|
||||||
|
onChangeVideo: () {},
|
||||||
|
onOpenIntegrations: () {},
|
||||||
|
onLeave: () {},
|
||||||
|
windowControls: false,
|
||||||
|
quickChat: quick,
|
||||||
|
videoBuilder: (context, controls) => Stack(fit: StackFit.expand, children: [
|
||||||
|
const ColoredBox(color: Colors.black),
|
||||||
|
if (controls != null) controls(VideoState()),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unmount(WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(scoped(_graph, const SizedBox()));
|
||||||
|
await tester.pump(const Duration(seconds: 5));
|
||||||
|
quick.dispose();
|
||||||
|
draft.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlsFrameState frame(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
||||||
|
MouseCursor frameCursor(WidgetTester tester) => tester
|
||||||
|
.widget<MouseRegion>(find.descendant(of: find.byType(ControlsFrame), matching: find.byType(MouseRegion)).first)
|
||||||
|
.cursor;
|
||||||
|
final field = find.byKey(const ValueKey('quick-chat-field'));
|
||||||
|
bool fieldFocused() => quick.focus.hasFocus;
|
||||||
|
|
||||||
|
Future<void> hideControls(WidgetTester tester) async {
|
||||||
|
await tester.pump(Duration(seconds: s.controlsHideSecs.value + 1));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('Enter opens the box; keys go to it; the cursor and controls stay hidden', (tester) async {
|
||||||
|
await mount(tester);
|
||||||
|
// A still mouse over the bottom centre, where the box comes up.
|
||||||
|
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await mouse.addPointer(location: const Offset(640, 690));
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await hideControls(tester);
|
||||||
|
expect(frame(tester).visible, isFalse);
|
||||||
|
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(quick.open.value, isTrue);
|
||||||
|
expect(fieldFocused(), isTrue, reason: 'the box has the keyboard at once');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(frame(tester).visible, isFalse, reason: 'typing is not mouse activity');
|
||||||
|
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||||
|
final fieldWidget = tester.widget<TextField>(field);
|
||||||
|
expect(fieldWidget.mouseCursor, SystemMouseCursors.none, reason: 'no I-beam over a still mouse');
|
||||||
|
|
||||||
|
// Space and F reach the box, not ready or fullscreen.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||||
|
await tester.pump();
|
||||||
|
expect(readyToggles, 0);
|
||||||
|
expect(fullscreenToggles, 0);
|
||||||
|
|
||||||
|
// Esc closes; the draft stays; the controls do not come back.
|
||||||
|
await tester.enterText(field, 'half a thought');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(quick.open.value, isFalse);
|
||||||
|
expect(draft.text, 'half a thought');
|
||||||
|
expect(frame(tester).visible, isFalse, reason: 'the box going is not mouse movement');
|
||||||
|
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||||
|
|
||||||
|
// The player's keys are back.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||||
|
expect(readyToggles, 1);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||||
|
expect(fullscreenToggles, 1);
|
||||||
|
|
||||||
|
// Enter brings the draft back.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(quick.open.value, isTrue);
|
||||||
|
expect(tester.widget<TextField>(field).controller!.text, 'half a thought');
|
||||||
|
await unmount(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a send shows your own line: the echo with toasts, and the controls stay hidden', (tester) async {
|
||||||
|
await mount(tester);
|
||||||
|
await hideControls(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(field, 'hello there');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(quick.open.value, isFalse);
|
||||||
|
expect(draft.text, isEmpty, reason: 'sent: the shared draft is cleared');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
final echo = find.byKey(const ValueKey('quick-chat-echo'));
|
||||||
|
expect(echo, findsOneWidget);
|
||||||
|
expect(find.descendant(of: echo, matching: find.text('hello there')), findsOneWidget);
|
||||||
|
expect(frame(tester).visible, isFalse, reason: 'held down while the echo is in their place');
|
||||||
|
// Your own chat toast coming back is not shown again.
|
||||||
|
final own = ToastItem(
|
||||||
|
id: 99, text: 'Me: hello there', at: DateTime.now(), actor: 'Me', chat: true, own: true,
|
||||||
|
category: NoticeCategory.chat);
|
||||||
|
expect(quick.echoed(own), isTrue);
|
||||||
|
await tester.pump(QuickChat.echoTime);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(echo, findsNothing);
|
||||||
|
expect(frame(tester).visible, isFalse);
|
||||||
|
// Empty Enter just closes.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(quick.open.value, isFalse);
|
||||||
|
expect(echo, findsNothing);
|
||||||
|
await unmount(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('with danmaku your own line flies instead of the echo', (tester) async {
|
||||||
|
s.notifyStyle.value = NotifyStyle.danmaku;
|
||||||
|
await mount(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(field, 'flying');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(find.byKey(const ValueKey('quick-chat-echo')), findsNothing);
|
||||||
|
final line = find.descendant(of: find.byType(DanmakuLayer), matching: find.textContaining('flying', findRichText: true));
|
||||||
|
expect(line, findsWidgets);
|
||||||
|
await tester.pump(const Duration(seconds: 10));
|
||||||
|
await unmount(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the box hides by itself after a while without typing; the draft stays', (tester) async {
|
||||||
|
await mount(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(field, 'later');
|
||||||
|
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
|
||||||
|
expect(quick.open.value, isTrue);
|
||||||
|
await tester.enterText(field, 'later, maybe');
|
||||||
|
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
|
||||||
|
expect(quick.open.value, isTrue, reason: 'typing restarts the wait');
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
expect(quick.open.value, isFalse);
|
||||||
|
expect(draft.text, 'later, maybe');
|
||||||
|
await unmount(tester);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
|
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
|
||||||
/// Lower a number when a use goes; never raise one or add a class.
|
/// Lower a number when a use goes; never raise one or add a class.
|
||||||
const baseline = {
|
const baseline = {
|
||||||
'AppSettings': 49,
|
'AppSettings': 47,
|
||||||
'JellyfinAccounts': 10,
|
'JellyfinAccounts': 10,
|
||||||
'UpdateController': 8,
|
'UpdateController': 8,
|
||||||
'Profile': 8,
|
'Profile': 8,
|
||||||
|
|||||||
@@ -45,6 +45,47 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('subtitle lift steps', () {
|
||||||
|
test('a few large eased-out steps, the last exactly on target', () {
|
||||||
|
final up = liftSteps(0, 100);
|
||||||
|
expect(up, hasLength(liftStepCount));
|
||||||
|
expect(up.last, 100);
|
||||||
|
// Eased out: the first step moves most, each one less.
|
||||||
|
for (var i = 1; i < up.length; i++) {
|
||||||
|
expect(up[i], greaterThan(up[i - 1]));
|
||||||
|
if (i > 1) expect(up[i] - up[i - 1], lessThan(up[i - 1] - (i > 1 ? up[i - 2] : 0)));
|
||||||
|
}
|
||||||
|
expect(up.first, greaterThan(30), reason: 'most of the way at once');
|
||||||
|
final down = liftSteps(100, 0);
|
||||||
|
expect(down.last, 0);
|
||||||
|
expect(down.first, lessThan(70));
|
||||||
|
expect(liftSteps(40, 40.2), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('SubtitleLift steps toward the latest target, liftStepGap apart', (tester) async {
|
||||||
|
final lift = SubtitleLift();
|
||||||
|
addTearDown(lift.dispose);
|
||||||
|
final seen = <double>[];
|
||||||
|
lift.addListener(() => seen.add(lift.value));
|
||||||
|
lift.animateTo(80);
|
||||||
|
expect(seen, hasLength(1), reason: 'the first step at once');
|
||||||
|
await tester.pump(liftStepGap * (liftStepCount - 1));
|
||||||
|
expect(seen, liftSteps(0, 80));
|
||||||
|
expect(lift.value, 80);
|
||||||
|
await tester.pump(liftStepGap * 4);
|
||||||
|
expect(seen, hasLength(liftStepCount), reason: 'nothing after the target');
|
||||||
|
// A new target mid-way starts from where it is.
|
||||||
|
seen.clear();
|
||||||
|
lift.animateTo(0);
|
||||||
|
await tester.pump(liftStepGap);
|
||||||
|
final from = lift.value, n = seen.length;
|
||||||
|
expect(from, inExclusiveRange(0, 80));
|
||||||
|
lift.animateTo(80);
|
||||||
|
await tester.pump(liftStepGap * liftStepCount);
|
||||||
|
expect(seen.sublist(n), liftSteps(from, 80));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('dev tools: dev versions and debug runs only', () {
|
test('dev tools: dev versions and debug runs only', () {
|
||||||
expect(devToolsFor(version: '1.3.3-dev.2', debug: false), isTrue);
|
expect(devToolsFor(version: '1.3.3-dev.2', debug: false), isTrue);
|
||||||
expect(devToolsFor(version: '0.0.0-dev', debug: false), isTrue);
|
expect(devToolsFor(version: '0.0.0-dev', debug: false), isTrue);
|
||||||
|
|||||||
+121
-15
@@ -600,7 +600,7 @@ fails every seek until a fresh open, so the coordinator runs "Reload
|
|||||||
video" once per media for it (`mpvStreamLost`, `onStreamLost`).
|
video" once per media for it (`mpvStreamLost`, `onStreamLost`).
|
||||||
|
|
||||||
Output size ("Sharp video and subtitles", `AppSettings.screenResVideo`,
|
Output size ("Sharp video and subtitles", `AppSettings.screenResVideo`,
|
||||||
on by default, live; Settings → Player and the player settings card):
|
on by default, live; Settings → Player and the player panel):
|
||||||
media_kit renders mpv into a texture the size of the video and Flutter
|
media_kit renders mpv into a texture the size of the video and Flutter
|
||||||
scales it, so libass subtitles, mpv's scaler and the shaders all work on
|
scales it, so libass subtitles, mpv's scaler and the shaders all work on
|
||||||
a 720p image for a 720p file. With the setting on and the GPU renderer
|
a 720p image for a 720p file. With the setting on and the GPU renderer
|
||||||
@@ -2121,7 +2121,8 @@ panel) and chat messages (Copy message, and a link in it).
|
|||||||
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
|
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
|
||||||
`AnimeFlowClient.fromAccounts`, "Play the next episode automatically":
|
`AnimeFlowClient.fromAccounts`, "Play the next episode automatically":
|
||||||
whether this app adds the continuation, see "What's next"), notifications & sounds
|
whether this app adds the continuation, see "What's next"), notifications & sounds
|
||||||
(what pops up, chat pop-ups, joins and leaves while sitting out or in
|
(what pops up, chat pop-ups, toasts or danmaku over the player with the
|
||||||
|
danmaku look, joins and leaves while sitting out or in
|
||||||
the background), updates, shortcuts, about.
|
the background), updates, shortcuts, about.
|
||||||
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
|
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
|
||||||
`DropTarget` (a "Drop to open" overlay while dragging) and a
|
`DropTarget` (a "Drop to open" overlay while dragging) and a
|
||||||
@@ -2218,14 +2219,28 @@ panel) and chat messages (Copy message, and a link in it).
|
|||||||
and the members card says "can't play this: ...". 34 px avatars, a main line
|
and the members card says "can't play this: ...". 34 px avatars, a main line
|
||||||
and a muted detail line. The pill glass, colour only for problems; it fades through
|
and a muted detail line. The pill glass, colour only for problems; it fades through
|
||||||
between kinds. The mini shows its short line in its hover bar. ⚙ →
|
between kinds. The mini shows its short line in its hover bar. ⚙ →
|
||||||
Player settings (both skins) opens the player settings card (`controls/
|
Player settings (both skins) toggles the player panel
|
||||||
player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line
|
(`controls/player_panel.dart`, `PlayerControlsModel.panelOpen`): a
|
||||||
when hidden, upscaling shader, sharp video and subtitles (the output
|
312 px `AppGlass` column docked at the right of the full player,
|
||||||
at screen size, see "Media"), hardware decoding (set live on mpv's
|
between the top bar and the control bar (it never covers the bar),
|
||||||
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
|
sliding and fading in from the right; it stays open while the controls
|
||||||
the same widgets Settings → Player shows; all apply live. Settings →
|
hide and show, until its × or Esc (the player's Esc closes it first),
|
||||||
Player also has the controls' own choices (`controlsSettingRows`, see
|
and leaving the full player closes it. The mouse on it holds the
|
||||||
"Player controls"). The progress line (`AppSettings.progressLine`) is
|
controls up (`ControlsHoldRegion`). Its sections fold (their titles
|
||||||
|
toggle; folded ones are kept for the app's run, Notifications and
|
||||||
|
Controls start folded): tracks (subtitle, audio; remembered as from
|
||||||
|
the track menus), subtitles (size, mpv `sub-scale` from
|
||||||
|
`AppSettings.subScale`, set on every player; above the controls, see
|
||||||
|
"Subtitle lift"), picture (upscaling shader and quality, sharp video
|
||||||
|
and subtitles, see "Media", hardware decoding, set live on mpv's
|
||||||
|
`hwdec`), sound (volume), notifications (Toasts / Danmaku and, with
|
||||||
|
Danmaku, the look's knobs, see "Danmaku notifications") and controls
|
||||||
|
(design, progress line); a pointer to Settings → Player and Mark a
|
||||||
|
problem at its foot. Compact rows of its own over the same settings and
|
||||||
|
actions; Settings → Player shows the full rows (`playerSettingRows`:
|
||||||
|
design, progress line, shader, sharp video, hardware decoding, subtitle
|
||||||
|
size, subtitles above the controls) and the controls' own choices
|
||||||
|
(`controlsSettingRows`, see "Player controls"); all apply live. The progress line (`AppSettings.progressLine`) is
|
||||||
what the bottom controls leave while hidden: Standard a 2 px line,
|
what the bottom controls leave while hidden: Standard a 2 px line,
|
||||||
Minimal its collapsed timeline. Fullscreen adds the
|
Minimal its collapsed timeline. Fullscreen adds the
|
||||||
`WindowButtons` to the top bar while the controls are up (restore
|
`WindowButtons` to the top bar while the controls are up (restore
|
||||||
@@ -2261,7 +2276,8 @@ panel) and chat messages (Copy message, and a link in it).
|
|||||||
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
|
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
|
||||||
(strip button or Tab): chat with avatars; the "Activity" chip (off by
|
(strip button or Tab): chat with avatars; the "Activity" chip (off by
|
||||||
default, `showActivity`, kept for the app's run only) adds the other
|
default, `showActivity`, kept for the app's run only) adds the other
|
||||||
feed lines between them. Opening focuses the field; Tab or Esc in it
|
feed lines between them. Its field shares the quick chat box's draft
|
||||||
|
(see "Quick chat"). Opening focuses the field; Tab or Esc in it
|
||||||
close the panel and focus goes back where it was. Unread chat badges
|
close the panel and focus goes back where it was. Unread chat badges
|
||||||
the strip's and the player controls' chat buttons; in fullscreen with
|
the strip's and the player controls' chat buttons; in fullscreen with
|
||||||
the controls down and the video not playing a glass chip
|
the controls down and the video not playing a glass chip
|
||||||
@@ -2279,7 +2295,96 @@ panel) and chat messages (Copy message, and a link in it).
|
|||||||
are up and come down to 16 px from the edge while they are hidden
|
are up and come down to 16 px from the edge while they are hidden
|
||||||
(`AnimatedPositioned`, the shell's `ease`; the player reports its
|
(`AnimatedPositioned`, the shell's `ease`; the player reports its
|
||||||
controls through the shell's `controlsShown`); elsewhere 16 px from the
|
controls through the shell's `controlsShown`); elsewhere 16 px from the
|
||||||
bottom left. What pops up at all is the Notifier's policy.
|
bottom left. What pops up at all is the Notifier's policy. Each
|
||||||
|
`ToastItem` carries its `NoticeCategory` and whether it is your own
|
||||||
|
(`own`); the shell leaves out those flying as danmaku and your own chat
|
||||||
|
coming back for a line the quick chat box already showed
|
||||||
|
(`PartyToasts.skip`).
|
||||||
|
- **Quick chat** (`shell/quick_chat.dart`): Enter in the full player
|
||||||
|
(not the mini: it is a corner thumbnail while you are elsewhere in the
|
||||||
|
app, where Enter belongs to that page; nor while sitting out) opens a
|
||||||
|
small box at the bottom centre, 16 px up, in the control bar's place,
|
||||||
|
without opening the chat panel. Enter is a `HardwareKeyboard` handler
|
||||||
|
of the player, so it works whatever plays and whatever button kept
|
||||||
|
focus; a `FocusManager` early handler swallows that Enter so the
|
||||||
|
focused button does not act on it too. `QuickChat` (the shell's, it
|
||||||
|
outlives the player's modes) holds the state: the draft is the chat
|
||||||
|
panel's `TextEditingController` (typing in either shows in both, a send
|
||||||
|
from either clears both), its `FocusNode` takes focus at once (keys
|
||||||
|
typed right after go to the box; `textInputFocused` keeps Space, Tab,
|
||||||
|
F and the arrows off the player meanwhile), and it closes by itself
|
||||||
|
after `idleClose` (8 s) without typing, Esc, or an empty Enter, keeping
|
||||||
|
the draft for the next Enter. Enter with text (an input method's
|
||||||
|
composing Enter excepted) sends it (`sendChat`) and closes. The box
|
||||||
|
(`QuickChatBox`) starts 300 px wide and widens with the text up to
|
||||||
|
640 px (or the room there is), then wraps up to four lines, growing
|
||||||
|
upward; your avatar leads it; it fades and slides in on the shell's
|
||||||
|
curve and stays mounted while closed (invisible, ignoring the pointer)
|
||||||
|
so focus is immediate. What you sent shows where the box was for
|
||||||
|
`echoTime` (2.6 s, `QuickChatEcho`, with an accent hairline), or, with
|
||||||
|
danmaku notifications, flies at once as your own line; your own chat
|
||||||
|
toast coming back from the server is not shown again
|
||||||
|
(`QuickChat.echoed`). While the box or its echo is up the controls are
|
||||||
|
held hidden (`ControlsFrame.suppress`: drawn hidden, `shown` false, the
|
||||||
|
cursor still follows the mouse) and the action area rides above the
|
||||||
|
box. Typing is not mouse activity: the frame wakes the controls only
|
||||||
|
on real pointer movement (an enter or hover at the last seen position,
|
||||||
|
as when the box comes or goes under a still pointer, does nothing), and
|
||||||
|
the field's I-beam stays hidden until the mouse moves over it, so
|
||||||
|
Enter, typing and sending never show the cursor or the controls.
|
||||||
|
- **Subtitle lift** (`video_view.dart`): bottom subtitles move above what
|
||||||
|
covers the bottom of the full player: the quick chat box or its echo
|
||||||
|
(always), and the control bar while it is up when
|
||||||
|
`AppSettings.liftSubtitles` is on (off by default; Settings → Player
|
||||||
|
and the player panel, "Subtitles above the controls"); 8 px above it.
|
||||||
|
`SubtitleLift` steps the covered height toward each new target in
|
||||||
|
`liftStepCount` (4) eased-out steps `liftStepGap` (42 ms) apart, the
|
||||||
|
first at once: about one step per frame of a 24 fps video, so each
|
||||||
|
lands on its own video frame (a `sub-pos` change per screen frame
|
||||||
|
landed unevenly on the video's frames and looked choppy).
|
||||||
|
`SubtitleLifter` turns each step into mpv `sub-pos` (`subtitleLiftPos`:
|
||||||
|
only as far as the letterbox under the picture leaves no room, at most
|
||||||
|
half the picture), back to 100 when nothing covers and for a new
|
||||||
|
player. What each mpv property does, on `tool/make_sub_test.sh`'s file
|
||||||
|
(mpv 0.41, libmpv as the app uses it): `sub-pos` moves bottom ASS
|
||||||
|
dialogue and SRT lines up and leaves `\an8` signs in place (with
|
||||||
|
`sub-ass-override` at its default `scale` or `yes`; `no` ignores it
|
||||||
|
for ASS); `sub-margin-y` moves SRT only (ASS only with
|
||||||
|
`sub-ass-override=force`, which restyles every line, signs too);
|
||||||
|
`sub-ass-style-overrides=MarginV=...` moves top signs down as well;
|
||||||
|
`video-margin-ratio-bottom` shrinks and moves the picture itself (with
|
||||||
|
`sub-ass-force-margins` the dialogue goes into the new black band).
|
||||||
|
- **Danmaku notifications** (`danmaku/`): Settings → Notifications &
|
||||||
|
sounds → Over the player → Notifications: Toasts (default) or Danmaku
|
||||||
|
(`AppSettings.notifyStyle`, live; also in the player panel). With
|
||||||
|
Danmaku and the full player up (not sitting out), what
|
||||||
|
`fliesAsDanmaku` picks (chat, joins, leaves, lost connection and back,
|
||||||
|
"starting now", Up next news someone caused) flies right to left
|
||||||
|
across the picture (`DanmakuNotices` under the controls feeds new
|
||||||
|
toasts to a `DanmakuLayer` through `danmakuForToast`); nudges,
|
||||||
|
problems, your own state and the app's notices stay toasts, and so does
|
||||||
|
everything with the player small. The player sets the shell's
|
||||||
|
`danmakuActive` so the toasts leave the flying ones out. The layer:
|
||||||
|
lanes in a band of the height (`DanmakuStyle.bandTop` / `bandBottom`,
|
||||||
|
default 10 to 45 %, under top signs, never down into the dialogue);
|
||||||
|
each line takes the topmost free lane (`DanmakuLanes`, pure: the last
|
||||||
|
line in a lane must have fully entered with a gap, and the new one, at
|
||||||
|
its own speed, cannot catch it before it leaves); at most
|
||||||
|
`maxOnScreen` fly, the rest wait in order (dropped after 15 s, 60 at
|
||||||
|
most); every line crosses in `crossSecs`, so long ones move faster;
|
||||||
|
white text at the chosen weight (`weight`, 400 to 900, the name a step
|
||||||
|
bolder) with a dark outline and no box, the sender's avatar and
|
||||||
|
coloured name first, the app's own notices without one, own lines in
|
||||||
|
an accent frame; the layer's own clock (lines fly on while paused),
|
||||||
|
ticking only while lines fly or wait. Each line is a widget moved by a
|
||||||
|
`Flow`: per frame only paint runs. The look (`AppSettings.danmakuStyle`,
|
||||||
|
one map, `DanmakuStyle.toJson` / `fromJson`, which clamps every value
|
||||||
|
to `DanmakuStyle.limits`) has every knob in the settings card
|
||||||
|
(`danmaku_settings.dart`: crossing time, at most on screen, lane band,
|
||||||
|
opacity, size, boldness, outline, avatars, frame my own lines, reset)
|
||||||
|
over a preview strip (a moving stand-in picture, a top sign, bottom
|
||||||
|
dialogue and sample lines in the current look; it plays for 16 s after
|
||||||
|
it opens or a knob moves, then rests until clicked).
|
||||||
|
|
||||||
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
||||||
servers and links, the profile picture and colour; saved even when a join
|
servers and links, the profile picture and colour; saved even when a join
|
||||||
@@ -2399,7 +2504,8 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
|
|||||||
mark a problem, player settings, leave. Icons, tooltips (with keys) and
|
mark a problem, player settings, leave. Icons, tooltips (with keys) and
|
||||||
the clock text (`clockText`) are defined there too, once.
|
the clock text (`clockText`) are defined there too, once.
|
||||||
- **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls
|
- **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls
|
||||||
are up (mouse movement shows them, `controlsHideSecs` without any or the
|
are up (real mouse movement shows them, see "Quick chat"; `suppress`
|
||||||
|
holds them hidden; `controlsHideSecs` without movement or the
|
||||||
mouse leaving hides them and the cursor), holds (`hold` during a seek
|
mouse leaving hides them and the cursor), holds (`hold` during a seek
|
||||||
drag, `holdDuring` / `holdWhile` around menus and popovers, `holdBy`
|
drag, `holdDuring` / `holdWhile` around menus and popovers, `holdBy`
|
||||||
the session's `ControlsHold`), and the double-click on the picture
|
the session's `ControlsHold`), and the double-click on the picture
|
||||||
@@ -2445,7 +2551,7 @@ part not downloaded still reads; chapter cuts are a light dark line.
|
|||||||
`mpvControls: true` from before becomes minimal), `timeDisplay`,
|
`mpvControls: true` from before becomes minimal), `timeDisplay`,
|
||||||
`controlsSize` (normal / large: the bottom bar ×1.25),
|
`controlsSize` (normal / large: the bottom bar ×1.25),
|
||||||
`controlsHideSecs` (2 / 3 / 5), `hiddenControls`, `progressLine`.
|
`controlsHideSecs` (2 / 3 / 5), `hiddenControls`, `progressLine`.
|
||||||
The player settings card has the skin and the progress line; Settings →
|
The player panel has the skin and the progress line; Settings →
|
||||||
Player → Controls has the rest.
|
Player → Controls has the rest.
|
||||||
- Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the
|
- Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the
|
||||||
controls, in the action area ("Player overlay"). The cards stay up; a
|
controls, in the action area ("Player overlay"). The cards stay up; a
|
||||||
@@ -2597,7 +2703,7 @@ dedicated server (`package:logging` loggers named by area: `relay`,
|
|||||||
- **Opting out**: Settings → About → "Share diagnostics with the server"
|
- **Opting out**: Settings → About → "Share diagnostics with the server"
|
||||||
(default on). Off: no lines are kept or sent; one batch per connection
|
(default on). Off: no lines are kept or sent; one batch per connection
|
||||||
says so (`share:false`). The local log is unchanged.
|
says so (`share:false`). The local log is unchanged.
|
||||||
- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player's ⚙ card and
|
- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player panel and
|
||||||
Settings → About open a small card with an optional note (200
|
Settings → About open a small card with an optional note (200
|
||||||
characters, one line); the mark goes at once with the lines waiting,
|
characters, one line); the mark goes at once with the lines waiting,
|
||||||
opted out or not, and the server writes `[who] === PROBLEM marked by
|
opted out or not, and the server writes `[who] === PROBLEM marked by
|
||||||
|
|||||||
+22
-47
@@ -85,52 +85,24 @@ video for subtitle placement and sharpness (default
|
|||||||
|
|
||||||
Test builds and runs from source (a version with `-dev`) and debug runs
|
Test builds and runs from source (a version with `-dev`) and debug runs
|
||||||
(`devTools`, `screens/home/ui.dart`) get Settings → About → Developer;
|
(`devTools`, `screens/home/ui.dart`) get Settings → About → Developer;
|
||||||
releases never show it. What is there is an experiment to look at, not
|
releases never show it:
|
||||||
a decided feature:
|
|
||||||
|
|
||||||
- **Danmaku preview** (`lib/src/danmaku/`): chat lines and short notices
|
- **Danmaku preview** (`danmaku/danmaku_preview.dart`): the danmaku layer
|
||||||
(joins, leaves, Up next adds, "starting now") flying right to left
|
of "Notifications: Danmaku" (ARCHITECTURE "Danmaku notifications") to
|
||||||
across the upper part of the picture. `DanmakuLayer` is the reusable
|
play with, starting from your own look; its knobs change only the
|
||||||
widget: lanes in a band of the height (`DanmakuStyle.bandTop` /
|
preview. The button opens it over the full player when one is up
|
||||||
`bandBottom`, default 10 to 45 %, under top signs, never down into the
|
(under the controls; the panel top right holds them up), else on a
|
||||||
dialogue); each line takes the topmost free lane (`DanmakuLanes`, pure:
|
page with a moving stand-in picture and a mock control bar of the skin
|
||||||
the last line in a lane must have fully entered with a gap, and the
|
in use. The panel (`DanmakuPanel`) fires samples (chat, own, join,
|
||||||
new one, at its own speed, cannot catch it before it leaves); at most
|
leave, Up next, starting, a burst), a random stream (Poisson arrivals
|
||||||
`maxOnScreen` fly, the rest wait in order (dropped after 15 s, 60 at
|
at a rate, short to long lines, CJK and emoji; in a session half the
|
||||||
most); every line crosses in `crossSecs`, so long ones move faster;
|
senders are the party's real members), this session's own chat and
|
||||||
white text with a dark outline and no box, the sender's avatar
|
notice toasts (mapped as the real feature maps them), every
|
||||||
(`StatusAvatar`: picture, else initial) and coloured name first, the
|
`DanmakuStyle` knob, and mock subtitles (a top sign and bottom
|
||||||
app's own notices without one, own lines in an accent frame; the
|
dialogue). The mock dialogue moves with the subtitle lift's steps as
|
||||||
layer's own clock, so lines fly on while paused. Each line is a widget
|
the real subtitles do: over the player when the quick chat box opens,
|
||||||
moved by a `Flow`: per frame only paint runs. The button opens it over
|
and on the stand-in page, which has its own quick chat box (Enter;
|
||||||
the full player when one is up (under the controls; the panel top
|
what you send flies as your own line). Tests: `danmaku_test`.
|
||||||
right holds them up), else on a page with a moving stand-in picture
|
|
||||||
and a mock control bar of the skin in use. The panel (`DanmakuPanel`)
|
|
||||||
fires samples (chat, own, join, leave, Up next, starting, a burst), a
|
|
||||||
random stream (Poisson arrivals at a rate, short to long lines, CJK
|
|
||||||
and emoji; in a session half the senders are the party's real
|
|
||||||
members), this session's own chat and notice toasts, and every
|
|
||||||
`DanmakuStyle` knob live (crossing time, cap, lane band, opacity,
|
|
||||||
size, outline, avatars, own frame), plus mock subtitles (a top sign
|
|
||||||
and bottom dialogue). Tests: `danmaku_test`.
|
|
||||||
- **Lift subtitles above the chat line and controls**
|
|
||||||
(`AppSettings.liftSubtitles`, off): Enter in the full player opens a
|
|
||||||
stand-in for the planned chat line (a one-line box at the bottom in
|
|
||||||
place of the control bar; Enter sends to the chat, Esc closes). While
|
|
||||||
it is open, or the control bar is up, bottom subtitles move above it
|
|
||||||
and back down when it goes (`SubtitleLifter`, `video_view.dart`): mpv
|
|
||||||
`sub-pos`, animated over the shell's 320 ms curve in 0.1 steps, only as
|
|
||||||
far as the letterbox under the picture leaves no room
|
|
||||||
(`subtitleLiftPos`). What each mpv property does, on
|
|
||||||
`make_sub_test.sh`'s file (mpv 0.41, libmpv as the app uses it):
|
|
||||||
`sub-pos` moves bottom ASS dialogue and SRT lines up and leaves `\an8`
|
|
||||||
signs in place (with `sub-ass-override` at its default `scale` or
|
|
||||||
`yes`; `no` ignores it for ASS); `sub-margin-y` moves SRT only (ASS
|
|
||||||
only with `sub-ass-override=force`, which restyles every line, signs
|
|
||||||
too); `sub-ass-style-overrides=MarginV=...` moves top signs down as
|
|
||||||
well; `video-margin-ratio-bottom` shrinks and moves the picture
|
|
||||||
itself (with `sub-ass-force-margins` the dialogue goes into the new
|
|
||||||
black band).
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -168,8 +140,11 @@ Three suites, run from each package's folder:
|
|||||||
the "Waiting for" status (`media_plan_test`), watch tracking and the
|
the "Waiting for" status (`media_plan_test`), watch tracking and the
|
||||||
device history (`watch_tracker_test`), what continues a file of a
|
device history (`watch_tracker_test`), what continues a file of a
|
||||||
local show (`queue_logic_test`), the video view's output size and
|
local show (`queue_logic_test`), the video view's output size and
|
||||||
subtitle lift and the dev-tools gate (`video_view_test`), danmaku lanes
|
subtitle lift steps and the dev-tools gate (`video_view_test`), danmaku
|
||||||
(`danmaku_test`)), `singletons_test` (the `.instance` count, see
|
lanes, style, the toasts that fly and an idle layer
|
||||||
|
(`danmaku_test`), the quick chat box over the player (`quick_chat_test`),
|
||||||
|
the player panel and the danmaku settings card (`player_settings_test`)),
|
||||||
|
`singletons_test` (the `.instance` count, see
|
||||||
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
||||||
tests plus `coord_presence`. The whole suite takes a couple of minutes;
|
tests plus `coord_presence`. The whole suite takes a couple of minutes;
|
||||||
run it only before a restructure step lands, otherwise only the files
|
run it only before a restructure step lands, otherwise only the files
|
||||||
|
|||||||
Reference in new issue
Block a user