9 Commits
Author SHA1 Message Date
foxyz ff51d52fbb Release v1.0.1
ci / updater-windows (push) Successful in 47s
ci / core (push) Successful in 27s
ci / server (push) Successful in 45s
release / linux (push) Successful in 3m49s
release / windows (push) Failing after 5m53s
2026-10-09 08:18:29 +03:00
foxyzandClaude Opus 5.5 f2272d7980 Space never presses a focused button; chat toasts open the chat; plain wheel always scrolls the page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:52:48 +03:00
foxyzandClaude Opus 5.5 cf80bb2167 End of video plays out, no false 'loading' near/after the end; bigger status pill avatars; start card fits the mini
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:31:03 +03:00
foxyzandClaude Opus 5.5 8effa573a0 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 <noreply@anthropic.com>
2026-10-09 07:06:48 +03:00
foxyzandClaude Opus 5.5 5f22be6c20 Roadmap: wishlist placed (ready gate, quick UI, drop/paste, Files, yt-dlp, AniList, ops)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:54:08 +03:00
foxyzandClaude Opus 5.5 740273745b Desktop: depend on fox_utils v0.1.7 (git) for drag & drop and clipboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:32:22 +03:00
foxyzandClaude Opus 5.5 27b4e1dc0c Sounds: full volume by default; everyone ready is the bell alone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:28:53 +03:00
foxyzandClaude Opus 5.5 fce392e425 Sounds: Japanese-house sound set in the app, layout demo removed
Eleven sounds for events that carry news (ready, unready, everyone ready,
join, leave, kick, new video, others' chat, idle warning, connection
lost/back); none for what the video already says. New voices: koto
(Karplus-Strong, bendable), furin chime, rin bowl, bamboo, hyoshigi,
shoji slide, in the Yo scale on D. Played by SynthSfx at the Settings
volume with per-sound switches and previews. The debug layout mock is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:23:22 +03:00
foxyzandClaude Opus 5.5 d8fd464e33 Sounds: synthesized sound set and a debug sound board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:45:14 +03:00
68 changed files with 2491 additions and 6209 deletions

No files matched your search

+25 -15
View File
@@ -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 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 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 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 Tab), ready to type; it shows messages, and its "Activity" toggle adds
person's avatar. joins, ready, seeks and the like between them. Joins, leaves, chat and
other news also pop up bottom left with the person's avatar; click a message to open the chat. 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 Your name and picture change for everyone in the server right away; the
colour is yours only. colour is yours only.
@@ -83,8 +86,10 @@ disk. Nothing is uploaded or stored anywhere.
## Library ## Library
What to watch, from three sources (the switch at the top; Ctrl+V outside What to watch, from three sources (the switch at the top). Drop a video,
a field pastes a link into Link): 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 - **Jellyfin**: your linked servers (see below). Library chips beside the
switch, then one **Continue** row (what you were watching and what comes 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 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 others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it. you). Your last 12 links are listed under it.
- **Files**: video folders you add on this computer, grouped by series, - **Files**: video folders you add on this computer, grouped by series
or any file through Browse. The others stream it from you. (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 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"). goes up until you join or host ("Join or host to watch together").
@@ -149,8 +154,8 @@ of help: Profile; Party & network (the Host card's default port and
password, recent servers with remove); Player (below); Jellyfin & password, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection: integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the "connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; there next episode automatically); Notifications & sounds (what pops up; the sound
is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About volume, and every sound with a preview and its own switch); Updates; Keyboard shortcuts (read-only); About
(the version). (the version).
Player settings live in the player: ⚙ at the right end of its controls Player settings live in the player: ⚙ at the right end of its controls
@@ -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 overrides it); in the player also the subtitle and audio track. Every
change applies right away. change applies right away.
Keys while the player fills the window: Space = ready / unready, Tab = Anywhere in a server (unless you are typing): Space = ready / unready,
chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30, 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 = PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
close the chat or fullscreen, else ask to leave, Ctrl+R = reload video, close the chat or fullscreen, else ask to leave, Ctrl+R = reload video,
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode, `<` / `>` 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 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 (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 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, The uosc player design (⚙ → Player design, Standard by default,
switches right away): video controls modeled on mpv's uosc theme. A thick 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 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 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 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 them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on (OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
anything longer than 20 minutes, "Skip 1:30" floats there during the first any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
6 minutes. during the first 6 minutes.
## Ports ## Ports
+3 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/shell/app_shell.dart'; import 'src/shell/app_shell.dart';
import 'src/widgets/app_keys.dart';
class WatchTogetherApp extends StatelessWidget { class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key}); const WatchTogetherApp({super.key});
@@ -11,7 +12,8 @@ class WatchTogetherApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: appName, title: appName,
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: const AppShell(), shortcuts: appShortcuts,
home: const AppKeys(child: AppShell()),
); );
} }
} }
+46 -18
View File
@@ -10,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
import '../../shaders/shader_menu.dart'; import '../../shaders/shader_menu.dart';
import '../../shaders/shader_presets.dart'; import '../../shaders/shader_presets.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
import '../volume.dart';
import 'controls.dart'; import 'controls.dart';
import 'menu.dart'; import 'menu.dart';
import 'style.dart'; import 'style.dart';
@@ -166,9 +167,10 @@ class _UoscBadgeButtonState extends State<UoscBadgeButton> {
} }
} }
/// 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 { class UoscVolumeButton extends StatefulWidget {
/// 0..100. /// 0..[maxVolume].
final double volume; final double volume;
final ValueChanged<double> onChanged; final ValueChanged<double> onChanged;
const UoscVolumeButton({super.key, required this.volume, required this.onChanged}); const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
@@ -179,29 +181,55 @@ class UoscVolumeButton extends StatefulWidget {
class _UoscVolumeButtonState extends State<UoscVolumeButton> { class _UoscVolumeButtonState extends State<UoscVolumeButton> {
double _beforeMute = 100; double _beforeMute = 100;
bool _hover = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final v = widget.volume; final v = widget.volume;
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded); final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
return Listener( return MouseRegion(
onPointerSignal: (e) { onEnter: (_) => setState(() => _hover = true),
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) { onExit: (_) => setState(() => _hover = false),
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0)); child: Listener(
} onPointerSignal: (e) {
}, if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
child: UoscButton( widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
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);
} }
}, },
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);
}
},
),
],
),
), ),
); );
} }
@@ -26,6 +26,8 @@ class UoscControls extends StatefulWidget {
final Widget? topLeft; final Widget? topLeft;
final VoidCallback? onDoubleTap; final VoidCallback? onDoubleTap;
final Duration hideAfter; final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
const UoscControls({ const UoscControls({
super.key, super.key,
required this.timeline, required this.timeline,
@@ -35,6 +37,7 @@ class UoscControls extends StatefulWidget {
this.topLeft, this.topLeft,
this.onDoubleTap, this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3), this.hideAfter = const Duration(seconds: 3),
this.shown,
}); });
static const buttonRowHeight = 40.0; static const buttonRowHeight = 40.0;
@@ -58,6 +61,10 @@ class UoscControlsState extends State<UoscControls> {
@override @override
void initState() { void initState() {
super.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(); _hideLater();
} }
@@ -69,7 +76,10 @@ class UoscControlsState extends State<UoscControls> {
/// Show the controls and restart the hide countdown. /// Show the controls and restart the hide countdown.
void wake() { void wake() {
if (!_visible && mounted) setState(() => _visible = true); if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater(); _hideLater();
} }
@@ -98,6 +108,7 @@ class UoscControlsState extends State<UoscControls> {
void _hide() { void _hide() {
if (!mounted || _holds > 0 || !_visible) return; if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false); setState(() => _visible = false);
widget.shown?.value = false;
} }
@override @override
+146
View File
@@ -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<double> 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<VolumeButton> createState() => _VolumeButtonState();
}
class _VolumeButtonState extends State<VolumeButton> {
bool _hover = false;
double _beforeMute = 100;
@override
Widget build(BuildContext context) {
final player = widget.player;
return StreamBuilder<double>(
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(),
),
],
),
),
);
},
);
}
}
+46 -28
View File
@@ -35,7 +35,11 @@ Widget _offlineTip(bool offline, Widget child) =>
class SeriesHeader extends StatelessWidget { class SeriesHeader extends StatelessWidget {
final SeriesGroup group; final SeriesGroup group;
final ThumbCache thumbs; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -49,34 +53,48 @@ class SeriesHeader extends StatelessWidget {
?first.parsed.group, ?first.parsed.group,
].join(' · '); ].join(' · ');
return Padding( return Padding(
padding: const EdgeInsets.only(top: 18, bottom: 4), padding: const EdgeInsets.only(top: 14),
child: Row( child: InkWell(
children: [ onTap: onTap,
ClipRRect( borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(8), child: Padding(
child: ThumbBuilder( padding: const EdgeInsets.symmetric(vertical: 4),
cache: thumbs, child: Row(
path: first.path, children: [
builder: (context, t, _) => ThumbImage(t, width: 96, height: 54), 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)),
],
),
),
],
), ),
); );
} }
+48 -18
View File
@@ -17,7 +17,8 @@ export 'thumb.dart' show ThumbnailLoader;
// The Files source: watched folders on the left (chips on top when // The Files source: watched folders on the left (chips on top when
// narrow), the open one on the right grouped into series and loose // 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; // 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. /// Below this width the sidebar turns into chips above the list.
const filesNarrowWidth = 700.0; const filesNarrowWidth = 700.0;
@@ -26,12 +27,23 @@ const filesNarrowWidth = 700.0;
final _scans = <String, FolderScan>{}; final _scans = <String, FolderScan>{};
final _running = <String, Future<FolderScan>>{}; final _running = <String, Future<FolderScan>>{};
String? _lastOpen; String? _lastOpen;
/// Series opened this session, by `folder\nseries key`.
final _expanded = <String>{};
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 @visibleForTesting
void resetFilesPageSession() { void resetFilesPageSession() {
_scans.clear(); _scans.clear();
_running.clear(); _running.clear();
_expanded.clear();
_lastOpen = null; _lastOpen = null;
} }
@@ -73,22 +85,21 @@ class FilesPage extends StatefulWidget {
/// A scan grouped for display, made once per scan. /// A scan grouped for display, made once per scan.
class _Library { class _Library {
final GroupedVideos grouped; final GroupedVideos grouped;
/// Series rows in order: a header (video null), then its episodes and
/// extras.
final List<(SeriesGroup, VideoFile?)> rows;
final int subtitleFiles; final int subtitleFiles;
_Library(FolderScan scan) _Library(FolderScan scan)
: grouped = groupBySeries(scan.videos), : grouped = groupBySeries(scan.videos),
rows = [], subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length {
for (final g in grouped.series) { /// Series rows in order: a header (video null), then, for the series
rows.add((g, null)); /// [open] says, its episodes and extras.
for (final v in [...g.episodes, ...g.extras]) { List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
rows.add((g, v)); 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>(); final _libraries = Expando<_Library>();
@@ -107,6 +118,7 @@ class _FilesPageState extends State<FilesPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_showRequests.addListener(_shown);
_store.ensureLoaded().then((_) { _store.ensureLoaded().then((_) {
if (!mounted) return; if (!mounted) return;
final open = _open; final open = _open;
@@ -118,6 +130,14 @@ class _FilesPageState extends State<FilesPage> {
}); });
} }
@override
void dispose() {
_showRequests.removeListener(_shown);
super.dispose();
}
void _shown() => _select(_lastOpen);
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir); Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
void _select(String? dir) { void _select(String? dir) {
@@ -477,6 +497,8 @@ class _FilesPageState extends State<FilesPage> {
if (scan.truncated) 'stopped after $maxScannedFiles files', if (scan.truncated) 'stopped after $maxScannedFiles files',
].join(' · '); ].join(' · ');
final loose = lib.grouped.loose; final loose = lib.grouped.loose;
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
return [ return [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Wrap( child: Wrap(
@@ -547,13 +569,21 @@ class _FilesPageState extends State<FilesPage> {
'It may have been moved, renamed or be on a drive that is not connected.', '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( SliverList.builder(
itemCount: lib.rows.length, itemCount: rows.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final (group, v) = lib.rows[i]; final (group, v) = rows[i];
if (v == null) { 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( return FileRow(
key: ValueKey(v.path), key: ValueKey(v.path),
@@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1);
/// The skip pill at [position]: inside the opening "Skip OP" (to its end), /// 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 /// 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 /// more episode, is the viewer's call). Without any opening info on media
/// longer than [fallbackMinDuration], "Skip 1:30" during the first /// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
/// [fallbackWindow]. Null otherwise. /// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
/// during the first [fallbackWindow]. Null otherwise.
SkipOffer? skipOfferAt({ SkipOffer? skipOfferAt({
required Duration position, required Duration position,
required Duration duration, required Duration duration,
required SkipRange? opening, required SkipRange? opening,
required SkipRange? ending, required SkipRange? ending,
bool chaptered = false,
}) { }) {
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail; bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end); if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.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; final to = position + fallbackSkip;
return SkipOffer(SkipKind.fixed, to < duration ? to : duration); return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
} }
@@ -32,6 +32,9 @@ class PlaybackController {
/// Back buffer (media_kit sets it to bufferSize too): enough for a short /// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB. /// seek back without holding another 256 MiB.
static const maxBackBytes = 64 * 1024 * 1024; 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; SyncPlayer? _player;
VideoController? _video; VideoController? _video;
@@ -39,7 +42,7 @@ class PlaybackController {
bool _probing = false; bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog(); final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub; StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Duration>> _timeSubs = []; final List<StreamSubscription<Object?>> _timeSubs = [];
String _chapterJson = ''; String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none). // `glsl-shaders` as last set on the current player ('' = none).
String _shaders = ''; String _shaders = '';
@@ -163,6 +166,7 @@ class PlaybackController {
_logSub = _player!.stream.log.listen(_onLog); _logSub = _player!.stream.log.listen(_onLog);
_watchMarks(_player!); _watchMarks(_player!);
await _pinBufferProps(); await _pinBufferProps();
await _pin('volume-max', '$maxVolume');
await applyShaders(); await applyShaders();
_watchdog.reset(); _watchdog.reset();
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch()); _watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
@@ -426,9 +430,18 @@ class PlaybackController {
// track-list observed in mpv (read back as JSON). // track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) { void _watchMarks(SyncPlayer p) {
_timeSubs.add(p.stream.position.listen((d) { _timeSubs.add(p.stream.position.listen((d) {
// At the end the bar shows full: mpv's last position is the last
// frame's start (and the 250 ms steps round down), short of duration.
if (p.state.completed && duration.value > Duration.zero) {
position.value = duration.value;
return;
}
final ms = d.inMilliseconds; final ms = d.inMilliseconds;
position.value = Duration(milliseconds: ms - ms % 250); position.value = Duration(milliseconds: ms - ms % 250);
})); }));
_timeSubs.add(p.stream.completed.listen((done) {
if (done && duration.value > Duration.zero) position.value = duration.value;
}));
_timeSubs.add(p.stream.buffer.listen((d) { _timeSubs.add(p.stream.buffer.listen((d) {
final ms = d.inMilliseconds; final ms = d.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250); buffered.value = Duration(milliseconds: ms - ms % 250);
@@ -501,7 +501,6 @@ class _SchedulePageState extends State<SchedulePage> {
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e); (byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
} }
return HScroll( return HScroll(
wheel: false,
controller: _scroll, controller: _scroll,
builder: (c) => ListView.separated( builder: (c) => ListView.separated(
key: const ValueKey('schedule-board'), key: const ValueKey('schedule-board'),
@@ -1,920 +0,0 @@
part of 'shell_demo.dart';
// DEBUG ONLY: the Files source for real. Folders you add are scanned for
// videos (everything else is counted and hidden), grouped into series by
// a small anitomy-style name parser; a click plays the file in the demo
// player. Kept in memory for the demo session.
const _videoExt = {
'mkv',
'mp4',
'webm',
'avi',
'mov',
'm4v',
'ts',
'm2ts',
'wmv',
'flv',
};
const _subExt = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
class _VideoFile {
final String path;
final int size;
final _ParsedName parsed;
const _VideoFile(this.path, this.size, this.parsed);
String get name => _baseName(path);
String get folder => _baseName(File(path).parent.path);
}
class _FolderScan {
final List<_VideoFile> videos;
final int hidden;
final int subtitles;
final List<String> hiddenExamples;
final bool truncated;
const _FolderScan(
this.videos,
this.hidden,
this.subtitles,
this.hiddenExamples,
this.truncated,
);
}
final _folders = ValueNotifier<List<String>>([]);
final _scans = <String, _FolderScan>{};
final _recentFiles = ValueNotifier<List<_VideoFile>>([]);
/// What a release file name says: series, season, episode, group.
class _ParsedName {
final String series;
final int? season;
final int? episode;
final String? group;
const _ParsedName(this.series, {this.season, this.episode, this.group});
String get label {
if (episode == null) return series;
final ep = 'E${episode.toString().padLeft(2, '0')}';
return season == null ? ep : 'S${season.toString().padLeft(2, '0')}$ep';
}
}
_ParsedName _parseName(String file) {
var s = file;
final dot = s.lastIndexOf('.');
if (dot > 0) s = s.substring(0, dot);
final group = RegExp(r'^\s*\[([^\]]+)\]').firstMatch(s)?.group(1);
// Brackets/parens hold group, resolution, codec, CRC.
s = s.replaceAll(RegExp(r'\[[^\]]*\]|\([^)]*\)'), ' ');
// Dots/underscores as separators (scene style), unless it has spaces.
if (!s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2) {
s = s.replaceAll(RegExp(r'[._]'), ' ');
}
int? season, episode;
var cut = s.length;
final se = RegExp(
r'\bS(\d{1,2})\s?E(\d{1,4})\b',
caseSensitive: false,
).firstMatch(s);
if (se != null) {
season = int.parse(se.group(1)!);
episode = int.parse(se.group(2)!);
cut = se.start;
} else {
final dash = RegExp(r'\s-\s(\d{1,4})(?:v\d)?\b').firstMatch(s);
final ep = RegExp(
r'\b(?:E|EP|Episode)\s?(\d{1,4})\b',
caseSensitive: false,
).firstMatch(s);
final m = dash ?? ep;
if (m != null) {
episode = int.parse(m.group(1)!);
cut = m.start;
}
}
var series = s.substring(0, cut);
// Season markers inside the title ("Title S2", "Title 2nd Season").
final sInTitle = RegExp(
r'\s(?:S(\d{1,2})|(\d)(?:st|nd|rd|th) Season)\s*$',
caseSensitive: false,
).firstMatch(series);
if (sInTitle != null && season == null) {
season = int.parse(sInTitle.group(1) ?? sInTitle.group(2)!);
}
// Release junk after the title.
series = series
.replaceAll(
RegExp(
r'\b(1080p|720p|2160p|480p|x26[45]|HEVC|WEB(-?DL|Rip)?|BD(Rip)?|BluRay|AAC|FLAC|10bit)\b.*',
caseSensitive: false,
),
'',
)
.replaceAll(RegExp(r'\s+'), ' ')
.replaceAll(RegExp(r'[\s\-–:]+$'), '')
.trim();
if (series.isEmpty) series = file;
return _ParsedName(series, season: season, episode: episode, group: group);
}
Future<_FolderScan> _scanFolder(String dir) async {
final videos = <_VideoFile>[];
var hidden = 0, subs = 0;
final examples = <String>[];
var seen = 0;
var truncated = false;
try {
await for (final e in Directory(
dir,
).list(recursive: true, followLinks: false)) {
if (e is! File) continue;
if (++seen > 20000) {
truncated = true;
break;
}
final name = e.path.split(Platform.pathSeparator).last;
if (name.startsWith('.')) continue;
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
if (_subExt.contains(ext)) {
subs++;
continue;
}
final isVideo = _videoExt.contains(ext);
final lower = name.toLowerCase();
int size = 0;
try {
size = await e.length();
} catch (_) {}
// Samples and trailers next to the real thing.
final junk =
isVideo &&
(lower.contains('sample') || lower.contains('trailer')) &&
size < 300 << 20;
if (!isVideo || junk) {
hidden++;
if (examples.length < 6) examples.add(name);
continue;
}
videos.add(_VideoFile(e.path, size, _parseName(name)));
}
} catch (_) {
// Unreadable subfolders: show what was found.
}
return _FolderScan(videos, hidden, subs, examples, truncated);
}
String _size(int b) => b >= 1 << 30
? '${(b / (1 << 30)).toStringAsFixed(1)} GB'
: '${(b / (1 << 20)).toStringAsFixed(0)} MB';
class _RealFilesTab extends StatefulWidget {
final bool online;
final VoidCallback onPlay;
const _RealFilesTab({required this.online, required this.onPlay});
@override
State<_RealFilesTab> createState() => _RealFilesTabState();
}
class _RealFilesTabState extends State<_RealFilesTab> {
String? _open; // null = Recent
bool _scanning = false;
bool _showOther = false;
Future<void> _addFolder() async {
String? dir;
try {
dir = await FilePicker.getDirectoryPath(
dialogTitle: 'Add a folder with videos',
);
} catch (_) {}
if (dir == null || _folders.value.contains(dir)) return;
_folders.value = [..._folders.value, dir];
await _select(dir, rescan: true);
}
Future<void> _select(String? dir, {bool rescan = false}) async {
setState(() {
_open = dir;
_showOther = false;
});
if (dir == null || (_scans.containsKey(dir) && !rescan)) return;
setState(() => _scanning = true);
final scan = await _scanFolder(dir);
if (!mounted) return;
setState(() {
_scans[dir] = scan;
_scanning = false;
});
}
Future<void> _browse() async {
List<PlatformFile> files = const [];
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick a video',
type: FileType.custom,
allowedExtensions: _videoExt.toList(),
);
} catch (_) {}
final path = files.isEmpty ? null : files.first.path;
if (path == null) return;
final f = File(path);
await _play(
_VideoFile(path, await f.length(), _parseName(f.uri.pathSegments.last)),
);
}
Future<void> _play(_VideoFile v) async {
_recentFiles.value = [
v,
..._recentFiles.value.where((x) => x.path != v.path),
].take(30).toList();
// Structured names read as "Series · S01E03"; anything else as the
// cleaned name and the folder it is in.
await _playMedia(
_DemoMedia(
v.parsed.series,
v.parsed.episode == null ? v.folder : v.parsed.label,
v.path,
),
);
widget.onPlay();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < 700;
final list = _list(context, narrow);
if (narrow) return list;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 260, child: _sidebar(context)),
Expanded(child: list),
],
);
},
);
}
Widget _sidebar(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder(
valueListenable: _folders,
builder: (context, folders, _) => ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
children: [
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
ListTile(
dense: true,
selected: _open == null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
leading: const Icon(Icons.history, size: 18),
title: const Text('Recent'),
onTap: () => _select(null),
),
for (final f in folders)
ListTile(
dense: true,
selected: _open == f,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
leading: const Icon(Icons.folder_outlined, size: 18),
title: Text(
_baseName(f),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_scans[f] == null
? 'scanning…'
: '${_scans[f]!.videos.length} videos',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11),
),
trailing: IconButton(
tooltip: 'Remove folder',
icon: const Icon(Icons.close, size: 16),
onPressed: () {
_folders.value = [...folders]..remove(f);
_scans.remove(f);
if (_open == f) _select(null);
},
),
onTap: () => _select(f),
),
TextButton.icon(
onPressed: _addFolder,
icon: const Icon(Icons.add, size: 18),
label: const Text('Add folder'),
),
const Divider(),
OutlinedButton.icon(
onPressed: _browse,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Browse…'),
),
const SizedBox(height: 6),
const Text(
'The system file picker, for anything outside your folders.',
style: TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
);
}
Widget _list(BuildContext context, bool narrow) {
final scheme = Theme.of(context).colorScheme;
final open = _open;
final scan = open == null ? null : _scans[open];
final children = <Widget>[
// Where you are: Recent, or the folder and its path.
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Icon(
open == null ? Icons.history : Icons.folder,
color: scheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
open == null ? 'Recent' : _baseName(open),
style: Theme.of(context).textTheme.headlineSmall,
),
if (open != null)
Text(
open,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
),
),
],
),
),
],
),
),
if (narrow)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: ValueListenableBuilder(
valueListenable: _folders,
builder: (context, folders, _) => Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text('Recent'),
selected: open == null,
onSelected: (_) => _select(null),
),
for (final f in folders)
ChoiceChip(
label: Text(_baseName(f)),
selected: open == f,
onSelected: (_) => _select(f),
),
ActionChip(
avatar: const Icon(Icons.add, size: 16),
label: const Text('Folder'),
onPressed: _addFolder,
),
ActionChip(
avatar: const Icon(Icons.folder_open, size: 16),
label: const Text('Browse…'),
onPressed: _browse,
),
],
),
),
),
if (widget.online)
Container(
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.lightBlueAccent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: const Row(
children: [
Icon(Icons.cell_tower, size: 18, color: Colors.lightBlueAccent),
SizedBox(width: 10),
Expanded(
child: Text(
'Files stream from this computer through the server. Keep the app open while it plays.',
),
),
],
),
),
];
if (open == null) {
children.add(
ValueListenableBuilder(
valueListenable: _recentFiles,
builder: (context, recent, _) => recent.isEmpty
? _empty(
context,
Icons.video_library_outlined,
'Nothing played yet',
'Add a folder with your videos, or Browse for a single file.',
action: FilledButton.icon(
onPressed: _addFolder,
icon: const Icon(Icons.create_new_folder_outlined),
label: const Text('Add folder'),
),
)
: Column(
children: [
for (final v in recent)
_episodeTile(context, v, showSeries: true),
],
),
),
);
} else if (_scanning || scan == null) {
children.add(
const Padding(
padding: EdgeInsets.only(top: 60),
child: Center(child: CircularProgressIndicator()),
),
);
} else {
final groups = <String, List<_VideoFile>>{};
for (final v in scan.videos) {
groups.putIfAbsent(v.parsed.series, () => []).add(v);
}
final names = groups.keys.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
for (final g in groups.values) {
g.sort((a, b) {
final s = (a.parsed.season ?? 0).compareTo(b.parsed.season ?? 0);
if (s != 0) return s;
final e = (a.parsed.episode ?? 0).compareTo(b.parsed.episode ?? 0);
return e != 0 ? e : a.name.compareTo(b.name);
});
}
children.add(
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Icon(
Icons.filter_alt_outlined,
size: 16,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
'${scan.videos.length} videos in ${groups.length} series'
'${scan.hidden > 0 ? ' · ${scan.hidden} other files hidden' : ''}'
'${scan.subtitles > 0 ? ' · ${scan.subtitles} subtitle files' : ''}'
'${scan.truncated ? ' · stopped after 20 000 files' : ''}',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
if (scan.hidden > 0) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () => setState(() => _showOther = !_showOther),
child: Text(_showOther ? 'Hide them' : 'Show some'),
),
],
TextButton.icon(
onPressed: () => _select(open, rescan: true),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Rescan'),
),
],
),
);
children.add(
AnimatedSize(
duration: _easeTime,
curve: _ease,
alignment: Alignment.topLeft,
child: !_showOther
? const SizedBox(width: double.infinity)
: Column(
children: [
for (final f in scan.hiddenExamples)
ListTile(
dense: true,
enabled: false,
leading: const Icon(
Icons.insert_drive_file_outlined,
size: 18,
),
title: Text(f),
trailing: const Text(
'not a video',
style: TextStyle(fontSize: 12),
),
),
],
),
),
);
if (scan.videos.isEmpty) {
children.add(
_empty(
context,
Icons.search_off,
'No videos here',
'Only video files are listed (mkv, mp4, webm…).',
),
);
}
// Series: files whose names carry an episode number. Everything
// else is a loose video, shown as a thumbnail grid.
final loose = <_VideoFile>[];
for (final name in names) {
final eps = groups[name]!;
if (eps.every((e) => e.parsed.episode == null)) {
loose.addAll(eps);
continue;
}
children.add(
Padding(
padding: const EdgeInsets.only(top: 18, bottom: 4),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _Thumb(eps.first.path, width: 96, height: 54),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${eps.length} ${eps.length == 1 ? 'episode' : 'episodes'}'
'${eps.first.parsed.group == null ? '' : ' · ${eps.first.parsed.group}'}',
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
],
),
),
],
),
),
);
for (final v in eps) {
children.add(_episodeTile(context, v));
}
}
if (loose.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Text(
names.length == loose.length ? 'Videos' : 'Other videos',
style: Theme.of(context).textTheme.titleMedium,
),
),
);
children.add(
LayoutBuilder(
builder: (context, box) {
const gap = 12.0;
final cols = (box.maxWidth / 240).floor().clamp(2, 8);
final w = (box.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final v in loose)
SizedBox(width: w, child: _videoCard(context, v)),
],
);
},
),
);
}
}
return ListView(
padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20),
children: children,
);
}
Widget _episodeTile(
BuildContext context,
_VideoFile v, {
bool showSeries = false,
}) {
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder(
valueListenable: _nowPlaying,
builder: (context, now, _) {
final playing = now?.path == v.path;
return ListTile(
dense: true,
contentPadding: EdgeInsets.only(left: showSeries ? 0 : 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
selected: playing,
leading: SizedBox(
width: 112,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: _Thumb(v.path, width: 80, height: 45),
),
const SizedBox(width: 8),
Icon(
playing ? Icons.graphic_eq : Icons.play_circle_outline,
size: 20,
color: scheme.primary,
),
],
),
),
minLeadingWidth: 112,
title: Text(
showSeries
? '${v.parsed.series} · ${v.parsed.label}'
: v.parsed.label,
),
subtitle: Text(
showSeries && v.parsed.episode == null ? v.folder : v.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
_size(v.size),
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
onTap: () => _play(v),
);
},
);
}
Widget _videoCard(BuildContext context, _VideoFile v) {
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder(
valueListenable: _nowPlaying,
builder: (context, now, _) {
final playing = now?.path == v.path;
return InkWell(
onTap: () => _play(v),
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: playing ? scheme.primary : Colors.transparent,
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
_Thumb(v.path),
Positioned(
right: 6,
bottom: 6,
child: _DurationBadge(v.path),
),
if (playing)
const Center(
child: Icon(
Icons.graphic_eq,
color: Colors.white,
size: 32,
),
),
],
),
),
),
),
const SizedBox(height: 6),
Text(
v.parsed.series,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null,
),
),
Text(
_size(v.size),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
);
},
);
}
Widget _empty(
BuildContext context,
IconData icon,
String title,
String sub, {
Widget? action,
}) => Padding(
padding: const EdgeInsets.only(top: 60),
child: Column(
children: [
Icon(
icon,
size: 44,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 10),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(sub, style: const TextStyle(color: Colors.grey)),
if (action != null) ...[const SizedBox(height: 14), action],
],
),
);
}
String _baseName(String path) {
final parts = path
.split(RegExp(r'[/\\]'))
.where((p) => p.isNotEmpty)
.toList();
return parts.isEmpty ? path : parts.last;
}
// ------------------------------------------------------------ thumbnails
//
// Demo: ffmpeg/ffprobe when installed (a frame at 25%, cached in the temp
// dir). The real app would ask mpv for the frame instead (no extra tool).
class _ThumbInfo {
final File? image;
final Duration? duration;
const _ThumbInfo(this.image, this.duration);
}
final _thumbs = <String, Future<_ThumbInfo>>{};
var _thumbRunning = 0;
final _thumbQueue = <Completer<void>>[];
Future<_ThumbInfo> _thumbFor(String path) =>
_thumbs.putIfAbsent(path, () async {
// At most 3 ffmpeg runs at a time.
if (_thumbRunning >= 3) {
final c = Completer<void>();
_thumbQueue.add(c);
await c.future;
}
_thumbRunning++;
try {
return await _makeThumb(path);
} finally {
_thumbRunning--;
if (_thumbQueue.isNotEmpty) _thumbQueue.removeAt(0).complete();
}
});
Future<_ThumbInfo> _makeThumb(String path) async {
Duration? dur;
try {
final r = await Process.run('ffprobe', [
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'csv=p=0',
path,
]);
final secs = double.tryParse('${r.stdout}'.trim());
if (secs != null) dur = Duration(milliseconds: (secs * 1000).round());
} catch (_) {
return const _ThumbInfo(null, null); // no ffmpeg on this machine
}
final dir = Directory('${Directory.systemTemp.path}/wt-demo-thumbs')
..createSync(recursive: true);
final key = path.hashCode.toUnsigned(32).toRadixString(16);
final out = File('${dir.path}/$key.jpg');
if (out.existsSync() && out.lengthSync() > 0) return _ThumbInfo(out, dur);
final at = dur == null ? 5.0 : dur.inMilliseconds / 1000 * 0.25;
try {
final r = await Process.run('ffmpeg', [
'-v',
'error',
'-y',
'-ss',
at.toStringAsFixed(2),
'-i',
path,
'-frames:v',
'1',
'-vf',
'scale=480:-2',
'-q:v',
'4',
out.path,
]);
if (r.exitCode == 0 && out.existsSync()) return _ThumbInfo(out, dur);
} catch (_) {}
return _ThumbInfo(null, dur);
}
class _Thumb extends StatelessWidget {
final String path;
final double? width, height;
const _Thumb(this.path, {this.width, this.height});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final placeholder = Container(
width: width,
height: height,
color: scheme.surfaceContainerHighest,
child: Icon(
Icons.movie_outlined,
color: scheme.onSurfaceVariant,
size: 20,
),
);
return FutureBuilder<_ThumbInfo>(
future: _thumbFor(path),
builder: (context, snap) {
final img = snap.data?.image;
return AnimatedSwitcher(
duration: _easeTime,
child: img == null
? placeholder
: Image.file(
img,
key: ValueKey(img.path),
width: width,
height: height,
fit: BoxFit.cover,
),
);
},
);
}
}
class _DurationBadge extends StatelessWidget {
final String path;
const _DurationBadge(this.path);
@override
Widget build(BuildContext context) => FutureBuilder<_ThumbInfo>(
future: _thumbFor(path),
builder: (context, snap) {
final d = snap.data?.duration;
if (d == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_fmt(d),
style: const TextStyle(color: Colors.white, fontSize: 11),
),
);
},
);
}
@@ -1,577 +0,0 @@
part of 'shell_demo.dart';
// DEBUG ONLY: a real media_kit player for the layout demo. One Player and
// VideoController, shown by whichever view is active (mini, full or
// fullscreen); only one Video widget is mounted at a time.
class _DemoMedia {
final String title;
final String subtitle;
final String? path;
const _DemoMedia(this.title, this.subtitle, [this.path]);
}
/// What is playing; null = nothing.
final _nowPlaying = ValueNotifier<_DemoMedia?>(null);
Player? _demoPlayer;
VideoController? _demoVideo;
Future<void> _playMedia(_DemoMedia m) async {
_nowPlaying.value = m;
if (m.path == null) return;
final p = _demoPlayer ??= Player();
_demoVideo ??= VideoController(p);
await p.open(Media(m.path!));
}
Future<void> _disposeDemoPlayer() async {
final p = _demoPlayer;
_demoPlayer = null;
_demoVideo = null;
_nowPlaying.value = null;
await p?.dispose();
}
String _fmt(Duration d) {
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
String two(int n) => n.toString().padLeft(2, '0');
return h > 0 ? '$h:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
}
/// The video itself, or a placeholder when nothing real is open.
class _VideoSurface extends StatelessWidget {
final double placeholderSize;
const _VideoSurface({this.placeholderSize = 32});
@override
Widget build(BuildContext context) {
final v = _demoVideo;
if (v == null || _nowPlaying.value?.path == null) {
return Container(
color: Colors.black,
alignment: Alignment.center,
child: Text(
'video · play something from Files',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white24,
fontSize: placeholderSize * 0.6,
),
),
);
}
return Video(controller: v, controls: NoVideoControls, fill: Colors.black);
}
}
/// Position bar + play/pause + time, bound to the real player (inert
/// when nothing is open).
class _Transport extends StatelessWidget {
final bool compact;
final List<Widget> trailing;
const _Transport({this.compact = false, this.trailing = const []});
@override
Widget build(BuildContext context) {
final p = _demoPlayer;
if (p == null) {
return Row(
children: [
const IconButton(
onPressed: null,
icon: Icon(Icons.play_arrow, color: Colors.white38),
),
const Text('--:-- / --:--', style: TextStyle(color: Colors.white38)),
const Spacer(),
...trailing,
],
);
}
return StreamBuilder<Duration>(
stream: p.stream.position,
builder: (context, _) {
final pos = p.state.position, dur = p.state.duration;
final frac = dur.inMilliseconds == 0
? 0.0
: pos.inMilliseconds / dur.inMilliseconds;
final bar = SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: compact ? 3 : 4,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: compact ? 0 : 7,
),
overlayShape: SliderComponentShape.noOverlay,
),
child: Slider(
value: frac.clamp(0.0, 1.0),
onChanged: dur == Duration.zero ? null : (v) => p.seek(dur * v),
),
);
final row = Row(
children: [
StreamBuilder<bool>(
stream: p.stream.playing,
builder: (context, _) => IconButton(
onPressed: p.playOrPause,
icon: Icon(
p.state.playing ? Icons.pause : Icons.play_arrow,
color: Colors.white,
),
),
),
Text(
'${_fmt(pos)} / ${_fmt(dur)}',
style: TextStyle(
color: Colors.white70,
fontSize: compact ? 12 : 14,
),
),
const Spacer(),
...trailing,
],
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [if (!compact) bar, row, if (compact) bar],
);
},
);
}
}
/// Full and fullscreen player. Controls fade out after 2.5 s without the
/// mouse moving; in fullscreen the party sits top-right and toasts stay.
class _RealFullPlayer extends StatefulWidget {
final bool fullscreen;
final VoidCallback onCollapse, onToggleFullscreen;
final Widget? partyOverlay;
const _RealFullPlayer({
required this.fullscreen,
required this.onCollapse,
required this.onToggleFullscreen,
this.partyOverlay,
});
@override
State<_RealFullPlayer> createState() => _RealFullPlayerState();
}
class _RealFullPlayerState extends State<_RealFullPlayer> {
bool _shown = true;
Timer? _hide;
void _poke() {
if (!_shown) setState(() => _shown = true);
_hide?.cancel();
_hide = Timer(const Duration(milliseconds: 2500), () {
if (mounted) setState(() => _shown = false);
});
}
@override
void initState() {
super.initState();
_poke();
}
@override
void dispose() {
_hide?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final m = _nowPlaying.value;
final controls = AnimatedOpacity(
duration: _easeTime,
curve: _ease,
opacity: _shown ? 1 : 0,
child: IgnorePointer(
ignoring: !_shown,
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 28),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black87, Colors.transparent],
),
),
child: Row(
children: [
Container(
width: 34,
height: 48,
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
m?.title ?? 'Nothing playing',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
Text(
m?.subtitle ?? '',
style: const TextStyle(color: Colors.white60),
),
],
),
),
if (widget.partyOverlay != null) ...[
widget.partyOverlay!,
const SizedBox(width: 8),
],
if (!widget.fullscreen)
IconButton(
onPressed: widget.onCollapse,
tooltip: 'Back to the library (keeps playing)',
icon: const Icon(
Icons.close_fullscreen,
color: Colors.white,
),
),
],
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 32, 16, 10),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black87, Colors.transparent],
),
),
child: _Transport(
trailing: [
Builder(
builder: (context) => IconButton(
tooltip: 'Player settings',
onPressed: () => _popover(
context,
at: const Alignment(0.97, 0.78),
child: const _PlayerSettingsCard(),
),
icon: const Icon(Icons.tune, color: Colors.white),
),
),
IconButton(
tooltip: widget.fullscreen
? 'Exit fullscreen (Esc)'
: 'Fullscreen (F)',
onPressed: widget.onToggleFullscreen,
icon: Icon(
widget.fullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
color: Colors.white,
),
),
],
),
),
),
],
),
),
);
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyF):
widget.onToggleFullscreen,
const SingleActivator(LogicalKeyboardKey.escape): () {
if (widget.fullscreen) widget.onToggleFullscreen();
},
const SingleActivator(LogicalKeyboardKey.space): () =>
_demoPlayer?.playOrPause(),
},
child: Focus(
autofocus: true,
child: MouseRegion(
onHover: (_) => _poke(),
cursor: _shown ? MouseCursor.defer : SystemMouseCursors.none,
child: GestureDetector(
onDoubleTap: widget.onToggleFullscreen,
onTap: () => _demoPlayer?.playOrPause(),
child: Container(
color: Colors.black,
child: Stack(
children: [
const Positioned.fill(child: _VideoSurface()),
Positioned.fill(child: controls),
// A thin progress line stays when the controls hide.
if (!_shown && _demoPlayer != null)
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: _ThinProgress(),
),
// Paused: who everyone is waiting for, always visible.
Positioned(
left: 0,
right: 0,
bottom: _shown ? 96 : 28,
child: const Center(child: _WaitingPill()),
),
// Fullscreen with the controls hidden: the party stays
// in the corner while paused.
if (widget.fullscreen &&
!_shown &&
widget.partyOverlay != null)
Positioned(
right: 16,
top: 14,
child: _PausedOnly(child: widget.partyOverlay!),
),
],
),
),
),
),
),
);
}
}
class _ThinProgress extends StatelessWidget {
const _ThinProgress();
@override
Widget build(BuildContext context) {
final p = _demoPlayer!;
return StreamBuilder<Duration>(
stream: p.stream.position,
builder: (context, _) {
final d = p.state.duration.inMilliseconds;
return LinearProgressIndicator(
value: d == 0 ? 0 : p.state.position.inMilliseconds / d,
minHeight: 2,
backgroundColor: Colors.transparent,
);
},
);
}
}
/// The gear in the player: everything about the player lives here.
class _PlayerSettingsCard extends StatefulWidget {
const _PlayerSettingsCard();
@override
State<_PlayerSettingsCard> createState() => _PlayerSettingsCardState();
}
class _PlayerSettingsCardState extends State<_PlayerSettingsCard> {
String _design = 'Standard';
bool _line = true;
String _shader = 'Off';
String _hwdec = 'Auto';
double _subSize = 1;
Widget _row(String label, Widget control, {String? help}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
if (help != null)
Text(
help,
style: const TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
),
control,
],
),
);
Widget _drop(String value, List<String> items, ValueChanged<String> on) =>
DropdownButton<String>(
value: value,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(10),
items: [
for (final i in items) DropdownMenuItem(value: i, child: Text(i)),
],
onChanged: (v) => setState(() => on(v!)),
);
@override
Widget build(BuildContext context) {
return Material(
elevation: 12,
borderRadius: BorderRadius.circular(16),
child: SizedBox(
width: 380,
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Player',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 6),
_row(
'Player design',
_drop(_design, const ['Standard', 'uosc'], (v) => _design = v),
),
_row(
'Progress line when hidden',
Switch(
value: _line,
onChanged: (v) => setState(() => _line = v),
),
help: 'A thin bar at the bottom while the controls are hidden',
),
_row(
'Upscaling shader',
_drop(_shader, const [
'Off',
'Anime4K A (HQ)',
'Anime4K B (HQ)',
'Anime4K A (Fast)',
], (v) => _shader = v),
help: 'Runs on your GPU; only you see it',
),
_row(
'Hardware decoding',
_drop(_hwdec, const ['Auto', 'On', 'Off'], (v) => _hwdec = v),
),
const Divider(),
_row(
'Subtitles',
_drop('English · ASS', const [
'Off',
'English · ASS',
'Signs only',
], (_) {}),
),
_row(
'Subtitle size',
SizedBox(
width: 140,
child: Slider(
value: _subSize,
min: 0.6,
max: 1.6,
onChanged: (v) => setState(() => _subSize = v),
),
),
),
_row(
'Audio',
_drop('Japanese', const ['Japanese', 'English'], (_) {}),
),
const SizedBox(height: 4),
const Text(
'Track choices carry to the next episode.',
style: TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
),
),
);
}
}
/// Shows [child] only while the demo player is paused (or has nothing).
class _PausedOnly extends StatelessWidget {
final Widget child;
const _PausedOnly({required this.child});
@override
Widget build(BuildContext context) {
final p = _demoPlayer;
return StreamBuilder<bool>(
stream: p?.stream.playing,
builder: (context, _) {
final paused = p == null || !p.state.playing;
return AnimatedOpacity(
duration: _easeTime,
curve: _ease,
opacity: paused ? 1 : 0,
child: IgnorePointer(ignoring: !paused, child: child),
);
},
);
}
}
/// "Waiting for Alex, Mika" or "Waiting for you · press Space", with
/// their avatars; only while paused.
class _WaitingPill extends StatelessWidget {
const _WaitingPill();
@override
Widget build(BuildContext context) {
final notReady = _members.where((m) => !m.ready && !m.stale).toList();
final meWaiting = !_me.value.ready;
final text = meWaiting
? 'Waiting for you · press Space when ready'
: notReady.isEmpty
? 'Paused'
: 'Waiting for ${notReady.map((m) => m.name).join(', ')}';
return _PausedOnly(
child: Container(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final m in (meWaiting ? [_me.value] : notReady).take(4))
Padding(
padding: const EdgeInsets.only(right: 4),
child: _Avatar(member: m, size: 28),
),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,355 +0,0 @@
part of 'shell_demo.dart';
// DEBUG ONLY: the Settings tab. App-wide things only; everything about
// the player lives behind the gear in the player (pointer card at the top
// of every page). Controls are placeholders that hold their own state.
class _SettingsTab extends StatefulWidget {
final VoidCallback onOpenPlayerSettings;
const _SettingsTab({required this.onOpenPlayerSettings});
@override
State<_SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends State<_SettingsTab> {
static const _sections = [
(Icons.person_outline, 'Profile'),
(Icons.hub_outlined, 'Party & network'),
(Icons.extension_outlined, 'Jellyfin & integrations'),
(Icons.notifications_none, 'Notifications & sounds'),
(Icons.palette_outlined, 'Appearance'),
(Icons.system_update_alt, 'Updates'),
(Icons.info_outline, 'About'),
];
int _at = 0;
final _bools = <String, bool>{
'toast-chat': true,
'toast-ready': true,
'toast-seek': false,
'unfocused': true,
'sounds': true,
'reduce-motion': false,
'auto-update': true,
'auto-next': true,
'remember-folders': true,
};
Widget _switch(String key, String title, [String? help]) => SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(title),
subtitle: help == null ? null : Text(help),
value: _bools[key]!,
onChanged: (v) => setState(() => _bools[key] = v),
);
// Material, not a coloured box: list tiles paint their ripple on it.
Widget _card(String title, List<Widget> children) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
...children,
],
),
),
),
);
List<Widget> _page() {
final scheme = Theme.of(context).colorScheme;
switch (_at) {
case 0:
return [
_card('You', [
ListTile(
contentPadding: EdgeInsets.zero,
leading: _Avatar(member: _me.value, size: 48, badges: false),
title: Text(_me.value.name),
subtitle: const Text(
'Name, picture and colour: click your avatar in the top left',
),
),
]),
];
case 1:
return [
_card('Hosting', [
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Default port'),
trailing: Text('8080'),
),
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Password'),
subtitle: Text(
'Asked when you host; blank = anyone who reaches the port can join',
),
),
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Relay buffer'),
subtitle: Text('Memory the server keeps for relayed videos'),
trailing: Text('256 MB ahead · 64 MB back'),
),
]),
_card('Recent servers', [
for (final s in [
'VPS · vps.example:8080',
'Bob · 192.168.1.20:8080',
])
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.dns_outlined),
title: Text(s),
trailing: IconButton(
onPressed: () {},
icon: const Icon(Icons.close, size: 18),
),
),
]),
];
case 2:
return [
_card('Jellyfin', [
for (final (name, user) in [
('Senko Jellyfin', 'foxyz'),
('Bob\'s box', 'guest'),
])
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.video_library_outlined),
title: Text(name),
subtitle: Text('signed in as $user'),
trailing: TextButton(
onPressed: () {},
child: const Text('Sign out'),
),
),
TextButton.icon(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text('Add a Jellyfin server'),
),
]),
_card('AnimeFlow', [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.downloading_outlined),
title: const Text('anime.senkochan.moe'),
subtitle: const Text(
'Uses your Senko Jellyfin sign-in · can view, request, approve',
),
trailing: _Pill('connected', Colors.greenAccent.shade400),
),
Text(
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
]),
_card('Local files', [
_switch(
'remember-folders',
'Remember folders',
'Folders added in Files stay between launches',
),
]),
];
case 3:
return [
_card('Pop-ups', [
_switch('toast-chat', 'Chat messages'),
_switch('toast-ready', 'Ready / joined / left'),
_switch(
'toast-seek',
'Seeks and skips',
'Always in the activity log',
),
_switch(
'unfocused',
'Desktop notification when the app is in the background',
),
]),
_card('Sounds', [
_switch(
'sounds',
'Play sounds',
'Soft cues for ready, join and chat; seeks stay silent',
),
]),
];
case 4:
return [
_card('Look', [
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Theme'),
trailing: Text('Dark'),
),
_switch(
'reduce-motion',
'Reduce motion',
'Fades instead of slides, no spreading avatars',
),
]),
_card('Playback', [
_switch(
'auto-next',
'Play the next episode automatically',
'10 s countdown at the end',
),
]),
];
case 5:
return [
_card('Updates', [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Watch together 0.2.2'),
subtitle: const Text('0.3.0 is available'),
trailing: FilledButton(
onPressed: () {},
child: const Text('Update and restart'),
),
),
_switch('auto-update', 'Check for updates on launch'),
]),
];
default:
return [
_card('About', [
const Text(
'Watch together for friends: synced playback, Jellyfin, relay.',
),
const SizedBox(height: 8),
Text(
'mpv · media_kit · Flutter',
style: TextStyle(color: scheme.onSurfaceVariant),
),
]),
];
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final pointer = Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Icons.tune, color: scheme.primary),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Player design, shaders, subtitles and audio live in the player: the ⚙ in its controls.',
),
),
TextButton(
onPressed: widget.onOpenPlayerSettings,
child: const Text('Open player settings'),
),
],
),
);
return LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < 760;
final nav = narrow
? SizedBox(
height: 52,
child: _HScroll(
builder: (c) => ListView(
controller: c,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
children: [
for (var i = 0; i < _sections.length; i++)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(_sections[i].$2),
selected: _at == i,
onSelected: (_) => setState(() => _at = i),
),
),
],
),
),
)
: SizedBox(
width: 250,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
children: [
for (var i = 0; i < _sections.length; i++)
ListTile(
selected: _at == i,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: Icon(_sections[i].$1, size: 20),
title: Text(_sections[i].$2),
onTap: () => setState(() => _at = i),
),
],
),
);
final page = _fadeSwitch(
KeyedSubtree(
key: ValueKey(_at),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
children: [
Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_sections[_at].$2,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 14),
pointer,
..._page(),
],
),
),
),
],
),
),
);
return narrow
? Column(
children: [
nav,
Expanded(child: page),
],
)
: Row(
children: [
nav,
Expanded(child: page),
],
);
},
);
}
}
File diff suppressed because it is too large. Load diff
@@ -7,11 +7,12 @@ import '../../update/update_controller.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.dart'; import 'update_indicator.dart';
/// Player keys, as handled by the session player's key handler (while /// Session keys: Space and Tab anywhere in a server (`AppKeys`), the
/// the player fills the window). /// rest by the session player's key handler (while it fills the window).
const sessionShortcuts = [ const sessionShortcuts = [
('Space', 'Toggle ready'), ('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'), ('← / →', 'Seek back / forward 10 s'),
('↑ / ↓', 'Volume up / down (up to 150 %)'),
('Shift+→', 'Skip 1:30 (for everyone)'), ('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'), ('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'), ('F', 'Toggle fullscreen'),
@@ -102,6 +102,17 @@ class ToastItem {
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'}; 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. /// `media.relay` of a state, or null when the media is not relayed.
MediaRelay? stateMediaRelay(StateBroadcast state) { MediaRelay? stateMediaRelay(StateBroadcast state) {
final r = state.media?['relay']; final r = state.media?['relay'];
@@ -184,7 +195,7 @@ class SessionCoordinator {
final autoAdvance = signal<AutoAdvance?>(null); final autoAdvance = signal<AutoAdvance?>(null);
/// The skip pill for the current position ([skipOfferAt]: Skip OP, /// 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]. /// is open or at `finished`. Press it with [skip].
late final Computed<SkipOffer?> skipOffer = computed(() { late final Computed<SkipOffer?> skipOffer = computed(() {
final r = serverState.value; final r = serverState.value;
@@ -195,6 +206,7 @@ class SessionCoordinator {
duration: duration, duration: duration,
opening: playback.opening.value, opening: playback.opening.value,
ending: playback.ending.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 // Set after a reconnect until the server echoes the restored ready, so
// the fresh member's ready=false doesn't overwrite [_lastReady]. // the fresh member's ready=false doesn't overwrite [_lastReady].
bool _restoringReady = false; 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. // Bumped by every connect/leave/cancel/retry: stale attempts no-op.
int _epoch = 0; int _epoch = 0;
Timer? _reconnectTimer; Timer? _reconnectTimer;
@@ -260,6 +275,8 @@ class SessionCoordinator {
int _toastId = 0; int _toastId = 0;
ConnectParams? _params; ConnectParams? _params;
bool _everConnected = false; bool _everConnected = false;
/// The connection-lost sound played; the reconnected one answers it once.
bool _lostSounded = false;
Map<String, String>? _openHeaders; Map<String, String>? _openHeaders;
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation // Jellyfin: the resolved ref, the mediaId it belongs to, a generation
@@ -354,8 +371,10 @@ class SessionCoordinator {
_openUrl = null; _openUrl = null;
_lastReady = false; _lastReady = false;
_restoringReady = false; _restoringReady = false;
_seenMediaId = -1;
_wasPlayAllowed = false; _wasPlayAllowed = false;
_everConnected = false; _everConnected = false;
_lostSounded = false;
_resumeToken = ''; _resumeToken = '';
_seekGate.reset(); _seekGate.reset();
_clearPendingSeek(); _clearPendingSeek();
@@ -423,6 +442,7 @@ class SessionCoordinator {
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) { if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
health.value = const HealthStatus( health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…'); HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
_scheduleReconnect(); _scheduleReconnect();
} }
}); });
@@ -664,12 +684,27 @@ class SessionCoordinator {
void _trackSelfReady(StateBroadcast r) { void _trackSelfReady(StateBroadcast r) {
final self = selfMember.peek(); final self = selfMember.peek();
if (self == null) return; 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 (_restoringReady) {
if (self.ready) _restoringReady = false; if (self.ready) _restoringReady = false;
} else { } else {
_lastReady = self.ready; _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) { void _updateStarving(StateBroadcast r, double bufferedAhead) {
@@ -682,7 +717,7 @@ class SessionCoordinator {
return; return;
} }
for (final m in r.members) { for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) { if (m.inSet && m.buffered < r.bufferNeed) {
starving.value = starving.value =
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered'; 'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
return; return;
@@ -727,7 +762,9 @@ class SessionCoordinator {
]; ];
} }
final kind = sfxForFeedAction(f.action); final kind = sfxForFeedAction(f.action);
if (kind != null) { // Your own message needs no sound.
final own = f.action == 'chat' && f.actor == selfMember.peek()?.name;
if (kind != null && !own) {
var readyIndex = 0; var readyIndex = 0;
if (f.action == 'ready') { if (f.action == 'ready') {
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0; readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
@@ -813,6 +850,7 @@ class SessionCoordinator {
if (!reconnecting.peek()) { if (!reconnecting.peek()) {
health.value = const HealthStatus( health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…'); HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
_scheduleReconnect(); _scheduleReconnect();
} }
} }
@@ -1490,7 +1528,8 @@ class SessionCoordinator {
reconnecting.value = false; reconnecting.value = false;
return; return;
} }
final wantReady = _lastReady; var wantReady = _lastReady;
final seenBefore = _seenMediaId;
try { try {
health.value = const HealthStatus( health.value = const HealthStatus(
HealthSeverity.info, 'reconnecting…'); HealthSeverity.info, 'reconnecting…');
@@ -1507,6 +1546,10 @@ class SessionCoordinator {
if (stale()) return; if (stale()) return;
reconnecting.value = false; reconnecting.value = false;
_everConnected = true; _everConnected = true;
if (_lostSounded) {
_lostSounded = false;
unawaited(sfx.play(const SfxCue(SfxKind.back)));
}
_resumeToken = sync.token; _resumeToken = sync.token;
_lastStateAt = DateTime.now().toUtc(); _lastStateAt = DateTime.now().toUtc();
health.value = const HealthStatus(HealthSeverity.ok, 'connected'); health.value = const HealthStatus(HealthSeverity.ok, 'connected');
@@ -1517,6 +1560,15 @@ class SessionCoordinator {
if (stale()) return; if (stale()) return;
// The server forgot the dropped member; restore the ready flag it // The server forgot the dropped member; restore the ready flag it
// last reported (not a local guess: others may have forced 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) { if (wantReady) {
_lastReady = true; _lastReady = true;
_restoringReady = true; _restoringReady = true;
@@ -1531,6 +1583,12 @@ class SessionCoordinator {
} }
} }
void _soundLost() {
if (!_everConnected || _lostSounded) return;
_lostSounded = true;
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
}
void _invalidateAttempts() { void _invalidateAttempts() {
_epoch++; _epoch++;
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
+21 -44
View File
@@ -1,20 +1,22 @@
/// The app's sounds. Only events that carry news get one: no sound for
/// what the video itself makes obvious (start, pause, end, seeks).
enum SfxKind { enum SfxKind {
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
ready, ready,
unready, unready,
/// Someone marks everyone ready.
forceReady, forceReady,
forceUnready,
forcePlay,
forceHold,
kick,
join, join,
leave, leave,
stale, kick,
/// A new video or episode is put up.
swap, swap,
seek,
pause,
resume,
chat, chat,
/// Idle warning: auto-unready soon.
warn, warn,
/// The connection to the server dropped / came back.
lost,
back,
} }
class SfxCue { class SfxCue {
@@ -23,42 +25,17 @@ class SfxCue {
const SfxCue(this.kind, {this.readyIndex = 0}); const SfxCue(this.kind, {this.readyIndex = 0});
} }
SfxKind? sfxForFeedAction(String action) { SfxKind? sfxForFeedAction(String action) => switch (action) {
switch (action) { 'ready' => SfxKind.ready,
case 'ready': 'unready' => SfxKind.unready,
return SfxKind.ready; 'force-ready' => SfxKind.forceReady,
case 'unready': 'kick' => SfxKind.kick,
return SfxKind.unready; 'join' => SfxKind.join,
case 'force-ready': 'leave' => SfxKind.leave,
return SfxKind.forceReady; 'swap' => SfxKind.swap,
case 'force-unready': 'chat' => SfxKind.chat,
return SfxKind.forceUnready; _ => null,
case 'force-play': };
return SfxKind.forcePlay;
case 'force-hold':
return SfxKind.forceHold;
case 'kick':
return SfxKind.kick;
case 'join':
return SfxKind.join;
case 'leave':
return SfxKind.leave;
case 'stale':
return SfxKind.stale;
case 'swap':
return SfxKind.swap;
case 'seek':
return SfxKind.seek;
case 'pause':
return SfxKind.pause;
case 'resume':
return SfxKind.resume;
case 'chat':
return SfxKind.chat;
default:
return null;
}
}
abstract class SfxService { abstract class SfxService {
Future<void> play(SfxCue cue); Future<void> play(SfxCue cue);
+96
View File
@@ -0,0 +1,96 @@
import 'sfx.dart';
import 'synth.dart';
// The sound of the house: koto for readiness, the wind chime for people
// arriving and leaving, the shoji door for a new video, bamboo for chat,
// clappers for a kick, the singing bowl for the idle warning, a string
// going slack and re-tuned for the connection. All in the Yo scale on D
// (D E G A B), so overlapping sounds agree; quiet and short.
const _d4 = -7, _a4 = 0, _b4 = 2;
const _d5 = 5, _g5 = 10, _a5 = 12, _b5 = 14;
const _d6 = 17, _e6 = 19, _g6 = 22, _a6 = 24, _b6 = 26;
double _p(int s) => semi(s);
/// The ready pluck's answer climbs with each person ready.
const _readyLadder = [_g5, _a5, _b5, _d6, _e6, _g6];
SoundSpec readySound(int readyIndex) {
final top = _readyLadder[(readyIndex - 1).clamp(0, _readyLadder.length - 1)];
return SoundSpec([
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55),
Note(0.085, _p(top), 1.1, bright: 0.65),
], peakDb: -17);
}
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) {
SfxKind.ready => readySound(readyIndex),
// The same strings, muted and stepping down.
SfxKind.unready => SoundSpec([
Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2),
Note(0.09, _p(_g5), 0.45, bright: 0.15),
], cutoff: 3800, peakDb: -20, room: 0.1),
// Just the bell: playback starting is the rest of the cue.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime),
], cutoff: 7000, peakDb: -18, room: 0.18),
// Wind through the furin: three uneven tinkles.
SfxKind.join => SoundSpec([
Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8),
Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55),
Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45),
], cutoff: 7000, peakDb: -19, room: 0.18),
// The wind settling: two softer, lower tinkles.
SfxKind.leave => SoundSpec([
Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6),
Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5),
], cutoff: 5000, peakDb: -22, room: 0.18),
// Hyoshigi: clack… clack, then a low muted string.
SfxKind.kick => SoundSpec([
Note(0, 1900, 0.09, voice: Voice.clack),
Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9),
Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7),
], cutoff: 5000, peakDb: -19, room: 0.12),
// The door slides open on something new: air, then a fifth.
SfxKind.swap => SoundSpec([
Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5),
Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8),
Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7),
], peakDb: -18),
// Bamboo: a hollow tok-tok, the second higher.
SfxKind.chat => SoundSpec([
Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85),
Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo),
], cutoff: 5500, peakDb: -19, room: 0.1),
// The singing bowl: one soft strike that hums.
SfxKind.warn => SoundSpec([
Note(0, _p(_d5), 2.6, voice: Voice.rin),
], cutoff: 4500, peakDb: -20, room: 0.1),
// A string going slack: plucked, its pitch sagging away.
SfxKind.lost => SoundSpec([
Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4),
Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6),
], cutoff: 3500, peakDb: -19),
// Re-tuned: a quick bend up that lands, and a tinkle.
SfxKind.back => SoundSpec([
Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5),
Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7),
Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2),
], peakDb: -18),
};
/// Shown in Settings → Notifications & sounds: name and when it plays.
const soundInfo = {
SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'),
SfxKind.unready: ('Not ready', 'Someone takes their ready back'),
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
SfxKind.join: ('Joined', 'Someone joins the server'),
SfxKind.leave: ('Left', 'Someone leaves'),
SfxKind.kick: ('Removed', 'Someone is kicked'),
SfxKind.swap: ('New video', 'A different video or episode is put up'),
SfxKind.chat: ('Message', 'A chat message from someone else'),
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
};
+322
View File
@@ -0,0 +1,322 @@
import 'dart:math' as math;
import 'dart:typed_data';
// A tiny offline synthesizer for UI sounds, voiced as a Japanese house:
// koto strings (Karplus-Strong), a glass wind chime (furin), a singing
// bowl (rin), bamboo knocks, wooden clappers (hyoshigi) and the air of a
// sliding door (shoji). Renders to 16-bit mono WAV bytes. Pure Dart.
const sampleRate = 44100;
enum Voice {
/// Plucked string; [Note.to] bends it (the delay line stretches).
koto,
/// Small glass bell: inharmonic partials, a slow shimmer.
chime,
/// Singing bowl: a soft strike and a long beating hum.
rin,
/// Hollow bamboo knock, pitched.
bamboo,
/// Two hardwood blocks struck together.
clack,
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
/// sweep of its band.
slide,
}
class Note {
final double at;
final double hz;
final double? to;
/// Length (s) to about -40 dB.
final double len;
final double gain;
final Voice voice;
/// Koto only: 0 = dark/muted pluck, 1 = bright.
final double bright;
const Note(this.at, this.hz, this.len,
{this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6});
}
class SoundSpec {
final List<Note> notes;
/// Gentle low-pass cutoff (Hz) over the mix.
final double cutoff;
/// Peak level after normalising, dBFS (quiet by design).
final double peakDb;
/// Room tail mix (0..1): the veranda, not a hall.
final double room;
const SoundSpec(this.notes, {this.cutoff = 6000, this.peakDb = -17, this.room = 0.14});
double get length =>
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02);
}
/// Semitones from A4 (440 Hz) to Hz.
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
Float64List render(SoundSpec spec, {int seed = 7}) {
final total = (spec.length * sampleRate).ceil();
final out = Float64List(total);
final rnd = math.Random(seed);
for (final n in spec.notes) {
final start = (n.at * sampleRate).round();
if (start >= total) continue;
final s = switch (n.voice) {
Voice.koto => _koto(n, rnd),
Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003),
Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012),
Voice.bamboo => _bamboo(n, rnd),
Voice.clack => _clack(n, rnd),
Voice.slide => _slide(n, rnd),
};
for (var i = 0; i < s.length && start + i < total; i++) {
out[start + i] += n.gain * s[i];
}
}
_lowpass(out, spec.cutoff);
if (spec.room > 0) _room(out, spec.room);
// 1 ms in, 10 ms out: no clicks at either end.
final fadeIn = (0.001 * sampleRate).round();
for (var i = 0; i < fadeIn && i < total; i++) {
out[i] *= i / fadeIn;
}
final fade = (0.01 * sampleRate).round();
for (var i = 0; i < fade && i < total; i++) {
out[total - 1 - i] *= i / fade;
}
var peak = 0.0;
for (final v in out) {
peak = math.max(peak, v.abs());
}
if (peak > 0) {
final g = math.pow(10, spec.peakDb / 20) / peak;
for (var i = 0; i < total; i++) {
out[i] *= g;
}
}
return out;
}
int _n(double secs) => (secs * sampleRate).round();
double _smooth(double t) => t * t * (3 - 2 * t);
/// Karplus-Strong with a fractional, movable delay: a noise burst in a
/// loop that loses a little of its highs each pass.
Float64List _koto(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4;
final buf = Float64List(maxPeriod.ceil() + 4);
final size = buf.length;
// Excitation: noise, smoothed more for a darker pluck.
final period0 = sampleRate / n.hz;
var prev = 0.0;
final smooth = 1 - (0.25 + 0.7 * n.bright);
for (var i = 0; i < period0.ceil(); i++) {
final x = rnd.nextDouble() * 2 - 1;
prev = smooth * prev + (1 - smooth) * x;
buf[i % size] = prev;
}
// Loop gain so the note reaches -40 dB at len.
final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble();
var w = period0.ceil();
var last = 0.0;
for (var i = 0; i < len; i++) {
final t = i / len;
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6)));
// The two-point average below delays half a sample: take it off.
final period = sampleRate / hz - 0.5;
final r = w - period;
final ri = r.floor();
final f = r - ri;
final a = buf[(ri % size + size) % size];
final b = buf[((ri + 1) % size + size) % size];
final y = a + (b - a) * f;
// Two-point average = the string's damping; the 0.5 mix keeps it bright.
final v = loss * (0.5 * y + 0.5 * last);
last = y;
buf[w % size] = v;
w++;
// A 3 ms attack takes the click off the pluck.
out[i] = v * (i < 132 ? i / 132 : 1);
}
return out;
}
/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled
/// with a slightly detuned twin so the tail shimmers.
Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(attackSecs);
for (final (ratio, amp, decay) in partials) {
final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio;
for (var i = 0; i < len; i++) {
final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1);
final ph = 2 * math.pi * i / sampleRate;
out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2));
}
}
return out;
}
/// A hollow tube: a pitched thump that drops into its note, an overtone
/// that dies fast, and a short click of air.
Float64List _bamboo(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
var ph = 0.0, ph2 = 0.0;
for (var i = 0; i < len; i++) {
final drop = math.exp(-i / _n(0.012));
final hz = n.hz * (1 + 0.5 * drop);
ph += 2 * math.pi * hz / sampleRate;
ph2 += 2 * math.pi * hz * 2.31 / sampleRate;
out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i));
}
final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd);
for (var i = 0; i < click.length && i < len; i++) {
out[i] += 0.5 * click[i] * (1 - i / click.length);
}
return out;
}
/// Hardwood: two resonances excited by a very short noise burst.
Float64List _clack(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final burst = Float64List(len);
for (var i = 0; i < _n(0.002) && i < len; i++) {
burst[i] = rnd.nextDouble() * 2 - 1;
}
for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) {
final r = _biquadBand(burst, f, q);
for (var i = 0; i < len; i++) {
out[i] += g * r[i];
}
}
final k = math.log(100) / len;
for (var i = 0; i < len; i++) {
out[i] *= math.exp(-k * i);
}
return out;
}
/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in
/// and out (a paper door sliding along its track).
Float64List _slide(Note n, math.Random rnd) {
final len = _n(n.len);
final noise = Float64List(len);
for (var i = 0; i < len; i++) {
noise[i] = rnd.nextDouble() * 2 - 1;
}
final out = Float64List(len);
// Sweep in short blocks (a fixed band per block is plenty).
const block = 256;
var z1 = 0.0, z2 = 0.0;
for (var b0 = 0; b0 < len; b0 += block) {
final t = b0 / len;
final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t);
final c = _bandCoeffs(f.toDouble(), 2.5);
for (var i = b0; i < math.min(len, b0 + block); i++) {
final y = c.$1 * noise[i] + z1;
z1 = c.$2 * noise[i] - c.$4 * y + z2;
z2 = c.$3 * noise[i] - c.$5 * y;
final tt = i / len;
// Rises, holds, eases off: sin^2 swell with a tail.
final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble();
// The track's grain: a slow flutter.
final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate);
out[i] = y * env * grain;
}
}
return out;
}
Float64List _bandNoise(int len, double f, double q, math.Random rnd) {
final x = Float64List(len);
for (var i = 0; i < len; i++) {
x[i] = rnd.nextDouble() * 2 - 1;
}
return _biquadBand(x, f, q);
}
/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0.
(double, double, double, double, double) _bandCoeffs(double f, double q) {
final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate;
final alpha = math.sin(w) / (2 * q);
final a0 = 1 + alpha;
return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0);
}
Float64List _biquadBand(Float64List x, double f, double q) {
final c = _bandCoeffs(f, q);
final y = Float64List(x.length);
var z1 = 0.0, z2 = 0.0;
for (var i = 0; i < x.length; i++) {
final v = c.$1 * x[i] + z1;
z1 = c.$2 * x[i] - c.$4 * v + z2;
z2 = c.$3 * x[i] - c.$5 * v;
y[i] = v;
}
return y;
}
/// One pole, twice: a soft 12 dB/oct roll-off.
void _lowpass(Float64List out, double cutoff) {
final a = math.exp(-2 * math.pi * cutoff / sampleRate);
for (var pass = 0; pass < 2; pass++) {
var y = 0.0;
for (var i = 0; i < out.length; i++) {
y = (1 - a) * out[i] + a * y;
out[i] = y;
}
}
}
/// A few feedback delays: wood and paper walls, a short tail.
void _room(Float64List out, double mix) {
const taps = [0.029, 0.041, 0.059];
final dry = Float64List.fromList(out);
for (final d in taps) {
final delay = _n(d);
final buf = Float64List(out.length);
for (var i = delay; i < out.length; i++) {
buf[i] = dry[i - delay] + 0.42 * buf[i - delay];
}
for (var i = 0; i < out.length; i++) {
out[i] += mix / taps.length * buf[i];
}
}
}
/// 16-bit PCM mono WAV.
Uint8List wav(Float64List samples) {
final data = samples.length * 2;
final b = ByteData(44 + data);
void str(int o, String s) {
for (var i = 0; i < s.length; i++) {
b.setUint8(o + i, s.codeUnitAt(i));
}
}
str(0, 'RIFF');
b.setUint32(4, 36 + data, Endian.little);
str(8, 'WAVE');
str(12, 'fmt ');
b.setUint32(16, 16, Endian.little);
b.setUint16(20, 1, Endian.little);
b.setUint16(22, 1, Endian.little);
b.setUint32(24, sampleRate, Endian.little);
b.setUint32(28, sampleRate * 2, Endian.little);
b.setUint16(32, 2, Endian.little);
b.setUint16(34, 16, Endian.little);
str(36, 'data');
b.setUint32(40, data, Endian.little);
for (var i = 0; i < samples.length; i++) {
b.setInt16(44 + i * 2, (samples[i].clamp(-1.0, 1.0) * 32767).round(), Endian.little);
}
return b.buffer.asUint8List();
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import 'sfx.dart';
import 'sound_bank.dart';
import 'synth.dart';
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
/// played on a small pool of audio-only mpv players (so two sounds can
/// overlap). [volume] (0..1) is read per play and applied on a square
/// curve (ears are log); [enabled] filters cues.
class SynthSfx implements SfxService {
final double Function() volume;
final bool Function(SfxKind kind) enabled;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled}) : enabled = enabled ?? ((_) => true);
static const _poolSize = 3;
final List<Player> _pool = [];
var _next = 0;
final Map<String, Future<String>> _files = {};
Directory? _dir;
@override
Future<void> play(SfxCue cue) async {
if (!enabled(cue.kind)) return;
await playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
}
/// Plays [kind] even when it is switched off (Settings' preview).
Future<void> preview(SfxKind kind, {int readyIndex = 1}) =>
playSpec('${kind.name}-$readyIndex', soundFor(kind, readyIndex: readyIndex));
/// Plays [spec], cached under [key] (same key = same sound).
Future<void> playSpec(String key, SoundSpec spec) async {
final v = volume();
if (v <= 0) return;
try {
final path = await (_files[key] ??= _write(key, spec));
final player = await _player();
await player.setVolume(100 * v * v);
await player.open(Media(path));
} catch (e) {
_files.remove(key);
debugPrint('sfx $key: $e');
}
}
Future<String> _write(String key, SoundSpec spec) async {
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
final f = File('${_dir!.path}/$key.wav');
await f.writeAsBytes(wav(render(spec)));
return f.path;
}
Future<Player> _player() async {
if (_pool.length < _poolSize) {
final p = Player(
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
);
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
_pool.add(p);
return p;
}
return _pool[_next++ % _poolSize];
}
Future<void> dispose() async {
for (final p in _pool) {
await p.dispose();
}
_pool.clear();
}
}
+178 -2
View File
@@ -1,10 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show File;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.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/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
@@ -18,6 +22,7 @@ import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import 'chat_panel.dart'; import 'chat_panel.dart';
import 'connect_cards.dart'; import 'connect_cards.dart';
import 'handoff.dart';
import 'library_tab.dart'; import 'library_tab.dart';
import 'motion.dart'; import 'motion.dart';
import 'party_strip.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 /// 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 /// fades the layer out, moves it while invisible and fades it back in, so
/// the video is never mounted twice nor remounted. /// 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 { class AppShell extends StatefulWidget {
final UpdateController? updates; final UpdateController? updates;
final bool autoUpdateCheck; final bool autoUpdateCheck;
@@ -88,6 +96,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Offset? _drag; // mini top-left while dragging Offset? _drag; // mini top-left while dragging
bool _snapping = false; 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(); final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm); late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm); late final _host = HostFields(SessionState.instance.lastForm);
@@ -128,6 +141,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_jellyfin.ensureLoaded(); _jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck(); if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKicked; _flow.onKicked = _showKicked;
HardwareKeyboard.instance.addHandler(_onKey);
final session = SessionState.instance; final session = SessionState.instance;
final coord = session.coordinator; final coord = session.coordinator;
_feedSeen = coord.feedLog.peek().length; _feedSeen = coord.feedLog.peek().length;
@@ -195,6 +209,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final stop in _stops) { for (final stop in _stops) {
stop(); stop();
} }
HardwareKeyboard.instance.removeHandler(_onKey);
_fade.dispose(); _fade.dispose();
_online.dispose(); _online.dispose();
_hasMedia.dispose(); _hasMedia.dispose();
@@ -368,6 +383,122 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
return null; 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<EditableTextState>() != 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<void> _paste() async {
var files = const <String>[];
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<void> _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<HandoffAction>(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<void> _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 // --------------------------------------------------------------- dialogs
Future<void> _confirmLeave() async { Future<void> _confirmLeave() async {
@@ -426,7 +557,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return CallbackShortcuts( final shell = CallbackShortcuts(
bindings: { bindings: {
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings), const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
}, },
@@ -474,6 +605,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}), }),
), ),
); );
return Listener(
onPointerHover: (e) => _pointer = e.localPosition,
onPointerDown: (e) => _pointer = e.localPosition,
child: _dropTarget(shell),
);
} }
Widget _body(bool online) { Widget _body(bool online) {
@@ -533,7 +669,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
), ),
), ),
if (online && _shown != PlayerMode.none) _playerLayer(area), if (online && _shown != PlayerMode.none) _playerLayer(area),
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()), Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
onOpenChat: () {
if (!_chatOpen) _toggleChat();
},
)),
], ],
); );
}); });
@@ -619,3 +759,39 @@ class AppShellState extends State<AppShell> 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),
],
),
),
),
);
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
color: self ? Profile.instance.color.value : colorForName(m.name), color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord), image: memberAvatarImage(m, coord),
ready: m.ready, ready: m.ready,
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs, buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale, stale: m.stale,
// The state marks the host (`members[].host`); your own app also // The state marks the host (`members[].host`); your own app also
+104 -33
View File
@@ -1,22 +1,70 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/text_focus.dart';
import 'avatar.dart'; import 'avatar.dart';
/// Side panel: chat with avatars, other activity as quiet lines, and the /// Activity lines (joins, ready, seeks...) between the messages: off by
/// message field. Width is fixed; the shell slides it in and out. /// default, kept for the app's run.
class ChatPanel extends StatelessWidget { 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; static const width = 340.0;
final TextEditingController chat; final TextEditingController chat;
final FocusNode chatFocus; final FocusNode chatFocus;
final VoidCallback onClose; final VoidCallback onClose;
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose}); const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
@override
State<ChatPanel> createState() => _ChatPanelState();
}
class _ChatPanelState extends State<ChatPanel> {
// 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() { void _send() {
SessionState.instance.coordinator.sendChat(chat.text); SessionState.instance.coordinator.sendChat(widget.chat.text);
chat.clear(); widget.chat.clear();
chatFocus.requestFocus(); 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 @override
@@ -24,7 +72,7 @@ class ChatPanel extends StatelessWidget {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Container( return Container(
key: const ValueKey('chat-panel'), key: const ValueKey('chat-panel'),
width: width, width: ChatPanel.width,
color: scheme.surfaceContainerLow, color: scheme.surfaceContainerLow,
child: Column( child: Column(
children: [ children: [
@@ -32,41 +80,64 @@ class ChatPanel extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0), padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
child: Row( child: Row(
children: [ children: [
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)), const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)), ValueListenableBuilder<bool>(
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( Expanded(
child: PartyWatch(builder: (context) { child: ValueListenableBuilder<bool>(
final log = SessionState.instance.coordinator.feedLog.value; valueListenable: showActivity,
if (log.isEmpty) { builder: (context, activity, _) => PartyWatch(builder: (context) {
return Center( final all = SessionState.instance.coordinator.feedLog.value;
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), 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(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: TextField( child: Focus(
key: const ValueKey('chat-field'), canRequestFocus: false,
controller: chat, skipTraversal: true,
focusNode: chatFocus, onKeyEvent: _fieldKey,
onSubmitted: (_) => _send(), child: TextField(
decoration: InputDecoration( key: const ValueKey('chat-field'),
hintText: 'Message (Enter)', controller: widget.chat,
isDense: true, focusNode: widget.chatFocus,
filled: true, onSubmitted: (_) => _send(),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), decoration: InputDecoration(
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)), 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)),
),
), ),
), ),
), ),
+197
View File
@@ -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<String> 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<String> 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<Widget> 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 <Widget>[]),
};
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),
],
),
),
),
);
}
}
+1 -1
View File
@@ -346,7 +346,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall), Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( 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, textAlign: TextAlign.center,
style: TextStyle(color: scheme.onSurfaceVariant), style: TextStyle(color: scheme.onSurfaceVariant),
), ),
+1 -28
View File
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem; import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
@@ -189,32 +188,6 @@ class _LibraryTabState extends State<LibraryTab> {
} }
} }
// -------------------------------------------------------------- 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<EditableTextState>() != 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<void> _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 // -------------------------------------------------------------- build
@override @override
@@ -306,10 +279,10 @@ class _LibraryTabState extends State<LibraryTab> {
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child), child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
); );
// Holds focus so the shell's shortcuts (Ctrl+,) work.
return Focus( return Focus(
key: const ValueKey('library-tab'), key: const ValueKey('library-tab'),
autofocus: true, autofocus: true,
onKeyEvent: _onKey,
child: LayoutBuilder(builder: (context, box) { child: LayoutBuilder(builder: (context, box) {
if (box.maxWidth >= detailsDockFrom) { if (box.maxWidth >= detailsDockFrom) {
return Row( return Row(
+7 -7
View File
@@ -90,17 +90,17 @@ Future<T?> showPopover<T>(
}, },
); );
/// Horizontal lists for mouse users: drag with the mouse, and the plain /// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
/// (vertical) wheel moves them sideways. At either end the wheel is left /// the plain wheel stays vertical (the page scrolls under the cursor, not
/// to the page, so a row never traps vertical scrolling. /// the row).
class HScroll extends StatefulWidget { class HScroll extends StatefulWidget {
final ScrollController? controller; final ScrollController? controller;
final Widget Function(ScrollController controller) builder; final Widget Function(ScrollController controller) builder;
/// Map the plain (vertical) wheel to sideways scrolling. Off where the /// Also map the plain (vertical) wheel to sideways scrolling (at either
/// children scroll vertically themselves (the schedule's day columns): /// end the wheel goes to the page). Off by default: one wheel doing two
/// there only mouse drag and Shift+wheel move sideways. /// things was confusing.
final bool wheel; final bool wheel;
const HScroll({super.key, this.controller, required this.builder, this.wheel = true}); const HScroll({super.key, this.controller, required this.builder, this.wheel = false});
@override @override
State<HScroll> createState() => _HScrollState(); State<HScroll> createState() => _HScrollState();
+24 -28
View File
@@ -1,17 +1,16 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../screens/demo/shell_demo.dart';
import '../screens/home/update_indicator.dart'; import '../screens/home/update_indicator.dart';
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge; import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import '../widgets/app_keys.dart';
import '../widgets/dialogs.dart'; import '../widgets/dialogs.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
@@ -29,6 +28,9 @@ enum ShellTab {
const partyStripHeight = 60.0; 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, /// Below this window width the strip shows 3 avatars and no server text,
/// so the tabs keep their room. /// so the tabs keep their room.
const stripCompactBelow = 1100.0; const stripCompactBelow = 1100.0;
@@ -90,6 +92,7 @@ class PartyStrip extends StatelessWidget {
message: 'Your profile', message: 'Your profile',
child: InkWell( child: InkWell(
key: const ValueKey('strip-profile'), key: const ValueKey('strip-profile'),
canRequestFocus: false,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: onProfile, onTap: onProfile,
child: StatusAvatar( child: StatusAvatar(
@@ -97,7 +100,7 @@ class PartyStrip extends StatelessWidget {
ready: self?.ready ?? false, ready: self?.ready ?? false,
host: online && coord.hostFile != null, host: online && coord.hostFile != null,
), ),
size: 32, size: stripAvatarSize,
ring: online, ring: online,
), ),
), ),
@@ -126,18 +129,6 @@ class PartyStrip extends StatelessWidget {
), ),
), ),
), ),
if (kDebugMode)
IconButton(
tooltip: 'Layout demo (debug builds only)',
icon: const Icon(
Icons.bug_report_outlined,
size: 20,
color: Colors.amber,
),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ShellDemo()),
),
),
UpdateIndicator(controller: updates), UpdateIndicator(controller: updates),
const SizedBox(width: 8), const SizedBox(width: 8),
if (online) ...[ if (online) ...[
@@ -153,25 +144,30 @@ class PartyStrip extends StatelessWidget {
key: const ValueKey('party-avatars'), key: const ValueKey('party-avatars'),
members: partyAvatars(coord), members: partyAvatars(coord),
shown: compact ? 3 : 5, shown: compact ? 3 : 5,
size: stripAvatarSize,
onTap: () => showMembersCard(context), onTap: () => showMembersCard(context),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
if (!compact) _ServerLabel(scheme: scheme), if (!compact) _ServerLabel(scheme: scheme),
const SizedBox(width: 6), const SizedBox(width: 6),
Badge( // Tab toggles the chat while the strip is shown.
isLabelVisible: unread > 0, ChatKey(
label: Text('$unread'), onToggle: onChat,
child: IconButton( child: Badge(
key: const ValueKey('strip-chat'), isLabelVisible: unread > 0,
tooltip: 'Chat & activity', label: Text('$unread'),
isSelected: chatOpen, child: IconButton(
onPressed: onChat, key: const ValueKey('strip-chat'),
icon: const Icon(Icons.chat_bubble_outline, size: 20), tooltip: 'Chat & activity (Tab)',
selectedIcon: Icon( isSelected: chatOpen,
Icons.chat_bubble, onPressed: onChat,
size: 20, icon: const Icon(Icons.chat_bubble_outline, size: 20),
color: scheme.primary, selectedIcon: Icon(
Icons.chat_bubble,
size: 20,
color: scheme.primary,
),
), ),
), ),
), ),
+57 -35
View File
@@ -87,16 +87,18 @@ StatusView? playerStatusFor({
} }
// As the server holds playback: someone in the ready set short of the // As the server holds playback: someone in the ready set short of the
// minimum buffer. // minimum buffer.
final need = r.bufferNeed;
for (final m in r.members) { for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) { if (m.inSet && m.buffered < need) {
final you = self != null && m.id == self.id; final you = self != null && m.id == self.id;
final p = (m.buffered / need).clamp(0.0, 1.0);
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…', return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
who: [m], self: you, progress: (m.buffered / RelayConstants.minBufferSecs).clamp(0.0, 1.0)); who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
} }
} }
if (self != null && !self.ready) { if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're ready", return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, short: 'Press Space when ready'); who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
} }
final active = [for (final m in r.members) if (!m.stale) m]; final active = [for (final m in r.members) if (!m.stale) m];
final others = [ final others = [
@@ -193,7 +195,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
child: GestureDetector( child: GestureDetector(
key: const ValueKey('player-status'), key: const ValueKey('player-status'),
onTap: ready ? coord.toggleReady : null, onTap: ready ? coord.toggleReady : null,
child: _Glass( child: PlayerGlass(
child: AnimatedSize( child: AnimatedSize(
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
@@ -217,9 +219,10 @@ class _PlayerStatusState extends State<PlayerStatus> {
} }
} }
class _Glass extends StatelessWidget { /// Frosted dark glass in a pill: the status pill, the unread chip.
class PlayerGlass extends StatelessWidget {
final Widget child; final Widget child;
const _Glass({required this.child}); const PlayerGlass({super.key, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -250,7 +253,7 @@ class _StatusLine extends StatelessWidget {
final ValueChanged<StatusAction> onAction; final ValueChanged<StatusAction> onAction;
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction}); const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
static const _avatar = 24.0; static const _avatar = 34.0;
Widget? _lead() { Widget? _lead() {
final v = view; final v = view;
@@ -259,7 +262,7 @@ class _StatusLine extends StatelessWidget {
case StatusKind.problem: case StatusKind.problem:
final error = v.tone == HealthSeverity.error; final error = v.tone == HealthSeverity.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded, return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
size: 18, color: error ? Colors.redAccent.shade100 : Colors.amber); size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
case StatusKind.reconnecting: case StatusKind.reconnecting:
return const _Spinner(color: Colors.amber); return const _Spinner(color: Colors.amber);
case StatusKind.connecting: case StatusKind.connecting:
@@ -275,9 +278,26 @@ class _StatusLine extends StatelessWidget {
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord), v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
]; ];
if (infos.isEmpty) return null; if (infos.isEmpty) return null;
return infos.length == 1 final avatar = infos.length == 1
? StatusAvatar(info: infos.first, size: _avatar, badges: false) ? StatusAvatar(info: infos.first, size: _avatar, badges: false)
: AvatarStack(members: infos, shown: 4, size: _avatar); : AvatarStack(members: infos, shown: 4, size: _avatar);
if (v.progress == null) return avatar;
// Loading: the progress runs round the avatar.
return Stack(
alignment: Alignment.center,
children: [
avatar,
SizedBox.square(
dimension: _avatar + 6,
child: CircularProgressIndicator(
value: v.progress,
strokeWidth: 2.5,
color: Colors.white,
backgroundColor: Colors.white24,
),
),
],
);
} }
} }
@@ -285,43 +305,45 @@ class _StatusLine extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final v = view; final v = view;
final lead = _lead(); final lead = _lead();
final avatars = v.who.isNotEmpty;
final action = v.action == StatusAction.ready ? null : v.action; final action = v.action == StatusAction.ready ? null : v.action;
return Padding( return Padding(
padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6), padding: EdgeInsets.fromLTRB(lead == null ? 18 : (avatars ? 5 : 14), 5, action == null ? 18 : 6, 5),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (lead != null) ...[ if (lead != null) ...[
SizedBox(height: _avatar, child: Center(child: lead)), SizedBox(height: _avatar + 6, child: Center(child: lead)),
const SizedBox(width: 8), SizedBox(width: avatars ? 10 : 8),
], ],
Flexible( Flexible(
child: Text( child: ConstrainedBox(
v.text, constraints: const BoxConstraints(minHeight: _avatar),
key: const ValueKey('player-status-text'), child: Column(
maxLines: 1, mainAxisSize: MainAxisSize.min,
overflow: TextOverflow.ellipsis, mainAxisAlignment: MainAxisAlignment.center,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
), Text(
if (v.progress != null) ...[ v.text,
const SizedBox(width: 8), key: const ValueKey('player-status-text'),
SizedBox.square( maxLines: 1,
dimension: 14, overflow: TextOverflow.ellipsis,
child: CircularProgressIndicator( style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
value: v.progress, ),
strokeWidth: 2, if (v.detail != null)
color: Colors.white, Text(
backgroundColor: Colors.white24, v.detail!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white60, fontSize: 12, fontWeight: FontWeight.w500, height: 1.2),
),
],
), ),
), ),
], ),
if (v.detail != null) ...[
const SizedBox(width: 8),
Text(v.detail!, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.w500)),
],
if (action != null) ...[ if (action != null) ...[
const SizedBox(width: 10), const SizedBox(width: 12),
_PillButton( _PillButton(
label: action == StatusAction.cancel ? 'Cancel' : 'Retry', label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
onPressed: () => onAction(action), onPressed: () => onAction(action),
+143 -21
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton; import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -10,7 +13,8 @@ import '../controls/player_settings.dart';
import '../controls/uosc/buttons.dart'; import '../controls/uosc/buttons.dart';
import '../controls/uosc/controls.dart'; import '../controls/uosc/controls.dart';
import '../controls/uosc/timeline.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/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart'; import '../screens/session/player_controls.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
@@ -18,6 +22,7 @@ import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../widgets/app_keys.dart';
import '../widgets/shown_frame_probe.dart'; import '../widgets/shown_frame_probe.dart';
import '../widgets/text_focus.dart'; import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
@@ -96,9 +101,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
int _toastSeq = 0; int _toastSeq = 0;
int _shaderNoticeSeen = 0; int _shaderNoticeSeen = 0;
bool _miniHover = false; bool _miniHover = false;
// Whether the standard controls are up (tracked like media_kit does: // Whether the controls are up: the standard ones tracked like media_kit
// shown on mount and on mouse movement, hidden 3 s later or on exit). // does (shown on mount and on mouse movement, hidden 3 s later or on
final _standardShown = ValueNotifier(true); // exit), the uosc ones reporting it.
final _controlsShown = ValueNotifier(true);
SessionCoordinator get _coord => SessionState.instance.coordinator; SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full; bool get _full => widget.mode == PlayerMode.full;
@@ -109,6 +115,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
_coord.jellyfin.ensureLoaded(); _coord.jellyfin.ensureLoaded();
_coord.playback.shownFrame = _frameProbe.sample; _coord.playback.shownFrame = _frameProbe.sample;
HardwareKeyboard.instance.addHandler(_globalKey); HardwareKeyboard.instance.addHandler(_globalKey);
// Tab (AppKeys) reaches the chat through here in fullscreen.
AppKeys.addChatToggle(_toggleChat);
// Shader messages (preset changes, load failures) become toasts. // Shader messages (preset changes, load failures) become toasts.
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0; _shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
_stopShaderWatch = effect(() { _stopShaderWatch = effect(() {
@@ -122,11 +130,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
@override @override
void dispose() { void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey); HardwareKeyboard.instance.removeHandler(_globalKey);
AppKeys.removeChatToggle(_toggleChat);
final playback = _coord.playback; final playback = _coord.playback;
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null; if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
_stopShaderWatch(); _stopShaderWatch();
_noFocus.dispose(); _noFocus.dispose();
_standardShown.dispose(); _controlsShown.dispose();
super.dispose(); super.dispose();
} }
@@ -152,7 +161,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
/// The player keys, while the player fills the body and nothing (a /// The player keys, while the player fills the body and nothing (a
/// dialog, a card, a text field) is in front of it. /// dialog, a card, a text field) is in front of it. Space and Tab are
/// app-wide ([AppKeys]).
bool _globalKey(KeyEvent event) { bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent || !mounted || !_full) return false; if (event is! KeyDownEvent || !mounted || !_full) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false; if (SessionState.instance.route.peek() != AppRoute.session) return false;
@@ -184,11 +194,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
_reloadVideo(); _reloadVideo();
return true; return true;
} }
if (key == LogicalKeyboardKey.space) {
_lastKeyAt = now;
coord.toggleReady();
return true;
}
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
final shift = HardwareKeyboard.instance.isShiftPressed; final shift = HardwareKeyboard.instance.isShiftPressed;
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) { if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
@@ -213,9 +218,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
coord.skipForward(); coord.skipForward();
return true; return true;
} }
if (key == LogicalKeyboardKey.tab) { if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
_lastKeyAt = now; final player = coord.playback.player;
_toggleChat(); player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5));
return true; return true;
} }
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
@@ -282,7 +287,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
_status(), _status(),
_progressLine(), _progressLine(),
_jellyfinOverlay(), _jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(), if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
] else ] else
_miniOverlay(), _miniOverlay(),
], ],
@@ -340,7 +345,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
final s = AppSettings.instance; final s = AppSettings.instance;
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink(); if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: _standardShown, valueListenable: _controlsShown,
builder: (context, shown, _) => shown builder: (context, shown, _) => shown
? const SizedBox.shrink() ? const SizedBox.shrink()
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true), : const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
@@ -437,7 +442,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
onPressed: coord.playNextEpisode, onPressed: coord.playNextEpisode,
), ),
), ),
const MaterialDesktopVolumeButton(), VolumeButton(player: playback.player, focusNode: _noFocus),
_ReadyControlButton(focusNode: _noFocus), _ReadyControlButton(focusNode: _noFocus),
_BarTextButton( _BarTextButton(
focusNode: _noFocus, focusNode: _noFocus,
@@ -504,7 +509,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _controlsSwitch(VideoState state) { Widget _controlsSwitch(VideoState state) {
return SessionControlsSwitch( return SessionControlsSwitch(
material: (_) => ControlsHoverProbe( material: (_) => ControlsHoverProbe(
shown: _standardShown, shown: _controlsShown,
hideAfter: _controlsHide, hideAfter: _controlsHide,
child: DoubleClickListener( child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen, onDoubleClick: widget.onToggleFullscreen,
@@ -536,12 +541,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
}); });
return UoscControls( return UoscControls(
shown: _controlsShown,
onDoubleTap: widget.onToggleFullscreen, onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(), topLeft: const NowPlayingTitle(),
top: Row( top: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded, icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)', tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
@@ -653,7 +665,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and /// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED / /// 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() { Widget _jellyfinOverlay() {
final coord = _coord; final coord = _coord;
return Positioned.fill( return Positioned.fill(
@@ -695,7 +708,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
spacing: 10, spacing: 10,
children: [ 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), if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
], ],
), ),
@@ -723,6 +742,40 @@ class _SessionPlayerState extends State<SessionPlayer> {
}), }),
); );
/// 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<bool>(
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 /// Mini: title, expand and close on hover; ready, time and a seekable
/// bar at the bottom. A thin progress line stays while not hovered. /// bar at the bottom. A thin progress line stays while not hovered.
Widget _miniOverlay() { Widget _miniOverlay() {
@@ -809,6 +862,75 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
} }
/// 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<bool> controlsShown;
final Widget child;
const SkipPillGate({super.key, required this.offer, required this.controlsShown, required this.child});
@override
State<SkipPillGate> createState() => _SkipPillGateState();
}
class _SkipPillGateState extends State<SkipPillGate> {
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<bool>(
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 = [ const _shaderKeys = [
LogicalKeyboardKey.digit0, LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1, LogicalKeyboardKey.digit1,
+94 -5
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
@@ -7,7 +9,11 @@ import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart'; import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -73,7 +79,23 @@ class SettingsTab extends StatelessWidget {
), ),
]; ];
case SettingsSection.network: 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: case SettingsSection.player:
return [ return [
const SettingsHintCard( const SettingsHintCard(
@@ -431,11 +453,78 @@ class NotificationsInfo extends StatelessWidget {
], ],
), ),
), ),
SettingsPanel( const SettingsPanel(title: 'Sounds', child: SoundSettings()),
title: 'Sounds',
child: line('No sounds yet', 'The app is silent; only the video makes noise'),
),
], ],
); );
} }
} }
/// Volume, then every sound with a preview button and its own switch.
class SoundSettings extends StatelessWidget {
const SoundSettings({super.key});
void _preview(SfxKind kind) {
final sfx = SessionState.instance.sfx;
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
}
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
final scheme = Theme.of(context).colorScheme;
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
return SignalBuilder(builder: (context) {
final volume = s.soundVolume.value;
final off = s.soundsOff.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
color: scheme.onSurfaceVariant),
Expanded(
child: Slider(
key: const ValueKey('sound-volume'),
value: volume,
onChanged: s.setSoundVolume,
onChangeEnd: (_) => _preview(SfxKind.ready),
),
),
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
],
),
Text('Soft cues for people and readiness; the video itself covers play, pause and the end. '
'Letting go of the slider plays the ready sound.', style: muted),
const SizedBox(height: 8),
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
IconButton(
key: ValueKey('sound-play-${kind.name}'),
tooltip: 'Play',
onPressed: volume == 0 ? null : () => _preview(kind),
icon: const Icon(Icons.play_arrow_rounded),
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Text(name), Text(help, style: muted)],
),
),
Switch(
key: ValueKey('sound-on-${kind.name}'),
value: !off.contains(kind.name),
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
),
],
),
),
],
);
});
}
}
+9 -5
View File
@@ -26,9 +26,11 @@ class StartCard extends StatelessWidget {
final episode = now.episode; final episode = now.episode;
return LayoutBuilder(builder: (context, box) { return LayoutBuilder(builder: (context, box) {
final h = box.maxHeight; final h = box.maxHeight;
// Small (the mini player): no cover, a smaller title near the bottom.
final small = h < 320;
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5; final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
final titleSize = (h * 0.05).clamp(22.0, 44.0); final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0); final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -76,12 +78,14 @@ class StartCard extends StatelessWidget {
Positioned( Positioned(
left: side, left: side,
right: side, right: side,
bottom: startCardBottom, bottom: small ? 12 : startCardBottom,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
ArtCover(hash: now.posterHash, width: coverWidth), if (!small) ...[
const SizedBox(width: 24), ArtCover(hash: now.posterHash, width: coverWidth),
const SizedBox(width: 24),
],
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+32 -5
View File
@@ -43,10 +43,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
} }
/// The coordinator's toasts as cards with avatars, bottom-left: each /// The coordinator's toasts as cards with avatars, bottom-left: each
/// slides in, stays [life], then slides out and collapses its gap. /// slides in, stays [life], then slides out and collapses its gap. A click
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
/// through.
class PartyToasts extends StatefulWidget { class PartyToasts extends StatefulWidget {
final Duration life; final Duration life;
const PartyToasts({super.key, this.life = const Duration(seconds: 5)}); final VoidCallback? onOpenChat;
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
@override @override
State<PartyToasts> createState() => _PartyToastsState(); State<PartyToasts> createState() => _PartyToastsState();
@@ -101,13 +104,37 @@ class _PartyToastsState extends State<PartyToasts> {
super.dispose(); super.dispose();
} }
void _open(ShellToast t) {
widget.onOpenChat?.call();
setState(() => t.leaving = true);
}
@override @override
Widget build(BuildContext context) => IgnorePointer(child: ToastColumn(toasts: _shown.reversed.take(6).toList().reversed.toList())); Widget build(BuildContext context) => ToastColumn(
toasts: _shown.reversed.take(6).toList().reversed.toList(),
onTap: widget.onOpenChat == null ? null : _open,
);
} }
class ToastColumn extends StatelessWidget { class ToastColumn extends StatelessWidget {
final List<ShellToast> toasts; final List<ShellToast> toasts;
const ToastColumn({super.key, required this.toasts}); /// Clicks on chat toasts; null (or any other toast): clicks pass through.
final ValueChanged<ShellToast>? onTap;
const ToastColumn({super.key, required this.toasts, this.onTap});
Widget _card(ShellToast t) {
final card = ToastCard(toast: t);
final tap = onTap;
if (!t.chat || tap == null || t.leaving) return IgnorePointer(child: card);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
key: ValueKey('toast-open-chat-${t.id}'),
onTap: () => tap(t),
child: card,
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -139,7 +166,7 @@ class ToastColumn extends StatelessWidget {
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
opacity: t.leaving ? 0 : 1, opacity: t.leaving ? 0 : 1,
child: Padding(padding: const EdgeInsets.only(top: 8), child: ToastCard(toast: t)), child: Padding(padding: const EdgeInsets.only(top: 8), child: _card(t)),
), ),
), ),
), ),
@@ -9,11 +9,13 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart'; import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart'; import 'connect_prefs.dart';
import 'server_isolate.dart'; import 'server_isolate.dart';
import 'settings.dart';
enum AppRoute { enum AppRoute {
home, home,
@@ -153,7 +155,10 @@ class SessionState {
final route = signal(AppRoute.home); final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []); final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService sfx = const NoopSfxService(); final SfxService sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(),
enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name),
);
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm(); final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance; JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
+31
View File
@@ -20,6 +20,9 @@ const _keyAutoAdvance = 'autoAdvance';
const _keyMpvControls = 'mpvControls'; const _keyMpvControls = 'mpvControls';
const _keyHideReReleases = 'hideReReleases'; const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine'; const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume';
const _keySoundsOff = 'soundsOff';
const _keyUnreadyOnSwap = 'unreadyOnSwap';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
@@ -31,6 +34,9 @@ class AppSettings {
final autoCheckUpdates = signal(true); final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next. /// Jellyfin episodes: at the end, count down 10 s and put up the next.
final autoAdvance = signal(true); 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 /// Player design: the uosc controls (`controls/uosc/`) instead of the
/// standard ones (media_kit's with our bars). Applies live. /// standard ones (media_kit's with our bars). Applies live.
final mpvControls = signal(false); final mpvControls = signal(false);
@@ -40,6 +46,10 @@ class AppSettings {
/// A 2 px progress line at the bottom of the video while the controls /// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live. /// are hidden (both designs). Applies live.
final progressLine = signal(true); final progressLine = signal(true);
/// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one.
final soundsOff = signal<Set<String>>(const {});
/// Video shaders: one choice, used on join and changed both here and /// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button). /// in the session (Ctrl+0..6, the shader button).
@@ -76,12 +86,18 @@ class AppSettings {
if (auto is bool) autoCheckUpdates.value = auto; if (auto is bool) autoCheckUpdates.value = auto;
final advance = box.get(_keyAutoAdvance); final advance = box.get(_keyAutoAdvance);
if (advance is bool) autoAdvance.value = advance; if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap);
if (unready is bool) unreadyOnSwap.value = unready;
final mpv = box.get(_keyMpvControls); final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv; if (mpv is bool) mpvControls.value = mpv;
final hideRe = box.get(_keyHideReReleases); final hideRe = box.get(_keyHideReReleases);
if (hideRe is bool) hideReReleases.value = hideRe; if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine); final line = box.get(_keyProgressLine);
if (line is bool) progressLine.value = line; if (line is bool) progressLine.value = line;
final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff);
if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>());
final preset = box.get(_keyShaderPreset); final preset = box.get(_keyShaderPreset);
shaderPreset.value = shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -119,6 +135,11 @@ class AppSettings {
_put(_keyAutoAdvance, on); _put(_keyAutoAdvance, on);
} }
void setUnreadyOnSwap(bool on) {
unreadyOnSwap.value = on;
_put(_keyUnreadyOnSwap, on);
}
void setMpvControls(bool on) { void setMpvControls(bool on) {
mpvControls.value = on; mpvControls.value = on;
_put(_keyMpvControls, on); _put(_keyMpvControls, on);
@@ -129,6 +150,16 @@ class AppSettings {
_put(_keyProgressLine, on); _put(_keyProgressLine, on);
} }
void setSoundVolume(double v) {
soundVolume.value = v.clamp(0, 1);
_put(_keySoundVolume, soundVolume.value);
}
void setSoundOn(String kind, bool on) {
soundsOff.value = Set.unmodifiable(on ? ({...soundsOff.value}..remove(kind)) : {...soundsOff.value, kind});
_put(_keySoundsOff, soundsOff.value.toList());
}
void setHideReReleases(bool on) { void setHideReReleases(bool on) {
hideReReleases.value = on; hideReReleases.value = on;
_put(_keyHideReReleases, on); _put(_keyHideReReleases, on);
+10 -2
View File
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
return false; return false;
} }
/// At `finished`, a player at most this far behind the end keeps playing
/// to it.
const finishSlackSecs = 3.0;
class StateDiff { class StateDiff {
static const debounceWindow = Duration(seconds: 2); static const debounceWindow = Duration(seconds: 2);
@@ -73,7 +77,9 @@ class StateDiff {
} }
// End of media: everyone stops where they are (at or just before the // End of media: everyone stops where they are (at or just before the
// end). No seek to the very end and no drift correction. // end). No seek to the very end and no drift correction. A player a
// little behind the server's clock plays its last moments out (mpv
// stops at the end by itself) instead of being cut short.
if (state.transport == 'finished') { if (state.transport == 'finished') {
final first = _lastEventId == -1; final first = _lastEventId == -1;
final changed = first || final changed = first ||
@@ -87,7 +93,9 @@ class StateDiff {
if (!changed) return out; if (!changed) return out;
out.add(const RateAction(1.0)); out.add(const RateAction(1.0));
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0)); if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
out.add(const TransportAction(false)); if (first || state.position - ownPosition > finishSlackSecs) {
out.add(const TransportAction(false));
}
return out; return out;
} }
+103
View File
@@ -0,0 +1,103 @@
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, and Space never presses the
/// focused button (the app's [Shortcuts] override): Space is the ready key,
/// and a button keeps focus after a click. Text fields still type spaces
/// (their own shortcuts come first).
Map<ShortcutActivator, Intent> get appShortcuts => {
...WidgetsApp.defaultShortcuts,
const SingleActivator(LogicalKeyboardKey.tab): const DoNothingIntent(),
const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingIntent(),
const SingleActivator(LogicalKeyboardKey.space): 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 = <VoidCallback>[];
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<AppKeys> createState() => _AppKeysState();
}
class _AppKeysState extends State<AppKeys> {
@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<ChatKey> createState() => _ChatKeyState();
}
class _ChatKeyState extends State<ChatKey> {
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;
}
@@ -6,12 +6,16 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <fox_utils/fox_utils_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h> #include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <window_manager/window_manager_plugin.h> #include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) fox_utils_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FoxUtilsPlugin");
fox_utils_plugin_register_with_registrar(fox_utils_registrar);
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
fox_utils
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video
screen_retriever_linux screen_retriever_linux
+27 -2
View File
@@ -105,6 +105,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.15" version: "0.7.15"
dio:
dependency: transitive
description:
name: dio
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
url: "https://pub.dev"
source: hosted
version: "5.11.1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -208,6 +224,15 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fox_utils:
dependency: "direct main"
description:
path: "."
ref: "v0.1.7"
resolved-ref: dc16bba05491f54daf528ed83484ea9b6eab8825
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
source: git
version: "0.1.7"
hive_ce: hive_ce:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -723,14 +748,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "0.2.2" version: "1.0.0"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "0.2.2" version: "1.0.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+6 -1
View File
@@ -1,7 +1,7 @@
name: watch_together name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary. description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.0.0 version: 1.0.1
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
@@ -25,6 +25,11 @@ dependencies:
window_manager: ^0.5.2 window_manager: ^0.5.2
archive: ^4.0.7 archive: ^4.0.7
crypto: ^3.0.6 crypto: ^3.0.6
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
fox_utils:
git:
url: https://git.senkochan.moe/Foxyz/fox_utils.git
ref: v0.1.7
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+68
View File
@@ -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');
});
}
+15 -1
View File
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/sync/sync_client.dart'; import 'package:watch_together/src/sync/sync_client.dart';
@@ -214,7 +215,8 @@ void main() {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast')); final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
await server.start(); await server.start();
final coord = SessionCoordinator(); final sounds = _RecordingSfx();
final coord = SessionCoordinator(sfx: sounds);
final friend = SyncClient(); final friend = SyncClient();
try { try {
await coord.connect(ConnectParams( await coord.connect(ConnectParams(
@@ -227,6 +229,12 @@ void main() {
final toasts = coord.toasts.peek(); final toasts = coord.toasts.peek();
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr'); expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr'); expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
// Others' chat makes a sound, your own does not.
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
coord.sendChat('mine');
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
expect(sounds.kinds, contains(SfxKind.join));
} finally { } finally {
await friend.dispose(); await friend.dispose();
await coord.dispose(); await coord.dispose();
@@ -235,3 +243,9 @@ void main() {
}, },
); );
} }
class _RecordingSfx implements SfxService {
final kinds = <SfxKind>[];
@override
Future<void> play(SfxCue cue) async => kinds.add(cue.kind);
}
+23
View File
@@ -74,6 +74,12 @@ Future<void> openFolder(WidgetTester tester, String name) async {
await frames(tester); await frames(tester);
} }
/// Series start collapsed: a click on the header lists the episodes.
Future<void> 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() { void main() {
setUp(() async { setUp(() async {
tmp = Directory.systemTemp.createTempSync('files_page_'); tmp = Directory.systemTemp.createTempSync('files_page_');
@@ -147,6 +153,9 @@ void main() {
expect(find.byType(SeriesHeader), findsOneWidget); expect(find.byType(SeriesHeader), findsOneWidget);
expect(find.text('Show'), findsOneWidget); expect(find.text('Show'), findsOneWidget);
expect(find.text('3 episodes · 1 extra · Grp'), 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']) { for (final l in ['E01', 'E02', 'E03', 'NCOP1']) {
expect(find.text(l), findsOneWidget); expect(find.text(l), findsOneWidget);
} }
@@ -157,6 +166,13 @@ void main() {
// Durations come from the thumbnail. // Durations come from the thumbnail.
expect(find.text('24:00 · 1 KB'), findsNWidgets(4)); expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
expect(find.text('24:00'), findsOneWidget); // card badge 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 { testWidgets('hidden files are counted and some can be shown', (tester) async {
@@ -180,6 +196,7 @@ void main() {
store.addFolder(anime); store.addFolder(anime);
await pumpPage(tester); await pumpPage(tester);
await openFolder(tester, 'anime'); await openFolder(tester, 'anime');
await openSeries(tester);
touch('$anime/[Grp] Show - 04.mkv'); touch('$anime/[Grp] Show - 04.mkv');
expect(find.text('E04'), findsNothing); expect(find.text('E04'), findsNothing);
await tester.tap(find.text('Rescan')); await tester.tap(find.text('Rescan'));
@@ -197,6 +214,7 @@ void main() {
return null; return null;
}); });
await openFolder(tester, 'anime'); await openFolder(tester, 'anime');
await openSeries(tester);
expect(find.text('E01'), findsOneWidget); expect(find.text('E01'), findsOneWidget);
await tester.tap(find.text('E01')); await tester.tap(find.text('E01'));
await tester.tap(find.text('Some Movie (2019)')); 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); expect(find.textContaining('Keep the app open while it plays'), findsOneWidget);
await openFolder(tester, 'anime'); await openFolder(tester, 'anime');
await openSeries(tester);
await tester.tap(find.text('E02')); await tester.tap(find.text('E02'));
await frames(tester); await frames(tester);
@@ -254,6 +273,7 @@ void main() {
)); ));
await frames(tester); await frames(tester);
await openFolder(tester, 'anime'); await openFolder(tester, 'anime');
await openSeries(tester);
expect(find.text('now playing'), findsOneWidget); expect(find.text('now playing'), findsOneWidget);
expect(find.byIcon(Icons.graphic_eq), findsOneWidget); expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
}); });
@@ -267,6 +287,7 @@ void main() {
await tester.tap(find.widgetWithText(InputChip, 'anime')); await tester.tap(find.widgetWithText(InputChip, 'anime'));
await frames(tester); await frames(tester);
expect(find.text('$anime/'), findsOneWidget); expect(find.text('$anime/'), findsOneWidget);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget); expect(find.text('E01'), findsOneWidget);
// The chip's x removes the folder, like the sidebar's. // The chip's x removes the folder, like the sidebar's.
await tester.tap(find.byTooltip('Remove folder')); await tester.tap(find.byTooltip('Remove folder'));
@@ -287,6 +308,7 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.text('anime').first); await tester.tap(find.text('anime').first);
await frames(tester); await frames(tester);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget); expect(find.text('E01'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.text('Show some')); await tester.tap(find.text('Show some'));
@@ -312,6 +334,7 @@ void main() {
return Completer<Thumbnail?>().future; // never finishes return Completer<Thumbnail?>().future; // never finishes
}); });
await openFolder(tester, 'big'); await openFolder(tester, 'big');
await openSeries(tester, 'Long Show');
expect(find.text('E01'), findsOneWidget); expect(find.text('E01'), findsOneWidget);
final first = thumbRequests.toSet(); final first = thumbRequests.toSet();
expect(first, isNotEmpty); expect(first, isNotEmpty);
+46
View File
@@ -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<String> 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<HandoffNothing>());
expect(of(paths: ['/gone/x.mkv']), isA<HandoffNothing>());
expect(of(), isA<HandoffNothing>());
});
test('pasted text: a link, absolute paths or file URIs; files win over text', () {
expect(of(text: ' https://cdn.example/a b'), isA<HandoffNothing>());
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<HandoffNothing>(), reason: 'relative');
expect(of(text: 'hello'), isA<HandoffNothing>());
expect((of(paths: ['/v/Season 1'], text: 'https://x.example') as HandoffFolder).path, '/v/Season 1');
});
}
+3 -2
View File
@@ -501,7 +501,8 @@ void main() {
expect(a.jellyfinPlan.peek(), isA<OwnPlay>()); expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
expect(a.relaying.peek(), isTrue); expect(a.relaying.peek(), isTrue);
expect(b.jellyfinBlocked.peek(), isNull); 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(); x.toggleReady();
} }
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing'); 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.streams('ATOKEN'), isNotEmpty);
expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) => expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue); _FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
for (final x in [a, b]) { for (final x in [a, b, d]) {
x.toggleReady(); x.toggleReady();
} }
+1 -19
View File
@@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts.dart';
@@ -278,7 +277,7 @@ void main() {
expect(tester.takeException(), isNull); 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); final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link);
expect(find.text('How the others get it'), findsOneWidget); expect(find.text('How the others get it'), findsOneWidget);
const url = 'https://www.youtube.com/watch?v=abc'; const url = 'https://www.youtube.com/watch?v=abc';
@@ -304,23 +303,6 @@ void main() {
await settle(tester, 10); await settle(tester, 10);
expect(h.controller.link.text, url); 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); expect(tester.takeException(), isNull);
}); });
+11 -2
View File
@@ -111,8 +111,14 @@ void main() {
group('skip offers', () { group('skip offers', () {
final op = SkipRange(s(30), m(2), SkipSource.chapter); final op = SkipRange(s(30), m(2), SkipSource.chapter);
final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter); final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter);
SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt( SkipOffer? at(Duration p,
position: p, duration: duration ?? m(24), opening: opening, ending: ending); {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', () { test('Skip OP inside the opening, to its end', () {
expect(at(s(29), opening: op, ending: ed), isNull); 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))); expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30)));
// An opening known (but not playing) means no fallback. // An opening known (but not playing) means no fallback.
expect(at(m(3), opening: op), isNull); 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)));
}); });
}); });
+10
View File
@@ -91,4 +91,14 @@ void main() {
expect(n.contains('SECRET'), isFalse); 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');
});
} }
+59
View File
@@ -0,0 +1,59 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth.dart';
double _pitch(List<double> x, int from) {
final c = <double>[];
for (var l = 0; l < 700; l++) {
var s = 0.0;
for (var i = from; i < from + 2048; i++) {
s += x[i] * x[i + l];
}
c.add(s);
}
var best = 0.0, lag = 0.0;
for (var l = 30; l < 699; l++) {
if (c[l] > best && c[l] >= c[l - 1] && c[l] >= c[l + 1]) {
best = c[l];
lag = l + (c[l - 1] - c[l + 1]) / (2 * (c[l - 1] - 2 * c[l] + c[l + 1]));
}
}
return sampleRate / lag;
}
void main() {
test('every sound is described, quiet, short and clean', () {
for (final k in SfxKind.values) {
expect(soundInfo[k], isNotNull, reason: k.name);
final r = render(soundFor(k));
final peak = r.fold<double>(0, (m, v) => math.max(m, v.abs()));
expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '${k.name} peak');
expect(r.length / sampleRate, lessThan(3), reason: '${k.name} length');
expect(r.every((v) => v.isFinite), isTrue, reason: k.name);
expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '${k.name} clicks');
}
});
test('the ready ladder climbs and stays in range', () {
expect(render(readySound(0)), render(readySound(1)));
expect(render(readySound(99)), render(readySound(6)));
});
test('koto strings are in tune', () {
for (final hz in [293.66, 587.33, 880.0, 1174.66]) {
final r = render(SoundSpec([Note(0, hz, 1.0)], room: 0, cutoff: 20000));
final cents = 1200 * math.log(_pitch(r, 2000) / hz) / math.ln2;
expect(cents.abs(), lessThan(5), reason: '$hz');
}
});
test('wav header and length', () {
final b = wav(render(soundFor(SfxKind.chat)));
expect(String.fromCharCodes(b.sublist(0, 4)), 'RIFF');
expect(String.fromCharCodes(b.sublist(8, 12)), 'WAVE');
expect(b.length, 44 + render(soundFor(SfxKind.chat)).length * 2);
});
}
+25
View File
@@ -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 <String>[]);
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<TextField>(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 { testWidgets('profile rename goes to the server through the hook', (tester) async {
final pushed = <String?>[]; final pushed = <String?>[];
final p = Profile.instance; final p = Profile.instance;
+7 -4
View File
@@ -158,12 +158,15 @@ void main() {
}); });
group('finished', () { group('finished', () {
test('stops in place: pause, no seek to the end, no drift seeks', () { test('a player just behind plays out; far behind pauses; no seeks', () {
final diff = StateDiff();
final t = DateTime.now().toUtc(); final t = DateTime.now().toUtc();
diff.diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
final end = _state(eventId: 2, transport: 'finished', position: 120); final end = _state(eventId: 2, transport: 'finished', position: 120);
final actions = diff.diff(end, 119.4, t); final near = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
final nearActions = near.diff(end, 119.4, t);
expect(nearActions.whereType<SeekAction>(), isEmpty);
expect(nearActions.whereType<TransportAction>(), isEmpty);
final diff = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
final actions = diff.diff(end, 110, t);
expect(actions.whereType<SeekAction>(), isEmpty); expect(actions.whereType<SeekAction>(), isEmpty);
expect(actions.whereType<TransportAction>().single.playing, isFalse); expect(actions.whereType<TransportAction>().single.playing, isFalse);
// Far off and later: still nothing. // Far off and later: still nothing.
@@ -6,12 +6,15 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <fox_utils/fox_utils_plugin.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> #include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h> #include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <window_manager/window_manager_plugin.h> #include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FoxUtilsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FoxUtilsPlugin"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar( MediaKitVideoPluginCApiRegisterWithRegistrar(
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
fox_utils
media_kit_libs_windows_video media_kit_libs_windows_video
media_kit_video media_kit_video
screen_retriever_windows screen_retriever_windows
+91 -31
View File
@@ -80,20 +80,30 @@ Loosely modeled on Syncplay, with a different readiness design.
server stores `buffered` and liveness, and takes the first reported server stores `buffered` and liveness, and takes the first reported
duration for the current media (swap resets it); a report with `eventId` duration for the current media (swap resets it); a report with `eventId`
older than current has its position ignored. older than current has its position ignored.
- **Ready gate** (HELD to PLAYING): the in-set is non-empty, every in-set - **Ready gate** (HELD to PLAYING): the in-set is non-empty and every
member is ready, fresh and has `buffered >= need`, and quorum holds: in-set in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s
size >= 2, unless at most one member is fresh. `need` is 4 s (`minBufferSecs`), or near the end what is left minus 2 s
(`minBufferSecs`), or near the end what is left minus 2 s (`endSlackSecs`, (`endSlackSecs`, floor 0), since mpv cannot buffer past the end. So a
floor 0), since mpv cannot buffer past the end. So a lone viewer plays lone viewer plays alone; with others present everyone must ready.
alone; with a second viewer present one Space waits for the other. Joining - **In-set** (who the gate waits for): a member joins it on joining, or
members start with `inSet=false, ready=false` and never block. Disconnect on coming back from stale, while not PLAYING (any media, the one the
removes from the set; swap clears everyone. 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 - **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 `need` buffered or stale flips the server to HELD (feed action `pause` by
`server`). Recovery resumes by itself when the gate passes. `server`). Recovery resumes by itself when the gate passes.
- **End**: while PLAYING with a known duration, reaching it sets FINISHED - **End**: while PLAYING with a known duration, reaching it sets FINISHED
(feed `finished` by `server`, position = duration). Clients pause where they (feed `finished` by `server`, position = duration). Clients pause where they
are; FINISHED never auto-resumes. A seek turns it into HELD at the new are, except one within 3 s of the end (`finishSlackSecs`), which plays
out to mpv's own stop; at the end the position shows the full duration.
FINISHED never auto-resumes. A seek turns it into HELD at the new
position (resuming at once if the gate passes); a swap resets to HELD. position (resuming at once if the gate passes); a swap resets to HELD.
- **Seek**: allowed from any member in any transport; the server writes the - **Seek**: allowed from any member in any transport; the server writes the
position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in
@@ -119,7 +129,8 @@ Loosely modeled on Syncplay, with a different readiness design.
user is not seeking. user is not seeking.
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the - **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. 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 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. `onDone` does not fire for dead peers, so presence never relies on it.
- **Resume**: a reconnecting client sends its previous token as - **Resume**: a reconnecting client sends its previous token as
@@ -137,7 +148,7 @@ Loosely modeled on Syncplay, with a different readiness design.
moment playback stopped. While HELD and self ready: warning at 8 min, 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. auto-unready at 10 min, and never less than 2 min after the warning.
- **Deviations from Syncplay**: clock never follows the slowest watcher; no - **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). 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. Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
@@ -285,8 +296,9 @@ Skip rules (pure, `media_marks.dart`):
- `skipOfferAt` (the coordinator's `skipOffer`, null at `finished`): inside - `skipOfferAt` (the coordinator's `skipOffer`, null at `finished`): inside
the opening (up to its last second) "Skip OP" to its end; inside the 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 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" decides). Without any opening, without chapters (a chaptered file
during the first 6 min. 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, - +1:30 (`skipForward`: the pill, the controls' "1:30" button,
Shift+Right) is a `nudge`, so repeated presses add up. Shift+Right) is a `nudge`, so repeated presses add up.
- Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next - Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next
@@ -513,10 +525,11 @@ One window, `AppShell` (`lib/src/shell/`), whatever the connection state
through for views that replace each other (the old one gone after 35%, the through for views that replace each other (the old one gone after 35%, the
new one fading and scaling in from 0.985), `showPopover` (cards that fade new one fading and scaling in from 0.985), `showPopover` (cards that fade
and scale in from where they open; they scroll when the window is short) 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 and `HScroll` (horizontal rows: mouse drag or Shift+wheel; the plain wheel
sideways until an end, then the page). always scrolls the page). Space never presses a focused button
(`appShortcuts`): it is the ready key.
- **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 the Library / Schedule / Settings tabs with a growing underline (an
`HScroll` when narrow), the update indicator, then offline Join and Host `HScroll` when narrow), the update indicator, then offline Join and Host
(connect cards), online the party: `AvatarStack` (host first, then (connect cards), online the party: `AvatarStack` (host first, then
@@ -528,8 +541,7 @@ sideways until an end, then the page).
of `NetworkInterface.list` (IPv4, no loopback or link-local, private of `NetworkInterface.list` (IPv4, no loopback or link-local, private
ranges first) with the Host port, and a note that friends outside the ranges first) with the Host port, and a note that friends outside the
LAN need a forwarded port or a reachable server). Empty strip space is the window LAN need a forwarded port or a reachable server). Empty strip space is the window
drag area; the window buttons sit at its end. Debug builds add a button drag area; the window buttons sit at its end.
that opens the layout mock (`screens/demo/`).
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the - **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
picture with a gap in the background colour (both scale with the size): picture with a gap in the background colour (both scale with the size):
green ready, amber buffering (with a spinner), grey not ready, dimmed green ready, amber buffering (with a spinner), grey not ready, dimmed
@@ -572,8 +584,10 @@ sideways until an end, then the page).
(`library_link.dart`): paste field, a preview (YouTube oEmbed title and (`library_link.dart`): paste field, a preview (YouTube oEmbed title and
thumbnail, else the file name), the three relay modes online, and the thumbnail, else the file name), the three relay modes online, and the
last 12 links put up (`recentLinks` in the `connect_form` box). Files last 12 links put up (`recentLinks` in the `connect_form` box). Files
is `FilesPage` (`files/`). Ctrl+V outside a field pastes a link into is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
Link. Offline everything browses; the buttons say "Join or host to 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 watch together". Schedule (`schedule/`) is a week of day columns built
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in 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 memory and 1 h on disk, stale data shown on 429 or failure) matched
@@ -590,6 +604,30 @@ sideways until an end, then the page).
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
(what pops up; no switches), updates, shortcuts, about. (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).
Nothing for what the video already says (play, pause, seeks, the end).
The set is synthesized, not sampled: `synth.dart` renders Japanese-house
voices (Karplus-Strong koto with bends, furin chime, rin bowl, bamboo,
hyoshigi clappers, a shoji slide of filtered noise) through a low-pass
and a short room to quiet WAVs; `sound_bank.dart` voices each event in
the Yo scale on D. `SynthSfx` renders a sound on first use into the temp
dir and plays it on a pool of three audio-only mpv players, at
`AppSettings.soundVolume` (square curve) unless switched off in
`soundsOff`. Settings → Notifications & sounds has the volume and a
preview and switch per sound.
- **Player** (`session_player.dart`): `SessionPlayer` holds the one - **Player** (`session_player.dart`): `SessionPlayer` holds the one
`Video` of the current player (a new `GlobalKey` per player generation). `Video` of the current player (a new `GlobalKey` per player generation).
The shell keeps it in one layer of the body at one place in the element The shell keeps it in one layer of the body at one place in the element
@@ -602,7 +640,13 @@ sideways until an end, then the page).
is `windowManager.setFullScreen` with the strip hidden (party avatars in is `windowManager.setFullScreen` with the strip hidden (party avatars in
a corner while paused); F, Esc and double-click on the picture toggle it. 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 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 is closed opens it: full right after joining or after putting it up
here, else mini. Both control designs show `NowPlayingTitle` top left here, else mini. Both control designs show `NowPlayingTitle` top left
(`now_playing_title.dart`: 2:3 cover, title, episode line from (`now_playing_title.dart`: 2:3 cover, title, episode line from
@@ -612,13 +656,16 @@ sideways until an end, then the page).
under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls: under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls:
the banner (else a blur of the cover, else dark), darkened, with cover, the banner (else a blur of the cover, else dark), darkened, with cover,
big title and episode; it fades out on play and stays gone for that big title and episode; it fades out on play and stays gone for that
media. One status pill (`player_status.dart`) sits bottom centre above media; under 320 px tall (the mini) it drops the cover and shrinks the
title. One status pill (`player_status.dart`) sits bottom centre above
the controls: `playerStatusFor` (pure) picks the most pressing of the controls: `playerStatusFor` (pure) picks the most pressing of
removed, reconnecting (Cancel), a warn/error health message (Retry), removed, reconnecting (Cancel), a warn/error health message (Retry),
connecting (only after 2 s), finished, someone loading (their avatar connecting (only after 2 s), finished, someone loading (below
and buffer progress), you not ready (a click marks you ready) and who `StateBroadcast.bufferNeed`, the gate's own need: less near the end,
everyone waits for (avatars, "2/3 ready"); nothing otherwise, info none at finished; their avatar with the progress round it), you not
never. Frosted glass, colour only for problems; it fades through ready (a click marks you ready) and who everyone waits for (avatars,
"2/3 ready"); nothing otherwise, info never. 34 px avatars, a main line
and a muted detail line. Frosted glass, colour only for problems; it fades through
between kinds. The mini shows its short line in its hover bar. ⚙ in both between kinds. The mini shows its short line in its hover bar. ⚙ in both
control designs opens the player settings card (`controls/ control designs opens the player settings card (`controls/
player_settings.dart`, `showPopover`): player design, progress line player_settings.dart`, `showPopover`): player design, progress line
@@ -632,11 +679,17 @@ sideways until an end, then the page).
`WindowButtons` to the top bar while the controls are up (restore `WindowButtons` to the top bar while the controls are up (restore
leaves fullscreen). leaves fullscreen).
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right - **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
(strip button or Tab): chat with avatars, 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 **Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
own have none); each own have none); each
slides in, stays 5 s, then slides out and collapses its gap. slides in, stays 5 s, then slides out and collapses its gap. A click on
a chat toast opens the chat; the others let clicks through.
Settings persist in two Hive boxes: `connect_form` (last form, recent Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join servers and links, the profile picture and colour; saved even when a join
@@ -707,8 +760,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 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 the controls up through a guarded dynamic `onHover()` on media_kit's
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
1:30, Next episode) and cards live outside the auto-hiding controls, so 1:30, Next episode) and cards live outside the auto-hiding controls. The
they stay up when the controls hide. 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; The uosc player design (`controls/uosc/`, after mpv's uosc theme;
"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s "Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s
@@ -787,7 +846,8 @@ this repo, written by `tool/release.sh`.
- Host by key, not by address: loopback detection broke behind a - Host by key, not by address: loopback detection broke behind a
same-machine reverse proxy or tunnel. same-machine reverse proxy or tunnel.
- Server owns the clock; reports never move it: avoids ping-pong between clients. - 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 - Relic for the server, `web_socket_channel` for the client; packages are
chosen on merit, not locked. chosen on merit, not locked.
- Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS - Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS
+5 -1
View File
@@ -6,7 +6,11 @@ Layout and rules: [ARCHITECTURE.md](ARCHITECTURE.md).
Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop
build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev). build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev).
mpv libraries come from `media_kit_libs_video`. mpv libraries come from `media_kit_libs_video`. `fox_utils` (drag & drop,
clipboard files and images; native Linux/Windows plugin) is the owner's
shared package, a git dependency pinned to a tag of
`git.senkochan.moe/Foxyz/fox_utils`: bump it by tagging there and changing
`ref` in the desktop pubspec.
``` ```
cd apps/desktop && flutter pub get && flutter run -d linux cd apps/desktop && flutter pub get && flutter run -d linux
+6 -5
View File
@@ -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 (version, password, name, avatar). Wrong password or version: refused with
a reason. Otherwise the server answers with a welcome and the current state. 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 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 says we are, and buffers. Arriving while the video is stopped, you are
anyone back. waited for; arriving while it plays, you never hold anyone back.
3. **Ready gate.** Press Space to say you are ready. The server starts 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 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 (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. 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 Everyone who was there before it started must be ready, so one person
others. A lone viewer can play alone. 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 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) 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 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 5. **Seek.** Anyone can seek, anytime. The server moves the clock and
everybody follows. Seeking does not change anyone's ready flag. everybody follows. Seeking does not change anyone's ready flag.
6. **Swap.** Anyone can change the video by pasting another link. Everybody 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 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 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 (stops blocking) and removed after 30 s. The app reconnects by itself and
+8 -4
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 9) # Protocol (proto = 10)
Wire format only. Behavior (gate, clock, timeouts) is in Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth: [ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
## Client to server ## 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":"...", "media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"} "resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto {"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.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is `swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
dropped. `swap.media.relay` makes it relayed media (see "Relay"). 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 - Frames before `welcome` (other than `hello`) are ignored; unparsable or
unknown frames are ignored silently. unknown frames are ignored silently.
@@ -258,8 +261,9 @@ server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive
## Join and reconnect ## Join and reconnect
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server `hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
position and buffers. A joining member is `ready=false, inSet=false` until position and buffers. A joining member is `ready=false`; it is `inSet`
the first `ready`. A socket the server sees close loses its token; reconnect 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 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 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. drop), `resume` takes it over and the token and media URL stay the same.
+28 -7
View File
@@ -6,13 +6,33 @@ off by deleting it in the same commit as the code. Wire changes also edit
## Backlog ## Backlog
1. **Windows: first real use.** CI builds and publishes 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. **Files**: next episode from Files (by parsed episode, with the
countdown). Later: tracking for file series.
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).
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".
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.
6. **Windows: first real use.** CI builds and publishes
`watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the `watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
updater tests on NTFS; a friend has joined from it. Still unchecked: updater tests on NTFS; a friend has joined from it. Still unchecked:
fullscreen controls, hosting (firewall prompt), in-app update from one fullscreen controls, hosting (firewall prompt), in-app update from one
release to the next. The rest of the desktop tests are not in CI. release to the next. The rest of the desktop tests are not in CI.
2. **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: PC -> VPS -> friend, Jellyfin and a file. Then:
- Source takeover: when the source leaves, another member with an - Source takeover: when the source leaves, another member with an
account on that Jellyfin (or the same file: name, size, hash) takes account on that Jellyfin (or the same file: name, size, hash) takes
@@ -22,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 - Show who is relaying and how fast in the member list; warn the source
when its upload is below the media's bitrate. when its upload is below the media's bitrate.
3. **Jellyfin** (epic, below). 8. **Jellyfin** (epic, below).
4. **Smaller items** 9. **Smaller items**
- Video output robustness: if media_kit's hardware path regresses to - Video output robustness: if media_kit's hardware path regresses to
software (see ARCHITECTURE, Media: render mode), vendor a software (see ARCHITECTURE, Media: render mode), vendor a
media_kit_video fix that builds the EGL display from GDK's native media_kit_video fix that builds the EGL display from GDK's native
@@ -35,13 +55,14 @@ off by deleting it in the same commit as the code. Wire changes also edit
show the open link with an open-in-browser button. show the open link with an open-in-browser button.
- Time UI: remaining time, scrubber preview. - Time UI: remaining time, scrubber preview.
- mpv / mpv.net shortcut parity. - mpv / mpv.net shortcut parity.
- Sounds: replace `NoopSfxService` (cues already fire). Candidates:
Kenney Interface Sounds (CC0), ObsydianX Pack 1 (CC0), akx/Notifications
(CC0); playback via flutter_soloud. Keep seek/pause/resume near silent.
- Chaos suite: host drop 5 s / 30 s, viewer stall, 20-seek storm, long - Chaos suite: host drop 5 s / 30 s, viewer stall, 20-seek storm, long
outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no
OOM, no stuck session, drift recovers. OOM, no stuck session, drift recovers.
- Chat history pane (today: activity log only). - Chat history pane (today: activity log only).
- Images in chat (paste a screenshot; server memory only): possible,
not wanted yet.
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
to dio from `fox_utils` when touching them, for retries/interceptors.
- The `state` broadcast carries the full media URL, which can include an - The `state` broadcast carries the full media URL, which can include an
`api_key` (recognised Jellyfin links become refs; other links do not). `api_key` (recognised Jellyfin links become refs; other links do not).
- TLS: document a reverse proxy in front of the server. - TLS: document a reverse proxy in front of the server.
+1 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants { abstract class RelayConstants {
static const int protoVersion = 9; static const int protoVersion = 10;
// Relay (`/r/...`): a member's bytes through the server's buffer. // Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20; static const int relaySliceBytes = 1 << 20;
+11
View File
@@ -556,6 +556,17 @@ class MemberView {
} }
class StateBroadcast { class StateBroadcast {
/// Buffer an in-set member needs before playback can go on, as the
/// server's gate counts it: [RelayConstants.minBufferSecs], less near the
/// end (mpv cannot buffer past it), none once finished.
double get bufferNeed {
if (transport == 'finished') return 0;
final d = (media?['duration'] as num?)?.toDouble() ?? 0;
const full = RelayConstants.minBufferSecs;
if (d <= 0) return full;
return (d - position - RelayConstants.endSlackSecs).clamp(0.0, full).toDouble();
}
final int eventId; final int eventId;
final String transport; final String transport;
final double position; final double position;
+18 -11
View File
@@ -186,12 +186,17 @@ class SyncEngine {
return left.clamp(0.0, full).toDouble(); return left.clamp(0.0, full).toDouble();
} }
/// Any frame from a member: fresh again, and a ready member the stale /// Any frame from a member: fresh again. Back in the set: a ready member
/// sweep took out of the set goes back in (ready && !inSet only arises /// the stale sweep (or a swap while it was stale) took out (ready &&
/// from that sweep). /// !inSet only arises from those), and any member back from stale while
/// not playing (like a join then).
void _seen(EngineMember member) { void _seen(EngineMember member) {
final wasStale = isStale(member);
member.lastSeen = now; 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. /// Heartbeat: stale sweep, end of media.
@@ -202,8 +207,11 @@ class SyncEngine {
String applyJoin(String token, String name, String avatar) { String applyJoin(String token, String name, String avatar) {
final id = 'm${_nextId++}'; 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] = 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)); feed.add(FeedMessage(actor: name, action: 'join', at: now));
return id; return id;
} }
@@ -367,9 +375,11 @@ class SyncEngine {
serverTime = now; serverTime = now;
eventId++; eventId++;
transport = Transport.held; 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) { for (final member in members.values) {
member.ready = false; member.inSet = !isStale(member, t);
member.inSet = false;
member.buffered = 0; member.buffered = 0;
} }
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now)); 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(); final inSet = members.values.where((m) => m.inSet).toList();
if (inSet.isEmpty) return; 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( final allGood = inSet.every(
(m) => m.ready && m.buffered >= need && !isStale(m, t)); (m) => m.ready && m.buffered >= need && !isStale(m, t));
if (allGood && quorum) { if (allGood) {
serverTime = t; serverTime = t;
eventId++; eventId++;
transport = Transport.playing; transport = Transport.playing;
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter. description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.0.0 version: 1.0.1
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+78 -12
View File
@@ -37,12 +37,12 @@ void main() {
}); });
group('membership + gate', () { 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'); join('a', 'alice');
expect(engine.transport, Transport.held); expect(engine.transport, Transport.held);
expect(engine.members['a']!.inSet, isFalse); expect(engine.members['a']!.inSet, isTrue);
final state = engine.broadcast(); final state = engine.broadcast();
expect(state.members.single.inSet, isFalse); expect(state.members.single.inSet, isTrue);
}); });
test('single ready + buffered auto-plays', () { 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', () { test('one ready of two fresh stays held', () {
join('a', 'alice'); join('a', 'alice');
join('b', 'bob'); join('b', 'bob');
@@ -153,6 +168,7 @@ void main() {
t = t.add(const Duration(seconds: 16)); t = t.add(const Duration(seconds: 16));
engine.applyReady('a', true); engine.applyReady('a', true);
buffer('a', 6.0); buffer('a', 6.0);
engine.tick();
expect(engine.transport, Transport.playing); expect(engine.transport, Transport.playing);
}); });
@@ -257,19 +273,66 @@ void main() {
}); });
}); });
group('swap resets everything', () { group('swap', () {
test('swap bumps mediaId, clears sets, holds', () { 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'); join('a', 'alice');
engine.applyReady('a', true); engine.applyReady('a', true);
buffer('a', 4.0); buffer('a', 4.0);
expect(engine.transport, Transport.playing); expect(engine.transport, Transport.playing);
engine.applySwap('a', engine.applySwap('a', ep2);
const HelloMedia(name: 'Ep02.mkv', size: 9, duration: 9.0));
expect(engine.mediaId, 2); expect(engine.mediaId, 2);
expect(engine.transport, Transport.held); expect(engine.transport, Transport.held);
expect(engine.positionAt(), 0); expect(engine.positionAt(), 0);
expect(engine.members['a']!.inSet, isFalse); expect(engine.members['a']!.inSet, isTrue);
expect(engine.members['a']!.ready, isFalse); 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); isTrue);
}); });
test('unready member does not rejoin on reports', () { test('unready late joiner does not join on reports', () {
join('a', 'alice'); join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 6.0); buffer('a', 6.0);
expect(engine.members['a']!.inSet, isFalse); join('b', 'bob');
buffer('b', 6.0);
expect(engine.members['b']!.inSet, isFalse);
}); });
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter. description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none publish_to: none
version: 1.0.0 version: 1.0.1
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+2 -2
View File
@@ -80,8 +80,8 @@ void main() {
}); });
group('core welcome + proto', () { group('core welcome + proto', () {
test('proto 9: avatars by hash, profile intent', () { test('proto 10: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 9); expect(RelayConstants.protoVersion, 10);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false); 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('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse); expect(v.toJson().containsKey('avatar'), isFalse);
+6 -6
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.0.0", "version": "1.0.1",
"protocol": 9, "protocol": 10,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.0/watch-together-1.0.0-linux-x64.tar.gz", "url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.1/watch-together-1.0.1-linux-x64.tar.gz",
"notes": "- Player: now-playing title with cover, start banner, one status pill; media art for everyone", "notes": "- Space never presses a focused button; chat toasts open the chat; plain wheel always scrolls the page",
"assets": { "assets": {
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.0/watch-together-1.0.0-linux-x64.tar.gz", "linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.1/watch-together-1.0.1-linux-x64.tar.gz",
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.0/watch-together-1.0.0-windows-x64.zip" "windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.1/watch-together-1.0.1-windows-x64.zip"
} }
} }