From 8effa573a0bcaedefb703c59da680f17b760bd91 Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 07:06:48 +0300 Subject: [PATCH] Protocol 10 presence gate, drop/paste anywhere, quick UI fixes Ready gate: whoever is in the server while it is not playing counts (joining while playing does not pause anyone); a swap counts everyone present and keeps ready flags; the quorum rule is gone. Optional 'Unready when the video changes'. No 'Skip 1:30' guess when the media has chapters. Drag & drop and Ctrl+V anywhere (fox_utils): files, folders and links get a small card asking how to open them. Files series start collapsed. UI: Tab never moves focus and Space is always ready outside text fields; chat focuses its field; unread badge on uosc and a chip in fullscreen; people-only chat with an Activity toggle; skip pills show 5 s then with the controls; volume up to 150%; own avatar same size as the party. Co-Authored-By: Claude Opus 5.5 --- README.md | 36 ++-- apps/desktop/lib/app.dart | 4 +- .../lib/src/controls/uosc/buttons.dart | 64 ++++-- .../lib/src/controls/uosc/controls.dart | 13 +- apps/desktop/lib/src/controls/volume.dart | 146 +++++++++++++ apps/desktop/lib/src/files/ui/file_tiles.dart | 74 ++++--- apps/desktop/lib/src/files/ui/files_page.dart | 66 ++++-- .../desktop/lib/src/playback/media_marks.dart | 8 +- .../lib/src/playback/playback_controller.dart | 4 + .../lib/src/screens/home/settings_page.dart | 5 +- .../lib/src/session/session_coordinator.dart | 47 ++++- apps/desktop/lib/src/shell/app_shell.dart | 174 +++++++++++++++- apps/desktop/lib/src/shell/chat_panel.dart | 137 +++++++++--- apps/desktop/lib/src/shell/handoff.dart | 197 ++++++++++++++++++ apps/desktop/lib/src/shell/library_link.dart | 2 +- apps/desktop/lib/src/shell/library_tab.dart | 29 +-- apps/desktop/lib/src/shell/party_strip.dart | 37 ++-- apps/desktop/lib/src/shell/player_status.dart | 7 +- .../desktop/lib/src/shell/session_player.dart | 164 +++++++++++++-- apps/desktop/lib/src/shell/settings_tab.dart | 18 +- apps/desktop/lib/src/state/settings.dart | 11 + apps/desktop/lib/src/widgets/app_keys.dart | 101 +++++++++ apps/desktop/test/app_keys_test.dart | 68 ++++++ apps/desktop/test/files_page_test.dart | 23 ++ apps/desktop/test/handoff_test.dart | 46 ++++ apps/desktop/test/jellyfin_e2e_test.dart | 5 +- apps/desktop/test/library_tab_test.dart | 20 +- apps/desktop/test/media_marks_test.dart | 13 +- apps/desktop/test/session_logic_test.dart | 10 + apps/desktop/test/shell_test.dart | 25 +++ docs/ARCHITECTURE.md | 81 +++++-- docs/HOW-IT-WORKS.md | 11 +- docs/PROTOCOL.md | 12 +- docs/ROADMAP.md | 49 ++--- packages/core/lib/src/constants.dart | 2 +- packages/core/lib/src/sync_engine.dart | 29 ++- packages/core/test/sync_engine_test.dart | 90 ++++++-- packages/server/test/session_test.dart | 4 +- 38 files changed, 1529 insertions(+), 303 deletions(-) create mode 100644 apps/desktop/lib/src/controls/volume.dart create mode 100644 apps/desktop/lib/src/shell/handoff.dart create mode 100644 apps/desktop/lib/src/widgets/app_keys.dart create mode 100644 apps/desktop/test/app_keys_test.dart create mode 100644 apps/desktop/test/handoff_test.dart diff --git a/README.md b/README.md index 4b7a768..c2bde80 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,11 @@ a video first plays, its banner fills the picture until playback starts. One pill above the controls says what playback waits for ("Press Space when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or -Tab); joins, leaves, chat and other news also pop up bottom left with the -person's avatar. +Tab), ready to type; it shows messages, and its "Activity" toggle adds +joins, ready, seeks and the like between them. Joins, leaves, chat and +other news also pop up bottom left with the person's avatar. Unread chat +shows as a count on the chat buttons and, in fullscreen with the controls +hidden, as a small chip top right. Your name and picture change for everyone in the server right away; the colour is yours only. @@ -83,8 +86,10 @@ disk. Nothing is uploaded or stored anywhere. ## Library -What to watch, from three sources (the switch at the top; Ctrl+V outside -a field pastes a link into Link): +What to watch, from three sources (the switch at the top). Drop a video, +a folder or a link anywhere on the window, or press Ctrl+V outside a +text field, and a small card asks how to open it (put it up, add the +folder to Files, or fill Link). - **Jellyfin**: your linked servers (see below). Library chips beside the switch, then one **Continue** row (what you were watching and what comes @@ -102,8 +107,8 @@ a field pastes a link into Link): thumbnail for YouTube, else the file name); in a server, pick how the others get it (everyone fetches it, the server fetches it, or through you). Your last 12 links are listed under it. -- **Files**: video folders you add on this computer, grouped by series, - or any file through Browse. The others stream it from you. +- **Files**: video folders you add on this computer, grouped by series + (click a series to list its episodes), or any file through Browse. The others stream it from you. Put it up and the player opens. Offline the library browses but nothing goes up until you join or host ("Join or host to watch together"). @@ -162,8 +167,9 @@ hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and audio track. Every change applies right away. -Keys while the player fills the window: Space = ready / unready, Tab = -chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30, +Anywhere in a server (unless you are typing): Space = ready / unready, +Tab = chat & activity (Tab never moves between buttons). Keys while the +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 = close the chat or fullscreen, else ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode, @@ -174,7 +180,9 @@ Controls: the seek bar marks chapter starts with a diamond; hovering it shows the time and the chapter title. The chapters button lists them (current one checked; click to jump). "1:30" skips ahead 1:30. Audio and Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 -2.0 · title"; subtitles have Off. +2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on +it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar +turns amber and mpv amplifies. The uosc player design (⚙ → Player design, Standard by default, switches right away): video controls modeled on mpv's uosc theme. A thick @@ -189,12 +197,14 @@ the same. Skipping: during the opening a "Skip OP" button floats in the corner (it jumps to the end of the opening), during the ending "Skip ED" (to the end -of the ending only; what comes after is yours to skip). The opening and +of the ending only; what comes after is yours to skip). Any skip button +(also "Skip 1:30") shows for 5 seconds when it comes up, then only while +the controls are up (move the mouse). The opening and ending come from Jellyfin's intro / outro segments when your Jellyfin has them (needs a segment plugin, e.g. Intro Skipper), else from chapter names -(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on -anything longer than 20 minutes, "Skip 1:30" floats there during the first -6 minutes. +(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or +any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there +during the first 6 minutes. ## Ports diff --git a/apps/desktop/lib/app.dart b/apps/desktop/lib/app.dart index 515a5b0..2f9a8cb 100644 --- a/apps/desktop/lib/app.dart +++ b/apps/desktop/lib/app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'src/screens/home/ui.dart'; import 'src/shell/app_shell.dart'; +import 'src/widgets/app_keys.dart'; class WatchTogetherApp extends StatelessWidget { const WatchTogetherApp({super.key}); @@ -11,7 +12,8 @@ class WatchTogetherApp extends StatelessWidget { return MaterialApp( title: appName, theme: ThemeData.dark(), - home: const AppShell(), + shortcuts: appShortcuts, + home: const AppKeys(child: AppShell()), ); } } diff --git a/apps/desktop/lib/src/controls/uosc/buttons.dart b/apps/desktop/lib/src/controls/uosc/buttons.dart index ce83abc..6260bb4 100644 --- a/apps/desktop/lib/src/controls/uosc/buttons.dart +++ b/apps/desktop/lib/src/controls/uosc/buttons.dart @@ -10,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick; import '../../shaders/shader_menu.dart'; import '../../shaders/shader_presets.dart'; import '../../state/settings.dart'; +import '../volume.dart'; import 'controls.dart'; import 'menu.dart'; import 'style.dart'; @@ -166,9 +167,10 @@ class _UoscBadgeButtonState extends State { } } -/// Volume: click mutes / unmutes, the wheel steps 5 %. +/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to +/// [maxVolume], the wheel steps 5 %. class UoscVolumeButton extends StatefulWidget { - /// 0..100. + /// 0..[maxVolume]. final double volume; final ValueChanged onChanged; const UoscVolumeButton({super.key, required this.volume, required this.onChanged}); @@ -179,29 +181,55 @@ class UoscVolumeButton extends StatefulWidget { class _UoscVolumeButtonState extends State { double _beforeMute = 100; + bool _hover = false; @override Widget build(BuildContext context) { final v = widget.volume; final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded); - return Listener( - onPointerSignal: (e) { - if (e is PointerScrollEvent && e.scrollDelta.dy != 0) { - widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0)); - } - }, - child: UoscButton( - key: const ValueKey('uosc-volume'), - icon: icon, - tooltip: 'Volume ${v.round()} % (scroll to change)', - onPressed: () { - if (v > 0) { - _beforeMute = v; - widget.onChanged(0); - } else { - widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute); + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: Listener( + onPointerSignal: (e) { + if (e is PointerScrollEvent && e.scrollDelta.dy != 0) { + widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5)); } }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 150), + child: _hover + ? Padding( + padding: const EdgeInsets.only(left: 6, right: 2), + child: VolumeBar( + key: const ValueKey('uosc-volume-bar'), + volume: v, + onChanged: widget.onChanged, + color: UoscStyle.text, + trackColor: UoscStyle.dim.withValues(alpha: 0.5), + ), + ) + : const SizedBox.shrink(), + ), + UoscButton( + key: const ValueKey('uosc-volume'), + icon: icon, + color: v > 100 ? boostColor : null, + tooltip: 'Volume ${v.round()} % (scroll to change)', + onPressed: () { + if (v > 0) { + _beforeMute = v; + widget.onChanged(0); + } else { + widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute); + } + }, + ), + ], + ), ), ); } diff --git a/apps/desktop/lib/src/controls/uosc/controls.dart b/apps/desktop/lib/src/controls/uosc/controls.dart index 89d0f23..a8b6b1c 100644 --- a/apps/desktop/lib/src/controls/uosc/controls.dart +++ b/apps/desktop/lib/src/controls/uosc/controls.dart @@ -26,6 +26,8 @@ class UoscControls extends StatefulWidget { final Widget? topLeft; final VoidCallback? onDoubleTap; final Duration hideAfter; + /// Follows whether the controls are up (for overlays outside them). + final ValueNotifier? shown; const UoscControls({ super.key, required this.timeline, @@ -35,6 +37,7 @@ class UoscControls extends StatefulWidget { this.topLeft, this.onDoubleTap, this.hideAfter = const Duration(seconds: 3), + this.shown, }); static const buttonRowHeight = 40.0; @@ -58,6 +61,10 @@ class UoscControlsState extends State { @override void initState() { super.initState(); + // Not during this build: an overlay listening to [shown] may be built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.shown?.value = true; + }); _hideLater(); } @@ -69,7 +76,10 @@ class UoscControlsState extends State { /// Show the controls and restart the hide countdown. void wake() { - if (!_visible && mounted) setState(() => _visible = true); + if (!_visible && mounted) { + setState(() => _visible = true); + widget.shown?.value = true; + } _hideLater(); } @@ -98,6 +108,7 @@ class UoscControlsState extends State { void _hide() { if (!mounted || _holds > 0 || !_visible) return; setState(() => _visible = false); + widget.shown?.value = false; } @override diff --git a/apps/desktop/lib/src/controls/volume.dart b/apps/desktop/lib/src/controls/volume.dart new file mode 100644 index 0000000..88475fb --- /dev/null +++ b/apps/desktop/lib/src/controls/volume.dart @@ -0,0 +1,146 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../playback/playback_controller.dart' show PlaybackController; + +/// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies. +const maxVolume = PlaybackController.maxVolume * 1.0; + +/// The part of the volume bar above 100 %. +const boostColor = Color(0xFFFFB74D); + +/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume]. +double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume); + +/// A thin volume track over 0..[maxVolume]: filled up to the volume, the +/// part above 100 % in [boostColor], a tick at 100 %. Click or drag sets +/// it. +class VolumeBar extends StatelessWidget { + final double volume; + final ValueChanged onChanged; + final double width; + final Color color; + final Color trackColor; + const VolumeBar({ + super.key, + required this.volume, + required this.onChanged, + this.width = 64, + this.color = Colors.white, + this.trackColor = Colors.white30, + }); + + @override + Widget build(BuildContext context) { + void at(double x) => onChanged((x / width * maxVolume).clamp(0.0, maxVolume).roundToDouble()); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (e) => at(e.localPosition.dx), + onHorizontalDragUpdate: (e) => at(e.localPosition.dx), + child: SizedBox( + width: width, + height: 24, + child: CustomPaint(painter: _VolumePainter(volume.clamp(0.0, maxVolume), color, trackColor)), + ), + ); + } +} + +class _VolumePainter extends CustomPainter { + final double volume; + final Color color, trackColor; + _VolumePainter(this.volume, this.color, this.trackColor); + + @override + void paint(Canvas canvas, Size size) { + final y = size.height / 2; + double x(double v) => size.width * v / maxVolume; + final line = Paint() + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor); + final full = x(100); + canvas.drawLine(Offset(full, y - 5), Offset(full, y + 5), Paint() + ..color = trackColor + ..strokeWidth = 1.5); + if (volume > 0) canvas.drawLine(Offset(0, y), Offset(x(volume.clamp(0, 100)), y), line..color = color); + if (volume > 100) canvas.drawLine(Offset(full, y), Offset(x(volume), y), line..color = boostColor); + canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color); + } + + @override + bool shouldRepaint(_VolumePainter old) => + old.volume != volume || old.color != color || old.trackColor != trackColor; +} + +/// Volume for the standard bar (instead of media_kit's, which stops at +/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the +/// wheel steps 5 %. +class VolumeButton extends StatefulWidget { + final Player player; + final FocusNode? focusNode; + const VolumeButton({super.key, required this.player, this.focusNode}); + + @override + State createState() => _VolumeButtonState(); +} + +class _VolumeButtonState extends State { + bool _hover = false; + double _beforeMute = 100; + + @override + Widget build(BuildContext context) { + final player = widget.player; + return StreamBuilder( + stream: player.stream.volume, + initialData: player.state.volume, + builder: (context, snap) { + final v = snap.data ?? 100; + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: Listener( + onPointerSignal: (e) { + if (e is PointerScrollEvent && e.scrollDelta.dy != 0) { + player.setVolume(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5)); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 4), + IconButton( + key: const ValueKey('volume-button'), + focusNode: widget.focusNode, + tooltip: 'Volume ${v.round()} %', + iconSize: 22, + color: Colors.white, + onPressed: () { + if (v > 0) { + _beforeMute = v; + player.setVolume(0); + } else { + player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute); + } + }, + icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)), + ), + AnimatedSize( + duration: const Duration(milliseconds: 150), + child: _hover + ? Padding( + padding: const EdgeInsets.only(left: 4, right: 12), + child: VolumeBar(volume: v, onChanged: player.setVolume), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/apps/desktop/lib/src/files/ui/file_tiles.dart b/apps/desktop/lib/src/files/ui/file_tiles.dart index 75d7b2d..1e1f786 100644 --- a/apps/desktop/lib/src/files/ui/file_tiles.dart +++ b/apps/desktop/lib/src/files/ui/file_tiles.dart @@ -35,7 +35,11 @@ Widget _offlineTip(bool offline, Widget child) => class SeriesHeader extends StatelessWidget { final SeriesGroup group; final ThumbCache thumbs; - const SeriesHeader({super.key, required this.group, required this.thumbs}); + /// Its episodes are listed below; [onTap] opens or closes them. + final bool expanded; + final VoidCallback? onTap; + const SeriesHeader( + {super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap}); @override Widget build(BuildContext context) { @@ -49,34 +53,48 @@ class SeriesHeader extends StatelessWidget { ?first.parsed.group, ].join(' · '); return Padding( - padding: const EdgeInsets.only(top: 18, bottom: 4), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: ThumbBuilder( - cache: thumbs, - path: first.path, - builder: (context, t, _) => ThumbImage(t, width: 96, height: 54), - ), + padding: const EdgeInsets.only(top: 14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ThumbBuilder( + cache: thumbs, + path: first.path, + builder: (context, t, _) => ThumbImage(t, width: 96, height: 54), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(group.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium), + Text(detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + ], + ), + ), + if (onTap != null) + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant), + ), + const SizedBox(width: 8), + ], ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(group.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium), - Text(detail, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), - ], - ), - ), - ], + ), ), ); } diff --git a/apps/desktop/lib/src/files/ui/files_page.dart b/apps/desktop/lib/src/files/ui/files_page.dart index a76f973..2a3d75b 100644 --- a/apps/desktop/lib/src/files/ui/files_page.dart +++ b/apps/desktop/lib/src/files/ui/files_page.dart @@ -17,7 +17,8 @@ export 'thumb.dart' show ThumbnailLoader; // The Files source: watched folders on the left (chips on top when // narrow), the open one on the right grouped into series and loose // videos, or Recent. A folder is scanned when first opened and on Rescan; -// scans are kept for the app session. Playing goes through [onPlay]. +// scans are kept for the app session. Series start collapsed; the ones +// opened stay open for the app session. Playing goes through [onPlay]. /// Below this width the sidebar turns into chips above the list. const filesNarrowWidth = 700.0; @@ -26,12 +27,23 @@ const filesNarrowWidth = 700.0; final _scans = {}; final _running = >{}; String? _lastOpen; +/// Series opened this session, by `folder\nseries key`. +final _expanded = {}; +final _showRequests = ValueNotifier(0); -/// Forgets the session's scans and the open folder. +/// Opens [dir] (null: Recent) in the Files page: now if one is showing, +/// else when it next opens. +void showFilesFolder(String? dir) { + _lastOpen = dir; + _showRequests.value++; +} + +/// Forgets the session's scans, open series and the open folder. @visibleForTesting void resetFilesPageSession() { _scans.clear(); _running.clear(); + _expanded.clear(); _lastOpen = null; } @@ -73,22 +85,21 @@ class FilesPage extends StatefulWidget { /// A scan grouped for display, made once per scan. class _Library { final GroupedVideos grouped; - /// Series rows in order: a header (video null), then its episodes and - /// extras. - final List<(SeriesGroup, VideoFile?)> rows; final int subtitleFiles; _Library(FolderScan scan) : grouped = groupBySeries(scan.videos), - rows = [], - subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length { - for (final g in grouped.series) { - rows.add((g, null)); - for (final v in [...g.episodes, ...g.extras]) { - rows.add((g, v)); - } - } - } + subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length; + + /// Series rows in order: a header (video null), then, for the series + /// [open] says, its episodes and extras. + List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [ + for (final g in grouped.series) ...[ + (g, null), + if (open(g)) + for (final v in [...g.episodes, ...g.extras]) (g, v), + ], + ]; } final _libraries = Expando<_Library>(); @@ -107,6 +118,7 @@ class _FilesPageState extends State { @override void initState() { super.initState(); + _showRequests.addListener(_shown); _store.ensureLoaded().then((_) { if (!mounted) return; final open = _open; @@ -118,6 +130,14 @@ class _FilesPageState extends State { }); } + @override + void dispose() { + _showRequests.removeListener(_shown); + super.dispose(); + } + + void _shown() => _select(_lastOpen); + Future _runScan(String dir) => (widget.scan ?? scanFolder)(dir); void _select(String? dir) { @@ -477,6 +497,8 @@ class _FilesPageState extends State { if (scan.truncated) 'stopped after $maxScannedFiles files', ].join(' · '); final loose = lib.grouped.loose; + String seriesId(SeriesGroup g) => '$dir\n${g.key}'; + final rows = lib.rows((g) => _expanded.contains(seriesId(g))); return [ SliverToBoxAdapter( child: Wrap( @@ -547,13 +569,21 @@ class _FilesPageState extends State { 'It may have been moved, renamed or be on a drive that is not connected.', ), ), - // Series: files whose names carry an episode number. + // Series: files whose names carry an episode number; a header + // click opens or closes its episodes. SliverList.builder( - itemCount: lib.rows.length, + itemCount: rows.length, itemBuilder: (context, i) { - final (group, v) = lib.rows[i]; + final (group, v) = rows[i]; if (v == null) { - return SeriesHeader(key: ValueKey('series:${group.key}'), group: group, thumbs: _thumbs); + final id = seriesId(group); + return SeriesHeader( + key: ValueKey('series:${group.key}'), + group: group, + thumbs: _thumbs, + expanded: _expanded.contains(id), + onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)), + ); } return FileRow( key: ValueKey(v.path), diff --git a/apps/desktop/lib/src/playback/media_marks.dart b/apps/desktop/lib/src/playback/media_marks.dart index 6e8ec9b..15fe2ec 100644 --- a/apps/desktop/lib/src/playback/media_marks.dart +++ b/apps/desktop/lib/src/playback/media_marks.dart @@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1); /// The skip pill at [position]: inside the opening "Skip OP" (to its end), /// inside the ending "Skip ED" (to its end only: what follows, preview or /// more episode, is the viewer's call). Without any opening info on media -/// longer than [fallbackMinDuration], "Skip 1:30" during the first -/// [fallbackWindow]. Null otherwise. +/// longer than [fallbackMinDuration] and without chapters ([chaptered]: a +/// chaptered file without an OP chapter has none on purpose), "Skip 1:30" +/// during the first [fallbackWindow]. Null otherwise. SkipOffer? skipOfferAt({ required Duration position, required Duration duration, required SkipRange? opening, required SkipRange? ending, + bool chaptered = false, }) { bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail; if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end); if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end); - if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) { + if (opening == null && !chaptered && duration > fallbackMinDuration && position < fallbackWindow) { final to = position + fallbackSkip; return SkipOffer(SkipKind.fixed, to < duration ? to : duration); } diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index f4f038b..ed5ad9c 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -32,6 +32,9 @@ class PlaybackController { /// Back buffer (media_kit sets it to bufferSize too): enough for a short /// seek back without holding another 256 MiB. static const maxBackBytes = 64 * 1024 * 1024; + /// mpv's `volume-max` (its default is 130): the volume controls go + /// this far, past 100 % amplified. + static const maxVolume = 150; SyncPlayer? _player; VideoController? _video; @@ -163,6 +166,7 @@ class PlaybackController { _logSub = _player!.stream.log.listen(_onLog); _watchMarks(_player!); await _pinBufferProps(); + await _pin('volume-max', '$maxVolume'); await applyShaders(); _watchdog.reset(); _watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch()); diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index 5e34f16..caaedbc 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -7,11 +7,12 @@ import '../../update/update_controller.dart'; import 'ui.dart'; import 'update_indicator.dart'; -/// Player keys, as handled by the session player's key handler (while -/// the player fills the window). +/// Session keys: Space and Tab anywhere in a server (`AppKeys`), the +/// rest by the session player's key handler (while it fills the window). const sessionShortcuts = [ ('Space', 'Toggle ready'), ('← / →', 'Seek back / forward 10 s'), + ('↑ / ↓', 'Volume up / down (up to 150 %)'), ('Shift+→', 'Skip 1:30 (for everyone)'), ('PgUp / PgDn', 'Previous / next chapter'), ('F', 'Toggle fullscreen'), diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index ae13e72..381c464 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -102,6 +102,17 @@ class ToastItem { const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'}; +/// "Unready when the video changes": a state with a newer [mediaId] than +/// the last one this session saw ([lastMediaId], -1 before the first) while +/// self is ready (or a reconnect is restoring it). A swap keeps the +/// server's ready flags, so the client unreadies itself. +bool unreadyForNewMedia( + {required bool enabled, + required int lastMediaId, + required int mediaId, + required bool ready}) => + enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready; + /// `media.relay` of a state, or null when the media is not relayed. MediaRelay? stateMediaRelay(StateBroadcast state) { final r = state.media?['relay']; @@ -184,7 +195,7 @@ class SessionCoordinator { final autoAdvance = signal(null); /// The skip pill for the current position ([skipOfferAt]: Skip OP, - /// Skip ED, or Skip 1:30 without opening info); null while nothing + /// Skip ED, or Skip 1:30 without opening info or chapters); null while nothing /// is open or at `finished`. Press it with [skip]. late final Computed skipOffer = computed(() { final r = serverState.value; @@ -195,6 +206,7 @@ class SessionCoordinator { duration: duration, opening: playback.opening.value, ending: playback.ending.value, + chaptered: playback.chapters.value.isNotEmpty, ); }); @@ -223,6 +235,9 @@ class SessionCoordinator { // Set after a reconnect until the server echoes the restored ready, so // the fresh member's ready=false doesn't overwrite [_lastReady]. bool _restoringReady = false; + // Last mediaId seen in a state; unlike [_mediaId] it survives a + // reconnect, so only a real swap counts as a new video. + int _seenMediaId = -1; // Bumped by every connect/leave/cancel/retry: stale attempts no-op. int _epoch = 0; Timer? _reconnectTimer; @@ -356,6 +371,7 @@ class SessionCoordinator { _openUrl = null; _lastReady = false; _restoringReady = false; + _seenMediaId = -1; _wasPlayAllowed = false; _everConnected = false; _lostSounded = false; @@ -668,12 +684,27 @@ class SessionCoordinator { void _trackSelfReady(StateBroadcast r) { final self = selfMember.peek(); if (self == null) return; - if (r.mediaId != _mediaId) _restoringReady = false; + // [_seenMediaId], not [_mediaId]: that one is -1 after a lost + // connection, which would end the restore before its echo. + if (_seenMediaId >= 0 && r.mediaId != _seenMediaId) _restoringReady = false; if (_restoringReady) { if (self.ready) _restoringReady = false; } else { _lastReady = self.ready; } + final last = _seenMediaId; + _seenMediaId = r.mediaId; + // A ready restored by a reconnect may not have echoed yet: unready + // anyway (it was sent first, so this one lands last). + if (unreadyForNewMedia( + enabled: AppSettings.instance.unreadyOnSwap.peek(), + lastMediaId: last, + mediaId: r.mediaId, + ready: self.ready || _restoringReady)) { + _lastReady = false; + _restoringReady = false; + sync.sendReady(false, source: 'auto'); + } } void _updateStarving(StateBroadcast r, double bufferedAhead) { @@ -1497,7 +1528,8 @@ class SessionCoordinator { reconnecting.value = false; return; } - final wantReady = _lastReady; + var wantReady = _lastReady; + final seenBefore = _seenMediaId; try { health.value = const HealthStatus( HealthSeverity.info, 'reconnecting…'); @@ -1528,6 +1560,15 @@ class SessionCoordinator { if (stale()) return; // The server forgot the dropped member; restore the ready flag it // last reported (not a local guess: others may have forced it). + // Not when the video changed meanwhile and the user unreadies then. + if (unreadyForNewMedia( + enabled: AppSettings.instance.unreadyOnSwap.peek(), + lastMediaId: seenBefore, + mediaId: sync.lastState?.mediaId ?? seenBefore, + ready: wantReady)) { + wantReady = false; + _lastReady = false; + } if (wantReady) { _lastReady = true; _restoringReady = true; diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 26535ad..fbd7059 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -1,10 +1,14 @@ import 'dart:async'; +import 'dart:io' show File; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget; import 'package:signals/signals_flutter.dart'; import 'package:window_manager/window_manager.dart'; +import '../files/folder_store.dart'; +import '../files/ui/files_page.dart' show showFilesFolder; import '../jellyfin/accounts.dart'; import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/session_media.dart'; @@ -18,6 +22,7 @@ import '../state/settings.dart'; import '../update/update_controller.dart'; import 'chat_panel.dart'; import 'connect_cards.dart'; +import 'handoff.dart'; import 'library_tab.dart'; import 'motion.dart'; import 'party_strip.dart'; @@ -36,6 +41,9 @@ import 'toasts.dart'; /// hidden), mini it is a 384x216 box snapped to a corner. A mode change /// fades the layer out, moves it while invisible and fades it back in, so /// the video is never mounted twice nor remounted. +/// +/// A drop anywhere on the window or Ctrl+V outside a text field is a +/// [Handoff]: a card near the pointer asks how to open it. class AppShell extends StatefulWidget { final UpdateController? updates; final bool autoUpdateCheck; @@ -88,6 +96,11 @@ class AppShellState extends State with SingleTickerProviderStateMixin Offset? _drag; // mini top-left while dragging bool _snapping = false; + /// Last pointer position in the window (where a paste's card opens). + Offset? _pointer; + bool _dragging = false; + bool _handoffOpen = false; + final _flow = ConnectFlow(); late final _join = JoinFields(SessionState.instance.lastForm); late final _host = HostFields(SessionState.instance.lastForm); @@ -128,6 +141,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin _jellyfin.ensureLoaded(); if (widget.autoUpdateCheck) _autoCheck(); _flow.onKicked = _showKicked; + HardwareKeyboard.instance.addHandler(_onKey); final session = SessionState.instance; final coord = session.coordinator; _feedSeen = coord.feedLog.peek().length; @@ -195,6 +209,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin for (final stop in _stops) { stop(); } + HardwareKeyboard.instance.removeHandler(_onKey); _fade.dispose(); _online.dispose(); _hasMedia.dispose(); @@ -368,6 +383,122 @@ class AppShellState extends State with SingleTickerProviderStateMixin return null; } + // --------------------------------------------------------------- handoff + + /// Nothing (a dialog, a popover) is over the shell. + bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true); + + /// Ctrl+V outside a text field (a focused field pastes on its own). + bool _onKey(KeyEvent e) { + if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false; + final keys = HardwareKeyboard.instance; + if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false; + if (!mounted || !_onTop) return false; + final inField = + FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType() != null; + if (inField) return false; + unawaited(_paste()); + return true; + } + + /// Clipboard files first, then text (a link or absolute paths). A pasted + /// image has no taker yet, so the clipboard's image data is not read. + Future _paste() async { + var files = const []; + String? text; + try { + files = await ClipboardX.readFiles(); + } catch (_) {} + if (files.isEmpty) { + try { + text = await ClipboardX.readText(); + } catch (_) {} + } + await _handOff(classifyHandoff(paths: files, text: text), _pointer); + } + + Future _handOff(Handoff item, Offset? at) async { + if (!mounted || !_onTop || item is HandoffNothing || item is HandoffImage) return; + final size = MediaQuery.sizeOf(context); + final align = at == null || size.isEmpty + ? Alignment.center + : Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1)); + final online = _online.peek(); + _handoffOpen = true; + final HandoffAction? action; + try { + action = await showPopover(context, at: align, child: HandoffCard(item: item, online: online)); + } finally { + _handoffOpen = false; + } + if (action == null || !mounted) return; + switch ((item, action)) { + case (HandoffVideos(:final paths), HandoffAction.putUp): + final error = await _putUp(FileMedia(paths.first)); + if (error != null) { + SessionState.instance.coordinator.showToast(error); + } else { + await FolderStore.instance.ensureLoaded(); + FolderStore.instance.addRecent(paths.first); + } + case (HandoffVideos(:final paths), HandoffAction.openInFiles): + await FolderStore.instance.ensureLoaded(); + FolderStore.instance.addRecent(paths.first); + _openFiles(null); + case (HandoffVideos(:final paths), HandoffAction.addFolder): + await _addFolder(File(paths.first).parent.path); + case (HandoffFolder(:final path), _): + await _addFolder(path); + case (HandoffLink(:final url), _): + _picker.link.text = url; + _openLibrary(MediaKind.link); + default: + } + } + + Future _addFolder(String dir) async { + await FolderStore.instance.ensureLoaded(); + FolderStore.instance.addFolder(dir); + _openFiles(dir); + } + + /// Library → Files on [dir] (null: Recent). + void _openFiles(String? dir) { + showFilesFolder(dir); + _openLibrary(MediaKind.file); + } + + void _openLibrary(MediaKind source) { + if (!mounted) return; + _picker.source = source; + _selectTab(ShellTab.library); + } + + Widget _dropTarget(Widget child) => DropTarget( + onDragEntered: (_) => setState(() => _dragging = _onTop), + onDragExited: (_) => setState(() => _dragging = false), + onDragDone: (d) { + setState(() => _dragging = false); + _handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition); + }, + child: Stack( + fit: StackFit.expand, + children: [ + child, + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + duration: easeTime, + curve: ease, + opacity: _dragging ? 1 : 0, + child: const _DropOverlay(), + ), + ), + ), + ], + ), + ); + // --------------------------------------------------------------- dialogs Future _confirmLeave() async { @@ -426,7 +557,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return CallbackShortcuts( + final shell = CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings), }, @@ -474,6 +605,11 @@ class AppShellState extends State with SingleTickerProviderStateMixin }), ), ); + return Listener( + onPointerHover: (e) => _pointer = e.localPosition, + onPointerDown: (e) => _pointer = e.localPosition, + child: _dropTarget(shell), + ); } Widget _body(bool online) { @@ -619,3 +755,39 @@ class AppShellState extends State with SingleTickerProviderStateMixin ); } } + +/// Over the window while something is dragged over it. +class _DropOverlay extends StatelessWidget { + const _DropOverlay(); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + key: const ValueKey('drop-overlay'), + margin: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: scheme.surface.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: scheme.primary.withValues(alpha: 0.7), width: 2), + ), + alignment: Alignment.center, + child: Material( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + elevation: 8, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.file_download_outlined, color: scheme.primary), + const SizedBox(width: 10), + Text('Drop to open', style: Theme.of(context).textTheme.titleMedium), + ], + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/shell/chat_panel.dart b/apps/desktop/lib/src/shell/chat_panel.dart index 3a25984..d167f96 100644 --- a/apps/desktop/lib/src/shell/chat_panel.dart +++ b/apps/desktop/lib/src/shell/chat_panel.dart @@ -1,22 +1,70 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:sync_relay_core/core.dart'; import '../state/session_state.dart'; +import '../widgets/text_focus.dart'; import 'avatar.dart'; -/// Side panel: chat with avatars, other activity as quiet lines, and the -/// message field. Width is fixed; the shell slides it in and out. -class ChatPanel extends StatelessWidget { +/// Activity lines (joins, ready, seeks...) between the messages: off by +/// default, kept for the app's run. +final showActivity = ValueNotifier(false); + +/// Side panel: chat with avatars, other activity as quiet lines (behind +/// the "Activity" toggle), and the message field. Width is fixed; the +/// shell slides it in and out. The field takes focus on open; Tab and Esc +/// in it close the panel. +class ChatPanel extends StatefulWidget { static const width = 340.0; final TextEditingController chat; final FocusNode chatFocus; final VoidCallback onClose; const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose}); + @override + State createState() => _ChatPanelState(); +} + +class _ChatPanelState extends State { + // Where focus was before the field took it; it goes back there on close + // (else a field behind could take it, and Space with it). + FocusNode? _before; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final before = FocusManager.instance.primaryFocus; + if (before != widget.chatFocus) _before = before; + widget.chatFocus.requestFocus(); + }); + } + + @override + void dispose() { + final before = _before; + if (before != null && before.context != null && before.canRequestFocus && !textInputFocused(before)) { + before.requestFocus(); + } else if (textInputFocused()) { + FocusManager.instance.primaryFocus?.unfocus(); + } + super.dispose(); + } + void _send() { - SessionState.instance.coordinator.sendChat(chat.text); - chat.clear(); - chatFocus.requestFocus(); + SessionState.instance.coordinator.sendChat(widget.chat.text); + widget.chat.clear(); + widget.chatFocus.requestFocus(); + } + + KeyEventResult _fieldKey(FocusNode node, KeyEvent e) { + if (e is! KeyDownEvent) return KeyEventResult.ignored; + if (e.logicalKey != LogicalKeyboardKey.tab && e.logicalKey != LogicalKeyboardKey.escape) { + return KeyEventResult.ignored; + } + widget.onClose(); + return KeyEventResult.handled; } @override @@ -24,7 +72,7 @@ class ChatPanel extends StatelessWidget { final scheme = Theme.of(context).colorScheme; return Container( key: const ValueKey('chat-panel'), - width: width, + width: ChatPanel.width, color: scheme.surfaceContainerLow, child: Column( children: [ @@ -32,41 +80,64 @@ class ChatPanel extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 8, 4, 0), child: Row( children: [ - const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)), + const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)), const Spacer(), - IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)), + ValueListenableBuilder( + valueListenable: showActivity, + builder: (context, on, _) => FilterChip( + key: const ValueKey('chat-activity'), + label: const Text('Activity', style: TextStyle(fontSize: 12)), + tooltip: 'Show joins, ready, seeks and other activity', + selected: on, + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onSelected: (v) => showActivity.value = v, + ), + ), + IconButton(tooltip: 'Close (Tab)', onPressed: widget.onClose, icon: const Icon(Icons.close, size: 18)), ], ), ), Expanded( - child: PartyWatch(builder: (context) { - final log = SessionState.instance.coordinator.feedLog.value; - if (log.isEmpty) { - return Center( - child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + child: ValueListenableBuilder( + valueListenable: showActivity, + builder: (context, activity, _) => PartyWatch(builder: (context) { + final all = SessionState.instance.coordinator.feedLog.value; + final log = activity ? all : [for (final f in all) if (f.action == 'chat') f]; + if (log.isEmpty) { + return Center( + child: Text(activity ? 'Nothing yet' : 'No messages yet', + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + ); + } + return ListView.builder( + reverse: true, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: log.length, + itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]), ); - } - return ListView.builder( - reverse: true, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: log.length, - itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]), - ); - }), + }), + ), ), Padding( padding: const EdgeInsets.all(12), - child: TextField( - key: const ValueKey('chat-field'), - controller: chat, - focusNode: chatFocus, - onSubmitted: (_) => _send(), - decoration: InputDecoration( - hintText: 'Message (Enter)', - isDense: true, - filled: true, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), - suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)), + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _fieldKey, + child: TextField( + key: const ValueKey('chat-field'), + controller: widget.chat, + focusNode: widget.chatFocus, + onSubmitted: (_) => _send(), + decoration: InputDecoration( + hintText: 'Message (Enter)', + isDense: true, + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)), + ), ), ), ), diff --git a/apps/desktop/lib/src/shell/handoff.dart b/apps/desktop/lib/src/shell/handoff.dart new file mode 100644 index 0000000..7a649f1 --- /dev/null +++ b/apps/desktop/lib/src/shell/handoff.dart @@ -0,0 +1,197 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../files/scanner.dart' show FileKind, kindOf; +import '../files/ui/file_tiles.dart' show baseName; +import 'library_tab.dart' show offlinePutUpHint; + +// Something handed to the app, by a drop or by Ctrl+V outside a text +// field: what it is ([classifyHandoff], pure) and the small card that asks +// how to open it ([HandoffCard]). The shell wires both. + +/// What was dropped or pasted. +sealed class Handoff { + const Handoff(); +} + +/// Video files (by the Files page's extensions), in the order given. +final class HandoffVideos extends Handoff { + final List paths; + const HandoffVideos(this.paths); +} + +final class HandoffFolder extends Handoff { + final String path; + const HandoffFolder(this.path); +} + +/// An http(s) link. +final class HandoffLink extends Handoff { + final String url; + const HandoffLink(this.url); +} + +/// An image file. Nothing takes one yet; the shell ignores it. +final class HandoffImage extends Handoff { + final String path; + const HandoffImage(this.path); +} + +final class HandoffNothing extends Handoff { + const HandoffNothing(); +} + +const _imageExtensions = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'}; + +final _link = RegExp(r'^https?://\S+$', caseSensitive: false); +final _windowsAbsolute = RegExp(r'^([A-Za-z]:[\\/]|\\\\)'); + +bool _absolute(String p) => p.startsWith('/') || _windowsAbsolute.hasMatch(p); + +/// A `file://` URI as a path; anything else as is. +String _pathOf(String p) { + if (!p.startsWith('file://')) return p; + try { + return Uri.parse(p).toFilePath(); + } catch (_) { + return p; + } +} + +/// Classifies dropped or pasted [paths] (files first) or, when there are +/// none, pasted [text]: a link, or one absolute path (or `file://` URI) per +/// line. Videos win over folders, folders over images; paths that do not +/// exist ([typeOf], defaults to the file system) count for nothing. +Handoff classifyHandoff({ + List paths = const [], + String? text, + FileSystemEntityType Function(String path)? typeOf, +}) { + final type = typeOf ?? (p) => FileSystemEntity.typeSync(p); + var list = [for (final p in paths) if (p.trim().isNotEmpty) _pathOf(p.trim())]; + if (list.isEmpty && text != null) { + final t = text.trim(); + if (_link.hasMatch(t)) return HandoffLink(t); + final lines = [for (final l in t.split(RegExp(r'[\r\n]+'))) if (l.trim().isNotEmpty) _pathOf(l.trim())]; + if (lines.isEmpty || !lines.every(_absolute)) return const HandoffNothing(); + list = lines; + } + final types = {for (final p in list) p: type(p)}; + bool file(String p) => types[p] == FileSystemEntityType.file; + final videos = [for (final p in list) if (file(p) && kindOf(p) == FileKind.video) p]; + if (videos.isNotEmpty) return HandoffVideos(videos); + for (final p in list) { + if (types[p] == FileSystemEntityType.directory) return HandoffFolder(p); + } + for (final p in list) { + final dot = p.lastIndexOf('.'); + if (file(p) && dot >= 0 && _imageExtensions.contains(p.substring(dot + 1).toLowerCase())) { + return HandoffImage(p); + } + } + return const HandoffNothing(); +} + +/// What the card's buttons ask the shell to do. +enum HandoffAction { + /// Put the (first) video up for everyone. + putUp, + /// Offline: list the video in Files → Recent and open it there. + openInFiles, + /// Add the video's folder (or the folder) to Files and open it. + addFolder, + /// Open Library → Link with the URL filled. + openLink, +} + +/// The card asking how to open a [Handoff] (videos, a folder or a link); +/// pops the [HandoffAction] chosen. +class HandoffCard extends StatelessWidget { + final Handoff item; + final bool online; + const HandoffCard({super.key, required this.item, required this.online}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + void pick(HandoffAction a) => Navigator.of(context).pop(a); + Widget button(HandoffAction a, IconData icon, String label, {bool primary = false}) => primary + ? FilledButton.icon( + key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label)) + : OutlinedButton.icon( + key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label)); + + final (IconData icon, String title, String detail, List buttons) = switch (item) { + HandoffVideos(:final paths) => ( + Icons.movie_outlined, + baseName(paths.first), + paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer', + [ + if (online) + button(HandoffAction.putUp, Icons.play_arrow, 'Put it up', primary: true) + else + button(HandoffAction.openInFiles, Icons.history, 'Open in Files', primary: true), + button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'), + ], + ), + HandoffFolder(:final path) => ( + Icons.folder_outlined, + baseName(path), + path, + [button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add to Files', primary: true)], + ), + HandoffLink(:final url) => ( + Icons.link, + url, + online ? 'Pick how the others get it on the Link page' : offlinePutUpHint, + [ + button(HandoffAction.openLink, Icons.link, online ? 'Put it up as a link…' : 'Open in Link', + primary: true), + ], + ), + HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const []), + }; + return Material( + key: const ValueKey('handoff-card'), + elevation: 12, + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(18), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: scheme.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall), + Text(detail, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Wrap(spacing: 8, runSpacing: 8, children: buttons), + ], + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart index 3684447..dca5f0a 100644 --- a/apps/desktop/lib/src/shell/library_link.dart +++ b/apps/desktop/lib/src/shell/library_link.dart @@ -346,7 +346,7 @@ class _LibraryLinkSourceState extends State { Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall), const SizedBox(height: 6), Text( - 'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.', + 'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.', textAlign: TextAlign.center, style: TextStyle(color: scheme.onSurfaceVariant), ), diff --git a/apps/desktop/lib/src/shell/library_tab.dart b/apps/desktop/lib/src/shell/library_tab.dart index 9cc072d..871cabe 100644 --- a/apps/desktop/lib/src/shell/library_tab.dart +++ b/apps/desktop/lib/src/shell/library_tab.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem; @@ -189,32 +188,6 @@ class _LibraryTabState extends State { } } - // -------------------------------------------------------------- paste - - KeyEventResult _onKey(FocusNode node, KeyEvent e) { - final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed; - if (e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) { - return KeyEventResult.ignored; - } - // A focused text field handles its own paste. - final inField = - FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType() != null; - if (inField) return KeyEventResult.ignored; - unawaited(_pasteLink()); - return KeyEventResult.handled; - } - - /// Ctrl+V outside a field: an http(s) link goes to the Link source. - Future _pasteLink() async { - String? text; - try { - text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim(); - } catch (_) {} - if (text == null || !(text.startsWith('http://') || text.startsWith('https://'))) return; - _c.link.text = text; - _c.source = MediaKind.link; - } - // -------------------------------------------------------------- build @override @@ -306,10 +279,10 @@ class _LibraryTabState extends State { child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child), ); + // Holds focus so the shell's shortcuts (Ctrl+,) work. return Focus( key: const ValueKey('library-tab'), autofocus: true, - onKeyEvent: _onKey, child: LayoutBuilder(builder: (context, box) { if (box.maxWidth >= detailsDockFrom) { return Row( diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index f2d30f7..186d67c 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -10,6 +10,7 @@ import '../screens/home/update_indicator.dart'; import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge; import '../state/session_state.dart'; import '../update/update_controller.dart'; +import '../widgets/app_keys.dart'; import '../widgets/dialogs.dart'; import '../widgets/window_buttons.dart'; import 'avatar.dart'; @@ -27,6 +28,9 @@ enum ShellTab { const partyStripHeight = 60.0; +/// Every avatar in the strip, yours and the party's. +const stripAvatarSize = 42.0; + /// Below this window width the strip shows 3 avatars and no server text, /// so the tabs keep their room. const stripCompactBelow = 1100.0; @@ -95,7 +99,7 @@ class PartyStrip extends StatelessWidget { ready: self?.ready ?? false, host: online && coord.hostFile != null, ), - size: 32, + size: stripAvatarSize, ring: online, ), ), @@ -139,25 +143,30 @@ class PartyStrip extends StatelessWidget { key: const ValueKey('party-avatars'), members: partyAvatars(coord), shown: compact ? 3 : 5, + size: stripAvatarSize, onTap: () => showMembersCard(context), ), ), const SizedBox(width: 10), if (!compact) _ServerLabel(scheme: scheme), const SizedBox(width: 6), - Badge( - isLabelVisible: unread > 0, - label: Text('$unread'), - child: IconButton( - key: const ValueKey('strip-chat'), - tooltip: 'Chat & activity', - isSelected: chatOpen, - onPressed: onChat, - icon: const Icon(Icons.chat_bubble_outline, size: 20), - selectedIcon: Icon( - Icons.chat_bubble, - size: 20, - color: scheme.primary, + // Tab toggles the chat while the strip is shown. + ChatKey( + onToggle: onChat, + child: Badge( + isLabelVisible: unread > 0, + label: Text('$unread'), + child: IconButton( + key: const ValueKey('strip-chat'), + tooltip: 'Chat & activity (Tab)', + isSelected: chatOpen, + onPressed: onChat, + icon: const Icon(Icons.chat_bubble_outline, size: 20), + selectedIcon: Icon( + Icons.chat_bubble, + size: 20, + color: scheme.primary, + ), ), ), ), diff --git a/apps/desktop/lib/src/shell/player_status.dart b/apps/desktop/lib/src/shell/player_status.dart index 63857a7..1abd973 100644 --- a/apps/desktop/lib/src/shell/player_status.dart +++ b/apps/desktop/lib/src/shell/player_status.dart @@ -193,7 +193,7 @@ class _PlayerStatusState extends State { child: GestureDetector( key: const ValueKey('player-status'), onTap: ready ? coord.toggleReady : null, - child: _Glass( + child: PlayerGlass( child: AnimatedSize( duration: easeTime, curve: ease, @@ -217,9 +217,10 @@ class _PlayerStatusState extends State { } } -class _Glass extends StatelessWidget { +/// Frosted dark glass in a pill: the status pill, the unread chip. +class PlayerGlass extends StatelessWidget { final Widget child; - const _Glass({required this.child}); + const PlayerGlass({super.key, required this.child}); @override Widget build(BuildContext context) { diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 3656cf0..2c67333 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/gestures.dart' show kPrimaryMouseButton; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,7 +13,8 @@ import '../controls/player_settings.dart'; import '../controls/uosc/buttons.dart'; import '../controls/uosc/controls.dart'; import '../controls/uosc/timeline.dart'; -import '../playback/media_marks.dart' show SkipOffer, formatClock; +import '../controls/volume.dart'; +import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/player_controls.dart'; import '../session/session_coordinator.dart'; @@ -18,6 +22,7 @@ import '../shaders/shader_menu.dart'; import '../shaders/shader_presets.dart'; import '../state/session_state.dart'; import '../state/settings.dart'; +import '../widgets/app_keys.dart'; import '../widgets/shown_frame_probe.dart'; import '../widgets/text_focus.dart'; import '../widgets/window_buttons.dart'; @@ -96,9 +101,10 @@ class _SessionPlayerState extends State { int _toastSeq = 0; int _shaderNoticeSeen = 0; bool _miniHover = false; - // Whether the standard controls are up (tracked like media_kit does: - // shown on mount and on mouse movement, hidden 3 s later or on exit). - final _standardShown = ValueNotifier(true); + // Whether the controls are up: the standard ones tracked like media_kit + // does (shown on mount and on mouse movement, hidden 3 s later or on + // exit), the uosc ones reporting it. + final _controlsShown = ValueNotifier(true); SessionCoordinator get _coord => SessionState.instance.coordinator; bool get _full => widget.mode == PlayerMode.full; @@ -109,6 +115,8 @@ class _SessionPlayerState extends State { _coord.jellyfin.ensureLoaded(); _coord.playback.shownFrame = _frameProbe.sample; HardwareKeyboard.instance.addHandler(_globalKey); + // Tab (AppKeys) reaches the chat through here in fullscreen. + AppKeys.addChatToggle(_toggleChat); // Shader messages (preset changes, load failures) become toasts. _shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0; _stopShaderWatch = effect(() { @@ -122,11 +130,12 @@ class _SessionPlayerState extends State { @override void dispose() { HardwareKeyboard.instance.removeHandler(_globalKey); + AppKeys.removeChatToggle(_toggleChat); final playback = _coord.playback; if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null; _stopShaderWatch(); _noFocus.dispose(); - _standardShown.dispose(); + _controlsShown.dispose(); super.dispose(); } @@ -152,7 +161,8 @@ class _SessionPlayerState extends State { } /// The player keys, while the player fills the body and nothing (a - /// dialog, a card, a text field) is in front of it. + /// dialog, a card, a text field) is in front of it. Space and Tab are + /// app-wide ([AppKeys]). bool _globalKey(KeyEvent event) { if (event is! KeyDownEvent || !mounted || !_full) return false; if (SessionState.instance.route.peek() != AppRoute.session) return false; @@ -184,11 +194,6 @@ class _SessionPlayerState extends State { _reloadVideo(); return true; } - if (key == LogicalKeyboardKey.space) { - _lastKeyAt = now; - coord.toggleReady(); - return true; - } // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. final shift = HardwareKeyboard.instance.isShiftPressed; if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) { @@ -213,9 +218,9 @@ class _SessionPlayerState extends State { coord.skipForward(); return true; } - if (key == LogicalKeyboardKey.tab) { - _lastKeyAt = now; - _toggleChat(); + if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) { + final player = coord.playback.player; + player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5)); return true; } if (key == LogicalKeyboardKey.arrowRight) { @@ -282,7 +287,7 @@ class _SessionPlayerState extends State { _status(), _progressLine(), _jellyfinOverlay(), - if (widget.fullscreen) _pausedParty(), + if (widget.fullscreen) ...[_pausedParty(), _unreadChip()], ] else _miniOverlay(), ], @@ -340,7 +345,7 @@ class _SessionPlayerState extends State { final s = AppSettings.instance; if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink(); return ValueListenableBuilder( - valueListenable: _standardShown, + valueListenable: _controlsShown, builder: (context, shown, _) => shown ? const SizedBox.shrink() : const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true), @@ -437,7 +442,7 @@ class _SessionPlayerState extends State { onPressed: coord.playNextEpisode, ), ), - const MaterialDesktopVolumeButton(), + VolumeButton(player: playback.player, focusNode: _noFocus), _ReadyControlButton(focusNode: _noFocus), _BarTextButton( focusNode: _noFocus, @@ -504,7 +509,7 @@ class _SessionPlayerState extends State { Widget _controlsSwitch(VideoState state) { return SessionControlsSwitch( material: (_) => ControlsHoverProbe( - shown: _standardShown, + shown: _controlsShown, hideAfter: _controlsHide, child: DoubleClickListener( onDoubleClick: widget.onToggleFullscreen, @@ -536,12 +541,19 @@ class _SessionPlayerState extends State { ); }); return UoscControls( + shown: _controlsShown, onDoubleTap: widget.onToggleFullscreen, topLeft: const NowPlayingTitle(), top: Row( mainAxisSize: MainAxisSize.min, children: [ - UoscButton(icon: Icons.chat_bubble_outline_rounded, tooltip: 'Chat & activity (Tab)', onPressed: _toggleChat), + UoscButton( + key: const ValueKey('uosc-chat'), + icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded, + tooltip: 'Chat & activity (Tab)', + badge: widget.unread, + onPressed: _toggleChat, + ), UoscButton( icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded, tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)', @@ -653,7 +665,8 @@ class _SessionPlayerState extends State { /// Jellyfin: the blocked card, the end-of-episode card (countdown) and /// the "Next episode" pill; for any media the skip pill (Skip OP / ED / - /// 1:30) above it. Outside the auto-hiding controls, so they stay up. + /// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so + /// they stay up. Widget _jellyfinOverlay() { final coord = _coord; return Positioned.fill( @@ -695,7 +708,13 @@ class _SessionPlayerState extends State { crossAxisAlignment: CrossAxisAlignment.end, spacing: 10, children: [ - if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)), + if (skip != null) + SkipPillGate( + key: const ValueKey('skip-gate'), + offer: skip, + controlsShown: _controlsShown, + child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)), + ), if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), ], ), @@ -723,6 +742,40 @@ class _SessionPlayerState extends State { }), ); + /// Fullscreen hides the strip and its chat badge: while there is unread + /// chat and the controls are down, a small glass chip says so top right. + Widget _unreadChip() => Positioned( + right: 16, + top: 16, + child: IgnorePointer( + child: ValueListenableBuilder( + valueListenable: _controlsShown, + builder: (context, shown, _) { + final on = widget.unread > 0 && !widget.chatOpen && !shown; + return AnimatedOpacity( + duration: easeTime, + curve: ease, + opacity: on ? 1 : 0, + child: PlayerGlass( + key: const ValueKey('unread-chip'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.chat_bubble_rounded, size: 14, color: Colors.white70), + const SizedBox(width: 6), + Text('${widget.unread}', style: const TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ), + ), + ); + }, + ), + ), + ); + /// Mini: title, expand and close on hover; ready, time and a seekable /// bar at the bottom. A thin progress line stays while not hovered. Widget _miniOverlay() { @@ -809,6 +862,75 @@ class _SessionPlayerState extends State { } } +/// The skip pill's visibility: up for [firstShown] after an offer +/// appears (a new kind: OP, ED, 1:30), then only while the controls are +/// up or the mouse is on it; gone with the offer (the parent drops it). +class SkipPillGate extends StatefulWidget { + static const firstShown = Duration(seconds: 5); + final SkipOffer offer; + final ValueListenable controlsShown; + final Widget child; + const SkipPillGate({super.key, required this.offer, required this.controlsShown, required this.child}); + + @override + State createState() => _SkipPillGateState(); +} + +class _SkipPillGateState extends State { + SkipKind? _kind; + bool _fresh = false; + bool _hover = false; + Timer? _timer; + + @override + void initState() { + super.initState(); + _arm(); + } + + @override + void didUpdateWidget(SkipPillGate old) { + super.didUpdateWidget(old); + if (widget.offer.kind != _kind) _arm(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _arm() { + _kind = widget.offer.kind; + _fresh = true; + _timer?.cancel(); + _timer = Timer(SkipPillGate.firstShown, () { + if (mounted) setState(() => _fresh = false); + }); + } + + @override + Widget build(BuildContext context) { + // The pill covers the controls' hover area: on it, they may hide while + // it stays. + return ValueListenableBuilder( + valueListenable: widget.controlsShown, + builder: (context, shown, child) { + final on = _fresh || shown || _hover; + return IgnorePointer( + ignoring: !on, + child: MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: on ? 1 : 0, child: child), + ), + ); + }, + child: widget.child, + ); + } +} + const _shaderKeys = [ LogicalKeyboardKey.digit0, LogicalKeyboardKey.digit1, diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 03f05eb..7bcde37 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -79,7 +79,23 @@ class SettingsTab extends StatelessWidget { ), ]; case SettingsSection.network: - return const [HostDefaultsPanel(), RecentServersPanel()]; + return [ + const HostDefaultsPanel(), + const RecentServersPanel(), + SettingsPanel( + title: 'Watching', + child: SignalBuilder( + builder: (context) => SwitchListTile( + key: const ValueKey('unready-on-swap'), + contentPadding: EdgeInsets.zero, + title: const Text('Unready when the video changes'), + subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'), + value: AppSettings.instance.unreadyOnSwap.value, + onChanged: AppSettings.instance.setUnreadyOnSwap, + ), + ), + ), + ]; case SettingsSection.player: return [ const SettingsHintCard( diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 9353f67..fbb6699 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -22,6 +22,7 @@ const _keyHideReReleases = 'hideReReleases'; const _keyProgressLine = 'progressLine'; const _keySoundVolume = 'soundVolume'; const _keySoundsOff = 'soundsOff'; +const _keyUnreadyOnSwap = 'unreadyOnSwap'; enum HwdecMode { auto, off, custom } @@ -33,6 +34,9 @@ class AppSettings { final autoCheckUpdates = signal(true); /// Jellyfin episodes: at the end, count down 10 s and put up the next. final autoAdvance = signal(true); + /// Session: send ready=false when the video changes (a swap keeps the + /// server's ready flags). + final unreadyOnSwap = signal(false); /// Player design: the uosc controls (`controls/uosc/`) instead of the /// standard ones (media_kit's with our bars). Applies live. final mpvControls = signal(false); @@ -82,6 +86,8 @@ class AppSettings { if (auto is bool) autoCheckUpdates.value = auto; final advance = box.get(_keyAutoAdvance); if (advance is bool) autoAdvance.value = advance; + final unready = box.get(_keyUnreadyOnSwap); + if (unready is bool) unreadyOnSwap.value = unready; final mpv = box.get(_keyMpvControls); if (mpv is bool) mpvControls.value = mpv; final hideRe = box.get(_keyHideReReleases); @@ -129,6 +135,11 @@ class AppSettings { _put(_keyAutoAdvance, on); } + void setUnreadyOnSwap(bool on) { + unreadyOnSwap.value = on; + _put(_keyUnreadyOnSwap, on); + } + void setMpvControls(bool on) { mpvControls.value = on; _put(_keyMpvControls, on); diff --git a/apps/desktop/lib/src/widgets/app_keys.dart b/apps/desktop/lib/src/widgets/app_keys.dart new file mode 100644 index 0000000..339f7d1 --- /dev/null +++ b/apps/desktop/lib/src/widgets/app_keys.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../state/session_state.dart'; +import 'text_focus.dart'; + +/// Tab and Shift+Tab never move focus (the app's [Shortcuts] override): +/// a button that got focus that way would be pressed by Space, the ready +/// key. +Map get appShortcuts => { + ...WidgetsApp.defaultShortcuts, + const SingleActivator(LogicalKeyboardKey.tab): const DoNothingIntent(), + const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingIntent(), + }; + +/// The window's two app-wide keys, ahead of whatever has focus: while in a +/// server, Space is ready / not ready and Tab opens or closes the chat, +/// on every tab and in every player mode. Not while a text field has +/// focus (the chat field handles Tab and Esc itself) nor while a dialog or +/// menu is in front of [child]. +class AppKeys extends StatefulWidget { + /// Who toggles the chat, latest first: the strip while it is shown, the + /// full player in fullscreen (both hold the shell's toggle). + static final _chatToggles = []; + + static void addChatToggle(VoidCallback f) => _chatToggles.add(f); + static void removeChatToggle(VoidCallback f) => _chatToggles.remove(f); + + /// Space; tests count presses instead of sending them. + final VoidCallback? onToggleReady; + final Widget child; + const AppKeys({super.key, this.onToggleReady, required this.child}); + + @override + State createState() => _AppKeysState(); +} + +class _AppKeysState extends State { + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_key); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_key); + super.dispose(); + } + + bool _key(KeyEvent event) { + if (event is! KeyDownEvent || !mounted) return false; + final key = event.logicalKey; + if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false; + if (SessionState.instance.route.peek() != AppRoute.session) return false; + if (ModalRoute.of(context)?.isCurrent == false) return false; + if (textInputFocused()) return false; + final keys = HardwareKeyboard.instance; + if (keys.isControlPressed || keys.isAltPressed || keys.isMetaPressed) return false; + if (key == LogicalKeyboardKey.space) { + (widget.onToggleReady ?? SessionState.instance.coordinator.toggleReady)(); + return true; + } + final toggles = AppKeys._chatToggles; + if (toggles.isEmpty) return false; + toggles.last(); + return true; + } + + @override + Widget build(BuildContext context) => widget.child; +} + +/// Registers [onToggle] with [AppKeys] while mounted. +class ChatKey extends StatefulWidget { + final VoidCallback onToggle; + final Widget child; + const ChatKey({super.key, required this.onToggle, required this.child}); + + @override + State createState() => _ChatKeyState(); +} + +class _ChatKeyState extends State { + void _toggle() => widget.onToggle(); + + @override + void initState() { + super.initState(); + AppKeys.addChatToggle(_toggle); + } + + @override + void dispose() { + AppKeys.removeChatToggle(_toggle); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/desktop/test/app_keys_test.dart b/apps/desktop/test/app_keys_test.dart new file mode 100644 index 0000000..99c7434 --- /dev/null +++ b/apps/desktop/test/app_keys_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/shell/app_shell.dart'; +import 'package:watch_together/src/state/session_state.dart'; +import 'package:watch_together/src/update/update_controller.dart'; +import 'package:watch_together/src/widgets/app_keys.dart'; +import 'package:watch_together/src/widgets/text_focus.dart'; + +void main() { + final session = SessionState.instance; + final coord = session.coordinator; + + tearDown(() { + session.route.value = AppRoute.home; + coord.serverState.value = null; + }); + + // Regression: Tab moved focus (out of the chat field onto a button) and + // Space, the ready key, then pressed that button. + testWidgets('Tab opens and closes chat without moving focus; Space is ready', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + var readies = 0; + await tester.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + shortcuts: appShortcuts, + home: AppKeys( + onToggleReady: () => readies++, + child: AppShell( + updates: UpdateController(currentVersion: '0.0.0-dev'), + autoUpdateCheck: false, + windowControls: false, + videoBuilder: (context, controls) => const SizedBox(), + ), + ), + )); + coord.serverState.value = StateBroadcast( + eventId: 1, + transport: 'held', + position: 0, + serverTime: DateTime.now().toUtc(), + mediaId: 0, + members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0, stale: false)], + ); + session.route.value = AppRoute.session; + await tester.pumpAndSettle(); + final panel = find.byKey(const ValueKey('chat-panel')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(panel, findsOneWidget); + expect(textInputFocused(), isTrue, reason: 'opening the chat focuses its field'); + + // From the field: closes the chat, focus goes back, not to a button. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(panel, findsNothing); + expect(textInputFocused(), isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(readies, 1); + expect(panel, findsNothing, reason: 'Space pressed a focused chat button'); + }); +} diff --git a/apps/desktop/test/files_page_test.dart b/apps/desktop/test/files_page_test.dart index 10c768e..ed202b8 100644 --- a/apps/desktop/test/files_page_test.dart +++ b/apps/desktop/test/files_page_test.dart @@ -74,6 +74,12 @@ Future openFolder(WidgetTester tester, String name) async { await frames(tester); } +/// Series start collapsed: a click on the header lists the episodes. +Future openSeries(WidgetTester tester, [String title = 'Show']) async { + await tester.tap(find.descendant(of: find.byType(SeriesHeader), matching: find.text(title))); + await frames(tester); +} + void main() { setUp(() async { tmp = Directory.systemTemp.createTempSync('files_page_'); @@ -147,6 +153,9 @@ void main() { expect(find.byType(SeriesHeader), findsOneWidget); expect(find.text('Show'), findsOneWidget); expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget); + // Collapsed until clicked; open for the rest of the session. + expect(find.text('E01'), findsNothing); + await openSeries(tester); for (final l in ['E01', 'E02', 'E03', 'NCOP1']) { expect(find.text(l), findsOneWidget); } @@ -157,6 +166,13 @@ void main() { // Durations come from the thumbnail. expect(find.text('24:00 · 1 KB'), findsNWidgets(4)); expect(find.text('24:00'), findsOneWidget); // card badge + + await openFolder(tester, 'Recent'); + await openFolder(tester, 'anime'); + expect(find.text('E01'), findsOneWidget); + await openSeries(tester); + expect(find.text('E01'), findsNothing); + expect(find.byType(VideoCard), findsOneWidget); }); testWidgets('hidden files are counted and some can be shown', (tester) async { @@ -180,6 +196,7 @@ void main() { store.addFolder(anime); await pumpPage(tester); await openFolder(tester, 'anime'); + await openSeries(tester); touch('$anime/[Grp] Show - 04.mkv'); expect(find.text('E04'), findsNothing); await tester.tap(find.text('Rescan')); @@ -197,6 +214,7 @@ void main() { return null; }); await openFolder(tester, 'anime'); + await openSeries(tester); expect(find.text('E01'), findsOneWidget); await tester.tap(find.text('E01')); await tester.tap(find.text('Some Movie (2019)')); @@ -216,6 +234,7 @@ void main() { }); expect(find.textContaining('Keep the app open while it plays'), findsOneWidget); await openFolder(tester, 'anime'); + await openSeries(tester); await tester.tap(find.text('E02')); await frames(tester); @@ -254,6 +273,7 @@ void main() { )); await frames(tester); await openFolder(tester, 'anime'); + await openSeries(tester); expect(find.text('now playing'), findsOneWidget); expect(find.byIcon(Icons.graphic_eq), findsOneWidget); }); @@ -267,6 +287,7 @@ void main() { await tester.tap(find.widgetWithText(InputChip, 'anime')); await frames(tester); expect(find.text('$anime/'), findsOneWidget); + await openSeries(tester); expect(find.text('E01'), findsOneWidget); // The chip's x removes the folder, like the sidebar's. await tester.tap(find.byTooltip('Remove folder')); @@ -287,6 +308,7 @@ void main() { expect(tester.takeException(), isNull); await tester.tap(find.text('anime').first); await frames(tester); + await openSeries(tester); expect(find.text('E01'), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.text('Show some')); @@ -312,6 +334,7 @@ void main() { return Completer().future; // never finishes }); await openFolder(tester, 'big'); + await openSeries(tester, 'Long Show'); expect(find.text('E01'), findsOneWidget); final first = thumbRequests.toSet(); expect(first, isNotEmpty); diff --git a/apps/desktop/test/handoff_test.dart b/apps/desktop/test/handoff_test.dart new file mode 100644 index 0000000..b6ae49f --- /dev/null +++ b/apps/desktop/test/handoff_test.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/shell/handoff.dart'; + +// What a drop or a paste is: decided from paths and text alone (the file +// system stands behind a fake). + +void main() { + const fs = { + '/v/a.mkv': FileSystemEntityType.file, + '/v/b.MP4': FileSystemEntityType.file, + '/v/notes.txt': FileSystemEntityType.file, + '/v/cover.jpg': FileSystemEntityType.file, + '/v/Season 1': FileSystemEntityType.directory, + '/v/folder.mkv': FileSystemEntityType.directory, + r'C:\Videos\c.mkv': FileSystemEntityType.file, + }; + Handoff of({List paths = const [], String? text}) => + classifyHandoff(paths: paths, text: text, typeOf: (p) => fs[p] ?? FileSystemEntityType.notFound); + + test('videos by extension, in order; others dropped with them are left out', () { + final h = of(paths: ['/v/notes.txt', '/v/b.MP4', '/v/Season 1', '/v/a.mkv']); + expect((h as HandoffVideos).paths, ['/v/b.MP4', '/v/a.mkv']); + }); + + test('a folder (even one named like a video), else an image, else nothing', () { + expect((of(paths: ['/v/notes.txt', '/v/folder.mkv']) as HandoffFolder).path, '/v/folder.mkv'); + expect((of(paths: ['/v/cover.jpg']) as HandoffImage).path, '/v/cover.jpg'); + expect(of(paths: ['/v/notes.txt']), isA()); + expect(of(paths: ['/gone/x.mkv']), isA()); + expect(of(), isA()); + }); + + test('pasted text: a link, absolute paths or file URIs; files win over text', () { + expect(of(text: ' https://cdn.example/a b'), isA()); + expect((of(text: ' https://youtu.be/x?t=1 \n') as HandoffLink).url, 'https://youtu.be/x?t=1'); + expect((of(text: 'HTTP://x.example/v.mp4') as HandoffLink).url, 'HTTP://x.example/v.mp4'); + expect((of(text: '/v/a.mkv\n/v/b.MP4') as HandoffVideos).paths, ['/v/a.mkv', '/v/b.MP4']); + expect((of(text: 'file:///v/Season%201') as HandoffFolder).path, '/v/Season 1'); + expect((of(text: r'C:\Videos\c.mkv') as HandoffVideos).paths, [r'C:\Videos\c.mkv']); + expect(of(text: 'v/a.mkv'), isA(), reason: 'relative'); + expect(of(text: 'hello'), isA()); + expect((of(paths: ['/v/Season 1'], text: 'https://x.example') as HandoffFolder).path, '/v/Season 1'); + }); +} diff --git a/apps/desktop/test/jellyfin_e2e_test.dart b/apps/desktop/test/jellyfin_e2e_test.dart index c144a3c..3471390 100644 --- a/apps/desktop/test/jellyfin_e2e_test.dart +++ b/apps/desktop/test/jellyfin_e2e_test.dart @@ -501,7 +501,8 @@ void main() { expect(a.jellyfinPlan.peek(), isA()); expect(a.relaying.peek(), isTrue); expect(b.jellyfinBlocked.peek(), isNull); - for (final x in [a, b]) { + // Everyone present counts for the gate, Dave too. + for (final x in [a, b, d]) { x.toggleReady(); } await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing'); @@ -511,7 +512,7 @@ void main() { expect(jelly.streams('ATOKEN'), isNotEmpty); expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) => _FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue); - for (final x in [a, b]) { + for (final x in [a, b, d]) { x.toggleReady(); } diff --git a/apps/desktop/test/library_tab_test.dart b/apps/desktop/test/library_tab_test.dart index 203609a..f03d74a 100644 --- a/apps/desktop/test/library_tab_test.dart +++ b/apps/desktop/test/library_tab_test.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; @@ -278,7 +277,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('link: preview, how the others get it, put up, recent links, Ctrl+V', (tester) async { + testWidgets('link: preview, how the others get it, put up, recent links', (tester) async { final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link); expect(find.text('How the others get it'), findsOneWidget); const url = 'https://www.youtube.com/watch?v=abc'; @@ -304,23 +303,6 @@ void main() { await settle(tester, 10); expect(h.controller.link.text, url); - // Ctrl+V outside a field, on another source: the link lands in Link. - await tester.tap(find.text('Jellyfin')); - await settle(tester, 10); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'}; - return null; - }); - addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); - FocusManager.instance.primaryFocus?.unfocus(); - Focus.of(tester.element(find.byKey(const ValueKey('media-source')))).requestFocus(); - await tester.pump(); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyV); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await settle(tester, 10); - expect(h.controller.source, MediaKind.link); - expect(h.controller.link.text, 'https://cdn.example/b.mkv'); expect(tester.takeException(), isNull); }); diff --git a/apps/desktop/test/media_marks_test.dart b/apps/desktop/test/media_marks_test.dart index 9aaafb4..9a47769 100644 --- a/apps/desktop/test/media_marks_test.dart +++ b/apps/desktop/test/media_marks_test.dart @@ -111,8 +111,14 @@ void main() { group('skip offers', () { final op = SkipRange(s(30), m(2), SkipSource.chapter); final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter); - SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt( - position: p, duration: duration ?? m(24), opening: opening, ending: ending); + SkipOffer? at(Duration p, + {SkipRange? opening, SkipRange? ending, Duration? duration, bool chaptered = false}) => + skipOfferAt( + position: p, + duration: duration ?? m(24), + opening: opening, + ending: ending, + chaptered: chaptered); test('Skip OP inside the opening, to its end', () { expect(at(s(29), opening: op, ending: ed), isNull); @@ -141,6 +147,9 @@ void main() { expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30))); // An opening known (but not playing) means no fallback. expect(at(m(3), opening: op), isNull); + // Chapters without an OP chapter: no opening on purpose. + expect(at(m(1), chaptered: true), isNull); + expect(at(m(20, 10), ending: ed, chaptered: true), SkipOffer(SkipKind.ending, m(21, 30))); }); }); diff --git a/apps/desktop/test/session_logic_test.dart b/apps/desktop/test/session_logic_test.dart index debe1df..8e1b4e6 100644 --- a/apps/desktop/test/session_logic_test.dart +++ b/apps/desktop/test/session_logic_test.dart @@ -91,4 +91,14 @@ void main() { expect(n.contains('SECRET'), isFalse); }); }); + + test('unreadyForNewMedia: only a real swap while ready, setting on', () { + bool u({bool enabled = true, int last = 2, int id = 3, bool ready = true}) => + unreadyForNewMedia(enabled: enabled, lastMediaId: last, mediaId: id, ready: ready); + expect(u(), isTrue); + expect(u(enabled: false), isFalse); + expect(u(ready: false), isFalse); + expect(u(id: 2), isFalse, reason: 'same media'); + expect(u(last: -1), isFalse, reason: 'first state of a session is no swap'); + }); } diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index 5b83239..8292555 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -216,6 +216,31 @@ void main() { }); }); + testWidgets('Ctrl+V outside a field: a link asks, then fills Library → Link', (tester) async { + await pumpShell(tester, const Size(1280, 800)); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'}; + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + // fox_utils' clipboard files: none. + const pasteboard = MethodChannel('pasteboard'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pasteboard, (call) async => const []); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pasteboard, null)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('handoff-card')), findsOneWidget); + expect(find.text('Open in Link'), findsOneWidget); // offline + await tester.tap(find.byKey(const ValueKey('handoff-openLink'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('handoff-card')), findsNothing); + final field = tester.widget(find.byKey(const ValueKey('link-field'))); + expect(field.controller!.text, 'https://cdn.example/b.mkv'); + expect(tester.takeException(), isNull); + }); + testWidgets('profile rename goes to the server through the hook', (tester) async { final pushed = []; final p = Profile.instance; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 424073c..879db75 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,14 +80,22 @@ Loosely modeled on Syncplay, with a different readiness design. server stores `buffered` and liveness, and takes the first reported duration for the current media (swap resets it); a report with `eventId` older than current has its position ignored. -- **Ready gate** (HELD to PLAYING): the in-set is non-empty, every in-set - member is ready, fresh and has `buffered >= need`, and quorum holds: in-set - size >= 2, unless at most one member is fresh. `need` is 4 s - (`minBufferSecs`), or near the end what is left minus 2 s (`endSlackSecs`, - floor 0), since mpv cannot buffer past the end. So a lone viewer plays - alone; with a second viewer present one Space waits for the other. Joining - members start with `inSet=false, ready=false` and never block. Disconnect - removes from the set; swap clears everyone. +- **Ready gate** (HELD to PLAYING): the in-set is non-empty and every + in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s + (`minBufferSecs`), or near the end what is left minus 2 s + (`endSlackSecs`, floor 0), since mpv cannot buffer past the end. So a + lone viewer plays alone; with others present everyone must ready. +- **In-set** (who the gate waits for): a member joins it on joining, or + on coming back from stale, while not PLAYING (any media, the one the + server started with included); joining while PLAYING it stays out + (`ready=false`) until it readies: a late joiner never pauses anyone. A + swap puts every fresh member in the set and keeps everyone's ready flag, + so the next video starts once everyone ready has buffered (the client + setting "Unready when the video changes", `AppSettings.unreadyOnSwap`, + default off, sends `ready=false` itself on a new `mediaId`; not for the + first state of a session, and a reconnect does not restore a ready that + a swap meanwhile would have dropped). Stale, leave and kick remove from + the set. - **Pause**: while PLAYING, any in-set member who becomes not ready, under `need` buffered or stale flips the server to HELD (feed action `pause` by `server`). Recovery resumes by itself when the gate passes. @@ -119,7 +127,8 @@ Loosely modeled on Syncplay, with a different readiness design. user is not seeking. - **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the gate, `stale` feed); over 30 s the socket is closed and the member removed. - A stale member that was ready goes back into the set with its next frame. + A stale member goes back into the set with its next frame if it is ready + (also one stale at a swap) or the server is not PLAYING. Closed sockets are polled with `isClosed` every second: Relic's server-side `onDone` does not fire for dead peers, so presence never relies on it. - **Resume**: a reconnecting client sends its previous token as @@ -137,7 +146,7 @@ Loosely modeled on Syncplay, with a different readiness design. moment playback stopped. While HELD and self ready: warning at 8 min, auto-unready at 10 min, and never less than 2 min after the warning. - **Deviations from Syncplay**: clock never follows the slowest watcher; no - pause-on-leave; anyone can force ready/unready (all or one); quorum gate; + pause-on-leave; anyone can force ready/unready (all or one); presence gate; the clock is server time (clients correct for their offset to it). Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit. @@ -285,8 +294,9 @@ Skip rules (pure, `media_marks.dart`): - `skipOfferAt` (the coordinator's `skipOffer`, null at `finished`): inside the opening (up to its last second) "Skip OP" to its end; inside the ending "Skip ED" to its end only (no guessing about previews: the viewer - decides). Without any opening and with media over 20 min, "Skip 1:30" - during the first 6 min. + decides). Without any opening, without chapters (a chaptered file + without an OP chapter has none on purpose) and with media over 20 min, + "Skip 1:30" during the first 6 min. - +1:30 (`skipForward`: the pill, the controls' "1:30" button, Shift+Right) is a `nudge`, so repeated presses add up. - Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next @@ -516,7 +526,7 @@ and scale in from where they open; they scroll when the window is short) and `HScroll` (horizontal rows: mouse drag, the vertical wheel scrolls them sideways until an end, then the page). -- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card), +- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's), the Library / Schedule / Settings tabs with a growing underline (an `HScroll` when narrow), the update indicator, then offline Join and Host (connect cards), online the party: `AvatarStack` (host first, then @@ -571,8 +581,10 @@ sideways until an end, then the page). (`library_link.dart`): paste field, a preview (YouTube oEmbed title and thumbnail, else the file name), the three relay modes online, and the last 12 links put up (`recentLinks` in the `connect_form` box). Files - is `FilesPage` (`files/`). Ctrl+V outside a field pastes a link into - Link. Offline everything browses; the buttons say "Join or host to + is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in + an isolate, videos grouped by series; series start collapsed, a header + click opens one and open ones stay open for the app session + (`showFilesFolder` opens a folder from outside). Offline everything browses; the buttons say "Join or host to watch together". Schedule (`schedule/`) is a week of day columns built from AniList's airing schedule (`anilist.dart`: pages cached 10 min in memory and 1 h on disk, stale data shown on 429 or failure) matched @@ -589,6 +601,17 @@ sideways until an end, then the page). Jellyfin & integrations (linked servers, AnimeFlow's `/me` via `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds (what pops up; no switches), updates, shortcuts, about. +- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one + `DropTarget` (a "Drop to open" overlay while dragging) and a + `HardwareKeyboard` handler for Ctrl+V outside a text field (clipboard + files first, then text). `classifyHandoff` (pure) turns paths or text + into videos (the Files extensions, `kindOf`), a folder, an http(s) link, + an image or nothing; videos win over folders, folders over images. + `HandoffCard` opens as a popover at the pointer: a video goes up through + the shell's put-up (offline: into Files → Recent) or adds its folder to + Files; a folder is added to Files and opened; a link fills Library → + Link (online the relay mode is picked there). Images are ignored. + Nothing happens while a dialog or popover is over the shell. - **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready, unready, force-ready, join, leave, kick, swap and others' chat (feed events), the idle warning, and connection lost / back (once per drop). @@ -614,7 +637,13 @@ sideways until an end, then the page). is `windowManager.setFullScreen` with the strip hidden (party avatars in a corner while paused); F, Esc and double-click on the picture toggle it. The player keys (Settings → Keyboard shortcuts) work while the player is - full and nothing is in front of it. Something coming up while the player + full and nothing is in front of it; ↑ / ↓ step the volume. Space (ready) + and Tab (chat) are app-wide: `AppKeys` (`widgets/app_keys.dart`, around + the shell) takes them first while in a server, unless a text field has + focus or a dialog or menu is in front. Tab reaches the shell's chat + toggle through `ChatKey` (the strip while shown, the player in + fullscreen). The app's shortcuts map Tab / Shift+Tab to nothing, so focus + never lands on a button Space would press. Something coming up while the player is closed opens it: full right after joining or after putting it up here, else mini. Both control designs show `NowPlayingTitle` top left (`now_playing_title.dart`: 2:3 cover, title, episode line from @@ -644,7 +673,12 @@ sideways until an end, then the page). `WindowButtons` to the top bar while the controls are up (restore leaves fullscreen). - **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right - (strip button or Tab): chat with avatars, other activity as quiet lines. + (strip button or Tab): chat with avatars; the "Activity" chip (off by + 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 + close the panel and focus goes back where it was. Unread chat badges + the strip's and both player designs' chat buttons; in fullscreen with + the controls down a glass chip (`PlayerGlass`) top right shows it. **Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with the avatar of their `ToastItem.actor` (feed and chat toasts; the app's own have none); each @@ -719,8 +753,14 @@ sits in the bottom bar, which is taller by the bar's height (the top bar keeps its row at the top). A drag sends no hover events, so the bar keeps the controls up through a guarded dynamic `onHover()` on media_kit's private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / -1:30, Next episode) and cards live outside the auto-hiding controls, so -they stay up when the controls hide. +1:30, Next episode) and cards live outside the auto-hiding controls. The +Next episode pill and the cards stay up; the skip pill (`SkipPillGate`) +shows for 5 s when an offer of a new kind appears, then only while the +controls are up (`ControlsHoverProbe` for this design, `UoscControls.shown` +for uosc) or the mouse is on it. Volume (`controls/volume.dart`, both +designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume` +(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part +above in amber; the wheel and ↑ / ↓ step 5 %, clamped. The uosc player design (`controls/uosc/`, after mpv's uosc theme; "Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s @@ -799,7 +839,8 @@ this repo, written by `tool/release.sh`. - Host by key, not by address: loopback detection broke behind a same-machine reverse proxy or tunnel. - Server owns the clock; reports never move it: avoids ping-pong between clients. -- Quorum gate: a lone viewer must be able to watch; two or more must start together. +- Presence gate: a lone viewer must be able to watch; everyone present + while the video is stopped must ready before it starts. - Relic for the server, `web_socket_channel` for the client; packages are chosen on merit, not locked. - Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 12e46aa..175e89e 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -52,14 +52,14 @@ Friends join either way with Join in the strip at the top of the window. (version, password, name, avatar). Wrong password or version: refused with a reason. Otherwise the server answers with a welcome and the current state. 2. **Catch up.** The app opens the current video, jumps to where the server - says we are, and buffers. A new arrival is not ready yet and never holds - anyone back. + says we are, and buffers. Arriving while the video is stopped, you are + waited for; arriving while it plays, you never hold anyone back. 3. **Ready gate.** Press Space to say you are ready. The server starts playback only when everyone who is ready has at least 4 seconds buffered (or what is left, near the end). At the end everyone stops and the server stays finished until someone seeks back or changes the video. - If two or more people are ready, one person alone cannot start it for the - others. A lone viewer can play alone. + Everyone who was there before it started must be ready, so one person + alone cannot start it for the others. A lone viewer can play alone. 4. **Play.** The server owns the clock. Each app compares its mpv position to the clock: a small lead slows mpv a little (0.95x), a big gap (over 5 s) jumps. If someone's buffer runs dry the server pauses everyone and resumes @@ -67,7 +67,8 @@ Friends join either way with Join in the strip at the top of the window. 5. **Seek.** Anyone can seek, anytime. The server moves the clock and everybody follows. Seeking does not change anyone's ready flag. 6. **Swap.** Anyone can change the video by pasting another link. Everybody - stops at 0, ready flags clear, and the gate runs again. + stops at 0 and the gate runs again for everyone there; ready flags stay + as they were (a setting unreadies you instead). 7. **Leave.** Closing or leaving drops you from the server; the others keep going. If you vanish without saying goodbye, you are marked stale after 15 s (stops blocking) and removed after 30 s. The app reconnects by itself and diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 0851d9d..622ceb1 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1,4 +1,4 @@ -# Protocol (proto = 9) +# Protocol (proto = 10) Wire format only. Behavior (gate, clock, timeouts) is in [ARCHITECTURE.md](ARCHITECTURE.md). Source of truth: @@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). ## Client to server ``` -{"t":"hello","proto":9,"secret":"","user":{"name":"","avatar":""}, +{"t":"hello","proto":10,"secret":"","user":{"name":"","avatar":""}, "media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...", "resume":""} {"t":"ready","ready":true,"source":"manual"} source: manual | auto @@ -84,6 +84,9 @@ Field notes: - `swap.media.url` is the link; `name`, `size`, `duration` are informational. `swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is dropped. `swap.media.relay` makes it relayed media (see "Relay"). + A swap keeps every member's `ready` and puts every fresh member in the + set (`inSet`); clients that want to be unready for the new media send + `ready` themselves (gate: ARCHITECTURE.md, "Ready gate"). - Frames before `welcome` (other than `hello`) are ignored; unparsable or unknown frames are ignored silently. @@ -258,8 +261,9 @@ server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive ## Join and reconnect `hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server -position and buffers. A joining member is `ready=false, inSet=false` until -the first `ready`. A socket the server sees close loses its token; reconnect +position and buffers. A joining member is `ready=false`; it is `inSet` +at once unless the server is `playing`, then from its first `ready` +(members present at a swap are put in the set by it). A socket the server sees close loses its token; reconnect is a fresh `hello` (with `resume` = the old token), after which the client re-sends its prior ready flag. If the old member was still there (unclean drop), `resume` takes it over and the token and media URL stay the same. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 88f4bbc..8d8eb57 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,56 +6,33 @@ off by deleting it in the same commit as the code. Wire changes also edit ## Backlog -1. **Ready gate and swap (protocol bump).** Bug: three in a server, a file - relayed from one; two readied and it played, the third (not ready) was - dragged along frame by frame. Members present at a swap must all count - for the gate (a joiner after the swap still counts only once ready); - a swap keeps everyone's ready flag, with a per-user "Unready when the - video changes" setting. Shared auto-skip lives here too (below). +1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the + server, personal, per show) is open. Constraint: a skip is a seek and + seeks are shared, and only real ranges (segments, OP/ED chapters) should + ever be skipped. -2. **Quick UI** - - Tab no longer moves focus (Tab toggles chat, then Space pressed a - random button); Space is always ready outside text fields. - - Opening chat focuses its field; Esc/Tab hands focus back. - - Unread badge on uosc's chat button; an unread hint in fullscreen. - - Your avatar in the strip the same size as the party's. - - Chat panel: people's messages only, a toggle shows activity. - - Skip OP/ED: no "Skip 1:30" guess when the file has chapters (a - missing OP chapter is on purpose); the pill shows for the first 5 s - of the range and again when the mouse moves (like the controls). - - Auto-skip OP/ED: a server-wide setting in `state` (anyone toggles it, - the feed says who); exactly one client seeks (the media's `swapBy`, - else the lowest fresh member id), only for real ranges (segments or - OP/ED chapters), as a normal user seek. - - Volume above 100% like mpv. +2. **Files**: next episode from Files (by parsed episode, with the + countdown). Later: tracking for file series. -3. **Drag & drop and paste** (`fox_utils`): drop or Ctrl+V anywhere; a - small card asks how to open it (file: put up / add its folder to Files; - link: put up with the link modes; image: the avatar editor when open). - -4. **Files**: detected series groups start collapsed (big libraries); - next episode from Files (by parsed episode, with the countdown). Later: - tracking for file series. - -5. **yt-dlp** (epic): players that open links themselves use mpv's ytdl +3. **yt-dlp** (epic): players that open links themselves use mpv's ytdl hook (bundled on Windows); then relay of single-file formats; then a resolver for the Link preview (title, thumbnail, duration). -6. **AniList account**: sign in (OAuth via a localhost callback), "My list" +4. **AniList account**: sign in (OAuth via a localhost callback), "My list" filter on the Schedule; today's "Following" (AnimeFlow's monitored shows) becomes "On the server". -7. **Ops**: `watch-together-server --upgrade` (a VPS has no GTK for the +5. **Ops**: `watch-together-server --upgrade` (a VPS has no GTK for the app's updater); check shaders on Windows; the relay e2e "source drops uncleanly and resumes" fails about half the time under the full suite. -8. **Windows: first real use.** CI builds and publishes +6. **Windows: first real use.** CI builds and publishes `watch-together--windows-x64.zip` (libmpv bundled) and runs the updater tests on NTFS; a friend has joined from it. Still unchecked: fullscreen controls, hosting (firewall prompt), in-app update from one release to the next. The rest of the desktop tests are not in CI. -9. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's +7. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's PC -> VPS -> friend, Jellyfin and a file. Then: - Source takeover: when the source leaves, another member with an account on that Jellyfin (or the same file: name, size, hash) takes @@ -65,9 +42,9 @@ off by deleting it in the same commit as the code. Wire changes also edit - Show who is relaying and how fast in the member list; warn the source when its upload is below the media's bitrate. -10. **Jellyfin** (epic, below). +8. **Jellyfin** (epic, below). -11. **Smaller items** +9. **Smaller items** - Video output robustness: if media_kit's hardware path regresses to software (see ARCHITECTURE, Media: render mode), vendor a media_kit_video fix that builds the EGL display from GDK's native diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart index 7e39603..067d904 100644 --- a/packages/core/lib/src/constants.dart +++ b/packages/core/lib/src/constants.dart @@ -1,5 +1,5 @@ abstract class RelayConstants { - static const int protoVersion = 9; + static const int protoVersion = 10; // Relay (`/r/...`): a member's bytes through the server's buffer. static const int relaySliceBytes = 1 << 20; diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index b210fcb..a77f5e9 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -186,12 +186,17 @@ class SyncEngine { return left.clamp(0.0, full).toDouble(); } - /// Any frame from a member: fresh again, and a ready member the stale - /// sweep took out of the set goes back in (ready && !inSet only arises - /// from that sweep). + /// Any frame from a member: fresh again. Back in the set: a ready member + /// the stale sweep (or a swap while it was stale) took out (ready && + /// !inSet only arises from those), and any member back from stale while + /// not playing (like a join then). void _seen(EngineMember member) { + final wasStale = isStale(member); member.lastSeen = now; - if (member.ready && !member.inSet) member.inSet = true; + if (!member.inSet && + (member.ready || (wasStale && transport != Transport.playing))) { + member.inSet = true; + } } /// Heartbeat: stale sweep, end of media. @@ -202,8 +207,11 @@ class SyncEngine { String applyJoin(String token, String name, String avatar) { final id = 'm${_nextId++}'; + // Joining while not playing: in the set, so the start waits for them; + // while playing, out until they ready (a late joiner never pauses). members[token] = - EngineMember(id: id, token: token, name: name, avatar: avatar, now: now); + EngineMember(id: id, token: token, name: name, avatar: avatar, now: now) + ..inSet = transport != Transport.playing; feed.add(FeedMessage(actor: name, action: 'join', at: now)); return id; } @@ -367,9 +375,11 @@ class SyncEngine { serverTime = now; eventId++; transport = Transport.held; + // Ready flags stay; everyone here now waits for everyone else here now + // (a stale member rejoins the set with its next frame if still ready). + final t = now; for (final member in members.values) { - member.ready = false; - member.inSet = false; + member.inSet = !isStale(member, t); member.buffered = 0; } feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now)); @@ -448,12 +458,9 @@ class SyncEngine { } final inSet = members.values.where((m) => m.inSet).toList(); if (inSet.isEmpty) return; - final freshCount = - members.values.where((m) => !isStale(m, t)).length; - final quorum = inSet.length >= 2 || freshCount <= 1; final allGood = inSet.every( (m) => m.ready && m.buffered >= need && !isStale(m, t)); - if (allGood && quorum) { + if (allGood) { serverTime = t; eventId++; transport = Transport.playing; diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index 25128f5..19ac5e4 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -37,12 +37,12 @@ void main() { }); group('membership + gate', () { - test('join lands out-of-set, playback stays held', () { + test('join while held lands in the set, playback stays held', () { join('a', 'alice'); expect(engine.transport, Transport.held); - expect(engine.members['a']!.inSet, isFalse); + expect(engine.members['a']!.inSet, isTrue); final state = engine.broadcast(); - expect(state.members.single.inSet, isFalse); + expect(state.members.single.inSet, isTrue); }); test('single ready + buffered auto-plays', () { @@ -126,7 +126,22 @@ void main() { }); }); - group('quorum gate', () { + group('presence gate', () { + test('initial media: three join while held, two ready stays held', () { + engine.media = const HelloMedia(name: 'f', size: 1, duration: 0); + join('a', 'alice'); + join('b', 'bob'); + join('c', 'cara'); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 6.0); + buffer('b', 6.0); + buffer('c', 6.0); + expect(engine.transport, Transport.held); + engine.applyReady('c', true); + expect(engine.transport, Transport.playing); + }); + test('one ready of two fresh stays held', () { join('a', 'alice'); join('b', 'bob'); @@ -153,6 +168,7 @@ void main() { t = t.add(const Duration(seconds: 16)); engine.applyReady('a', true); buffer('a', 6.0); + engine.tick(); expect(engine.transport, Transport.playing); }); @@ -257,19 +273,66 @@ void main() { }); }); - group('swap resets everything', () { - test('swap bumps mediaId, clears sets, holds', () { + group('swap', () { + const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0); + + test('swap bumps mediaId, holds, keeps ready, fresh members in set', () { join('a', 'alice'); engine.applyReady('a', true); buffer('a', 4.0); expect(engine.transport, Transport.playing); - engine.applySwap('a', - const HelloMedia(name: 'Ep02.mkv', size: 9, duration: 9.0)); + engine.applySwap('a', ep2); expect(engine.mediaId, 2); expect(engine.transport, Transport.held); expect(engine.positionAt(), 0); - expect(engine.members['a']!.inSet, isFalse); - expect(engine.members['a']!.ready, isFalse); + expect(engine.members['a']!.inSet, isTrue); + expect(engine.members['a']!.ready, isTrue); + expect(engine.members['a']!.buffered, 0); + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + }); + + test('a member present at the swap blocks until ready', () { + join('a', 'alice'); + join('b', 'bob'); + join('c', 'cara'); + engine.applySwap('a', ep2); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 6.0); + buffer('b', 6.0); + buffer('c', 6.0); + expect(engine.transport, Transport.held); + expect(engine.members['c']!.inSet, isTrue); + engine.applyReady('c', true); + expect(engine.transport, Transport.playing); + }); + + test('a joiner while playing never holds anyone back', () { + join('a', 'alice'); + engine.applySwap('a', ep2); + engine.applyReady('a', true); + buffer('a', 6.0); + expect(engine.transport, Transport.playing); + join('b', 'bob'); + buffer('b', 0); + expect(engine.members['b']!.inSet, isFalse); + expect(engine.transport, Transport.playing); + }); + + test('a stale member stays out, rejoins with its next frame if ready', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('b', true); + t = t.add(const Duration(seconds: 16)); + engine.applyReady('a', true); + engine.applySwap('a', ep2); + expect(engine.members['b']!.inSet, isFalse); + buffer('a', 6.0); + expect(engine.transport, Transport.playing); + buffer('b', 0); + expect(engine.members['b']!.inSet, isTrue); + expect(engine.transport, Transport.held); }); }); @@ -427,10 +490,13 @@ void main() { isTrue); }); - test('unready member does not rejoin on reports', () { + test('unready late joiner does not join on reports', () { join('a', 'alice'); + engine.applyReady('a', true); buffer('a', 6.0); - expect(engine.members['a']!.inSet, isFalse); + join('b', 'bob'); + buffer('b', 6.0); + expect(engine.members['b']!.inSet, isFalse); }); }); diff --git a/packages/server/test/session_test.dart b/packages/server/test/session_test.dart index ee7f2c3..0b4606e 100644 --- a/packages/server/test/session_test.dart +++ b/packages/server/test/session_test.dart @@ -80,8 +80,8 @@ void main() { }); group('core welcome + proto', () { - test('proto 9: avatars by hash, profile intent', () { - expect(RelayConstants.protoVersion, 9); + test('proto 10: avatars by hash, profile intent', () { + expect(RelayConstants.protoVersion, 10); const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false); expect(v.toJson().containsKey('avatarHash'), isFalse); expect(v.toJson().containsKey('avatar'), isFalse);