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 2414 additions and 6132 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
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
Tab); joins, leaves, chat and other news also pop up bottom left with the
person's avatar.
Tab), ready to type; it shows messages, and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and
other news also pop up bottom left with the person's avatar; 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
colour is yours only.
@@ -83,8 +86,10 @@ disk. Nothing is uploaded or stored anywhere.
## Library
What to watch, from three sources (the switch at the top; Ctrl+V outside
a field pastes a link into Link):
What to watch, from three sources (the switch at the top). Drop a video,
a folder or a link anywhere on the window, or press Ctrl+V outside a
text field, and a small card asks how to open it (put it up, add the
folder to Files, or fill Link).
- **Jellyfin**: your linked servers (see below). Library chips beside the
switch, then one **Continue** row (what you were watching and what comes
@@ -102,8 +107,8 @@ a field pastes a link into Link):
thumbnail for YouTube, else the file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it.
- **Files**: video folders you add on this computer, grouped by series,
or any file through Browse. The others stream it from you.
- **Files**: video folders you add on this computer, grouped by series
(click a series to list its episodes), or any file through Browse. The others stream it from you.
Put it up and the player opens. Offline the library browses but nothing
goes up until you join or host ("Join or host to watch together").
@@ -149,8 +154,8 @@ of help: Profile; Party & network (the Host card's default port and
password, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; there
is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About
next episode automatically); Notifications & sounds (what pops up; the sound
volume, and every sound with a preview and its own switch); Updates; Keyboard shortcuts (read-only); About
(the version).
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
change applies right away.
Keys while the player fills the window: Space = ready / unready, Tab =
chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30,
Anywhere in a server (unless you are typing): Space = ready / unready,
Tab = chat & activity (Tab never moves between buttons). Keys while the
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
close the chat or fullscreen, else ask to leave, Ctrl+R = reload video,
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
@@ -174,7 +180,9 @@ Controls: the seek bar marks chapter starts with a diamond; hovering it
shows the time and the chapter title. The chapters button lists them
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
2.0 · title"; subtitles have Off.
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar
turns amber and mpv amplifies.
The uosc player design (⚙ → Player design, Standard by default,
switches right away): video controls modeled on mpv's uosc theme. A thick
@@ -189,12 +197,14 @@ the same.
Skipping: during the opening a "Skip OP" button floats in the corner (it
jumps to the end of the opening), during the ending "Skip ED" (to the end
of the ending only; what comes after is yours to skip). The opening and
of the ending only; what comes after is yours to skip). Any skip button
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while
the controls are up (move the mouse). The opening and
ending come from Jellyfin's intro / outro segments when your Jellyfin has
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on
anything longer than 20 minutes, "Skip 1:30" floats there during the first
6 minutes.
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
during the first 6 minutes.
## Ports
+3 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'src/screens/home/ui.dart';
import 'src/shell/app_shell.dart';
import 'src/widgets/app_keys.dart';
class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key});
@@ -11,7 +12,8 @@ class WatchTogetherApp extends StatelessWidget {
return MaterialApp(
title: appName,
theme: ThemeData.dark(),
home: const AppShell(),
shortcuts: appShortcuts,
home: const AppKeys(child: AppShell()),
);
}
}
@@ -10,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
import '../../shaders/shader_menu.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart';
import '../volume.dart';
import 'controls.dart';
import 'menu.dart';
import 'style.dart';
@@ -166,9 +167,10 @@ class _UoscBadgeButtonState extends State<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 {
/// 0..100.
/// 0..[maxVolume].
final double volume;
final ValueChanged<double> onChanged;
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
@@ -179,20 +181,43 @@ class UoscVolumeButton extends StatefulWidget {
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
double _beforeMute = 100;
bool _hover = false;
@override
Widget build(BuildContext context) {
final v = widget.volume;
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
return Listener(
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Listener(
onPointerSignal: (e) {
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0));
widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
}
},
child: UoscButton(
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) {
@@ -203,6 +228,9 @@ class _UoscVolumeButtonState extends State<UoscVolumeButton> {
}
},
),
],
),
),
);
}
}
@@ -26,6 +26,8 @@ class UoscControls extends StatefulWidget {
final Widget? topLeft;
final VoidCallback? onDoubleTap;
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
const UoscControls({
super.key,
required this.timeline,
@@ -35,6 +37,7 @@ class UoscControls extends StatefulWidget {
this.topLeft,
this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3),
this.shown,
});
static const buttonRowHeight = 40.0;
@@ -58,6 +61,10 @@ class UoscControlsState extends State<UoscControls> {
@override
void initState() {
super.initState();
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
});
_hideLater();
}
@@ -69,7 +76,10 @@ class UoscControlsState extends State<UoscControls> {
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) setState(() => _visible = true);
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater();
}
@@ -98,6 +108,7 @@ class UoscControlsState extends State<UoscControls> {
void _hide() {
if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false);
widget.shown?.value = false;
}
@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(),
),
],
),
),
);
},
);
}
}
+20 -2
View File
@@ -35,7 +35,11 @@ Widget _offlineTip(bool offline, Widget child) =>
class SeriesHeader extends StatelessWidget {
final SeriesGroup group;
final ThumbCache thumbs;
const SeriesHeader({super.key, required this.group, required this.thumbs});
/// Its episodes are listed below; [onTap] opens or closes them.
final bool expanded;
final VoidCallback? onTap;
const SeriesHeader(
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
@override
Widget build(BuildContext context) {
@@ -49,7 +53,12 @@ class SeriesHeader extends StatelessWidget {
?first.parsed.group,
].join(' · ');
return Padding(
padding: const EdgeInsets.only(top: 18, bottom: 4),
padding: const EdgeInsets.only(top: 14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
ClipRRect(
@@ -76,8 +85,17 @@ class SeriesHeader extends StatelessWidget {
],
),
),
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),
],
),
),
),
);
}
}
+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
// narrow), the open one on the right grouped into series and loose
// videos, or Recent. A folder is scanned when first opened and on Rescan;
// scans are kept for the app session. Playing goes through [onPlay].
// scans are kept for the app session. Series start collapsed; the ones
// opened stay open for the app session. Playing goes through [onPlay].
/// Below this width the sidebar turns into chips above the list.
const filesNarrowWidth = 700.0;
@@ -26,12 +27,23 @@ const filesNarrowWidth = 700.0;
final _scans = <String, FolderScan>{};
final _running = <String, Future<FolderScan>>{};
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
void resetFilesPageSession() {
_scans.clear();
_running.clear();
_expanded.clear();
_lastOpen = null;
}
@@ -73,22 +85,21 @@ class FilesPage extends StatefulWidget {
/// A scan grouped for display, made once per scan.
class _Library {
final GroupedVideos grouped;
/// Series rows in order: a header (video null), then its episodes and
/// extras.
final List<(SeriesGroup, VideoFile?)> rows;
final int subtitleFiles;
_Library(FolderScan scan)
: grouped = groupBySeries(scan.videos),
rows = [],
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length {
for (final g in grouped.series) {
rows.add((g, null));
for (final v in [...g.episodes, ...g.extras]) {
rows.add((g, v));
}
}
}
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
/// Series rows in order: a header (video null), then, for the series
/// [open] says, its episodes and extras.
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
for (final g in grouped.series) ...[
(g, null),
if (open(g))
for (final v in [...g.episodes, ...g.extras]) (g, v),
],
];
}
final _libraries = Expando<_Library>();
@@ -107,6 +118,7 @@ class _FilesPageState extends State<FilesPage> {
@override
void initState() {
super.initState();
_showRequests.addListener(_shown);
_store.ensureLoaded().then((_) {
if (!mounted) return;
final open = _open;
@@ -118,6 +130,14 @@ class _FilesPageState extends State<FilesPage> {
});
}
@override
void dispose() {
_showRequests.removeListener(_shown);
super.dispose();
}
void _shown() => _select(_lastOpen);
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
void _select(String? dir) {
@@ -477,6 +497,8 @@ class _FilesPageState extends State<FilesPage> {
if (scan.truncated) 'stopped after $maxScannedFiles files',
].join(' · ');
final loose = lib.grouped.loose;
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
return [
SliverToBoxAdapter(
child: Wrap(
@@ -547,13 +569,21 @@ class _FilesPageState extends State<FilesPage> {
'It may have been moved, renamed or be on a drive that is not connected.',
),
),
// Series: files whose names carry an episode number.
// Series: files whose names carry an episode number; a header
// click opens or closes its episodes.
SliverList.builder(
itemCount: lib.rows.length,
itemCount: rows.length,
itemBuilder: (context, i) {
final (group, v) = lib.rows[i];
final (group, v) = rows[i];
if (v == null) {
return SeriesHeader(key: ValueKey('series:${group.key}'), group: group, thumbs: _thumbs);
final id = seriesId(group);
return SeriesHeader(
key: ValueKey('series:${group.key}'),
group: group,
thumbs: _thumbs,
expanded: _expanded.contains(id),
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
);
}
return FileRow(
key: ValueKey(v.path),
@@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1);
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
/// inside the ending "Skip ED" (to its end only: what follows, preview or
/// more episode, is the viewer's call). Without any opening info on media
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
/// [fallbackWindow]. Null otherwise.
/// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
/// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
/// during the first [fallbackWindow]. Null otherwise.
SkipOffer? skipOfferAt({
required Duration position,
required Duration duration,
required SkipRange? opening,
required SkipRange? ending,
bool chaptered = false,
}) {
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) {
if (opening == null && !chaptered && duration > fallbackMinDuration && position < fallbackWindow) {
final to = position + fallbackSkip;
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
}
@@ -32,6 +32,9 @@ class PlaybackController {
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB.
static const maxBackBytes = 64 * 1024 * 1024;
/// mpv's `volume-max` (its default is 130): the volume controls go
/// this far, past 100 % amplified.
static const maxVolume = 150;
SyncPlayer? _player;
VideoController? _video;
@@ -39,7 +42,7 @@ class PlaybackController {
bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Duration>> _timeSubs = [];
final List<StreamSubscription<Object?>> _timeSubs = [];
String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none).
String _shaders = '';
@@ -163,6 +166,7 @@ class PlaybackController {
_logSub = _player!.stream.log.listen(_onLog);
_watchMarks(_player!);
await _pinBufferProps();
await _pin('volume-max', '$maxVolume');
await applyShaders();
_watchdog.reset();
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
@@ -426,9 +430,18 @@ class PlaybackController {
// track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) {
_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;
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) {
final ms = d.inMilliseconds;
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);
}
return HScroll(
wheel: false,
controller: _scroll,
builder: (c) => ListView.separated(
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 'update_indicator.dart';
/// Player keys, as handled by the session player's key handler (while
/// the player fills the window).
/// Session keys: Space and Tab anywhere in a server (`AppKeys`), the
/// rest by the session player's key handler (while it fills the window).
const sessionShortcuts = [
('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'),
('↑ / ↓', 'Volume up / down (up to 150 %)'),
('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'),
@@ -102,6 +102,17 @@ class ToastItem {
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
/// "Unready when the video changes": a state with a newer [mediaId] than
/// the last one this session saw ([lastMediaId], -1 before the first) while
/// self is ready (or a reconnect is restoring it). A swap keeps the
/// server's ready flags, so the client unreadies itself.
bool unreadyForNewMedia(
{required bool enabled,
required int lastMediaId,
required int mediaId,
required bool ready}) =>
enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready;
/// `media.relay` of a state, or null when the media is not relayed.
MediaRelay? stateMediaRelay(StateBroadcast state) {
final r = state.media?['relay'];
@@ -184,7 +195,7 @@ class SessionCoordinator {
final autoAdvance = signal<AutoAdvance?>(null);
/// The skip pill for the current position ([skipOfferAt]: Skip OP,
/// Skip ED, or Skip 1:30 without opening info); null while nothing
/// Skip ED, or Skip 1:30 without opening info or chapters); null while nothing
/// is open or at `finished`. Press it with [skip].
late final Computed<SkipOffer?> skipOffer = computed(() {
final r = serverState.value;
@@ -195,6 +206,7 @@ class SessionCoordinator {
duration: duration,
opening: playback.opening.value,
ending: playback.ending.value,
chaptered: playback.chapters.value.isNotEmpty,
);
});
@@ -223,6 +235,9 @@ class SessionCoordinator {
// Set after a reconnect until the server echoes the restored ready, so
// the fresh member's ready=false doesn't overwrite [_lastReady].
bool _restoringReady = false;
// Last mediaId seen in a state; unlike [_mediaId] it survives a
// reconnect, so only a real swap counts as a new video.
int _seenMediaId = -1;
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
int _epoch = 0;
Timer? _reconnectTimer;
@@ -260,6 +275,8 @@ class SessionCoordinator {
int _toastId = 0;
ConnectParams? _params;
bool _everConnected = false;
/// The connection-lost sound played; the reconnected one answers it once.
bool _lostSounded = false;
Map<String, String>? _openHeaders;
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
@@ -354,8 +371,10 @@ class SessionCoordinator {
_openUrl = null;
_lastReady = false;
_restoringReady = false;
_seenMediaId = -1;
_wasPlayAllowed = false;
_everConnected = false;
_lostSounded = false;
_resumeToken = '';
_seekGate.reset();
_clearPendingSeek();
@@ -423,6 +442,7 @@ class SessionCoordinator {
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
_scheduleReconnect();
}
});
@@ -664,12 +684,27 @@ class SessionCoordinator {
void _trackSelfReady(StateBroadcast r) {
final self = selfMember.peek();
if (self == null) return;
if (r.mediaId != _mediaId) _restoringReady = false;
// [_seenMediaId], not [_mediaId]: that one is -1 after a lost
// connection, which would end the restore before its echo.
if (_seenMediaId >= 0 && r.mediaId != _seenMediaId) _restoringReady = false;
if (_restoringReady) {
if (self.ready) _restoringReady = false;
} else {
_lastReady = self.ready;
}
final last = _seenMediaId;
_seenMediaId = r.mediaId;
// A ready restored by a reconnect may not have echoed yet: unready
// anyway (it was sent first, so this one lands last).
if (unreadyForNewMedia(
enabled: AppSettings.instance.unreadyOnSwap.peek(),
lastMediaId: last,
mediaId: r.mediaId,
ready: self.ready || _restoringReady)) {
_lastReady = false;
_restoringReady = false;
sync.sendReady(false, source: 'auto');
}
}
void _updateStarving(StateBroadcast r, double bufferedAhead) {
@@ -682,7 +717,7 @@ class SessionCoordinator {
return;
}
for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
if (m.inSet && m.buffered < r.bufferNeed) {
starving.value =
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
return;
@@ -727,7 +762,9 @@ class SessionCoordinator {
];
}
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;
if (f.action == 'ready') {
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
@@ -813,6 +850,7 @@ class SessionCoordinator {
if (!reconnecting.peek()) {
health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…');
_soundLost();
_scheduleReconnect();
}
}
@@ -1490,7 +1528,8 @@ class SessionCoordinator {
reconnecting.value = false;
return;
}
final wantReady = _lastReady;
var wantReady = _lastReady;
final seenBefore = _seenMediaId;
try {
health.value = const HealthStatus(
HealthSeverity.info, 'reconnecting…');
@@ -1507,6 +1546,10 @@ class SessionCoordinator {
if (stale()) return;
reconnecting.value = false;
_everConnected = true;
if (_lostSounded) {
_lostSounded = false;
unawaited(sfx.play(const SfxCue(SfxKind.back)));
}
_resumeToken = sync.token;
_lastStateAt = DateTime.now().toUtc();
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
@@ -1517,6 +1560,15 @@ class SessionCoordinator {
if (stale()) return;
// The server forgot the dropped member; restore the ready flag it
// last reported (not a local guess: others may have forced it).
// Not when the video changed meanwhile and the user unreadies then.
if (unreadyForNewMedia(
enabled: AppSettings.instance.unreadyOnSwap.peek(),
lastMediaId: seenBefore,
mediaId: sync.lastState?.mediaId ?? seenBefore,
ready: wantReady)) {
wantReady = false;
_lastReady = false;
}
if (wantReady) {
_lastReady = true;
_restoringReady = true;
@@ -1531,6 +1583,12 @@ class SessionCoordinator {
}
}
void _soundLost() {
if (!_everConnected || _lostSounded) return;
_lostSounded = true;
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
}
void _invalidateAttempts() {
_epoch++;
_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 {
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
ready,
unready,
/// Someone marks everyone ready.
forceReady,
forceUnready,
forcePlay,
forceHold,
kick,
join,
leave,
stale,
kick,
/// A new video or episode is put up.
swap,
seek,
pause,
resume,
chat,
/// Idle warning: auto-unready soon.
warn,
/// The connection to the server dropped / came back.
lost,
back,
}
class SfxCue {
@@ -23,42 +25,17 @@ class SfxCue {
const SfxCue(this.kind, {this.readyIndex = 0});
}
SfxKind? sfxForFeedAction(String action) {
switch (action) {
case 'ready':
return SfxKind.ready;
case 'unready':
return SfxKind.unready;
case 'force-ready':
return SfxKind.forceReady;
case 'force-unready':
return SfxKind.forceUnready;
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;
}
}
SfxKind? sfxForFeedAction(String action) => switch (action) {
'ready' => SfxKind.ready,
'unready' => SfxKind.unready,
'force-ready' => SfxKind.forceReady,
'kick' => SfxKind.kick,
'join' => SfxKind.join,
'leave' => SfxKind.leave,
'swap' => SfxKind.swap,
'chat' => SfxKind.chat,
_ => null,
};
abstract class SfxService {
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:io' show File;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
@@ -18,6 +22,7 @@ import '../state/settings.dart';
import '../update/update_controller.dart';
import 'chat_panel.dart';
import 'connect_cards.dart';
import 'handoff.dart';
import 'library_tab.dart';
import 'motion.dart';
import 'party_strip.dart';
@@ -36,6 +41,9 @@ import 'toasts.dart';
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
/// fades the layer out, moves it while invisible and fades it back in, so
/// the video is never mounted twice nor remounted.
///
/// A drop anywhere on the window or Ctrl+V outside a text field is a
/// [Handoff]: a card near the pointer asks how to open it.
class AppShell extends StatefulWidget {
final UpdateController? updates;
final bool autoUpdateCheck;
@@ -88,6 +96,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Offset? _drag; // mini top-left while dragging
bool _snapping = false;
/// Last pointer position in the window (where a paste's card opens).
Offset? _pointer;
bool _dragging = false;
bool _handoffOpen = false;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm);
@@ -128,6 +141,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKicked;
HardwareKeyboard.instance.addHandler(_onKey);
final session = SessionState.instance;
final coord = session.coordinator;
_feedSeen = coord.feedLog.peek().length;
@@ -195,6 +209,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final stop in _stops) {
stop();
}
HardwareKeyboard.instance.removeHandler(_onKey);
_fade.dispose();
_online.dispose();
_hasMedia.dispose();
@@ -368,6 +383,122 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
return null;
}
// --------------------------------------------------------------- handoff
/// Nothing (a dialog, a popover) is over the shell.
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true);
/// Ctrl+V outside a text field (a focused field pastes on its own).
bool _onKey(KeyEvent e) {
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
final keys = HardwareKeyboard.instance;
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
if (!mounted || !_onTop) return false;
final inField =
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<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
Future<void> _confirmLeave() async {
@@ -426,7 +557,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return CallbackShortcuts(
final shell = CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
},
@@ -474,6 +605,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}),
),
);
return Listener(
onPointerHover: (e) => _pointer = e.localPosition,
onPointerDown: (e) => _pointer = e.localPosition,
child: _dropTarget(shell),
);
}
Widget _body(bool online) {
@@ -533,7 +669,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
),
),
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),
image: memberAvatarImage(m, coord),
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,
stale: m.stale,
// The state marks the host (`members[].host`); your own app also
+85 -14
View File
@@ -1,22 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart';
import '../widgets/text_focus.dart';
import 'avatar.dart';
/// Side panel: chat with avatars, other activity as quiet lines, and the
/// message field. Width is fixed; the shell slides it in and out.
class ChatPanel extends StatelessWidget {
/// Activity lines (joins, ready, seeks...) between the messages: off by
/// default, kept for the app's run.
final showActivity = ValueNotifier(false);
/// Side panel: chat with avatars, other activity as quiet lines (behind
/// the "Activity" toggle), and the message field. Width is fixed; the
/// shell slides it in and out. The field takes focus on open; Tab and Esc
/// in it close the panel.
class ChatPanel extends StatefulWidget {
static const width = 340.0;
final TextEditingController chat;
final FocusNode chatFocus;
final VoidCallback onClose;
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
@override
State<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() {
SessionState.instance.coordinator.sendChat(chat.text);
chat.clear();
chatFocus.requestFocus();
SessionState.instance.coordinator.sendChat(widget.chat.text);
widget.chat.clear();
widget.chatFocus.requestFocus();
}
KeyEventResult _fieldKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey != LogicalKeyboardKey.tab && e.logicalKey != LogicalKeyboardKey.escape) {
return KeyEventResult.ignored;
}
widget.onClose();
return KeyEventResult.handled;
}
@override
@@ -24,7 +72,7 @@ class ChatPanel extends StatelessWidget {
final scheme = Theme.of(context).colorScheme;
return Container(
key: const ValueKey('chat-panel'),
width: width,
width: ChatPanel.width,
color: scheme.surfaceContainerLow,
child: Column(
children: [
@@ -32,18 +80,35 @@ class ChatPanel extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
child: Row(
children: [
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)),
const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)),
ValueListenableBuilder<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(
child: PartyWatch(builder: (context) {
final log = SessionState.instance.coordinator.feedLog.value;
child: ValueListenableBuilder<bool>(
valueListenable: showActivity,
builder: (context, activity, _) => PartyWatch(builder: (context) {
final all = SessionState.instance.coordinator.feedLog.value;
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
if (log.isEmpty) {
return Center(
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
child: Text(activity ? 'Nothing yet' : 'No messages yet',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
);
}
return ListView.builder(
@@ -54,12 +119,17 @@ class ChatPanel extends StatelessWidget {
);
}),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: _fieldKey,
child: TextField(
key: const ValueKey('chat-field'),
controller: chat,
focusNode: chatFocus,
controller: widget.chat,
focusNode: widget.chatFocus,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText: 'Message (Enter)',
@@ -70,6 +140,7 @@ class ChatPanel extends StatelessWidget {
),
),
),
),
],
),
);
+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),
const SizedBox(height: 6),
Text(
'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.',
'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.',
textAlign: TextAlign.center,
style: TextStyle(color: scheme.onSurfaceVariant),
),
+1 -28
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
@@ -189,32 +188,6 @@ class _LibraryTabState extends State<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
@override
@@ -306,10 +279,10 @@ class _LibraryTabState extends State<LibraryTab> {
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
);
// Holds focus so the shell's shortcuts (Ctrl+,) work.
return Focus(
key: const ValueKey('library-tab'),
autofocus: true,
onKeyEvent: _onKey,
child: LayoutBuilder(builder: (context, box) {
if (box.maxWidth >= detailsDockFrom) {
return Row(
+7 -7
View File
@@ -90,17 +90,17 @@ Future<T?> showPopover<T>(
},
);
/// Horizontal lists for mouse users: drag with the mouse, and the plain
/// (vertical) wheel moves them sideways. At either end the wheel is left
/// to the page, so a row never traps vertical scrolling.
/// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
/// the plain wheel stays vertical (the page scrolls under the cursor, not
/// the row).
class HScroll extends StatefulWidget {
final ScrollController? controller;
final Widget Function(ScrollController controller) builder;
/// Map the plain (vertical) wheel to sideways scrolling. Off where the
/// children scroll vertically themselves (the schedule's day columns):
/// there only mouse drag and Shift+wheel move sideways.
/// Also map the plain (vertical) wheel to sideways scrolling (at either
/// end the wheel goes to the page). Off by default: one wheel doing two
/// things was confusing.
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
State<HScroll> createState() => _HScrollState();
+13 -17
View File
@@ -1,17 +1,16 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import '../screens/demo/shell_demo.dart';
import '../screens/home/update_indicator.dart';
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart';
import '../update/update_controller.dart';
import '../widgets/app_keys.dart';
import '../widgets/dialogs.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
@@ -29,6 +28,9 @@ enum ShellTab {
const partyStripHeight = 60.0;
/// Every avatar in the strip, yours and the party's.
const stripAvatarSize = 42.0;
/// Below this window width the strip shows 3 avatars and no server text,
/// so the tabs keep their room.
const stripCompactBelow = 1100.0;
@@ -90,6 +92,7 @@ class PartyStrip extends StatelessWidget {
message: 'Your profile',
child: InkWell(
key: const ValueKey('strip-profile'),
canRequestFocus: false,
customBorder: const CircleBorder(),
onTap: onProfile,
child: StatusAvatar(
@@ -97,7 +100,7 @@ class PartyStrip extends StatelessWidget {
ready: self?.ready ?? false,
host: online && coord.hostFile != null,
),
size: 32,
size: stripAvatarSize,
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),
const SizedBox(width: 8),
if (online) ...[
@@ -153,18 +144,22 @@ class PartyStrip extends StatelessWidget {
key: const ValueKey('party-avatars'),
members: partyAvatars(coord),
shown: compact ? 3 : 5,
size: stripAvatarSize,
onTap: () => showMembersCard(context),
),
),
const SizedBox(width: 10),
if (!compact) _ServerLabel(scheme: scheme),
const SizedBox(width: 6),
Badge(
// Tab toggles the chat while the strip is shown.
ChatKey(
onToggle: onChat,
child: Badge(
isLabelVisible: unread > 0,
label: Text('$unread'),
child: IconButton(
key: const ValueKey('strip-chat'),
tooltip: 'Chat & activity',
tooltip: 'Chat & activity (Tab)',
isSelected: chatOpen,
onPressed: onChat,
icon: const Icon(Icons.chat_bubble_outline, size: 20),
@@ -175,6 +170,7 @@ class PartyStrip extends StatelessWidget {
),
),
),
),
const SizedBox(width: 4),
OutlinedButton(
key: const ValueKey('strip-leave'),
+52 -30
View File
@@ -87,16 +87,18 @@ StatusView? playerStatusFor({
}
// As the server holds playback: someone in the ready set short of the
// minimum buffer.
final need = r.bufferNeed;
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 p = (m.buffered / need).clamp(0.0, 1.0);
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) {
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 others = [
@@ -193,7 +195,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
child: GestureDetector(
key: const ValueKey('player-status'),
onTap: ready ? coord.toggleReady : null,
child: _Glass(
child: PlayerGlass(
child: AnimatedSize(
duration: easeTime,
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;
const _Glass({required this.child});
const PlayerGlass({super.key, required this.child});
@override
Widget build(BuildContext context) {
@@ -250,7 +253,7 @@ class _StatusLine extends StatelessWidget {
final ValueChanged<StatusAction> 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() {
final v = view;
@@ -259,7 +262,7 @@ class _StatusLine extends StatelessWidget {
case StatusKind.problem:
final error = v.tone == HealthSeverity.error;
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:
return const _Spinner(color: Colors.amber);
case StatusKind.connecting:
@@ -275,9 +278,26 @@ class _StatusLine extends StatelessWidget {
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
];
if (infos.isEmpty) return null;
return infos.length == 1
final avatar = infos.length == 1
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
: 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) {
final v = view;
final lead = _lead();
final avatars = v.who.isNotEmpty;
final action = v.action == StatusAction.ready ? null : v.action;
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(
mainAxisSize: MainAxisSize.min,
children: [
if (lead != null) ...[
SizedBox(height: _avatar, child: Center(child: lead)),
const SizedBox(width: 8),
SizedBox(height: _avatar + 6, child: Center(child: lead)),
SizedBox(width: avatars ? 10 : 8),
],
Flexible(
child: Text(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: _avatar),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
v.text,
key: const ValueKey('player-status-text'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
),
),
if (v.progress != null) ...[
const SizedBox(width: 8),
SizedBox.square(
dimension: 14,
child: CircularProgressIndicator(
value: v.progress,
strokeWidth: 2,
color: Colors.white,
backgroundColor: Colors.white24,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
),
if (v.detail != null)
Text(
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) ...[
const SizedBox(width: 10),
const SizedBox(width: 12),
_PillButton(
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
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/material.dart';
import 'package:flutter/services.dart';
@@ -10,7 +13,8 @@ import '../controls/player_settings.dart';
import '../controls/uosc/buttons.dart';
import '../controls/uosc/controls.dart';
import '../controls/uosc/timeline.dart';
import '../playback/media_marks.dart' show SkipOffer, formatClock;
import '../controls/volume.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart';
import '../session/session_coordinator.dart';
@@ -18,6 +22,7 @@ import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
import '../widgets/app_keys.dart';
import '../widgets/shown_frame_probe.dart';
import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart';
@@ -96,9 +101,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
int _toastSeq = 0;
int _shaderNoticeSeen = 0;
bool _miniHover = false;
// Whether the standard controls are up (tracked like media_kit does:
// shown on mount and on mouse movement, hidden 3 s later or on exit).
final _standardShown = ValueNotifier(true);
// Whether the controls are up: the standard ones tracked like media_kit
// does (shown on mount and on mouse movement, hidden 3 s later or on
// exit), the uosc ones reporting it.
final _controlsShown = ValueNotifier(true);
SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full;
@@ -109,6 +115,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
_coord.jellyfin.ensureLoaded();
_coord.playback.shownFrame = _frameProbe.sample;
HardwareKeyboard.instance.addHandler(_globalKey);
// Tab (AppKeys) reaches the chat through here in fullscreen.
AppKeys.addChatToggle(_toggleChat);
// Shader messages (preset changes, load failures) become toasts.
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
_stopShaderWatch = effect(() {
@@ -122,11 +130,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey);
AppKeys.removeChatToggle(_toggleChat);
final playback = _coord.playback;
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
_stopShaderWatch();
_noFocus.dispose();
_standardShown.dispose();
_controlsShown.dispose();
super.dispose();
}
@@ -152,7 +161,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
/// The player keys, while the player fills the body and nothing (a
/// dialog, a card, a text field) is in front of it.
/// dialog, a card, a text field) is in front of it. Space and Tab are
/// app-wide ([AppKeys]).
bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent || !mounted || !_full) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false;
@@ -184,11 +194,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
_reloadVideo();
return true;
}
if (key == LogicalKeyboardKey.space) {
_lastKeyAt = now;
coord.toggleReady();
return true;
}
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
final shift = HardwareKeyboard.instance.isShiftPressed;
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
@@ -213,9 +218,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
coord.skipForward();
return true;
}
if (key == LogicalKeyboardKey.tab) {
_lastKeyAt = now;
_toggleChat();
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
final player = coord.playback.player;
player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5));
return true;
}
if (key == LogicalKeyboardKey.arrowRight) {
@@ -282,7 +287,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
_status(),
_progressLine(),
_jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(),
if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
] else
_miniOverlay(),
],
@@ -340,7 +345,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
final s = AppSettings.instance;
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: _standardShown,
valueListenable: _controlsShown,
builder: (context, shown, _) => shown
? const SizedBox.shrink()
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
@@ -437,7 +442,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
onPressed: coord.playNextEpisode,
),
),
const MaterialDesktopVolumeButton(),
VolumeButton(player: playback.player, focusNode: _noFocus),
_ReadyControlButton(focusNode: _noFocus),
_BarTextButton(
focusNode: _noFocus,
@@ -504,7 +509,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _controlsSwitch(VideoState state) {
return SessionControlsSwitch(
material: (_) => ControlsHoverProbe(
shown: _standardShown,
shown: _controlsShown,
hideAfter: _controlsHide,
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
@@ -536,12 +541,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
);
});
return UoscControls(
shown: _controlsShown,
onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(),
top: Row(
mainAxisSize: MainAxisSize.min,
children: [
UoscButton(icon: Icons.chat_bubble_outline_rounded, tooltip: 'Chat & activity (Tab)', onPressed: _toggleChat),
UoscButton(
key: const ValueKey('uosc-chat'),
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
tooltip: 'Chat & activity (Tab)',
badge: widget.unread,
onPressed: _toggleChat,
),
UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
@@ -653,7 +665,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
/// 1:30) above it. Outside the auto-hiding controls, so they stay up.
/// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so
/// they stay up.
Widget _jellyfinOverlay() {
final coord = _coord;
return Positioned.fill(
@@ -695,7 +708,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 10,
children: [
if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
if (skip != null)
SkipPillGate(
key: const ValueKey('skip-gate'),
offer: skip,
controlsShown: _controlsShown,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
),
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
],
),
@@ -723,6 +742,40 @@ class _SessionPlayerState extends State<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
/// bar at the bottom. A thin progress line stays while not hovered.
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 = [
LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1,
+94 -5
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
@@ -7,7 +9,11 @@ import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_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/settings.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -73,7 +79,23 @@ class SettingsTab extends StatelessWidget {
),
];
case SettingsSection.network:
return const [HostDefaultsPanel(), RecentServersPanel()];
return [
const HostDefaultsPanel(),
const RecentServersPanel(),
SettingsPanel(
title: 'Watching',
child: SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('unready-on-swap'),
contentPadding: EdgeInsets.zero,
title: const Text('Unready when the video changes'),
subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'),
value: AppSettings.instance.unreadyOnSwap.value,
onChanged: AppSettings.instance.setUnreadyOnSwap,
),
),
),
];
case SettingsSection.player:
return [
const SettingsHintCard(
@@ -431,11 +453,78 @@ class NotificationsInfo extends StatelessWidget {
],
),
),
SettingsPanel(
title: 'Sounds',
child: line('No sounds yet', 'The app is silent; only the video makes noise'),
),
const SettingsPanel(title: 'Sounds', child: SoundSettings()),
],
);
}
}
/// 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),
),
],
),
),
],
);
});
}
}
+7 -3
View File
@@ -26,9 +26,11 @@ class StartCard extends StatelessWidget {
final episode = now.episode;
return LayoutBuilder(builder: (context, box) {
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 titleSize = (h * 0.05).clamp(22.0, 44.0);
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0);
final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
return Stack(
fit: StackFit.expand,
children: [
@@ -76,12 +78,14 @@ class StartCard extends StatelessWidget {
Positioned(
left: side,
right: side,
bottom: startCardBottom,
bottom: small ? 12 : startCardBottom,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!small) ...[
ArtCover(hash: now.posterHash, width: coverWidth),
const SizedBox(width: 24),
],
Expanded(
child: Column(
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
/// 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 {
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
State<PartyToasts> createState() => _PartyToastsState();
@@ -101,13 +104,37 @@ class _PartyToastsState extends State<PartyToasts> {
super.dispose();
}
void _open(ShellToast t) {
widget.onOpenChat?.call();
setState(() => t.leaving = true);
}
@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 {
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
Widget build(BuildContext context) {
@@ -139,7 +166,7 @@ class ToastColumn extends StatelessWidget {
duration: easeTime,
curve: ease,
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/session_media.dart';
import '../sfx/sfx.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart';
import 'server_isolate.dart';
import 'settings.dart';
enum AppRoute {
home,
@@ -153,7 +155,10 @@ class SessionState {
final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []);
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);
final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
+31
View File
@@ -20,6 +20,9 @@ const _keyAutoAdvance = 'autoAdvance';
const _keyMpvControls = 'mpvControls';
const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume';
const _keySoundsOff = 'soundsOff';
const _keyUnreadyOnSwap = 'unreadyOnSwap';
enum HwdecMode { auto, off, custom }
@@ -31,6 +34,9 @@ class AppSettings {
final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
final autoAdvance = signal(true);
/// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags).
final unreadyOnSwap = signal(false);
/// Player design: the uosc controls (`controls/uosc/`) instead of the
/// standard ones (media_kit's with our bars). Applies live.
final mpvControls = signal(false);
@@ -40,6 +46,10 @@ class AppSettings {
/// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live.
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
/// in the session (Ctrl+0..6, the shader button).
@@ -76,12 +86,18 @@ class AppSettings {
if (auto is bool) autoCheckUpdates.value = auto;
final advance = box.get(_keyAutoAdvance);
if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap);
if (unready is bool) unreadyOnSwap.value = unready;
final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv;
final hideRe = box.get(_keyHideReReleases);
if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine);
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);
shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -119,6 +135,11 @@ class AppSettings {
_put(_keyAutoAdvance, on);
}
void setUnreadyOnSwap(bool on) {
unreadyOnSwap.value = on;
_put(_keyUnreadyOnSwap, on);
}
void setMpvControls(bool on) {
mpvControls.value = on;
_put(_keyMpvControls, on);
@@ -129,6 +150,16 @@ class AppSettings {
_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) {
hideReReleases.value = on;
_put(_keyHideReReleases, on);
+9 -1
View File
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
return false;
}
/// At `finished`, a player at most this far behind the end keeps playing
/// to it.
const finishSlackSecs = 3.0;
class StateDiff {
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). 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') {
final first = _lastEventId == -1;
final changed = first ||
@@ -87,7 +93,9 @@ class StateDiff {
if (!changed) return out;
out.add(const RateAction(1.0));
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
if (first || state.position - ownPosition > finishSlackSecs) {
out.add(const TransportAction(false));
}
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 <fox_utils/fox_utils_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <window_manager/window_manager_plugin.h>
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
fox_utils
media_kit_libs_linux
media_kit_video
screen_retriever_linux
+27 -2
View File
@@ -105,6 +105,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -208,6 +224,15 @@ packages:
description: flutter
source: sdk
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:
dependency: "direct main"
description:
@@ -723,14 +748,14 @@ packages:
path: "../../packages/core"
relative: true
source: path
version: "0.2.2"
version: "1.0.0"
sync_relay_server:
dependency: "direct main"
description:
path: "../../packages/server"
relative: true
source: path
version: "0.2.2"
version: "1.0.0"
synchronized:
dependency: transitive
description:
+6 -1
View File
@@ -1,7 +1,7 @@
name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none
version: 1.0.0
version: 1.0.1
environment:
sdk: '>=3.13.0-0 <4.0.0'
@@ -25,6 +25,11 @@ dependencies:
window_manager: ^0.5.2
archive: ^4.0.7
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:
flutter_test:
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:hive_ce/hive.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/sync/sync_client.dart';
@@ -214,7 +215,8 @@ void main() {
MediaKit.ensureInitialized();
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
await server.start();
final coord = SessionCoordinator();
final sounds = _RecordingSfx();
final coord = SessionCoordinator(sfx: sounds);
final friend = SyncClient();
try {
await coord.connect(ConnectParams(
@@ -227,6 +229,12 @@ void main() {
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: 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 {
await friend.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);
}
/// 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() {
setUp(() async {
tmp = Directory.systemTemp.createTempSync('files_page_');
@@ -147,6 +153,9 @@ void main() {
expect(find.byType(SeriesHeader), findsOneWidget);
expect(find.text('Show'), findsOneWidget);
expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget);
// Collapsed until clicked; open for the rest of the session.
expect(find.text('E01'), findsNothing);
await openSeries(tester);
for (final l in ['E01', 'E02', 'E03', 'NCOP1']) {
expect(find.text(l), findsOneWidget);
}
@@ -157,6 +166,13 @@ void main() {
// Durations come from the thumbnail.
expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
expect(find.text('24:00'), findsOneWidget); // card badge
await openFolder(tester, 'Recent');
await openFolder(tester, 'anime');
expect(find.text('E01'), findsOneWidget);
await openSeries(tester);
expect(find.text('E01'), findsNothing);
expect(find.byType(VideoCard), findsOneWidget);
});
testWidgets('hidden files are counted and some can be shown', (tester) async {
@@ -180,6 +196,7 @@ void main() {
store.addFolder(anime);
await pumpPage(tester);
await openFolder(tester, 'anime');
await openSeries(tester);
touch('$anime/[Grp] Show - 04.mkv');
expect(find.text('E04'), findsNothing);
await tester.tap(find.text('Rescan'));
@@ -197,6 +214,7 @@ void main() {
return null;
});
await openFolder(tester, 'anime');
await openSeries(tester);
expect(find.text('E01'), findsOneWidget);
await tester.tap(find.text('E01'));
await tester.tap(find.text('Some Movie (2019)'));
@@ -216,6 +234,7 @@ void main() {
});
expect(find.textContaining('Keep the app open while it plays'), findsOneWidget);
await openFolder(tester, 'anime');
await openSeries(tester);
await tester.tap(find.text('E02'));
await frames(tester);
@@ -254,6 +273,7 @@ void main() {
));
await frames(tester);
await openFolder(tester, 'anime');
await openSeries(tester);
expect(find.text('now playing'), findsOneWidget);
expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
});
@@ -267,6 +287,7 @@ void main() {
await tester.tap(find.widgetWithText(InputChip, 'anime'));
await frames(tester);
expect(find.text('$anime/'), findsOneWidget);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget);
// The chip's x removes the folder, like the sidebar's.
await tester.tap(find.byTooltip('Remove folder'));
@@ -287,6 +308,7 @@ void main() {
expect(tester.takeException(), isNull);
await tester.tap(find.text('anime').first);
await frames(tester);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.text('Show some'));
@@ -312,6 +334,7 @@ void main() {
return Completer<Thumbnail?>().future; // never finishes
});
await openFolder(tester, 'big');
await openSeries(tester, 'Long Show');
expect(find.text('E01'), findsOneWidget);
final first = thumbRequests.toSet();
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.relaying.peek(), isTrue);
expect(b.jellyfinBlocked.peek(), isNull);
for (final x in [a, b]) {
// Everyone present counts for the gate, Dave too.
for (final x in [a, b, d]) {
x.toggleReady();
}
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing');
@@ -511,7 +512,7 @@ void main() {
expect(jelly.streams('ATOKEN'), isNotEmpty);
expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
for (final x in [a, b]) {
for (final x in [a, b, d]) {
x.toggleReady();
}
+1 -19
View File
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
@@ -278,7 +277,7 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('link: preview, how the others get it, put up, recent links, Ctrl+V', (tester) async {
testWidgets('link: preview, how the others get it, put up, recent links', (tester) async {
final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link);
expect(find.text('How the others get it'), findsOneWidget);
const url = 'https://www.youtube.com/watch?v=abc';
@@ -304,23 +303,6 @@ void main() {
await settle(tester, 10);
expect(h.controller.link.text, url);
// Ctrl+V outside a field, on another source: the link lands in Link.
await tester.tap(find.text('Jellyfin'));
await settle(tester, 10);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'};
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
FocusManager.instance.primaryFocus?.unfocus();
Focus.of(tester.element(find.byKey(const ValueKey('media-source')))).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await settle(tester, 10);
expect(h.controller.source, MediaKind.link);
expect(h.controller.link.text, 'https://cdn.example/b.mkv');
expect(tester.takeException(), isNull);
});
+11 -2
View File
@@ -111,8 +111,14 @@ void main() {
group('skip offers', () {
final op = SkipRange(s(30), m(2), SkipSource.chapter);
final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter);
SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt(
position: p, duration: duration ?? m(24), opening: opening, ending: ending);
SkipOffer? at(Duration p,
{SkipRange? opening, SkipRange? ending, Duration? duration, bool chaptered = false}) =>
skipOfferAt(
position: p,
duration: duration ?? m(24),
opening: opening,
ending: ending,
chaptered: chaptered);
test('Skip OP inside the opening, to its end', () {
expect(at(s(29), opening: op, ending: ed), isNull);
@@ -141,6 +147,9 @@ void main() {
expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30)));
// An opening known (but not playing) means no fallback.
expect(at(m(3), opening: op), isNull);
// Chapters without an OP chapter: no opening on purpose.
expect(at(m(1), chaptered: true), isNull);
expect(at(m(20, 10), ending: ed, chaptered: true), SkipOffer(SkipKind.ending, m(21, 30)));
});
});
+10
View File
@@ -91,4 +91,14 @@ void main() {
expect(n.contains('SECRET'), isFalse);
});
});
test('unreadyForNewMedia: only a real swap while ready, setting on', () {
bool u({bool enabled = true, int last = 2, int id = 3, bool ready = true}) =>
unreadyForNewMedia(enabled: enabled, lastMediaId: last, mediaId: id, ready: ready);
expect(u(), isTrue);
expect(u(enabled: false), isFalse);
expect(u(ready: false), isFalse);
expect(u(id: 2), isFalse, reason: 'same media');
expect(u(last: -1), isFalse, reason: 'first state of a session is no swap');
});
}
+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 {
final pushed = <String?>[];
final p = Profile.instance;
+7 -4
View File
@@ -158,12 +158,15 @@ void main() {
});
group('finished', () {
test('stops in place: pause, no seek to the end, no drift seeks', () {
final diff = StateDiff();
test('a player just behind plays out; far behind pauses; no seeks', () {
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 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<TransportAction>().single.playing, isFalse);
// Far off and later: still nothing.
@@ -6,12 +6,15 @@
#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_video/media_kit_video_plugin_c_api.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FoxUtilsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FoxUtilsPlugin"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar(
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
fox_utils
media_kit_libs_windows_video
media_kit_video
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
duration for the current media (swap resets it); a report with `eventId`
older than current has its position ignored.
- **Ready gate** (HELD to PLAYING): the in-set is non-empty, every in-set
member is ready, fresh and has `buffered >= need`, and quorum holds: in-set
size >= 2, unless at most one member is fresh. `need` is 4 s
(`minBufferSecs`), or near the end what is left minus 2 s (`endSlackSecs`,
floor 0), since mpv cannot buffer past the end. So a lone viewer plays
alone; with a second viewer present one Space waits for the other. Joining
members start with `inSet=false, ready=false` and never block. Disconnect
removes from the set; swap clears everyone.
- **Ready gate** (HELD to PLAYING): the in-set is non-empty and every
in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s
(`minBufferSecs`), or near the end what is left minus 2 s
(`endSlackSecs`, floor 0), since mpv cannot buffer past the end. So a
lone viewer plays alone; with others present everyone must ready.
- **In-set** (who the gate waits for): a member joins it on joining, or
on coming back from stale, while not PLAYING (any media, the one the
server started with included); joining while PLAYING it stays out
(`ready=false`) until it readies: a late joiner never pauses anyone. A
swap puts every fresh member in the set and keeps everyone's ready flag,
so the next video starts once everyone ready has buffered (the client
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
default off, sends `ready=false` itself on a new `mediaId`; not for the
first state of a session, and a reconnect does not restore a ready that
a swap meanwhile would have dropped). Stale, leave and kick remove from
the set.
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
`need` buffered or stale flips the server to HELD (feed action `pause` by
`server`). Recovery resumes by itself when the gate passes.
- **End**: while PLAYING with a known duration, reaching it sets FINISHED
(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.
- **Seek**: allowed from any member in any transport; the server writes the
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.
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
gate, `stale` feed); over 30 s the socket is closed and the member removed.
A stale member that was ready goes back into the set with its next frame.
A stale member goes back into the set with its next frame if it is ready
(also one stale at a swap) or the server is not PLAYING.
Closed sockets are polled with `isClosed` every second: Relic's server-side
`onDone` does not fire for dead peers, so presence never relies on it.
- **Resume**: a reconnecting client sends its previous token as
@@ -137,7 +148,7 @@ Loosely modeled on Syncplay, with a different readiness design.
moment playback stopped. While HELD and self ready: warning at 8 min,
auto-unready at 10 min, and never less than 2 min after the warning.
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
pause-on-leave; anyone can force ready/unready (all or one); quorum gate;
pause-on-leave; anyone can force ready/unready (all or one); presence gate;
the clock is server time (clients correct for their offset to it).
Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
@@ -285,8 +296,9 @@ Skip rules (pure, `media_marks.dart`):
- `skipOfferAt` (the coordinator's `skipOffer`, null at `finished`): inside
the opening (up to its last second) "Skip OP" to its end; inside the
ending "Skip ED" to its end only (no guessing about previews: the viewer
decides). Without any opening and with media over 20 min, "Skip 1:30"
during the first 6 min.
decides). Without any opening, without chapters (a chaptered file
without an OP chapter has none on purpose) and with media over 20 min,
"Skip 1:30" during the first 6 min.
- +1:30 (`skipForward`: the pill, the controls' "1:30" button,
Shift+Right) is a `nudge`, so repeated presses add up.
- Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next
@@ -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
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 `HScroll` (horizontal rows: mouse drag, the vertical wheel scrolls them
sideways until an end, then the page).
and `HScroll` (horizontal rows: mouse drag or Shift+wheel; the plain wheel
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
`HScroll` when narrow), the update indicator, then offline Join and Host
(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
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
drag area; the window buttons sit at its end. Debug builds add a button
that opens the layout mock (`screens/demo/`).
drag area; the window buttons sit at its end.
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
picture with a gap in the background colour (both scale with the size):
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
thumbnail, else the file name), the three relay modes online, and the
last 12 links put up (`recentLinks` in the `connect_form` box). Files
is `FilesPage` (`files/`). Ctrl+V outside a field pastes a link into
Link. Offline everything browses; the buttons say "Join or host to
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
an isolate, videos grouped by series; series start collapsed, a header
click opens one and open ones stay open for the app session
(`showFilesFolder` opens a folder from outside). Offline everything browses; the buttons say "Join or host to
watch together". Schedule (`schedule/`) is a week of day columns built
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in
memory and 1 h on disk, stale data shown on 429 or failure) matched
@@ -590,6 +604,30 @@ sideways until an end, then the page).
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
(what pops up; no switches), updates, shortcuts, about.
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
`DropTarget` (a "Drop to open" overlay while dragging) and a
`HardwareKeyboard` handler for Ctrl+V outside a text field (clipboard
files first, then text). `classifyHandoff` (pure) turns paths or text
into videos (the Files extensions, `kindOf`), a folder, an http(s) link,
an image or nothing; videos win over folders, folders over images.
`HandoffCard` opens as a popover at the pointer: a video goes up through
the shell's put-up (offline: into Files → Recent) or adds its folder to
Files; a folder is added to Files and opened; a link fills Library →
Link (online the relay mode is picked there). Images are ignored.
Nothing happens while a dialog or popover is over the shell.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
unready, force-ready, join, leave, kick, swap and others' chat (feed
events), the idle warning, and connection lost / back (once per drop).
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
`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
@@ -602,7 +640,13 @@ sideways until an end, then the page).
is `windowManager.setFullScreen` with the strip hidden (party avatars in
a corner while paused); F, Esc and double-click on the picture toggle it.
The player keys (Settings → Keyboard shortcuts) work while the player is
full and nothing is in front of it. Something coming up while the player
full and nothing is in front of it; ↑ / ↓ step the volume. Space (ready)
and Tab (chat) are app-wide: `AppKeys` (`widgets/app_keys.dart`, around
the shell) takes them first while in a server, unless a text field has
focus or a dialog or menu is in front. Tab reaches the shell's chat
toggle through `ChatKey` (the strip while shown, the player in
fullscreen). The app's shortcuts map Tab / Shift+Tab to nothing, so focus
never lands on a button Space would press. Something coming up while the player
is closed opens it: full right after joining or after putting it up
here, else mini. Both control designs show `NowPlayingTitle` top left
(`now_playing_title.dart`: 2:3 cover, title, episode line from
@@ -612,13 +656,16 @@ sideways until an end, then the page).
under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls:
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
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
removed, reconnecting (Cancel), a warn/error health message (Retry),
connecting (only after 2 s), finished, someone loading (their avatar
and buffer progress), you not ready (a click marks you ready) and who
everyone waits for (avatars, "2/3 ready"); nothing otherwise, info
never. Frosted glass, colour only for problems; it fades through
connecting (only after 2 s), finished, someone loading (below
`StateBroadcast.bufferNeed`, the gate's own need: less near the end,
none at finished; their avatar with the progress round it), you not
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
control designs opens the player settings card (`controls/
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
leaves fullscreen).
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
(strip button or Tab): chat with avatars, other activity as quiet lines.
(strip button or Tab): chat with avatars; the "Activity" chip (off by
default, `showActivity`, kept for the app's run only) adds the other
feed lines between them. Opening focuses the field; Tab or Esc in it
close the panel and focus goes back where it was. Unread chat badges
the strip's and both player designs' chat buttons; in fullscreen with
the controls down a glass chip (`PlayerGlass`) top right shows it.
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
own have none); each
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
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
the controls up through a guarded dynamic `onHover()` on media_kit's
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
1:30, Next episode) and cards live outside the auto-hiding controls, so
they stay up when the controls hide.
1:30, Next episode) and cards live outside the auto-hiding controls. The
Next episode pill and the cards stay up; the skip pill (`SkipPillGate`)
shows for 5 s when an offer of a new kind appears, then only while the
controls are up (`ControlsHoverProbe` for this design, `UoscControls.shown`
for uosc) or the mouse is on it. Volume (`controls/volume.dart`, both
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part
above in amber; the wheel and ↑ / ↓ step 5 %, clamped.
The uosc player design (`controls/uosc/`, after mpv's uosc theme;
"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s
@@ -787,7 +846,8 @@ this repo, written by `tool/release.sh`.
- Host by key, not by address: loopback detection broke behind a
same-machine reverse proxy or tunnel.
- Server owns the clock; reports never move it: avoids ping-pong between clients.
- Quorum gate: a lone viewer must be able to watch; two or more must start together.
- Presence gate: a lone viewer must be able to watch; everyone present
while the video is stopped must ready before it starts.
- Relic for the server, `web_socket_channel` for the client; packages are
chosen on merit, not locked.
- Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS
+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
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
+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
a reason. Otherwise the server answers with a welcome and the current state.
2. **Catch up.** The app opens the current video, jumps to where the server
says we are, and buffers. A new arrival is not ready yet and never holds
anyone back.
says we are, and buffers. Arriving while the video is stopped, you are
waited for; arriving while it plays, you never hold anyone back.
3. **Ready gate.** Press Space to say you are ready. The server starts
playback only when everyone who is ready has at least 4 seconds buffered
(or what is left, near the end). At the end everyone stops and the server
stays finished until someone seeks back or changes the video.
If two or more people are ready, one person alone cannot start it for the
others. A lone viewer can play alone.
Everyone who was there before it started must be ready, so one person
alone cannot start it for the others. A lone viewer can play alone.
4. **Play.** The server owns the clock. Each app compares its mpv position
to the clock: a small lead slows mpv a little (0.95x), a big gap (over 5 s)
jumps. If someone's buffer runs dry the server pauses everyone and resumes
@@ -67,7 +67,8 @@ Friends join either way with Join in the strip at the top of the window.
5. **Seek.** Anyone can seek, anytime. The server moves the clock and
everybody follows. Seeking does not change anyone's ready flag.
6. **Swap.** Anyone can change the video by pasting another link. Everybody
stops at 0, ready flags clear, and the gate runs again.
stops at 0 and the gate runs again for everyone there; ready flags stay
as they were (a setting unreadies you instead).
7. **Leave.** Closing or leaving drops you from the server; the others keep
going. If you vanish without saying goodbye, you are marked stale after 15 s
(stops blocking) and removed after 30 s. The app reconnects by itself and
+8 -4
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 9)
# Protocol (proto = 10)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
## Client to server
```
{"t":"hello","proto":9,"secret":"","user":{"name":"","avatar":""},
{"t":"hello","proto":10,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
@@ -84,6 +84,9 @@ Field notes:
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
dropped. `swap.media.relay` makes it relayed media (see "Relay").
A swap keeps every member's `ready` and puts every fresh member in the
set (`inSet`); clients that want to be unready for the new media send
`ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
unknown frames are ignored silently.
@@ -258,8 +261,9 @@ server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive
## Join and reconnect
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
position and buffers. A joining member is `ready=false, inSet=false` until
the first `ready`. A socket the server sees close loses its token; reconnect
position and buffers. A joining member is `ready=false`; it is `inSet`
at once unless the server is `playing`, then from its first `ready`
(members present at a swap are put in the set by it). A socket the server sees close loses its token; reconnect
is a fresh `hello` (with `resume` = the old token), after which the client
re-sends its prior ready flag. If the old member was still there (unclean
drop), `resume` takes it over and the token and media URL stay the same.
+28 -7
View File
@@ -6,13 +6,33 @@ off by deleting it in the same commit as the code. Wire changes also edit
## 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
updater tests on NTFS; a friend has joined from it. Still unchecked:
fullscreen controls, hosting (firewall prompt), in-app update from one
release to the next. The rest of the desktop tests are not in CI.
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:
- Source takeover: when the source leaves, another member with an
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
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
software (see ARCHITECTURE, Media: render mode), vendor a
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.
- Time UI: remaining time, scrubber preview.
- 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
outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no
OOM, no stuck session, drift recovers.
- 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
`api_key` (recognised Jellyfin links become refs; other links do not).
- TLS: document a reverse proxy in front of the server.
+1 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 9;
static const int protoVersion = 10;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
+11
View File
@@ -556,6 +556,17 @@ class MemberView {
}
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 String transport;
final double position;
+18 -11
View File
@@ -186,12 +186,17 @@ class SyncEngine {
return left.clamp(0.0, full).toDouble();
}
/// Any frame from a member: fresh again, and a ready member the stale
/// sweep took out of the set goes back in (ready && !inSet only arises
/// from that sweep).
/// Any frame from a member: fresh again. Back in the set: a ready member
/// the stale sweep (or a swap while it was stale) took out (ready &&
/// !inSet only arises from those), and any member back from stale while
/// not playing (like a join then).
void _seen(EngineMember member) {
final wasStale = isStale(member);
member.lastSeen = now;
if (member.ready && !member.inSet) member.inSet = true;
if (!member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true;
}
}
/// Heartbeat: stale sweep, end of media.
@@ -202,8 +207,11 @@ class SyncEngine {
String applyJoin(String token, String name, String avatar) {
final id = 'm${_nextId++}';
// Joining while not playing: in the set, so the start waits for them;
// while playing, out until they ready (a late joiner never pauses).
members[token] =
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now);
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now)
..inSet = transport != Transport.playing;
feed.add(FeedMessage(actor: name, action: 'join', at: now));
return id;
}
@@ -367,9 +375,11 @@ class SyncEngine {
serverTime = now;
eventId++;
transport = Transport.held;
// Ready flags stay; everyone here now waits for everyone else here now
// (a stale member rejoins the set with its next frame if still ready).
final t = now;
for (final member in members.values) {
member.ready = false;
member.inSet = false;
member.inSet = !isStale(member, t);
member.buffered = 0;
}
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
@@ -448,12 +458,9 @@ class SyncEngine {
}
final inSet = members.values.where((m) => m.inSet).toList();
if (inSet.isEmpty) return;
final freshCount =
members.values.where((m) => !isStale(m, t)).length;
final quorum = inSet.length >= 2 || freshCount <= 1;
final allGood = inSet.every(
(m) => m.ready && m.buffered >= need && !isStale(m, t));
if (allGood && quorum) {
if (allGood) {
serverTime = t;
eventId++;
transport = Transport.playing;
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.0.0
version: 1.0.1
environment:
sdk: ^3.0.0
dependencies:
+78 -12
View File
@@ -37,12 +37,12 @@ void main() {
});
group('membership + gate', () {
test('join lands out-of-set, playback stays held', () {
test('join while held lands in the set, playback stays held', () {
join('a', 'alice');
expect(engine.transport, Transport.held);
expect(engine.members['a']!.inSet, isFalse);
expect(engine.members['a']!.inSet, isTrue);
final state = engine.broadcast();
expect(state.members.single.inSet, isFalse);
expect(state.members.single.inSet, isTrue);
});
test('single ready + buffered auto-plays', () {
@@ -126,7 +126,22 @@ void main() {
});
});
group('quorum gate', () {
group('presence gate', () {
test('initial media: three join while held, two ready stays held', () {
engine.media = const HelloMedia(name: 'f', size: 1, duration: 0);
join('a', 'alice');
join('b', 'bob');
join('c', 'cara');
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 6.0);
buffer('b', 6.0);
buffer('c', 6.0);
expect(engine.transport, Transport.held);
engine.applyReady('c', true);
expect(engine.transport, Transport.playing);
});
test('one ready of two fresh stays held', () {
join('a', 'alice');
join('b', 'bob');
@@ -153,6 +168,7 @@ void main() {
t = t.add(const Duration(seconds: 16));
engine.applyReady('a', true);
buffer('a', 6.0);
engine.tick();
expect(engine.transport, Transport.playing);
});
@@ -257,19 +273,66 @@ void main() {
});
});
group('swap resets everything', () {
test('swap bumps mediaId, clears sets, holds', () {
group('swap', () {
const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0);
test('swap bumps mediaId, holds, keeps ready, fresh members in set', () {
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 4.0);
expect(engine.transport, Transport.playing);
engine.applySwap('a',
const HelloMedia(name: 'Ep02.mkv', size: 9, duration: 9.0));
engine.applySwap('a', ep2);
expect(engine.mediaId, 2);
expect(engine.transport, Transport.held);
expect(engine.positionAt(), 0);
expect(engine.members['a']!.inSet, isFalse);
expect(engine.members['a']!.ready, isFalse);
expect(engine.members['a']!.inSet, isTrue);
expect(engine.members['a']!.ready, isTrue);
expect(engine.members['a']!.buffered, 0);
buffer('a', 4.0);
expect(engine.transport, Transport.playing);
});
test('a member present at the swap blocks until ready', () {
join('a', 'alice');
join('b', 'bob');
join('c', 'cara');
engine.applySwap('a', ep2);
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 6.0);
buffer('b', 6.0);
buffer('c', 6.0);
expect(engine.transport, Transport.held);
expect(engine.members['c']!.inSet, isTrue);
engine.applyReady('c', true);
expect(engine.transport, Transport.playing);
});
test('a joiner while playing never holds anyone back', () {
join('a', 'alice');
engine.applySwap('a', ep2);
engine.applyReady('a', true);
buffer('a', 6.0);
expect(engine.transport, Transport.playing);
join('b', 'bob');
buffer('b', 0);
expect(engine.members['b']!.inSet, isFalse);
expect(engine.transport, Transport.playing);
});
test('a stale member stays out, rejoins with its next frame if ready', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('b', true);
t = t.add(const Duration(seconds: 16));
engine.applyReady('a', true);
engine.applySwap('a', ep2);
expect(engine.members['b']!.inSet, isFalse);
buffer('a', 6.0);
expect(engine.transport, Transport.playing);
buffer('b', 0);
expect(engine.members['b']!.inSet, isTrue);
expect(engine.transport, Transport.held);
});
});
@@ -427,10 +490,13 @@ void main() {
isTrue);
});
test('unready member does not rejoin on reports', () {
test('unready late joiner does not join on reports', () {
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 6.0);
expect(engine.members['a']!.inSet, isFalse);
join('b', 'bob');
buffer('b', 6.0);
expect(engine.members['b']!.inSet, isFalse);
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none
version: 1.0.0
version: 1.0.1
environment:
sdk: ^3.0.0
dependencies:
+2 -2
View File
@@ -80,8 +80,8 @@ void main() {
});
group('core welcome + proto', () {
test('proto 9: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 9);
test('proto 10: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 10);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse);
+6 -6
View File
@@ -1,10 +1,10 @@
{
"version": "1.0.0",
"protocol": 9,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.0/watch-together-1.0.0-linux-x64.tar.gz",
"notes": "- Player: now-playing title with cover, start banner, one status pill; media art for everyone",
"version": "1.0.1",
"protocol": 10,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.1/watch-together-1.0.1-linux-x64.tar.gz",
"notes": "- Space never presses a focused button; chat toasts open the chat; plain wheel always scrolls the page",
"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",
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.0/watch-together-1.0.0-windows-x64.zip"
"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.1/watch-together-1.0.1-windows-x64.zip"
}
}