Protocol 11: sit out replaces kicking; nudge
Anyone can sit anyone out (host included); only you bring yourself back. Out members stay in chat and the party, never block or count, and load no media; a card with the live position and Join back (Space) replaces the video. The mini player's x sits you out; optionally idle does too. Nudge: the server forwards it to the target only (3/s, 20/min per sender); the target hears the nudge sound, gets a toast and a taskbar attention request. Kick, its messages, errors and dialog are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbfd351ee4
commit
8b81da4069
46 files changed
+855
-599
No files matched your search
@@ -48,17 +48,17 @@ profile: name, picture with a crop editor, colour, and links to the
|
|||||||
settings), the Library / Schedule / Settings tabs, and the party. Offline
|
settings), the Library / Schedule / Settings tabs, and the party. Offline
|
||||||
the party side has Join and Host; in a server it shows everyone's avatar
|
the party side has Join and Host; in a server it shows everyone's avatar
|
||||||
with a ring for their state (green ready, amber buffering, grey not ready,
|
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||||
dimmed reconnecting; a crown on whoever hosts, a tower on whoever
|
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
|
||||||
passes the video on). Click the avatars for the member list (force ready,
|
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||||
kick, "Copy invite address"; when you host it lists this computer's
|
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
|
||||||
network addresses with the port, and friends outside your network need
|
network addresses with the port, and friends outside your network need
|
||||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
|
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
|
||||||
count, and Leave. Empty strip space drags the window.
|
count, and Leave. Empty strip space drags the window.
|
||||||
|
|
||||||
The player fills the window under the strip. Pick a tab and it shrinks to
|
The player fills the window under the strip. Pick a tab and it shrinks to
|
||||||
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
||||||
corner; double-click or ⤢ makes it big again, × stops watching (you stay in
|
corner; double-click or ⤢ makes it big again, × sits you out (below) and
|
||||||
the server, muted) and the strip's screen button brings it back. F or
|
× again closes it; the strip's screen button brings it back. F or
|
||||||
double-click on the picture goes fullscreen (the strip hides, the party
|
double-click on the picture goes fullscreen (the strip hides, the party
|
||||||
shows in a corner while paused, the window buttons sit in the player's top
|
shows in a corner while paused, the window buttons sit in the player's top
|
||||||
bar while its controls are up), Esc leaves it. The controls show the
|
bar while its controls are up), Esc leaves it. The controls show the
|
||||||
@@ -76,11 +76,23 @@ hidden, as a small chip top right.
|
|||||||
Your name and picture change for everyone in the server right away; the
|
Your name and picture change for everyone in the server right away; the
|
||||||
colour is yours only.
|
colour is yours only.
|
||||||
|
|
||||||
|
**Sitting out.** Step away without leaving: Sit out (your profile, your
|
||||||
|
row in the member list, or × on the mini player) unloads the video on your
|
||||||
|
end, and nobody waits for you any more; you stay in chat. Anyone can sit
|
||||||
|
anyone out, the host too ("Alice sat you out" tells you who). The player
|
||||||
|
then shows the cover, title and where the others are; Join back (or Space)
|
||||||
|
loads the video there and readies you. Settings → Party & network → "When idle,
|
||||||
|
sit out instead of just unready" does it after 10 idle minutes.
|
||||||
|
|
||||||
|
**Nudge.** Nudge in the member list pokes someone, sitting out or not:
|
||||||
|
only they hear it (a sound, a pop-up, and the taskbar entry asks for
|
||||||
|
attention when the window is in the background).
|
||||||
|
|
||||||
## Host from the app
|
## Host from the app
|
||||||
|
|
||||||
Host in the strip: set a port and an optional password (blank = anyone can
|
Host in the strip: set a port and an optional password (blank = anyone can
|
||||||
join), then Host. The app starts a server inside itself and joins it; you
|
join), then Host. The app starts a server inside itself and joins it; you
|
||||||
are the host and nobody can kick you. Nothing is up yet: pick what to
|
are the host (the crown). Nothing is up yet: pick what to
|
||||||
watch in the Library tab (below) and put it up. Leaving or
|
watch in the Library tab (below) and put it up. Leaving or
|
||||||
closing the app stops the server. Friends need to reach your machine: open
|
closing the app stops the server. Friends need to reach your machine: open
|
||||||
the TCP port on your router (or use a LAN or VPN address). Friends open
|
the TCP port on your router (or use a LAN or VPN address). Friends open
|
||||||
@@ -142,7 +154,7 @@ then everyone (you included) uses Join.
|
|||||||
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`); replaces the flags |
|
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`); replaces the flags |
|
||||||
|
|
||||||
It prints `READY port=... name=...` once listening and stops cleanly on
|
It prints `READY port=... name=...` once listening and stops cleanly on
|
||||||
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
|
Ctrl+C or SIGTERM. A dedicated server has no host.
|
||||||
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
||||||
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
||||||
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
||||||
|
|||||||
@@ -332,13 +332,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
|
|||||||
|
|
||||||
/// Who performs the auto-advance swap, so several counting clients do not
|
/// Who performs the auto-advance swap, so several counting clients do not
|
||||||
/// swap twice: the lowest member id among members that play the item with
|
/// swap twice: the lowest member id among members that play the item with
|
||||||
/// their own account (`jf[serverId] == true`, not stale). Only what every
|
/// their own account (`jf[serverId] == true`, not stale, not out). Only what every
|
||||||
/// client sees in `state` decides (who put the item up is not in it: a
|
/// client sees in `state` decides (who put the item up is not in it: a
|
||||||
/// late joiner never saw that swap), so all clients agree.
|
/// late joiner never saw that swap), so all clients agree.
|
||||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||||
final eligible = [
|
final eligible = [
|
||||||
for (final m in members)
|
for (final m in members)
|
||||||
if (!m.stale && m.jf[serverId] == true) m
|
if (!m.stale && !m.out && m.jf[serverId] == true) m
|
||||||
];
|
];
|
||||||
if (eligible.isEmpty) return null;
|
if (eligible.isEmpty) return null;
|
||||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// The runner's window channel (linux/runner/my_application.cc,
|
||||||
|
/// windows/runner/flutter_window.cpp).
|
||||||
|
const _window = MethodChannel('starsync/window');
|
||||||
|
|
||||||
|
/// Asks the desktop to point at the app's window (taskbar flash, urgency
|
||||||
|
/// hint) when it is not focused; nothing when it is. The runner clears it
|
||||||
|
/// once the window gets focus. Quietly nothing where the runner lacks it.
|
||||||
|
Future<void> requestWindowAttention() async {
|
||||||
|
try {
|
||||||
|
await _window.invokeMethod<void>('attention');
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Tests, other platforms.
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('window attention: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,10 +23,6 @@ class ConnectFlow extends ChangeNotifier {
|
|||||||
int _attempt = 0;
|
int _attempt = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
/// Set when an attempt ends in a kick, so the shell shows the removal
|
|
||||||
/// dialog.
|
|
||||||
void Function(String reason)? onKicked;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
@@ -110,12 +106,7 @@ class ConnectFlow extends ChangeNotifier {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (myAttempt != _attempt) return;
|
if (myAttempt != _attempt) return;
|
||||||
busy = null;
|
busy = null;
|
||||||
final kick = _session.coordinator.kickReason.peek();
|
errors[mode] = CardError('Could not connect', '$e');
|
||||||
if (kick != null && kick.isNotEmpty) {
|
|
||||||
onKicked?.call(kick);
|
|
||||||
} else {
|
|
||||||
errors[mode] = CardError('Could not connect', '$e');
|
|
||||||
}
|
|
||||||
_notify();
|
_notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../jellyfin/session_media.dart';
|
|||||||
import '../jellyfin/track_prefs.dart';
|
import '../jellyfin/track_prefs.dart';
|
||||||
import '../playback/media_marks.dart';
|
import '../playback/media_marks.dart';
|
||||||
import '../playback/playback_controller.dart';
|
import '../playback/playback_controller.dart';
|
||||||
|
import '../platform/attention.dart';
|
||||||
import '../playback/yt_dlp_app.dart';
|
import '../playback/yt_dlp_app.dart';
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
@@ -102,7 +103,7 @@ class ToastItem {
|
|||||||
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
||||||
}
|
}
|
||||||
|
|
||||||
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
const _toastActions = {'join', 'leave', 'swap', 'chat'};
|
||||||
|
|
||||||
/// "Unready when the video changes": a state with a newer [mediaId] than
|
/// "Unready when the video changes": a state with a newer [mediaId] than
|
||||||
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
||||||
@@ -180,7 +181,6 @@ class SessionCoordinator {
|
|||||||
final feedLog = signal<List<FeedMessage>>([]);
|
final feedLog = signal<List<FeedMessage>>([]);
|
||||||
final toasts = signal<List<ToastItem>>([]);
|
final toasts = signal<List<ToastItem>>([]);
|
||||||
final reconnecting = signal(false);
|
final reconnecting = signal(false);
|
||||||
final kickReason = signal<String?>(null);
|
|
||||||
final starving = signal<String?>(null);
|
final starving = signal<String?>(null);
|
||||||
|
|
||||||
/// The current media's Jellyfin ref (null for links and files).
|
/// The current media's Jellyfin ref (null for links and files).
|
||||||
@@ -226,10 +226,16 @@ class SessionCoordinator {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Self sits out: the player has nothing open until [joinBack].
|
||||||
|
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
|
||||||
|
|
||||||
|
/// Asks the desktop to point at the window (a nudge); tests swap it.
|
||||||
|
Future<void> Function() requestAttention = requestWindowAttention;
|
||||||
|
|
||||||
Timer? _ticker;
|
Timer? _ticker;
|
||||||
StreamSubscription? _stateSub;
|
StreamSubscription? _stateSub;
|
||||||
StreamSubscription? _feedSub;
|
StreamSubscription? _feedSub;
|
||||||
StreamSubscription? _kickSub;
|
StreamSubscription? _nudgeSub;
|
||||||
StreamSubscription? _errSub;
|
StreamSubscription? _errSub;
|
||||||
StreamSubscription? _statusSub;
|
StreamSubscription? _statusSub;
|
||||||
|
|
||||||
@@ -242,6 +248,13 @@ class SessionCoordinator {
|
|||||||
// Set after a reconnect until the server echoes the restored ready, so
|
// Set after a reconnect until the server echoes the restored ready, so
|
||||||
// the fresh member's ready=false doesn't overwrite [_lastReady].
|
// the fresh member's ready=false doesn't overwrite [_lastReady].
|
||||||
bool _restoringReady = false;
|
bool _restoringReady = false;
|
||||||
|
// Last out flag the server reported for self; a reconnect that lands
|
||||||
|
// as a new member sits out again.
|
||||||
|
bool _lastOut = false;
|
||||||
|
// [joinBack] ran while playing: ready once the player has buffered
|
||||||
|
// (or after [_joinBackWait]), so the others do not pause for the load.
|
||||||
|
DateTime? _joinBackAt;
|
||||||
|
static const _joinBackWait = Duration(seconds: 15);
|
||||||
// Last mediaId seen in a state; unlike [_mediaId] it survives a
|
// Last mediaId seen in a state; unlike [_mediaId] it survives a
|
||||||
// reconnect, so only a real swap counts as a new video.
|
// reconnect, so only a real swap counts as a new video.
|
||||||
int _seenMediaId = -1;
|
int _seenMediaId = -1;
|
||||||
@@ -372,7 +385,6 @@ class SessionCoordinator {
|
|||||||
if (epoch != _epoch) throw const ConnectCancelled();
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
_params = params;
|
_params = params;
|
||||||
_manualClose = false;
|
_manualClose = false;
|
||||||
kickReason.value = null;
|
|
||||||
_wsHost = params.host;
|
_wsHost = params.host;
|
||||||
_wsPort = params.port;
|
_wsPort = params.port;
|
||||||
diff.reset();
|
diff.reset();
|
||||||
@@ -380,6 +392,8 @@ class SessionCoordinator {
|
|||||||
_openUrl = null;
|
_openUrl = null;
|
||||||
_lastReady = false;
|
_lastReady = false;
|
||||||
_restoringReady = false;
|
_restoringReady = false;
|
||||||
|
_lastOut = false;
|
||||||
|
_joinBackAt = null;
|
||||||
_seenMediaId = -1;
|
_seenMediaId = -1;
|
||||||
_wasPlayAllowed = false;
|
_wasPlayAllowed = false;
|
||||||
_everConnected = false;
|
_everConnected = false;
|
||||||
@@ -443,12 +457,12 @@ class SessionCoordinator {
|
|||||||
void _subscribe() {
|
void _subscribe() {
|
||||||
_stateSub ??= sync.states.listen(_onState);
|
_stateSub ??= sync.states.listen(_onState);
|
||||||
_feedSub ??= sync.feeds.listen(_onFeed);
|
_feedSub ??= sync.feeds.listen(_onFeed);
|
||||||
_kickSub ??= sync.kicked.listen(_onKicked);
|
_nudgeSub ??= sync.nudges.listen(_onNudge);
|
||||||
_errSub ??= sync.errors.listen((msg) {
|
_errSub ??= sync.errors.listen((msg) {
|
||||||
health.value = HealthStatus(HealthSeverity.warn, msg);
|
health.value = HealthStatus(HealthSeverity.warn, msg);
|
||||||
});
|
});
|
||||||
_statusSub ??= sync.status.listen((s) {
|
_statusSub ??= sync.status.listen((s) {
|
||||||
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
|
if (s == SyncStatus.closed && !_manualClose) {
|
||||||
health.value = const HealthStatus(
|
health.value = const HealthStatus(
|
||||||
HealthSeverity.warn, 'connection lost — retrying…');
|
HealthSeverity.warn, 'connection lost — retrying…');
|
||||||
_soundLost();
|
_soundLost();
|
||||||
@@ -613,6 +627,12 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
Future<void> _applyState(StateBroadcast r) async {
|
Future<void> _applyState(StateBroadcast r) async {
|
||||||
_trackSelfReady(r);
|
_trackSelfReady(r);
|
||||||
|
if (_lastOut) {
|
||||||
|
// A newer media than the one this member serves: that relay ended.
|
||||||
|
if (_relaySourceMediaId != -1 && _relaySourceMediaId != r.mediaId) _stopRelaySource();
|
||||||
|
await _unloadForOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_seekGate.observe(r, sync.memberId);
|
_seekGate.observe(r, sync.memberId);
|
||||||
final newEvent = r.eventId != _lastEventId;
|
final newEvent = r.eventId != _lastEventId;
|
||||||
_lastEventId = r.eventId;
|
_lastEventId = r.eventId;
|
||||||
@@ -740,6 +760,7 @@ class SessionCoordinator {
|
|||||||
} else {
|
} else {
|
||||||
_lastReady = self.ready;
|
_lastReady = self.ready;
|
||||||
}
|
}
|
||||||
|
_lastOut = self.out;
|
||||||
final last = _seenMediaId;
|
final last = _seenMediaId;
|
||||||
_seenMediaId = r.mediaId;
|
_seenMediaId = r.mediaId;
|
||||||
// A ready restored by a reconnect may not have echoed yet: unready
|
// A ready restored by a reconnect may not have echoed yet: unready
|
||||||
@@ -755,6 +776,27 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sitting out: nothing open, nothing pulled. A relay this member
|
||||||
|
/// serves goes on (it does not use the player). The next state after
|
||||||
|
/// [joinBack] opens the media as new.
|
||||||
|
Future<void> _unloadForOut() async {
|
||||||
|
_wasPlayAllowed = false;
|
||||||
|
_awaitLiveFor = -1;
|
||||||
|
starving.value = null;
|
||||||
|
if (_mediaId == -1 && _openUrl == null) return;
|
||||||
|
_mediaId = -1;
|
||||||
|
_openUrl = null;
|
||||||
|
_openHeaders = null;
|
||||||
|
_seekGate.reset();
|
||||||
|
_clearPendingSeek();
|
||||||
|
diff.reset();
|
||||||
|
_leaveJellyfinItem();
|
||||||
|
if (!_playerReady) return;
|
||||||
|
try {
|
||||||
|
await playback.stop();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
||||||
if (r.transport == 'playing') {
|
if (r.transport == 'playing') {
|
||||||
starving.value = null;
|
starving.value = null;
|
||||||
@@ -773,14 +815,15 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
final self = selfMember.peek();
|
final self = selfMember.peek();
|
||||||
if (self != null && self.ready) {
|
if (self != null && self.ready) {
|
||||||
final waiters = r.members
|
final present = r.members.where((m) => !m.out).toList();
|
||||||
|
final waiters = present
|
||||||
.where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
|
.where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
|
||||||
.map((m) => m.name)
|
.map((m) => m.name)
|
||||||
.toList();
|
.toList();
|
||||||
if (waiters.isNotEmpty) {
|
if (waiters.isNotEmpty) {
|
||||||
final readyCount = r.members.where((m) => m.ready).length;
|
final readyCount = present.where((m) => m.ready).length;
|
||||||
starving.value =
|
starving.value =
|
||||||
'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)';
|
'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,9 +852,15 @@ class SessionCoordinator {
|
|||||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
|
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
final selfName = selfMember.peek()?.name;
|
||||||
|
// Sat out by someone else: say who (the feed names members by name).
|
||||||
|
if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) {
|
||||||
|
_pushToast('${f.actor} sat you out', actor: f.actor);
|
||||||
|
unawaited(sfx.play(const SfxCue(SfxKind.kick)));
|
||||||
|
}
|
||||||
final kind = sfxForFeedAction(f.action);
|
final kind = sfxForFeedAction(f.action);
|
||||||
// Your own message needs no sound.
|
// Your own message needs no sound.
|
||||||
final own = f.action == 'chat' && f.actor == selfMember.peek()?.name;
|
final own = f.action == 'chat' && f.actor == selfName;
|
||||||
if (kind != null && !own) {
|
if (kind != null && !own) {
|
||||||
var readyIndex = 0;
|
var readyIndex = 0;
|
||||||
if (f.action == 'ready') {
|
if (f.action == 'ready') {
|
||||||
@@ -827,8 +876,6 @@ class SessionCoordinator {
|
|||||||
return '${f.actor} joined';
|
return '${f.actor} joined';
|
||||||
case 'leave':
|
case 'leave':
|
||||||
return '${f.actor} left';
|
return '${f.actor} left';
|
||||||
case 'kick':
|
|
||||||
return '${f.actor} kicked ${f.target ?? ''}';
|
|
||||||
case 'swap':
|
case 'swap':
|
||||||
return '${f.actor} changed media';
|
return '${f.actor} changed media';
|
||||||
default:
|
default:
|
||||||
@@ -836,26 +883,13 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onKicked(String reason) async {
|
/// Someone poked this member: sound (even when its switch is off; the
|
||||||
kickReason.value = reason;
|
/// volume still applies), a toast, and the window asks for attention.
|
||||||
_manualClose = true;
|
void _onNudge(NudgeNotice n) {
|
||||||
_epoch++;
|
final name = n.name.isEmpty ? 'Someone' : n.name;
|
||||||
_reconnectTimer?.cancel();
|
_pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
|
||||||
_reconnectTimer = null;
|
unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
|
||||||
reconnecting.value = false;
|
unawaited(requestAttention());
|
||||||
_ticker?.cancel();
|
|
||||||
_ticker = null;
|
|
||||||
// Stop, not pause: a paused player keeps filling its cache from the
|
|
||||||
// host's media URL.
|
|
||||||
_openUrl = null;
|
|
||||||
await _stopJellyfin();
|
|
||||||
try {
|
|
||||||
await playback.stop();
|
|
||||||
} catch (_) {}
|
|
||||||
await sync.disconnect();
|
|
||||||
health.value = HealthStatus(
|
|
||||||
HealthSeverity.error,
|
|
||||||
reason.isEmpty ? 'kicked from server' : 'kicked: $reason');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startTicker() {
|
void _startTicker() {
|
||||||
@@ -871,20 +905,22 @@ class SessionCoordinator {
|
|||||||
if (sync.token.isNotEmpty) sync.sendPing();
|
if (sync.token.isNotEmpty) sync.sendPing();
|
||||||
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
||||||
// Duration only once the open media is the server's current one.
|
// Duration only once the open media is the server's current one.
|
||||||
final known = !_opening && _openUrl != null && _mediaId == r.mediaId;
|
// Sitting out: still a report (it keeps this member fresh), empty.
|
||||||
|
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||||
sync.sendReport(
|
sync.sendReport(
|
||||||
position: playback.positionSecs,
|
position: _lastOut ? 0 : playback.positionSecs,
|
||||||
buffered: playback.bufferedSecsAhead,
|
buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
|
||||||
eventId: r.eventId,
|
eventId: r.eventId,
|
||||||
duration: known ? playback.durationSecs : 0,
|
duration: known ? playback.durationSecs : 0,
|
||||||
mediaId: known ? r.mediaId : 0,
|
mediaId: known ? r.mediaId : 0,
|
||||||
jf: _jfAccessFor(r),
|
jf: _jfAccessFor(r),
|
||||||
);
|
);
|
||||||
_checkSeekLanded(r);
|
if (!_lastOut) {
|
||||||
unawaited(_tickJellyfin(r));
|
_checkSeekLanded(r);
|
||||||
|
unawaited(_tickJellyfin(r));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!_manualClose &&
|
if (!_manualClose &&
|
||||||
kickReason.peek() == null &&
|
|
||||||
_lastStateAt != null &&
|
_lastStateAt != null &&
|
||||||
now.difference(_lastStateAt!).inSeconds >= 5) {
|
now.difference(_lastStateAt!).inSeconds >= 5) {
|
||||||
if (serverState.peek() != null) {
|
if (serverState.peek() != null) {
|
||||||
@@ -905,6 +941,8 @@ class SessionCoordinator {
|
|||||||
// No warning when there is nothing to lose (unready), and none while
|
// No warning when there is nothing to lose (unready), and none while
|
||||||
// PLAYING: the ready flag gates nothing then, and watching counts as
|
// PLAYING: the ready flag gates nothing then, and watching counts as
|
||||||
// activity so a pause after a long watch starts a fresh idle clock.
|
// activity so a pause after a long watch starts a fresh idle clock.
|
||||||
|
if (r != null) _readyAfterJoinBack(r, now);
|
||||||
|
final sitOutWhenIdle = AppSettings.instance.idleSitsOut.peek();
|
||||||
final event = _idle.tick(now,
|
final event = _idle.tick(now,
|
||||||
playing: serverState.peek()?.transport == 'playing',
|
playing: serverState.peek()?.transport == 'playing',
|
||||||
selfReady: selfMember.peek()?.ready ?? false);
|
selfReady: selfMember.peek()?.ready ?? false);
|
||||||
@@ -915,18 +953,46 @@ class SessionCoordinator {
|
|||||||
...toasts.peek(),
|
...toasts.peek(),
|
||||||
ToastItem(
|
ToastItem(
|
||||||
id: _toastId,
|
id: _toastId,
|
||||||
text: 'idle — auto-unready in 2 min unless you press Space',
|
text: sitOutWhenIdle
|
||||||
|
? 'idle — sitting out in 2 min unless you press Space'
|
||||||
|
: 'idle — auto-unready in 2 min unless you press Space',
|
||||||
at: now)
|
at: now)
|
||||||
];
|
];
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||||
case IdleEvent.unready:
|
case IdleEvent.unready:
|
||||||
_lastReady = false;
|
_lastReady = false;
|
||||||
sync.sendReady(false, source: 'auto');
|
if (sitOutWhenIdle) {
|
||||||
|
sitOut();
|
||||||
|
} else {
|
||||||
|
sync.sendReady(false, source: 'auto');
|
||||||
|
}
|
||||||
case IdleEvent.none:
|
case IdleEvent.none:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After [joinBack] while playing: ready once the player holds what the
|
||||||
|
/// gate needs, or after [_joinBackWait] (then the gate waits for it).
|
||||||
|
void _readyAfterJoinBack(StateBroadcast r, DateTime now) {
|
||||||
|
final at = _joinBackAt;
|
||||||
|
if (at == null) return;
|
||||||
|
final self = selfMember.peek();
|
||||||
|
if (self == null || self.out) {
|
||||||
|
if (now.difference(at) > _joinBackWait) _joinBackAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loaded = _playerReady && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||||
|
if (self.ready ||
|
||||||
|
(loaded && playback.bufferedSecsAhead >= r.bufferNeed) ||
|
||||||
|
now.difference(at) > _joinBackWait) {
|
||||||
|
_joinBackAt = null;
|
||||||
|
if (!self.ready) {
|
||||||
|
_lastReady = true;
|
||||||
|
sync.sendReady(true, source: 'manual');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _clearPendingSeek() {
|
void _clearPendingSeek() {
|
||||||
_pendingSeekEvent = null;
|
_pendingSeekEvent = null;
|
||||||
_pendingSeekAt = null;
|
_pendingSeekAt = null;
|
||||||
@@ -993,6 +1059,10 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
void toggleReady() {
|
void toggleReady() {
|
||||||
final self = selfMember.peek();
|
final self = selfMember.peek();
|
||||||
|
if (self?.out ?? false) {
|
||||||
|
joinBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Ready but out of the set (dropped out while stale) gates like
|
// Ready but out of the set (dropped out while stale) gates like
|
||||||
// unready, so Space puts self back in instead of unreadying.
|
// unready, so Space puts self back in instead of unreadying.
|
||||||
final next = !((self?.ready ?? false) && (self?.inSet ?? false));
|
final next = !((self?.ready ?? false) && (self?.inSet ?? false));
|
||||||
@@ -1080,12 +1150,43 @@ class SessionCoordinator {
|
|||||||
sync.sendProfile(name: name, avatar: avatar);
|
sync.sendProfile(name: name, avatar: avatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
void kick(String memberId, {String reason = ''}) {
|
/// Sits [memberId] out (anyone, the host included), self when null.
|
||||||
|
void sitOut([String? memberId]) {
|
||||||
|
final target = memberId ?? sync.memberId;
|
||||||
|
if (target.isEmpty) return;
|
||||||
markInput();
|
markInput();
|
||||||
sync.sendKick(memberId, reason: reason);
|
if (target == sync.memberId) _joinBackAt = null;
|
||||||
|
sync.sendOut(target, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back from sitting out: the media opens at the server position and
|
||||||
|
/// this member readies. Held: at once (the gate waits for the buffer);
|
||||||
|
/// playing: once buffered, so the others do not pause for the load.
|
||||||
|
void joinBack() {
|
||||||
|
final self = selfMember.peek();
|
||||||
|
if (self == null || !self.out) return;
|
||||||
|
markInput();
|
||||||
|
sync.sendOut(self.id, false);
|
||||||
|
if (serverState.peek()?.transport == 'playing') {
|
||||||
|
_joinBackAt = DateTime.now().toUtc();
|
||||||
|
} else {
|
||||||
|
_joinBackAt = null;
|
||||||
|
_lastReady = true;
|
||||||
|
sync.sendReady(true, source: 'manual');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pokes [memberId]: only they hear it (sitting out or not).
|
||||||
|
void nudgeMember(String memberId) {
|
||||||
|
if (memberId.isEmpty || memberId == sync.memberId) return;
|
||||||
|
markInput();
|
||||||
|
sync.sendNudge(memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _swap(HelloMedia media) {
|
void _swap(HelloMedia media) {
|
||||||
|
// Putting something up means watching it: back first.
|
||||||
|
final self = selfMember.peek();
|
||||||
|
if (self != null && self.out) sync.sendOut(self.id, false);
|
||||||
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
|
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
|
||||||
sync.sendSwap(media);
|
sync.sendSwap(media);
|
||||||
}
|
}
|
||||||
@@ -1266,11 +1367,11 @@ class SessionCoordinator {
|
|||||||
/// results), shown like the session's own.
|
/// results), shown like the session's own.
|
||||||
void showToast(String text) => _pushToast(text);
|
void showToast(String text) => _pushToast(text);
|
||||||
|
|
||||||
void _pushToast(String text) {
|
void _pushToast(String text, {String? actor}) {
|
||||||
_toastId++;
|
_toastId++;
|
||||||
toasts.value = [
|
toasts.value = [
|
||||||
...toasts.peek(),
|
...toasts.peek(),
|
||||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc())
|
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1304,7 +1405,7 @@ class SessionCoordinator {
|
|||||||
if (reporter != null && snap != null) unawaited(reporter.stop(snap));
|
if (reporter != null && snap != null) unawaited(reporter.stop(snap));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leave / kick / window close: Stopped, bounded so leaving never hangs.
|
/// Leave / window close: Stopped, bounded so leaving never hangs.
|
||||||
Future<void> _stopJellyfin() async {
|
Future<void> _stopJellyfin() async {
|
||||||
final reporter = _reporter;
|
final reporter = _reporter;
|
||||||
final snap = _snapNow();
|
final snap = _snapNow();
|
||||||
@@ -1592,7 +1693,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleReconnect({bool manual = false}) {
|
void _scheduleReconnect({bool manual = false}) {
|
||||||
if (_manualClose || kickReason.peek() != null || _params == null) return;
|
if (_manualClose || _params == null) return;
|
||||||
if (!manual && !_everConnected) return;
|
if (!manual && !_everConnected) return;
|
||||||
if (reconnecting.peek()) return;
|
if (reconnecting.peek()) return;
|
||||||
reconnecting.value = true;
|
reconnecting.value = true;
|
||||||
@@ -1607,14 +1708,15 @@ class SessionCoordinator {
|
|||||||
/// would kill the newer connection and leave a ghost member).
|
/// would kill the newer connection and leave a ghost member).
|
||||||
Future<void> _reconnect(int epoch) async {
|
Future<void> _reconnect(int epoch) async {
|
||||||
_reconnectTimer = null;
|
_reconnectTimer = null;
|
||||||
bool stale() => epoch != _epoch || kickReason.peek() != null;
|
bool stale() => epoch != _epoch;
|
||||||
final params = _params;
|
final params = _params;
|
||||||
if (stale() || params == null) return;
|
if (stale() || params == null) return;
|
||||||
if (_manualClose) {
|
if (_manualClose) {
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var wantReady = _lastReady;
|
final wasOut = _lastOut;
|
||||||
|
var wantReady = _lastReady && !wasOut;
|
||||||
final seenBefore = _seenMediaId;
|
final seenBefore = _seenMediaId;
|
||||||
try {
|
try {
|
||||||
health.value = const HealthStatus(
|
health.value = const HealthStatus(
|
||||||
@@ -1655,6 +1757,10 @@ class SessionCoordinator {
|
|||||||
wantReady = false;
|
wantReady = false;
|
||||||
_lastReady = false;
|
_lastReady = false;
|
||||||
}
|
}
|
||||||
|
// A new member (the server dropped the old one) sits out again; a
|
||||||
|
// resumed one still does.
|
||||||
|
final selfNow = sync.lastState?.members.where((m) => m.id == sync.memberId).firstOrNull;
|
||||||
|
if (wasOut && !(selfNow?.out ?? false)) sync.sendOut(sync.memberId, true);
|
||||||
if (wantReady) {
|
if (wantReady) {
|
||||||
_lastReady = true;
|
_lastReady = true;
|
||||||
_restoringReady = true;
|
_restoringReady = true;
|
||||||
@@ -1700,7 +1806,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void retry() {
|
void retry() {
|
||||||
if (kickReason.peek() != null || _params == null) return;
|
if (_params == null) return;
|
||||||
_invalidateAttempts();
|
_invalidateAttempts();
|
||||||
_manualClose = false;
|
_manualClose = false;
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
@@ -1761,7 +1867,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
await _stateSub?.cancel();
|
await _stateSub?.cancel();
|
||||||
await _feedSub?.cancel();
|
await _feedSub?.cancel();
|
||||||
await _kickSub?.cancel();
|
await _nudgeSub?.cancel();
|
||||||
await _errSub?.cancel();
|
await _errSub?.cancel();
|
||||||
await _statusSub?.cancel();
|
await _statusSub?.cancel();
|
||||||
_aniList?.close();
|
_aniList?.close();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ enum SfxKind {
|
|||||||
forceReady,
|
forceReady,
|
||||||
join,
|
join,
|
||||||
leave,
|
leave,
|
||||||
|
/// Someone sat you out (played by the session, not from a feed action).
|
||||||
kick,
|
kick,
|
||||||
/// A new video or episode is put up.
|
/// A new video or episode is put up.
|
||||||
swap,
|
swap,
|
||||||
@@ -31,7 +32,6 @@ SfxKind? sfxForFeedAction(String action) => switch (action) {
|
|||||||
'ready' => SfxKind.ready,
|
'ready' => SfxKind.ready,
|
||||||
'unready' => SfxKind.unready,
|
'unready' => SfxKind.unready,
|
||||||
'force-ready' => SfxKind.forceReady,
|
'force-ready' => SfxKind.forceReady,
|
||||||
'kick' => SfxKind.kick,
|
|
||||||
'join' => SfxKind.join,
|
'join' => SfxKind.join,
|
||||||
'leave' => SfxKind.leave,
|
'leave' => SfxKind.leave,
|
||||||
'swap' => SfxKind.swap,
|
'swap' => SfxKind.swap,
|
||||||
|
|||||||
@@ -258,11 +258,11 @@ const soundInfo = {
|
|||||||
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
|
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
|
||||||
SfxKind.join: ('Joined', 'Someone joins the server'),
|
SfxKind.join: ('Joined', 'Someone joins the server'),
|
||||||
SfxKind.leave: ('Left', 'Someone leaves'),
|
SfxKind.leave: ('Left', 'Someone leaves'),
|
||||||
SfxKind.kick: ('Removed', 'Someone is kicked'),
|
SfxKind.kick: ('Sat out', 'Someone sits you out'),
|
||||||
SfxKind.swap: ('New video', 'A different video or episode is put up'),
|
SfxKind.swap: ('New video', 'A different video or episode is put up'),
|
||||||
SfxKind.chat: ('Message', 'A chat message from someone else'),
|
SfxKind.chat: ('Message', 'A chat message from someone else'),
|
||||||
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
|
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
|
||||||
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
|
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
|
||||||
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
|
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
|
||||||
SfxKind.nudge: ('Nudge', 'Someone nudges you to get your attention'),
|
SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'),
|
||||||
};
|
};
|
||||||
@@ -112,9 +112,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
/// The next media to come up opens full (just joined, or put up here).
|
/// The next media to come up opens full (just joined, or put up here).
|
||||||
bool _autoFull = false;
|
bool _autoFull = false;
|
||||||
int _lastMediaId = -1;
|
int _lastMediaId = -1;
|
||||||
double? _mutedVolume;
|
|
||||||
bool _dialogOpen = false;
|
bool _dialogOpen = false;
|
||||||
String? _lastKickShown;
|
|
||||||
final _stops = <void Function()>[];
|
final _stops = <void Function()>[];
|
||||||
// Rebuild the shell on these flips, not on every state broadcast.
|
// Rebuild the shell on these flips, not on every state broadcast.
|
||||||
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
||||||
@@ -140,7 +138,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
// Reads linked servers and checks their tokens in the background.
|
// Reads linked servers and checks their tokens in the background.
|
||||||
_jellyfin.ensureLoaded();
|
_jellyfin.ensureLoaded();
|
||||||
if (widget.autoUpdateCheck) _autoCheck();
|
if (widget.autoUpdateCheck) _autoCheck();
|
||||||
_flow.onKicked = _showKicked;
|
|
||||||
HardwareKeyboard.instance.addHandler(_onKey);
|
HardwareKeyboard.instance.addHandler(_onKey);
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
final coord = session.coordinator;
|
final coord = session.coordinator;
|
||||||
@@ -181,17 +178,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
if (!_chatOpen) setState(() => _unread += chats);
|
if (!_chatOpen) setState(() => _unread += chats);
|
||||||
}));
|
}));
|
||||||
}));
|
}));
|
||||||
// Kicked: back offline, and say so.
|
// Back from sitting out with the player closed: show it (the media
|
||||||
|
// plays again).
|
||||||
|
var wasOut = false;
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
final kick = coord.kickReason.value;
|
final out = coord.selfOut.value;
|
||||||
if (kick == null) {
|
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||||
_lastKickShown = null;
|
_later(() {
|
||||||
return;
|
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
untracked(() {
|
wasOut = out;
|
||||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
|
||||||
});
|
|
||||||
if (kick.isNotEmpty) _later(() => _showKicked(kick));
|
|
||||||
}));
|
}));
|
||||||
// The hosted server died mid-session (SessionState leaves): say so.
|
// The hosted server died mid-session (SessionState leaves): say so.
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
@@ -242,7 +239,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _wentOffline() {
|
void _wentOffline() {
|
||||||
_mutedVolume = null;
|
|
||||||
if (_fullscreen) _toggleFullscreen();
|
if (_fullscreen) _toggleFullscreen();
|
||||||
setState(() {
|
setState(() {
|
||||||
_chatOpen = false;
|
_chatOpen = false;
|
||||||
@@ -258,7 +254,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
Future<void> _show(PlayerMode m) async {
|
Future<void> _show(PlayerMode m) async {
|
||||||
if (m == _mode) return;
|
if (m == _mode) return;
|
||||||
final seq = ++_modeSeq;
|
final seq = ++_modeSeq;
|
||||||
if (m != PlayerMode.none) _unmute();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_from = _shown;
|
_from = _shown;
|
||||||
_mode = m;
|
_mode = m;
|
||||||
@@ -282,27 +277,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_show(PlayerMode.mini);
|
_show(PlayerMode.mini);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mini's ×: stop watching but stay in the server (muted until shown).
|
/// The player's ×: sit out (stay in the server, nothing loaded; the
|
||||||
|
/// player shows the join-back card). Once out, × closes the player.
|
||||||
void _closePlayer() {
|
void _closePlayer() {
|
||||||
final playback = SessionState.instance.coordinator.playback;
|
final coord = SessionState.instance.coordinator;
|
||||||
if (playback.alive.peek()) {
|
if (coord.selfOut.peek()) {
|
||||||
try {
|
_show(PlayerMode.none);
|
||||||
_mutedVolume = playback.player.state.volume;
|
} else {
|
||||||
playback.player.setVolume(0);
|
coord.sitOut();
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
_show(PlayerMode.none);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _unmute() {
|
|
||||||
final v = _mutedVolume;
|
|
||||||
if (v == null) return;
|
|
||||||
_mutedVolume = null;
|
|
||||||
final playback = SessionState.instance.coordinator.playback;
|
|
||||||
if (!playback.alive.peek()) return;
|
|
||||||
try {
|
|
||||||
playback.player.setVolume(v);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setWindowFullscreen(bool on) async {
|
Future<void> _setWindowFullscreen(bool on) async {
|
||||||
@@ -520,12 +503,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
if (ok == true) await SessionState.instance.leave();
|
if (ok == true) await SessionState.instance.leave();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showKicked(String reason) async {
|
|
||||||
if (reason == _lastKickShown) return;
|
|
||||||
_lastKickShown = reason;
|
|
||||||
await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _alert(String title, String text) async {
|
Future<void> _alert(String title, String text) async {
|
||||||
if (!mounted || _dialogOpen) return;
|
if (!mounted || _dialogOpen) return;
|
||||||
_dialogOpen = true;
|
_dialogOpen = true;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class AvatarInfo {
|
|||||||
final Color color;
|
final Color color;
|
||||||
final ImageProvider? image;
|
final ImageProvider? image;
|
||||||
final bool ready, buffering, relaying, stale, host, self;
|
final bool ready, buffering, relaying, stale, host, self;
|
||||||
|
/// Sitting out: dimmed, with an "away" mark.
|
||||||
|
final bool out;
|
||||||
const AvatarInfo({
|
const AvatarInfo({
|
||||||
this.id = '',
|
this.id = '',
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -28,6 +30,7 @@ class AvatarInfo {
|
|||||||
this.stale = false,
|
this.stale = false,
|
||||||
this.host = false,
|
this.host = false,
|
||||||
this.self = false,
|
this.self = false,
|
||||||
|
this.out = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Your own avatar from the profile (also offline).
|
/// Your own avatar from the profile (also offline).
|
||||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
|
||||||
final p = Profile.instance;
|
final p = Profile.instance;
|
||||||
final png = p.picture.value;
|
final png = p.picture.value;
|
||||||
final name = p.name.value;
|
final name = p.name.value;
|
||||||
@@ -86,6 +89,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
|||||||
ready: ready,
|
ready: ready,
|
||||||
host: host,
|
host: host,
|
||||||
self: true,
|
self: true,
|
||||||
|
out: out,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +111,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
|||||||
// knows before the first state.
|
// knows before the first state.
|
||||||
host: m.host || (self && coord.hostFile != null),
|
host: m.host || (self && coord.hostFile != null),
|
||||||
self: self,
|
self: self,
|
||||||
|
out: m.out,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +137,8 @@ class PartyWatch extends StatelessWidget {
|
|||||||
|
|
||||||
/// Circle avatar with a status ring outside it and a gap in the background
|
/// Circle avatar with a status ring outside it and a gap in the background
|
||||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||||
/// tower = passing the video on, spinner = buffering, dimmed = stale.
|
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||||
|
/// dimmed with a moon = sitting out.
|
||||||
class StatusAvatar extends StatelessWidget {
|
class StatusAvatar extends StatelessWidget {
|
||||||
final AvatarInfo info;
|
final AvatarInfo info;
|
||||||
final double size;
|
final double size;
|
||||||
@@ -176,7 +182,7 @@ class StatusAvatar extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||||
padding: EdgeInsets.all(stroke),
|
padding: EdgeInsets.all(stroke),
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: info.stale ? 0.4 : 1,
|
opacity: info.stale || info.out ? 0.4 : 1,
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: size / 2,
|
radius: size / 2,
|
||||||
backgroundColor: info.color,
|
backgroundColor: info.color,
|
||||||
@@ -205,6 +211,17 @@ class StatusAvatar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (info.out)
|
||||||
|
Positioned(
|
||||||
|
key: const ValueKey('away-mark'),
|
||||||
|
left: -3,
|
||||||
|
bottom: -3,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||||
|
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (badges && info.relaying)
|
if (badges && info.relaying)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: -3,
|
right: -3,
|
||||||
@@ -240,7 +257,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = widget.size;
|
final size = widget.size;
|
||||||
int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2);
|
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
|
||||||
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
||||||
final visible = sorted.take(widget.shown).toList();
|
final visible = sorted.take(widget.shown).toList();
|
||||||
final extra = sorted.length - visible.length;
|
final extra = sorted.length - visible.length;
|
||||||
|
|||||||
@@ -232,9 +232,11 @@ class ActivityRow extends StatelessWidget {
|
|||||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||||
case 'stale':
|
case 'stale':
|
||||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||||
case 'kick':
|
case 'out':
|
||||||
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
final who = f.target;
|
||||||
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
|
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
|
||||||
|
case 'back':
|
||||||
|
return (Icons.login, Colors.green, 'joined back');
|
||||||
default:
|
default:
|
||||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
||||||
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
|||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/dialogs.dart';
|
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -337,7 +336,8 @@ class StripTab extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Who is in the server, from the avatars: status, host / relay pills,
|
/// Who is in the server, from the avatars: status, host / relay pills,
|
||||||
/// per member force ready and kick, and the address to invite friends.
|
/// per member force ready, nudge and sit out, and the address to invite
|
||||||
|
/// friends.
|
||||||
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
||||||
context,
|
context,
|
||||||
at: const Alignment(0.7, -0.84),
|
at: const Alignment(0.7, -0.84),
|
||||||
@@ -469,6 +469,8 @@ class _MemberTile extends StatelessWidget {
|
|||||||
final m = member;
|
final m = member;
|
||||||
final status = m.stale
|
final status = m.stale
|
||||||
? 'reconnecting…'
|
? 'reconnecting…'
|
||||||
|
: m.out
|
||||||
|
? 'sitting out'
|
||||||
: info.buffering
|
: info.buffering
|
||||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||||
: m.ready
|
: m.ready
|
||||||
@@ -494,27 +496,30 @@ class _MemberTile extends StatelessWidget {
|
|||||||
trailing: PopupMenuButton<String>(
|
trailing: PopupMenuButton<String>(
|
||||||
tooltip: 'More',
|
tooltip: 'More',
|
||||||
icon: const Icon(Icons.more_vert, size: 18),
|
icon: const Icon(Icons.more_vert, size: 18),
|
||||||
onSelected: (v) async {
|
onSelected: (v) {
|
||||||
if (v == 'force') {
|
switch (v) {
|
||||||
coord.forceTarget(m.id, !m.ready);
|
case 'force':
|
||||||
} else if (v == 'kick') {
|
coord.forceTarget(m.id, !m.ready);
|
||||||
final ok = await showKickConfirmDialog(context, m.name);
|
case 'nudge':
|
||||||
if (ok == true) coord.kick(m.id);
|
coord.nudgeMember(m.id);
|
||||||
|
case 'out':
|
||||||
|
coord.sitOut(m.id);
|
||||||
|
case 'back':
|
||||||
|
coord.joinBack();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
PopupMenuItem(
|
if (!m.out)
|
||||||
value: 'force',
|
|
||||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
|
||||||
),
|
|
||||||
if (!info.self && !info.host)
|
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'kick',
|
value: 'force',
|
||||||
child: Text(
|
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||||
'Kick',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
if (!info.self)
|
||||||
|
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
|
||||||
|
if (info.self && m.out)
|
||||||
|
const PopupMenuItem(value: 'back', child: Text('Join back'))
|
||||||
|
else if (!m.out)
|
||||||
|
const PopupMenuItem(value: 'out', child: Text('Sit out')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import 'avatar.dart';
|
|||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// What the player's status pill says, most pressing first.
|
/// What the player's status pill says, most pressing first.
|
||||||
enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting }
|
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting }
|
||||||
|
|
||||||
/// The pill's button: stop reconnecting, retry, or mark yourself ready
|
/// The pill's button: stop reconnecting, retry, or mark yourself ready
|
||||||
/// (the whole pill).
|
/// (the whole pill).
|
||||||
@@ -59,16 +59,10 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
|||||||
StatusView? playerStatusFor({
|
StatusView? playerStatusFor({
|
||||||
required HealthStatus health,
|
required HealthStatus health,
|
||||||
bool reconnecting = false,
|
bool reconnecting = false,
|
||||||
String? kickReason,
|
|
||||||
StateBroadcast? state,
|
StateBroadcast? state,
|
||||||
MemberView? self,
|
MemberView? self,
|
||||||
bool slowConnect = false,
|
bool slowConnect = false,
|
||||||
}) {
|
}) {
|
||||||
if (kickReason != null) {
|
|
||||||
return StatusView(StatusKind.kicked,
|
|
||||||
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
|
|
||||||
tone: HealthSeverity.error, short: 'Removed from the server');
|
|
||||||
}
|
|
||||||
if (reconnecting) {
|
if (reconnecting) {
|
||||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||||
@@ -82,6 +76,8 @@ StatusView? playerStatusFor({
|
|||||||
}
|
}
|
||||||
final r = state;
|
final r = state;
|
||||||
if (r == null || r.media == null || r.transport == 'playing') return null;
|
if (r == null || r.media == null || r.transport == 'playing') return null;
|
||||||
|
// Sitting out: the join-back card says it; nobody waits for you.
|
||||||
|
if (self != null && self.out) return null;
|
||||||
if (r.transport == 'finished') {
|
if (r.transport == 'finished') {
|
||||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||||
}
|
}
|
||||||
@@ -100,7 +96,8 @@ StatusView? playerStatusFor({
|
|||||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', 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];
|
// Members sitting out are never waited for nor counted.
|
||||||
|
final active = [for (final m in r.members) if (!m.stale && !m.out) m];
|
||||||
final others = [
|
final others = [
|
||||||
for (final m in active)
|
for (final m in active)
|
||||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||||
@@ -117,7 +114,6 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
|||||||
return playerStatusFor(
|
return playerStatusFor(
|
||||||
health: c.health.value,
|
health: c.health.value,
|
||||||
reconnecting: c.reconnecting.value,
|
reconnecting: c.reconnecting.value,
|
||||||
kickReason: c.kickReason.value,
|
|
||||||
state: c.serverState.value,
|
state: c.serverState.value,
|
||||||
self: c.selfMember.value,
|
self: c.selfMember.value,
|
||||||
slowConnect: slowConnect,
|
slowConnect: slowConnect,
|
||||||
@@ -258,7 +254,6 @@ class _StatusLine extends StatelessWidget {
|
|||||||
Widget? _lead() {
|
Widget? _lead() {
|
||||||
final v = view;
|
final v = view;
|
||||||
switch (v.kind) {
|
switch (v.kind) {
|
||||||
case StatusKind.kicked:
|
|
||||||
case StatusKind.problem:
|
case StatusKind.problem:
|
||||||
final error = v.tone == HealthSeverity.error;
|
final error = v.tone == HealthSeverity.error;
|
||||||
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
final hosting = online && coord.hostFile != null;
|
final hosting = online && coord.hostFile != null;
|
||||||
final color = _p.color.value;
|
final color = _p.color.value;
|
||||||
final png = _p.picture.value;
|
final png = _p.picture.value;
|
||||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting);
|
final out = self?.out ?? false;
|
||||||
|
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -166,7 +167,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
Pill(online ? 'in a server' : 'offline',
|
Pill(online ? 'in a server' : 'offline',
|
||||||
online ? Colors.greenAccent : Colors.grey),
|
online ? Colors.greenAccent : Colors.grey),
|
||||||
if (online)
|
if (online)
|
||||||
Pill((self?.ready ?? false) ? 'ready' : 'not ready', Colors.lightBlueAccent),
|
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
|
||||||
|
Colors.lightBlueAccent),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -237,6 +239,16 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
|
if (self != null)
|
||||||
|
_link(
|
||||||
|
out ? Icons.login_rounded : Icons.event_seat_outlined,
|
||||||
|
out ? 'Join back' : 'Sit out',
|
||||||
|
trailing: Text(out ? 'Space' : 'stay in the server', style: small),
|
||||||
|
onTap: () {
|
||||||
|
out ? coord.joinBack() : coord.sitOut();
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
},
|
||||||
|
),
|
||||||
_link(
|
_link(
|
||||||
Icons.extension_outlined,
|
Icons.extension_outlined,
|
||||||
'Jellyfin & integrations',
|
'Jellyfin & integrations',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import 'avatar.dart';
|
|||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'now_playing_title.dart';
|
import 'now_playing_title.dart';
|
||||||
import 'player_status.dart';
|
import 'player_status.dart';
|
||||||
|
import 'sit_out_card.dart';
|
||||||
import 'start_banner.dart';
|
import 'start_banner.dart';
|
||||||
|
|
||||||
/// Where the session's video is shown: nowhere, the floating mini player,
|
/// Where the session's video is shown: nowhere, the floating mini player,
|
||||||
@@ -298,6 +299,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
|
if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
|
||||||
] else
|
] else
|
||||||
_miniOverlay(),
|
_miniOverlay(),
|
||||||
|
// Sitting out: nothing is loaded; the card covers the
|
||||||
|
// empty player (which stays mounted for joining back).
|
||||||
|
if (_coord.selfOut.value) _sitOut(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -784,6 +788,38 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// [SitOutCard] with the mini's (or the full player's) expand /
|
||||||
|
/// collapse and close.
|
||||||
|
Widget _sitOut() => Positioned.fill(
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
focusNode: _noFocus,
|
||||||
|
onPressed: _full ? widget.onCollapse : widget.onExpand,
|
||||||
|
tooltip: _full ? 'Small player' : 'Expand',
|
||||||
|
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
focusNode: _noFocus,
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
tooltip: 'Close the player',
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Mini: title, expand and close on hover; ready, time and a seekable
|
/// Mini: title, expand and close on hover; ready, time and a seekable
|
||||||
/// bar at the bottom. A thin progress line stays while not hovered.
|
/// bar at the bottom. A thin progress line stays while not hovered.
|
||||||
Widget _miniOverlay() {
|
Widget _miniOverlay() {
|
||||||
@@ -823,7 +859,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
onPressed: widget.onClose,
|
onPressed: widget.onClose,
|
||||||
tooltip: 'Stop watching (stay in the server)',
|
tooltip: 'Sit out (stay in the server)',
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ class SettingsTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SettingsPanel(
|
||||||
|
title: 'When idle',
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) => SwitchListTile(
|
||||||
|
key: const ValueKey('idle-sits-out'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('When idle, sit out instead of just unready'),
|
||||||
|
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
|
||||||
|
value: AppSettings.instance.idleSitsOut.value,
|
||||||
|
onChanged: AppSettings.instance.setIdleSitsOut,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
case SettingsSection.player:
|
case SettingsSection.player:
|
||||||
return [
|
return [
|
||||||
@@ -510,8 +523,9 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
line('Joins, leaves, kicks, video changes and chat',
|
line('Joins, leaves, video changes and chat',
|
||||||
'Pop up bottom left for 5 s, with the person\'s picture'),
|
'Pop up bottom left for 5 s, with the person\'s picture'),
|
||||||
|
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
||||||
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
||||||
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
|
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
|
||||||
],
|
],
|
||||||
@@ -617,10 +631,15 @@ class SoundSettings extends StatelessWidget {
|
|||||||
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
|
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Switch(
|
// A nudge always plays (an explicit poke): no switch,
|
||||||
key: ValueKey('sound-on-${kind.name}'),
|
// its place kept so the rows line up.
|
||||||
value: !off.contains(kind.name),
|
Visibility.maintain(
|
||||||
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
|
visible: kind != SfxKind.nudge,
|
||||||
|
child: Switch(
|
||||||
|
key: ValueKey('sound-on-${kind.name}'),
|
||||||
|
value: !off.contains(kind.name),
|
||||||
|
onChanged: volume == 0 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../playback/media_marks.dart' show formatClock;
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
import '../sync/state_diff.dart' show serverPositionAt;
|
||||||
|
import 'now_playing_title.dart';
|
||||||
|
import 'start_banner.dart';
|
||||||
|
|
||||||
|
/// In place of the video while self sits out: the media's start card
|
||||||
|
/// (cover, title, episode), where the others are ("12:34 / 24:00",
|
||||||
|
/// playing or paused) and "Join back" (or Space). [compact]: the mini
|
||||||
|
/// player.
|
||||||
|
class SitOutCard extends StatefulWidget {
|
||||||
|
final bool compact;
|
||||||
|
const SitOutCard({super.key, this.compact = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SitOutCard> createState() => _SitOutCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SitOutCardState extends State<SitOutCard> {
|
||||||
|
// The position moves on while playing: redraw each second.
|
||||||
|
late final Timer _tick;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tick.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final coord = SessionState.instance.coordinator;
|
||||||
|
final small = widget.compact;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: artChanges(),
|
||||||
|
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||||
|
final r = coord.serverState.value;
|
||||||
|
final now = coord.nowPlaying.value;
|
||||||
|
final playing = r?.transport == 'playing';
|
||||||
|
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||||
|
final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow());
|
||||||
|
Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
|
||||||
|
final where = r?.media == null
|
||||||
|
? 'Nothing is up'
|
||||||
|
: duration > 0
|
||||||
|
? '${formatClock(secs(at.clamp(0, duration)))} / ${formatClock(secs(duration))}'
|
||||||
|
: formatClock(secs(at));
|
||||||
|
final text = TextStyle(color: Colors.white, fontSize: small ? 12 : 15);
|
||||||
|
final panel = Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("You're sitting out", style: text.copyWith(fontWeight: FontWeight.w700, fontSize: small ? 13 : 20)),
|
||||||
|
SizedBox(height: small ? 4 : 8),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (r?.media != null) ...[
|
||||||
|
Icon(playing ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||||
|
color: Colors.white70, size: small ? 14 : 18),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
Text(where, key: const ValueKey('sit-out-position'), style: text.copyWith(color: Colors.white70)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: small ? 8 : 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const ValueKey('join-back'),
|
||||||
|
onPressed: coord.joinBack,
|
||||||
|
icon: Icon(Icons.login_rounded, size: small ? 16 : 20),
|
||||||
|
label: Text(small ? 'Join back' : 'Join back (Space)'),
|
||||||
|
style: small
|
||||||
|
? FilledButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (now != null) StartCard(now: now) else const ColoredBox(color: Color(0xFF0C0D11)),
|
||||||
|
// Small: the card's title sits at the bottom, so the panel goes up top.
|
||||||
|
Align(
|
||||||
|
alignment: small ? const Alignment(0, -0.35) : Alignment.center,
|
||||||
|
child: panel,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,11 +34,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
|||||||
? Icons.login
|
? Icons.login
|
||||||
: rest.startsWith('left')
|
: rest.startsWith('left')
|
||||||
? Icons.logout
|
? Icons.logout
|
||||||
: rest.startsWith('kicked')
|
: rest.startsWith('sat you out')
|
||||||
? Icons.gavel
|
? Icons.bedtime
|
||||||
: rest.startsWith('changed')
|
: rest.startsWith('nudged')
|
||||||
? Icons.swap_horiz
|
? Icons.notifications_active_outlined
|
||||||
: Icons.info_outline;
|
: rest.startsWith('changed')
|
||||||
|
? Icons.swap_horiz
|
||||||
|
: Icons.info_outline;
|
||||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class ServerCrashed implements Exception {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
||||||
/// recognised as the (unkickable) host.
|
/// recognised as the host (`members[].host`).
|
||||||
String newHostKey() {
|
String newHostKey() {
|
||||||
final r = Random.secure();
|
final r = Random.secure();
|
||||||
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
||||||
@@ -159,7 +159,8 @@ class SessionState {
|
|||||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final SfxService sfx = SynthSfx(
|
final SfxService sfx = SynthSfx(
|
||||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||||
enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name),
|
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||||
|
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||||
presetFor: soundPresetFor,
|
presetFor: soundPresetFor,
|
||||||
);
|
);
|
||||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||||
@@ -183,7 +184,6 @@ class SessionState {
|
|||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
ServerIsolate? get hostedServer => _server;
|
ServerIsolate? get hostedServer => _server;
|
||||||
void Function()? _stopKickWatch;
|
|
||||||
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
||||||
// next step and unwinds instead of connecting.
|
// next step and unwinds instead of connecting.
|
||||||
int _op = 0;
|
int _op = 0;
|
||||||
@@ -309,7 +309,6 @@ class SessionState {
|
|||||||
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
_watchKick();
|
|
||||||
route.value = AppRoute.session;
|
route.value = AppRoute.session;
|
||||||
} finally {
|
} finally {
|
||||||
await _saveFormQuietly();
|
await _saveFormQuietly();
|
||||||
@@ -353,15 +352,6 @@ class SessionState {
|
|||||||
await stopServer();
|
await stopServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A kicked host must not keep serving.
|
|
||||||
void _watchKick() {
|
|
||||||
_stopKickWatch?.call();
|
|
||||||
_stopKickWatch = effect(() {
|
|
||||||
// Microtask: stopServer disposes this effect, not from inside it.
|
|
||||||
if (coordinator.kickReason.value != null) scheduleMicrotask(stopServer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saved whatever the outcome: a failed join is exactly when the user
|
/// Saved whatever the outcome: a failed join is exactly when the user
|
||||||
/// wants the form back.
|
/// wants the form back.
|
||||||
Future<void> _saveFormQuietly() async {
|
Future<void> _saveFormQuietly() async {
|
||||||
@@ -387,8 +377,6 @@ class SessionState {
|
|||||||
|
|
||||||
Future<void> stopServer() async {
|
Future<void> stopServer() async {
|
||||||
coordinator.hostFile = null;
|
coordinator.hostFile = null;
|
||||||
_stopKickWatch?.call();
|
|
||||||
_stopKickWatch = null;
|
|
||||||
final server = _server;
|
final server = _server;
|
||||||
_server = null;
|
_server = null;
|
||||||
await server?.stop();
|
await server?.stop();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const _keySoundsOff = 'soundsOff';
|
|||||||
const _keySoundPreset = 'soundPreset';
|
const _keySoundPreset = 'soundPreset';
|
||||||
const _keySoundPresetFor = 'soundPresetFor';
|
const _keySoundPresetFor = 'soundPresetFor';
|
||||||
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||||
|
const _keyIdleSitsOut = 'idleSitsOut';
|
||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
@@ -39,6 +40,8 @@ class AppSettings {
|
|||||||
/// Session: send ready=false when the video changes (a swap keeps the
|
/// Session: send ready=false when the video changes (a swap keeps the
|
||||||
/// server's ready flags).
|
/// server's ready flags).
|
||||||
final unreadyOnSwap = signal(false);
|
final unreadyOnSwap = signal(false);
|
||||||
|
/// Session: the idle auto-unready sits this member out instead.
|
||||||
|
final idleSitsOut = signal(false);
|
||||||
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
||||||
/// standard ones (media_kit's with our bars). Applies live.
|
/// standard ones (media_kit's with our bars). Applies live.
|
||||||
final mpvControls = signal(false);
|
final mpvControls = signal(false);
|
||||||
@@ -95,6 +98,8 @@ class AppSettings {
|
|||||||
if (advance is bool) autoAdvance.value = advance;
|
if (advance is bool) autoAdvance.value = advance;
|
||||||
final unready = box.get(_keyUnreadyOnSwap);
|
final unready = box.get(_keyUnreadyOnSwap);
|
||||||
if (unready is bool) unreadyOnSwap.value = unready;
|
if (unready is bool) unreadyOnSwap.value = unready;
|
||||||
|
final idleOut = box.get(_keyIdleSitsOut);
|
||||||
|
if (idleOut is bool) idleSitsOut.value = idleOut;
|
||||||
final mpv = box.get(_keyMpvControls);
|
final mpv = box.get(_keyMpvControls);
|
||||||
if (mpv is bool) mpvControls.value = mpv;
|
if (mpv is bool) mpvControls.value = mpv;
|
||||||
final hideRe = box.get(_keyHideReReleases);
|
final hideRe = box.get(_keyHideReReleases);
|
||||||
@@ -156,6 +161,11 @@ class AppSettings {
|
|||||||
_put(_keyUnreadyOnSwap, on);
|
_put(_keyUnreadyOnSwap, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setIdleSitsOut(bool on) {
|
||||||
|
idleSitsOut.value = on;
|
||||||
|
_put(_keyIdleSitsOut, on);
|
||||||
|
}
|
||||||
|
|
||||||
void setMpvControls(bool on) {
|
void setMpvControls(bool on) {
|
||||||
mpvControls.value = on;
|
mpvControls.value = on;
|
||||||
_put(_keyMpvControls, on);
|
_put(_keyMpvControls, on);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:convert';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
enum SyncStatus { idle, connecting, open, kicked, closed, error }
|
enum SyncStatus { idle, connecting, open, closed, error }
|
||||||
|
|
||||||
class SyncClient {
|
class SyncClient {
|
||||||
/// Local clock; injectable so tests can run a client whose clock is off.
|
/// Local clock; injectable so tests can run a client whose clock is off.
|
||||||
@@ -23,13 +23,14 @@ class SyncClient {
|
|||||||
StreamSubscription? _sub;
|
StreamSubscription? _sub;
|
||||||
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||||
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
||||||
final StreamController<String> _kicked = StreamController.broadcast();
|
final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
|
||||||
final StreamController<String> _errors = StreamController.broadcast();
|
final StreamController<String> _errors = StreamController.broadcast();
|
||||||
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
||||||
|
|
||||||
Stream<StateBroadcast> get states => _states.stream;
|
Stream<StateBroadcast> get states => _states.stream;
|
||||||
Stream<FeedMessage> get feeds => _feeds.stream;
|
Stream<FeedMessage> get feeds => _feeds.stream;
|
||||||
Stream<String> get kicked => _kicked.stream;
|
/// Someone poked this member (`nudge`).
|
||||||
|
Stream<NudgeNotice> get nudges => _nudges.stream;
|
||||||
Stream<String> get errors => _errors.stream;
|
Stream<String> get errors => _errors.stream;
|
||||||
Stream<SyncStatus> get status => _status.stream;
|
Stream<SyncStatus> get status => _status.stream;
|
||||||
|
|
||||||
@@ -153,14 +154,10 @@ class SyncClient {
|
|||||||
// one toast, one sfx.
|
// one toast, one sfx.
|
||||||
if (f.action != 'chat') _feeds.add(f);
|
if (f.action != 'chat') _feeds.add(f);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
case 'kicked':
|
case 'nudge':
|
||||||
try {
|
try {
|
||||||
final msg = KickedMessage.fromJson(decoded);
|
_nudges.add(NudgeNotice.fromJson(decoded));
|
||||||
_kicked.add(msg.reason);
|
} catch (_) {}
|
||||||
_status.add(SyncStatus.kicked);
|
|
||||||
} catch (_) {
|
|
||||||
_kicked.add('');
|
|
||||||
}
|
|
||||||
case 'pong':
|
case 'pong':
|
||||||
try {
|
try {
|
||||||
final pong = PongMessage.fromJson(decoded);
|
final pong = PongMessage.fromJson(decoded);
|
||||||
@@ -255,8 +252,14 @@ class SyncClient {
|
|||||||
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendKick(String target, {String reason = ''}) {
|
/// Sits the member [target] out, or with [out] false brings this
|
||||||
_send(KickRequest(target: target, reason: reason).toJson());
|
/// member back (only oneself can come back).
|
||||||
|
void sendOut(String target, bool out) {
|
||||||
|
_send(OutIntent(target: target, out: out).toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendNudge(String target) {
|
||||||
|
_send(NudgeIntent(target: target).toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
@@ -283,7 +286,7 @@ class SyncClient {
|
|||||||
await disconnect();
|
await disconnect();
|
||||||
await _states.close();
|
await _states.close();
|
||||||
await _feeds.close();
|
await _feeds.close();
|
||||||
await _kicked.close();
|
await _nudges.close();
|
||||||
await _errors.close();
|
await _errors.close();
|
||||||
await _status.close();
|
await _status.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
Future<bool?> showKickConfirmDialog(
|
|
||||||
BuildContext context, String memberName) {
|
|
||||||
return showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Text('Remove $memberName?'),
|
|
||||||
content: const Text('They can come straight back in.'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: const Text('Remove'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
struct _MyApplication {
|
struct _MyApplication {
|
||||||
GtkApplication parent_instance;
|
GtkApplication parent_instance;
|
||||||
char** dart_entrypoint_arguments;
|
char** dart_entrypoint_arguments;
|
||||||
|
// "starsync/window" (lib/src/platform/attention.dart).
|
||||||
|
FlMethodChannel* window_channel;
|
||||||
};
|
};
|
||||||
|
|
||||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||||
@@ -19,6 +21,29 @@ static void first_frame_cb(MyApplication* self, FlView* view) {
|
|||||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "starsync/window": `attention` sets the urgency hint while the window is
|
||||||
|
// not focused; focusing it clears the hint.
|
||||||
|
static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
|
||||||
|
gpointer user_data) {
|
||||||
|
GtkWindow* window = GTK_WINDOW(user_data);
|
||||||
|
g_autoptr(FlMethodResponse) response = nullptr;
|
||||||
|
if (g_strcmp0(fl_method_call_get_name(call), "attention") == 0) {
|
||||||
|
if (!gtk_window_is_active(window)) {
|
||||||
|
gtk_window_set_urgency_hint(window, TRUE);
|
||||||
|
}
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
|
} else {
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
|
||||||
|
}
|
||||||
|
fl_method_call_respond(call, response, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event,
|
||||||
|
gpointer user_data) {
|
||||||
|
gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
// Implements GApplication::activate.
|
// Implements GApplication::activate.
|
||||||
static void my_application_activate(GApplication* application) {
|
static void my_application_activate(GApplication* application) {
|
||||||
MyApplication* self = MY_APPLICATION(application);
|
MyApplication* self = MY_APPLICATION(application);
|
||||||
@@ -88,6 +113,15 @@ static void my_application_activate(GApplication* application) {
|
|||||||
|
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||||
|
|
||||||
|
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||||
|
self->window_channel = fl_method_channel_new(
|
||||||
|
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
|
||||||
|
"starsync/window", FL_METHOD_CODEC(codec));
|
||||||
|
fl_method_channel_set_method_call_handler(self->window_channel,
|
||||||
|
window_method_cb, window, nullptr);
|
||||||
|
g_signal_connect(window, "focus-in-event", G_CALLBACK(window_focus_in_cb),
|
||||||
|
nullptr);
|
||||||
|
|
||||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +168,7 @@ static void my_application_shutdown(GApplication* application) {
|
|||||||
static void my_application_dispose(GObject* object) {
|
static void my_application_dispose(GObject* object) {
|
||||||
MyApplication* self = MY_APPLICATION(object);
|
MyApplication* self = MY_APPLICATION(object);
|
||||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||||
|
g_clear_object(&self->window_channel);
|
||||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -296,10 +296,10 @@ void main() {
|
|||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')),
|
expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')),
|
||||||
findsNothing);
|
findsNothing);
|
||||||
// The host cannot be kicked: no Kick in its menu.
|
// The host can be sat out like anyone.
|
||||||
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More')));
|
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Kick'), findsNothing);
|
expect(find.text('Sit out'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
|
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ final png = Uint8List.fromList(const [
|
|||||||
0x42, 0x60, 0x82,
|
0x42, 0x60, 0x82,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) =>
|
MemberView mv(String id, String name,
|
||||||
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale);
|
{bool ready = false, bool? inSet, double buffered = 30, bool stale = false, bool out = false}) =>
|
||||||
|
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale, out: out);
|
||||||
|
|
||||||
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
|
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
|
||||||
StateBroadcast(
|
StateBroadcast(
|
||||||
@@ -55,17 +56,8 @@ void main() {
|
|||||||
group('status', () {
|
group('status', () {
|
||||||
final me = mv('me', 'Me', ready: true);
|
final me = mv('me', 'Me', ready: true);
|
||||||
|
|
||||||
test('problems first: removed, reconnecting, health', () {
|
test('problems first: reconnecting, health', () {
|
||||||
var v = playerStatusFor(
|
var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
|
||||||
health: const HealthStatus(HealthSeverity.error, 'kicked: spam'),
|
|
||||||
kickReason: 'spam',
|
|
||||||
reconnecting: true,
|
|
||||||
state: held([me]));
|
|
||||||
expect(v!.kind, StatusKind.kicked);
|
|
||||||
expect(v.text, 'You were removed: spam');
|
|
||||||
expect(v.tone, HealthSeverity.error);
|
|
||||||
expect(v.action, isNull);
|
|
||||||
v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
|
|
||||||
reconnecting: true);
|
reconnecting: true);
|
||||||
expect(v!.text, 'Reconnecting…');
|
expect(v!.text, 'Reconnecting…');
|
||||||
expect(v.action, StatusAction.cancel);
|
expect(v.action, StatusAction.cancel);
|
||||||
@@ -109,11 +101,14 @@ void main() {
|
|||||||
expect(v.self, isTrue);
|
expect(v.self, isTrue);
|
||||||
final w = playerStatusFor(
|
final w = playerStatusFor(
|
||||||
health: ok,
|
health: ok,
|
||||||
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true)]),
|
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true), mv('o', 'Ola', out: true)]),
|
||||||
self: me)!;
|
self: me)!;
|
||||||
expect(w.kind, StatusKind.waiting);
|
expect(w.kind, StatusKind.waiting);
|
||||||
expect(w.text, 'Waiting for Bob and Mika');
|
expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out');
|
||||||
expect(w.detail, '1/3 ready');
|
expect(w.detail, '1/3 ready');
|
||||||
|
final outMe = mv('me', 'Me', out: true);
|
||||||
|
expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull,
|
||||||
|
reason: 'sitting out: the join-back card speaks');
|
||||||
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('host path: isolate server, host unkickable, crash leaves the session',
|
test('host path: isolate server, host sat out and nudging, crash leaves the session',
|
||||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
Hive.init(
|
Hive.init(
|
||||||
@@ -131,16 +131,16 @@ void main() {
|
|||||||
final hostId = ((state['members'] as List)
|
final hostId = ((state['members'] as List)
|
||||||
.cast<Map>()
|
.cast<Map>()
|
||||||
.firstWhere((m) => m['name'] == 'host'))['id'];
|
.firstWhere((m) => m['name'] == 'host'))['id'];
|
||||||
friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson()));
|
// Anyone may sit the host out; its player unloads, the server serves on.
|
||||||
final err = await friend.next('err');
|
friend.ws.add(jsonEncode(OutIntent(target: '$hostId', out: true).toJson()));
|
||||||
expect(err['message'], "can't kick the host");
|
await _poll(() => session.coordinator.selfOut.peek(), 'host sat out');
|
||||||
|
|
||||||
// The host can kick the friend (plain loopback peers are not hosts).
|
// The host nudges the friend: only the friend hears it.
|
||||||
final friendId = ((state['members'] as List)
|
final friendId = ((state['members'] as List)
|
||||||
.cast<Map>()
|
.cast<Map>()
|
||||||
.firstWhere((m) => m['name'] == 'friend'))['id'];
|
.firstWhere((m) => m['name'] == 'friend'))['id'];
|
||||||
session.coordinator.kick('$friendId');
|
session.coordinator.nudgeMember('$friendId');
|
||||||
await friend.next('kicked');
|
expect((await friend.next('nudge'))['name'], 'host');
|
||||||
|
|
||||||
session.hostedServer!.isolate.kill(priority: Isolate.immediate);
|
session.hostedServer!.isolate.kill(priority: Isolate.immediate);
|
||||||
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
|
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
|
||||||
|
|||||||
@@ -166,13 +166,13 @@ void main() {
|
|||||||
expect(find.descendant(of: card, matching: find.text(m.name)), findsOneWidget);
|
expect(find.descendant(of: card, matching: find.text(m.name)), findsOneWidget);
|
||||||
}
|
}
|
||||||
expect(find.descendant(of: card, matching: find.text('reconnecting…')), findsOneWidget);
|
expect(find.descendant(of: card, matching: find.text('reconnecting…')), findsOneWidget);
|
||||||
// The kick menu asks before kicking.
|
// Someone else: nudge or sit them out (no kicking).
|
||||||
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More')));
|
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Kick'));
|
expect(find.text('Nudge'), findsOneWidget);
|
||||||
await tester.pumpAndSettle();
|
expect(find.text('Sit out'), findsOneWidget);
|
||||||
expect(find.text('Remove Bob?'), findsOneWidget);
|
expect(find.text('Kick'), findsNothing);
|
||||||
await tester.tap(find.text('Cancel'));
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tapAt(const Offset(40, 700));
|
await tester.tapAt(const Offset(40, 700));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
|
#include <flutter/standard_method_codec.h>
|
||||||
|
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||||
@@ -25,6 +27,24 @@ bool FlutterWindow::OnCreate() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
RegisterPlugins(flutter_controller_->engine());
|
RegisterPlugins(flutter_controller_->engine());
|
||||||
|
window_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||||
|
flutter_controller_->engine()->messenger(), "starsync/window",
|
||||||
|
&flutter::StandardMethodCodec::GetInstance());
|
||||||
|
window_channel_->SetMethodCallHandler(
|
||||||
|
[this](const flutter::MethodCall<flutter::EncodableValue>& call,
|
||||||
|
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||||
|
if (call.method_name() != "attention") {
|
||||||
|
result->NotImplemented();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HWND hwnd = GetHandle();
|
||||||
|
if (hwnd != nullptr && GetForegroundWindow() != hwnd) {
|
||||||
|
// Flashes until the window comes to the front.
|
||||||
|
FLASHWINFO info{sizeof(FLASHWINFO), hwnd, FLASHW_ALL | FLASHW_TIMERNOFG, 0, 0};
|
||||||
|
FlashWindowEx(&info);
|
||||||
|
}
|
||||||
|
result->Success();
|
||||||
|
});
|
||||||
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||||
|
|
||||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||||
@@ -40,6 +60,7 @@ bool FlutterWindow::OnCreate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void FlutterWindow::OnDestroy() {
|
void FlutterWindow::OnDestroy() {
|
||||||
|
window_channel_ = nullptr;
|
||||||
if (flutter_controller_) {
|
if (flutter_controller_) {
|
||||||
flutter_controller_ = nullptr;
|
flutter_controller_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
#include <flutter/dart_project.h>
|
#include <flutter/dart_project.h>
|
||||||
#include <flutter/flutter_view_controller.h>
|
#include <flutter/flutter_view_controller.h>
|
||||||
|
#include <flutter/method_channel.h>
|
||||||
|
#include <flutter/encodable_value.h>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
@@ -28,6 +30,10 @@ class FlutterWindow : public Win32Window {
|
|||||||
|
|
||||||
// The Flutter instance hosted by this window.
|
// The Flutter instance hosted by this window.
|
||||||
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
||||||
|
|
||||||
|
// "starsync/window": `attention` flashes the taskbar entry while the
|
||||||
|
// window is not in front (lib/src/platform/attention.dart).
|
||||||
|
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> window_channel_;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // RUNNER_FLUTTER_WINDOW_H_
|
#endif // RUNNER_FLUTTER_WINDOW_H_
|
||||||
+52
-22
@@ -38,7 +38,7 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
|||||||
uncaught async error is logged and the server keeps going. If the isolate
|
uncaught async error is logged and the server keeps going. If the isolate
|
||||||
dies anyway, the session shows "server crashed: ..." and leaves; the
|
dies anyway, the session shows "server crashed: ..." and leaves; the
|
||||||
shell says so in a dialog (during setup the host attempt fails with it).
|
shell says so in a dialog (during setup the host attempt fails with it).
|
||||||
Stop (leave, close, cancel during setup, a failed own join, being kicked)
|
Stop (leave, close, cancel during setup, a failed own join)
|
||||||
sends `stop`, waits up to 3 s for exit, then kills the isolate.
|
sends `stop`, waits up to 3 s for exit, then kills the isolate.
|
||||||
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||||
and shipped next to the app as `watch-together-server`. It parses flags
|
and shipped next to the app as `watch-together-server`. It parses flags
|
||||||
@@ -55,11 +55,12 @@ warning is logged). Otherwise one shared secret, compared at hello.
|
|||||||
|
|
||||||
Host: when hosting, the app generates a random `hostKey` per hosting, puts
|
Host: when hosting, the app generates a random `hostKey` per hosting, puts
|
||||||
it in the server config and sends it in its own `hello`. The member whose
|
it in the server config and sends it in its own `hello`. The member whose
|
||||||
key matches is the host and cannot be kicked. The address a connection
|
key matches is the host (`members[].host`: the crown, art rights). The
|
||||||
comes from plays no part (so a reverse proxy or tunnel on the same machine
|
address a connection comes from plays no part (so a reverse proxy or tunnel
|
||||||
does not make everyone the host). The dedicated server has no key, so it
|
on the same machine does not make everyone the host). The dedicated server
|
||||||
has no host. Kick = revoke token, send `kicked`, close; the person may
|
has no key, so it has no host. Nobody removes anyone: members sit each
|
||||||
rejoin at once with a new token.
|
other out ("Sitting out" below), the host included, since the hosted
|
||||||
|
server and its `/m/` file serving do not need the host's player.
|
||||||
|
|
||||||
## Sync model
|
## Sync model
|
||||||
|
|
||||||
@@ -94,8 +95,22 @@ Loosely modeled on Syncplay, with a different readiness design.
|
|||||||
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
|
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
|
||||||
default off, sends `ready=false` itself on a new `mediaId`; not for the
|
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
|
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
|
a swap meanwhile would have dropped). Stale, leave and sitting out
|
||||||
the set.
|
remove from the set.
|
||||||
|
- **Sitting out** (`EngineMember.out`, `state.members[].out`): a member
|
||||||
|
stays connected (chat, feed, party, nudges) but is never in the set and
|
||||||
|
never ready: no `ready` or `force` reaches it, a swap leaves it out, its
|
||||||
|
reports keep it fresh but their `buffered` counts as 0. Anyone can sit
|
||||||
|
anyone out (`out` with `out:true`, the host included); only the member
|
||||||
|
itself comes back (`out:false`): it is then in the set like a fresh join
|
||||||
|
(unless PLAYING) and still unready. The app: an out member's player has
|
||||||
|
nothing open (no stream or relay pulls; a relay it serves goes on) and
|
||||||
|
shows the join-back card (cover, title, episode, the server position and
|
||||||
|
playing/paused). "Join back" or Space sends `out:false` and then
|
||||||
|
`ready`: at once while HELD (the gate waits for its buffer), while
|
||||||
|
PLAYING once the player has buffered `need` (at most 15 s), so the others
|
||||||
|
do not pause for the load. Putting something up while out comes back
|
||||||
|
first. A reconnect that lands as a new member sits out again.
|
||||||
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
|
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
|
||||||
`need` buffered or stale flips the server to HELD (feed action `pause` by
|
`need` buffered or stale flips the server to HELD (feed action `pause` by
|
||||||
`server`). Recovery resumes by itself when the gate passes.
|
`server`). Recovery resumes by itself when the gate passes.
|
||||||
@@ -137,16 +152,26 @@ Loosely modeled on Syncplay, with a different readiness design.
|
|||||||
`hello.resume`. If the server still has that member (an unclean drop it has
|
`hello.resume`. If the server still has that member (an unclean drop it has
|
||||||
not noticed), the new socket takes it over: same token, member id, ready
|
not noticed), the new socket takes it over: same token, member id, ready
|
||||||
flag and media URL (the player keeps streaming), no join/leave feed, and the
|
flag and media URL (the player keeps streaming), no join/leave feed, and the
|
||||||
dead socket is closed without removing the member. An unknown or kicked
|
dead socket is closed without removing the member. An unknown token joins
|
||||||
token joins as a new member.
|
as a new member.
|
||||||
- **Client policies**: no state broadcast for 5 s means connection lost, clear
|
- **Client policies**: no state broadcast for 5 s means connection lost, clear
|
||||||
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
|
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
|
||||||
a cancelled attempt stale) or kicked. After a reconnect the ready flag is
|
a cancelled attempt stale). After a reconnect the ready flag is
|
||||||
restored from the server's state. Cancel during setup aborts the join.
|
restored from the server's state. Cancel during setup aborts the join.
|
||||||
Closing the window leaves gracefully (2 s timeout).
|
Closing the window leaves gracefully (2 s timeout).
|
||||||
- **Idle** (`IdleTracker`): measured from the later of the last input and the
|
- **Idle** (`IdleTracker`): measured from the later of the last input and the
|
||||||
moment playback stopped. While HELD and self ready: warning at 8 min,
|
moment playback stopped. While HELD and self ready: warning at 8 min,
|
||||||
auto-unready at 10 min, and never less than 2 min after the warning.
|
auto-unready at 10 min, and never less than 2 min after the warning.
|
||||||
|
With "When idle, sit out instead of just unready"
|
||||||
|
(`AppSettings.idleSitsOut`, default off) the 10 min step sits out.
|
||||||
|
- **Nudge**: `nudge` names a member; the server passes it to that member
|
||||||
|
only (no feed), at most 3 per second and 20 per minute per sender
|
||||||
|
(`nudgesPerSec`, `nudgesPerMin`; the rest is dropped silently). The
|
||||||
|
nudged app plays the nudge sound whatever its per-sound switch (the
|
||||||
|
volume still applies), toasts "Alice nudged you" and asks the desktop
|
||||||
|
for attention when its window is not focused: the runners' `starsync/window`
|
||||||
|
channel (`attention`), the urgency hint on Linux (GTK, cleared on focus)
|
||||||
|
and `FlashWindowEx` until foreground on Windows.
|
||||||
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
|
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
|
||||||
pause-on-leave; anyone can force ready/unready (all or one); presence gate;
|
pause-on-leave; anyone can force ready/unready (all or one); presence gate;
|
||||||
the clock is server time (clients correct for their offset to it).
|
the clock is server time (clients correct for their offset to it).
|
||||||
@@ -192,7 +217,7 @@ Range table (`resolveMediaRequest`, tested byte-exact):
|
|||||||
| `bytes=-n` | 206 last n bytes |
|
| `bytes=-n` | 206 last n bytes |
|
||||||
| unsatisfiable | 416, `Content-Range: bytes */size` |
|
| unsatisfiable | 416, `Content-Range: bytes */size` |
|
||||||
| host has no file | 503 + `Retry-After: 2` |
|
| host has no file | 503 + `Retry-After: 2` |
|
||||||
| bad / unknown token, kicked, unknown session | 401, 403, 410 |
|
| bad / unknown token, unknown session | 401, 410 |
|
||||||
|
|
||||||
Always `Accept-Ranges: bytes`; `Content-Type` from the extension
|
Always `Accept-Ranges: bytes`; `Content-Type` from the extension
|
||||||
(`contentTypeForFile`). Relic hands the Range over as a typed `RangeHeader`;
|
(`contentTypeForFile`). Relic hands the Range over as a typed `RangeHeader`;
|
||||||
@@ -365,7 +390,7 @@ errors leave it out).
|
|||||||
|
|
||||||
- Available (`state.relay`) only while the current media is a ref whose
|
- Available (`state.relay`) only while the current media is a ref whose
|
||||||
`serverId` equals the credential's. Only that item is relayed, only to a
|
`serverId` equals the credential's. Only that item is relayed, only to a
|
||||||
valid, unkicked viewer token: no open proxy.
|
valid viewer token: no open proxy.
|
||||||
- Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with
|
- Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with
|
||||||
`Authorization: MediaBrowser` built from exactly the configured fields
|
`Authorization: MediaBrowser` built from exactly the configured fields
|
||||||
(Jellyfin rewrites a token's device fields from the header), forwards
|
(Jellyfin rewrites a token's device fields from the header), forwards
|
||||||
@@ -573,7 +598,7 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
|||||||
- **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the
|
- **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the
|
||||||
file is open and mpv has picked its tracks; Progress every 10 s and at
|
file is open and mpv has picked its tracks; Progress every 10 s and at
|
||||||
once on pause, resume, a seek (position more than 3 s from where playback
|
once on pause, resume, a seek (position more than 3 s from where playback
|
||||||
should be) or a track change; Stopped on swap, leave, kick, window close
|
should be) or a track change; Stopped on swap, leave, sitting out, window close
|
||||||
(bounded to 1.5 s inside the graceful leave) and at `finished` (position =
|
(bounded to 1.5 s inside the graceful leave) and at `finished` (position =
|
||||||
duration, so Jellyfin marks it played); seeking back from the end sends
|
duration, so Jellyfin marks it played); seeking back from the end sends
|
||||||
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's
|
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's
|
||||||
@@ -631,7 +656,8 @@ always scrolls the page). Space never presses a focused button
|
|||||||
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
|
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
|
||||||
"ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
|
"ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
|
||||||
(asks first). The stack opens the members card (status, host / relay
|
(asks first). The stack opens the members card (status, host / relay
|
||||||
pills, "via host", force ready, kick for all but the host, "Copy invite
|
pills, "via host", "sitting out"; per member force ready, Nudge and Sit
|
||||||
|
out (anyone, the host too), your own row Sit out / Join back; "Copy invite
|
||||||
address": the dialled address when joined; hosting, `inviteAddresses`
|
address": the dialled address when joined; hosting, `inviteAddresses`
|
||||||
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
|
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
|
||||||
ranges first) with the Host port, and a note that friends outside the
|
ranges first) with the Host port, and a note that friends outside the
|
||||||
@@ -640,7 +666,7 @@ always scrolls the page). Space never presses a focused button
|
|||||||
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
||||||
picture with a gap in the background colour (both scale with the size):
|
picture with a gap in the background colour (both scale with the size):
|
||||||
green ready, amber buffering (with a spinner), grey not ready, dimmed
|
green ready, amber buffering (with a spinner), grey not ready, dimmed
|
||||||
stale; a crown for the host (`members[].host` in the state; your own
|
stale, dimmed with a moon ("away") sitting out (last in the stack); a crown for the host (`members[].host` in the state; your own
|
||||||
app also knows before the first state) and a tower for the member
|
app also knows before the first state) and a tower for the member
|
||||||
passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
|
passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
|
||||||
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
|
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
|
||||||
@@ -712,8 +738,9 @@ always scrolls the page). Space never presses a focused button
|
|||||||
Link (online the relay mode is picked there). Images are ignored.
|
Link (online the relay mode is picked there). Images are ignored.
|
||||||
Nothing happens while a dialog or popover is over the shell.
|
Nothing happens while a dialog or popover is over the shell.
|
||||||
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
|
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
|
||||||
unready, force-ready, join, leave, kick, swap and others' chat (feed
|
unready, force-ready, join, leave, swap and others' chat (feed
|
||||||
events), the idle warning, and connection lost / back (once per drop).
|
events), being sat out by someone (the `kick` sound), a nudge (always,
|
||||||
|
whatever its switch), the idle warning, and connection lost / back (once per drop).
|
||||||
Nothing for what the video already says (play, pause, seeks, the end).
|
Nothing for what the video already says (play, pause, seeks, the end).
|
||||||
The sounds are synthesized, not sampled: `synth.dart` renders voices
|
The sounds are synthesized, not sampled: `synth.dart` renders voices
|
||||||
(plucked strings with bends, glass and star bells, a singing bowl,
|
(plucked strings with bends, glass and star bells, a singing bowl,
|
||||||
@@ -735,8 +762,10 @@ always scrolls the page). Space never presses a focused button
|
|||||||
snaps to the nearest corner with the shared curve). A mode change fades
|
snaps to the nearest corner with the shared curve). A mode change fades
|
||||||
the layer out, moves it while invisible and fades it in (with the tabs
|
the layer out, moves it while invisible and fades it in (with the tabs
|
||||||
when the full player comes or goes), so the video is never mounted twice
|
when the full player comes or goes), so the video is never mounted twice
|
||||||
nor remounted. A tab sends the full player to mini; × on the mini closes
|
nor remounted. A tab sends the full player to mini; × on the mini sits
|
||||||
it (muted, still in the server) and the strip brings it back. Fullscreen
|
out (the player unloads and shows the join-back card, `SitOutCard`, over
|
||||||
|
the still mounted `Video`); × on that card closes the player, the strip
|
||||||
|
brings it back, and joining back with it closed shows the mini. Fullscreen
|
||||||
is `windowManager.setFullScreen` with the strip hidden (party avatars in
|
is `windowManager.setFullScreen` with the strip hidden (party avatars in
|
||||||
a corner while paused); F, Esc and double-click on the picture toggle it.
|
a corner while paused); F, Esc and double-click on the picture toggle it.
|
||||||
The player keys (Settings → Keyboard shortcuts) work while the player is
|
The player keys (Settings → Keyboard shortcuts) work while the player is
|
||||||
@@ -902,7 +931,7 @@ fullscreen has no drag area and shows the buttons in the player's top bar.
|
|||||||
|
|
||||||
Every failure must show in the UI with a cause and a next step; nothing
|
Every failure must show in the UI with a cause and a next step; nothing
|
||||||
stalls silently. The app has a `HealthStatus` signal (severity + message)
|
stalls silently. The app has a `HealthStatus` signal (severity + message)
|
||||||
for connecting, reconnecting, wrong password, version mismatch, kicked,
|
for connecting, reconnecting, wrong password, version mismatch,
|
||||||
host offline, and starvation ("waiting for X - N.N s buffered").
|
host offline, and starvation ("waiting for X - N.N s buffered").
|
||||||
Errors are logged with a stack trace at the boundary, then turned into a
|
Errors are logged with a stack trace at the boundary, then turned into a
|
||||||
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
|
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
|
||||||
@@ -950,7 +979,8 @@ this repo, written by `tool/release.sh`.
|
|||||||
- No transcoding: bandwidth is plentiful and the Jellyfin box should not be loaded.
|
- No transcoding: bandwidth is plentiful and the Jellyfin box should not be loaded.
|
||||||
- No roles, friends trust model: simplest thing that fits the use.
|
- No roles, friends trust model: simplest thing that fits the use.
|
||||||
- Blank password = open server: the owner chooses between open and private.
|
- Blank password = open server: the owner chooses between open and private.
|
||||||
- The host cannot be kicked: the server dies with the host anyway.
|
- Sit out instead of kick: friends never need to throw someone out, only to
|
||||||
|
stop waiting for them; the person stays in chat and can be nudged back.
|
||||||
- Server runs inside the app on an isolate, and ships as a separate compiled
|
- Server runs inside the app on an isolate, and ships as a separate compiled
|
||||||
server: a Flutter binary cannot run headless, so a dedicated server needs
|
server: a Flutter binary cannot run headless, so a dedicated server needs
|
||||||
its own build of `packages/server`. An isolate keeps the UI responsive and
|
its own build of `packages/server`. An isolate keeps the UI responsive and
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ Friends join either way with Join in the strip at the top of the window.
|
|||||||
(stops blocking) and removed after 30 s. The app reconnects by itself and
|
(stops blocking) and removed after 30 s. The app reconnects by itself and
|
||||||
takes over your old place (same member, ready flag and video).
|
takes over your old place (same member, ready flag and video).
|
||||||
|
|
||||||
There are no roles: anyone can seek, swap, force-ready others and kick
|
There are no roles: anyone can seek, swap, force-ready others, nudge
|
||||||
anyone except the host.
|
anyone, and sit anyone out (the host too). Someone sitting out stays
|
||||||
|
connected and in the chat, but their app loads nothing and nobody waits
|
||||||
|
for them until they join back.
|
||||||
|
|
||||||
## Where video comes from
|
## Where video comes from
|
||||||
|
|
||||||
|
|||||||
+25
-10
@@ -43,7 +43,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
|||||||
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
|
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
|
||||||
{"t":"ping","c":1767225600000}
|
{"t":"ping","c":1767225600000}
|
||||||
{"t":"chat","text":"..."}
|
{"t":"chat","text":"..."}
|
||||||
{"t":"kick","target":"<memberId>","reason":""}
|
{"t":"out","target":"<memberId>","out":true}
|
||||||
|
{"t":"nudge","target":"<memberId>"}
|
||||||
{"t":"profile","name":"...","avatar":"<base64>"}
|
{"t":"profile","name":"...","avatar":"<base64>"}
|
||||||
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
|
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
|
||||||
```
|
```
|
||||||
@@ -54,11 +55,11 @@ Field notes:
|
|||||||
- `hello.hostKey` (optional, omitted when empty): the hosting app
|
- `hello.hostKey` (optional, omitted when empty): the hosting app
|
||||||
generates a random key per hosting, gives it to its server and sends it
|
generates a random key per hosting, gives it to its server and sends it
|
||||||
from its own client. The member whose key equals the server's is the host
|
from its own client. The member whose key equals the server's is the host
|
||||||
and cannot be kicked. A server without a key (the dedicated server) has no
|
(`members[].host`). A server without a key (the dedicated server) has no
|
||||||
host. Where the connection comes from does not matter.
|
host. Where the connection comes from does not matter.
|
||||||
- `hello.resume` (optional, omitted when empty): the token from this
|
- `hello.resume` (optional, omitted when empty): the token from this
|
||||||
client's previous `welcome`, sent on reconnect. If the server still has
|
client's previous `welcome`, sent on reconnect. If the server still has
|
||||||
that member and it is not kicked, the connection takes it over: `welcome`
|
that member, the connection takes it over: `welcome`
|
||||||
returns the same `token` and `memberId`, the member keeps its flags, and
|
returns the same `token` and `memberId`, the member keeps its flags, and
|
||||||
there is no `join`/`leave` feed. Otherwise it is ignored and a new member
|
there is no `join`/`leave` feed. Otherwise it is ignored and a new member
|
||||||
joins.
|
joins.
|
||||||
@@ -81,6 +82,19 @@ Field notes:
|
|||||||
`rejected: avatar too large` (the socket stays open, nothing changes).
|
`rejected: avatar too large` (the socket stays open, nothing changes).
|
||||||
- `art`: pictures of the current media (see "Media art").
|
- `art`: pictures of the current media (see "Media art").
|
||||||
- `chat.text` is cut to 500 characters.
|
- `chat.text` is cut to 500 characters.
|
||||||
|
- `out`: `out:true` sits the member `target` out (anyone, the host
|
||||||
|
included); `out:false` brings the sender back and only works with its own
|
||||||
|
member id as `target`. An out member is never in the set and never
|
||||||
|
ready: `ready` from it and `force` on it are ignored, a swap leaves it
|
||||||
|
out, the `buffered` of its reports counts as 0 (it keeps sending
|
||||||
|
`report`: that keeps it fresh). Coming back puts it in the set like a
|
||||||
|
join (unless `playing`), unready; it sends `ready` itself. Unknown
|
||||||
|
targets and no-op changes are ignored. Feed: `out` (`target` = the name
|
||||||
|
of who was sat out, absent when the actor sat itself out) and `back`.
|
||||||
|
- `nudge`: the server sends `{"t":"nudge","from":<memberId>,"name":<name>}`
|
||||||
|
to `target` only (no feed). At most 3 per second and 20 per minute per
|
||||||
|
sender; the rest, nudges to oneself and to unknown targets are dropped
|
||||||
|
silently.
|
||||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||||
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
||||||
@@ -99,12 +113,12 @@ Field notes:
|
|||||||
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
||||||
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
|
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
|
||||||
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
|
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
|
||||||
"host"?}],
|
"host"?,"out"?}],
|
||||||
"seekBy":"m1","seekSeq":3,"relay":true}
|
"seekBy":"m1","seekSeq":3,"relay":true}
|
||||||
{"t":"pong","c":1767225600000,"s":1767225600012}
|
{"t":"pong","c":1767225600000,"s":1767225600012}
|
||||||
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
|
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
|
||||||
{"t":"chat","text":"...","from":"<name>"}
|
{"t":"chat","text":"...","from":"<name>"}
|
||||||
{"t":"kicked","reason":""}
|
{"t":"nudge","from":"m2","name":"Alice"}
|
||||||
{"t":"err","message":""}
|
{"t":"err","message":""}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -130,12 +144,13 @@ Field notes:
|
|||||||
`report.jf`, omitted when it never sent one. `members[].avatarHash`
|
`report.jf`, omitted when it never sent one. `members[].avatarHash`
|
||||||
(omitted when the member has no picture) names the picture's bytes (see
|
(omitted when the member has no picture) names the picture's bytes (see
|
||||||
"Avatars"). `members[].host` (omitted when false): the member sent the
|
"Avatars"). `members[].host` (omitted when false): the member sent the
|
||||||
server's `hostKey` (see `hello.hostKey`).
|
server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when
|
||||||
|
false): the member sits out (see `out`).
|
||||||
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
|
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
|
||||||
(the clock `serverTime` uses). With `t3` the local receive time, the offset
|
(the clock `serverTime` uses). With `t3` the local receive time, the offset
|
||||||
is `s - (c + t3) / 2`, accurate to half the round trip.
|
is `s - (c + t3) / 2`, accurate to half the round trip.
|
||||||
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
||||||
`force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`,
|
`force-play`, `force-hold`, `out`, `back`, `join`, `leave`, `stale`, `swap`,
|
||||||
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target`
|
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target`
|
||||||
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
|
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
|
||||||
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
|
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
|
||||||
@@ -154,7 +169,7 @@ Field notes:
|
|||||||
- `GET /a/<sid>/<token>/<hash>`: the bytes with their image content type,
|
- `GET /a/<sid>/<token>/<hash>`: the bytes with their image content type,
|
||||||
`cache-control: private, max-age=31536000, immutable` (a hash names fixed
|
`cache-control: private, max-age=31536000, immutable` (a hash names fixed
|
||||||
bytes; clients fetch each hash once, from the host they dialled).
|
bytes; clients fetch each hash once, from the host they dialled).
|
||||||
Errors: 401 bad token, 403 kicked, 404 no current member has that
|
Errors: 401 bad token, 404 no current member has that
|
||||||
picture, 410 unknown session. Pictures go with their member (leave,
|
picture, 410 unknown session. Pictures go with their member (leave,
|
||||||
change, removal).
|
change, removal).
|
||||||
|
|
||||||
@@ -204,7 +219,7 @@ state media.art = {"poster":"<hash>","banner":"<hash>"}
|
|||||||
file) with its own credential and streams it through. `Range` is forwarded
|
file) with its own credential and streams it through. `Range` is forwarded
|
||||||
as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and
|
as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and
|
||||||
`Accept-Ranges` come back as Jellyfin sent them. Errors: 401 bad token,
|
`Accept-Ranges` come back as Jellyfin sent them. Errors: 401 bad token,
|
||||||
403 kicked or not the current item, 404 no relay for the current media,
|
403 not the current item, 404 no relay for the current media,
|
||||||
410 unknown session, 502 Jellyfin unreachable or rejecting the server's
|
410 unknown session, 502 Jellyfin unreachable or rejecting the server's
|
||||||
credential. Bytes count toward `bytesServed`.
|
credential. Bytes count toward `bytesServed`.
|
||||||
- Pasted Jellyfin links sent as plain `url` stay plain links.
|
- Pasted Jellyfin links sent as plain `url` stay plain links.
|
||||||
@@ -261,7 +276,7 @@ to the entry.
|
|||||||
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media (one file), Range
|
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media (one file), Range
|
||||||
table as for `/m`. `GET /r/<sid>/<token>/<mediaId>/<i>/<name>`: track
|
table as for `/m`. `GET /r/<sid>/<token>/<mediaId>/<i>/<name>`: track
|
||||||
`i` (from 0) of a media with `tracks` (track 0 of a one-file media
|
`i` (from 0) of a media with `tracks` (track 0 of a one-file media
|
||||||
too). Errors: 401 bad token, 403 kicked, 404 not the current relayed
|
too). Errors: 401 bad token, 404 not the current relayed
|
||||||
media (a player still on an older `mediaId` gets 404, never the new
|
media (a player still on an older `mediaId` gets 404, never the new
|
||||||
bytes) or no such track (the one-file URL of a media with `tracks`), 410
|
bytes) or no such track (the one-file URL of a media with `tracks`), 410
|
||||||
unknown session, 503 + `retry-after` the source never connected or the
|
unknown session, 503 + `retry-after` the source never connected or the
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class AppConfig {
|
|||||||
/// client dialled.
|
/// client dialled.
|
||||||
final String advertiseHost;
|
final String advertiseHost;
|
||||||
/// Secret the hosting app's own client sends in hello to be marked host
|
/// Secret the hosting app's own client sends in hello to be marked host
|
||||||
/// (unkickable). Empty means no member is the host (dedicated server).
|
/// (`members[].host`). Empty means no member is the host (dedicated server).
|
||||||
final String hostKey;
|
final String hostKey;
|
||||||
/// Optional login for relaying Jellyfin media to guests; null = no relay.
|
/// Optional login for relaying Jellyfin media to guests; null = no relay.
|
||||||
final JellyfinCredential? jellyfin;
|
final JellyfinCredential? jellyfin;
|
||||||
|
|||||||
@@ -50,4 +50,8 @@ abstract class RelayConstants {
|
|||||||
static const int maxBannerBytes = 768 * 1024;
|
static const int maxBannerBytes = 768 * 1024;
|
||||||
static const int maxNameLength = 32;
|
static const int maxNameLength = 32;
|
||||||
static const int maxChatLength = 500;
|
static const int maxChatLength = 500;
|
||||||
|
/// Nudges one member may send: at most this many per second and per
|
||||||
|
/// minute; the server drops the rest silently.
|
||||||
|
static const int nudgesPerSec = 3;
|
||||||
|
static const int nudgesPerMin = 20;
|
||||||
}
|
}
|
||||||
@@ -288,31 +288,46 @@ class WelcomeMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class KickedMessage {
|
/// Sits [target] (a member id) out, or with [out] false brings oneself
|
||||||
final String reason;
|
/// back. Out: connected, but not in the ready gate and not playing.
|
||||||
const KickedMessage({this.reason = ''});
|
class OutIntent {
|
||||||
|
final String target;
|
||||||
|
final bool out;
|
||||||
|
const OutIntent({required this.target, required this.out});
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {'t': 'kicked', 'reason': reason};
|
Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
|
||||||
|
|
||||||
factory KickedMessage.fromJson(Map<String, Object?> json) {
|
factory OutIntent.fromJson(Map<String, Object?> json) {
|
||||||
if (json['t'] != 'kicked') throw const ProtocolException('not a kicked message');
|
if (json['t'] != 'out') throw const ProtocolException('not an out intent');
|
||||||
return KickedMessage(reason: (json['reason'] as String?) ?? '');
|
return OutIntent(target: (json['target'] as String?) ?? '', out: (json['out'] as bool?) ?? true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class KickRequest {
|
/// Client to server: poke [target] (a member id). The server passes it on
|
||||||
|
/// to that member only, as a [NudgeNotice].
|
||||||
|
class NudgeIntent {
|
||||||
final String target;
|
final String target;
|
||||||
final String reason;
|
const NudgeIntent({required this.target});
|
||||||
const KickRequest({required this.target, this.reason = ''});
|
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {'t': 'kick', 'target': target, 'reason': reason};
|
Map<String, Object?> toJson() => {'t': 'nudge', 'target': target};
|
||||||
|
|
||||||
factory KickRequest.fromJson(Map<String, Object?> json) {
|
factory NudgeIntent.fromJson(Map<String, Object?> json) {
|
||||||
if (json['t'] != 'kick') throw const ProtocolException('not a kick request');
|
if (json['t'] != 'nudge') throw const ProtocolException('not a nudge intent');
|
||||||
return KickRequest(
|
return NudgeIntent(target: (json['target'] as String?) ?? '');
|
||||||
target: (json['target'] as String?) ?? '',
|
}
|
||||||
reason: (json['reason'] as String?) ?? '',
|
}
|
||||||
);
|
|
||||||
|
/// Server to the nudged member: who poked it ([from] member id, [name]).
|
||||||
|
class NudgeNotice {
|
||||||
|
final String from;
|
||||||
|
final String name;
|
||||||
|
const NudgeNotice({required this.from, required this.name});
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'t': 'nudge', 'from': from, 'name': name};
|
||||||
|
|
||||||
|
factory NudgeNotice.fromJson(Map<String, Object?> json) {
|
||||||
|
if (json['t'] != 'nudge') throw const ProtocolException('not a nudge notice');
|
||||||
|
return NudgeNotice(from: (json['from'] as String?) ?? '', name: (json['name'] as String?) ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +551,8 @@ class MemberView {
|
|||||||
final Map<String, bool> jf;
|
final Map<String, bool> jf;
|
||||||
/// The hosting app's own member (sent the server's host key).
|
/// The hosting app's own member (sent the server's host key).
|
||||||
final bool host;
|
final bool host;
|
||||||
|
/// Sitting out: connected, outside the ready gate, not playing.
|
||||||
|
final bool out;
|
||||||
const MemberView({
|
const MemberView({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -546,6 +563,7 @@ class MemberView {
|
|||||||
required this.stale,
|
required this.stale,
|
||||||
this.jf = const {},
|
this.jf = const {},
|
||||||
this.host = false,
|
this.host = false,
|
||||||
|
this.out = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
@@ -558,6 +576,7 @@ class MemberView {
|
|||||||
'stale': stale,
|
'stale': stale,
|
||||||
if (jf.isNotEmpty) 'jf': jf,
|
if (jf.isNotEmpty) 'jf': jf,
|
||||||
if (host) 'host': true,
|
if (host) 'host': true,
|
||||||
|
if (out) 'out': true,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
||||||
@@ -570,6 +589,7 @@ class MemberView {
|
|||||||
stale: (json['stale'] as bool?) ?? false,
|
stale: (json['stale'] as bool?) ?? false,
|
||||||
jf: jfAccessFromJson(json['jf']) ?? const {},
|
jf: jfAccessFromJson(json['jf']) ?? const {},
|
||||||
host: json['host'] == true,
|
host: json['host'] == true,
|
||||||
|
out: json['out'] == true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class EngineMember {
|
|||||||
Map<String, bool> jf = const {};
|
Map<String, bool> jf = const {};
|
||||||
/// The hosting app's own member (set by the server from the host key).
|
/// The hosting app's own member (set by the server from the host key).
|
||||||
bool host = false;
|
bool host = false;
|
||||||
|
/// Sitting out: never in the set, never ready, its buffer ignored.
|
||||||
|
bool out = false;
|
||||||
DateTime lastSeen;
|
DateTime lastSeen;
|
||||||
EngineMember({
|
EngineMember({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -94,6 +96,7 @@ class EngineMember {
|
|||||||
stale: stale,
|
stale: stale,
|
||||||
jf: jf,
|
jf: jf,
|
||||||
host: host,
|
host: host,
|
||||||
|
out: out,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +196,8 @@ class SyncEngine {
|
|||||||
void _seen(EngineMember member) {
|
void _seen(EngineMember member) {
|
||||||
final wasStale = isStale(member);
|
final wasStale = isStale(member);
|
||||||
member.lastSeen = now;
|
member.lastSeen = now;
|
||||||
if (!member.inSet &&
|
if (!member.out &&
|
||||||
|
!member.inSet &&
|
||||||
(member.ready || (wasStale && transport != Transport.playing))) {
|
(member.ready || (wasStale && transport != Transport.playing))) {
|
||||||
member.inSet = true;
|
member.inSet = true;
|
||||||
}
|
}
|
||||||
@@ -301,6 +305,8 @@ class SyncEngine {
|
|||||||
final member = members[token];
|
final member = members[token];
|
||||||
if (member == null) return;
|
if (member == null) return;
|
||||||
_seen(member);
|
_seen(member);
|
||||||
|
// Sitting out: back first ([applyOut]), then ready.
|
||||||
|
if (member.out) return;
|
||||||
if (member.ready == ready && member.inSet) {
|
if (member.ready == ready && member.inSet) {
|
||||||
evaluate();
|
evaluate();
|
||||||
return;
|
return;
|
||||||
@@ -320,12 +326,13 @@ class SyncEngine {
|
|||||||
final actorName = actor?.name ?? '?';
|
final actorName = actor?.name ?? '?';
|
||||||
if (target == 'all') {
|
if (target == 'all') {
|
||||||
for (final member in members.values) {
|
for (final member in members.values) {
|
||||||
|
if (member.out) continue;
|
||||||
member.ready = ready;
|
member.ready = ready;
|
||||||
if (ready) member.inSet = true;
|
if (ready) member.inSet = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final member = byId(target);
|
final member = byId(target);
|
||||||
if (member == null) return;
|
if (member == null || member.out) return;
|
||||||
member.ready = ready;
|
member.ready = ready;
|
||||||
if (ready) member.inSet = true;
|
if (ready) member.inSet = true;
|
||||||
feed.add(FeedMessage(
|
feed.add(FeedMessage(
|
||||||
@@ -343,6 +350,28 @@ class SyncEngine {
|
|||||||
evaluate();
|
evaluate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sits the member [targetId] out ([out] true; anyone may, the host
|
||||||
|
/// included), or brings [actorToken]'s own member back ([out] false;
|
||||||
|
/// only oneself). Out: not ready, not in the set, so it never blocks.
|
||||||
|
/// Back: in the set like a fresh join (unless playing), still unready:
|
||||||
|
/// its own `ready` follows once it has buffered.
|
||||||
|
void applyOut(String actorToken, String targetId, bool out) {
|
||||||
|
final actor = members[actorToken];
|
||||||
|
final member = byId(targetId);
|
||||||
|
if (actor == null || member == null) return;
|
||||||
|
_seen(actor);
|
||||||
|
final self = identical(actor, member);
|
||||||
|
if (!out && !self) return;
|
||||||
|
if (member.out == out) return;
|
||||||
|
member.out = out;
|
||||||
|
member.ready = false;
|
||||||
|
member.buffered = 0;
|
||||||
|
member.inSet = !out && transport != Transport.playing;
|
||||||
|
feed.add(FeedMessage(
|
||||||
|
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
|
||||||
|
evaluate();
|
||||||
|
}
|
||||||
|
|
||||||
void applySeek(String token, double seekPosition, {int seq = 0}) {
|
void applySeek(String token, double seekPosition, {int seq = 0}) {
|
||||||
final member = members[token];
|
final member = members[token];
|
||||||
if (member == null) return;
|
if (member == null) return;
|
||||||
@@ -379,7 +408,7 @@ class SyncEngine {
|
|||||||
// (a stale member rejoins the set with its next frame if still ready).
|
// (a stale member rejoins the set with its next frame if still ready).
|
||||||
final t = now;
|
final t = now;
|
||||||
for (final member in members.values) {
|
for (final member in members.values) {
|
||||||
member.inSet = !isStale(member, t);
|
member.inSet = !member.out && !isStale(member, t);
|
||||||
member.buffered = 0;
|
member.buffered = 0;
|
||||||
}
|
}
|
||||||
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
||||||
@@ -393,7 +422,7 @@ class SyncEngine {
|
|||||||
if (!reportPosition.isFinite) return;
|
if (!reportPosition.isFinite) return;
|
||||||
_seen(member);
|
_seen(member);
|
||||||
if (jf != null) member.jf = Map.unmodifiable(jf);
|
if (jf != null) member.jf = Map.unmodifiable(jf);
|
||||||
member.buffered = !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
||||||
final m = media;
|
final m = media;
|
||||||
// First report of the length wins; only for the media the client has open.
|
// First report of the length wins; only for the media the client has open.
|
||||||
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
|
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
|
||||||
|
|||||||
@@ -36,6 +36,55 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('sitting out', () {
|
||||||
|
test('an out member never blocks, is never ready, its buffer is ignored', () {
|
||||||
|
join('a', 'alice');
|
||||||
|
final bob = join('b', 'bob');
|
||||||
|
engine.applyReady('a', true);
|
||||||
|
buffer('a', 4.0);
|
||||||
|
expect(engine.transport, Transport.held, reason: 'bob is in the set');
|
||||||
|
engine.applyOut('a', bob, true);
|
||||||
|
final b = engine.members['b']!;
|
||||||
|
expect([b.out, b.inSet, b.ready], [true, false, false]);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
final feed = engine.feed.drain().lastWhere((f) => f.action == 'out');
|
||||||
|
expect([feed.actor, feed.target], ['alice', 'bob']);
|
||||||
|
expect(engine.broadcast().members.last.toJson()['out'], isTrue);
|
||||||
|
expect(engine.broadcast().members.first.toJson().containsKey('out'), isFalse);
|
||||||
|
// Ready, force, swap and reports leave it out.
|
||||||
|
engine.applyReady('b', true);
|
||||||
|
engine.applyForce('a', 'all', true);
|
||||||
|
buffer('b', 9);
|
||||||
|
engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u'));
|
||||||
|
expect([b.ready, b.inSet, b.buffered], [false, false, 0]);
|
||||||
|
expect(engine.members['a']!.inSet, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only oneself comes back: in the set unready, ready follows', () {
|
||||||
|
final alice = join('a', 'alice');
|
||||||
|
final bob = join('b', 'bob');
|
||||||
|
engine.applyOut('b', bob, true);
|
||||||
|
expect(engine.feed.drain().last.target, isNull, reason: 'sat out by itself');
|
||||||
|
engine.applyReady('a', true);
|
||||||
|
buffer('a', 4.0);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
engine.applyOut('a', bob, false);
|
||||||
|
expect(engine.members['b']!.out, isTrue, reason: 'others cannot bring bob back');
|
||||||
|
engine.applyOut('b', bob, false);
|
||||||
|
final b = engine.members['b']!;
|
||||||
|
expect([b.out, b.inSet, b.ready], [false, false, false], reason: 'playing: in from its ready');
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
engine.applyReady('b', true);
|
||||||
|
expect(engine.transport, Transport.held, reason: 'now the gate waits for bob');
|
||||||
|
buffer('b', 4.0);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
// The host can be sat out like anyone.
|
||||||
|
engine.members['a']!.host = true;
|
||||||
|
engine.applyOut('b', alice, true);
|
||||||
|
expect(engine.members['a']!.out, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('membership + gate', () {
|
group('membership + gate', () {
|
||||||
test('join while held lands in the set, playback stays held', () {
|
test('join while held lands in the set, playback stays held', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ void mountJellyfinRelayRoute(
|
|||||||
switch (session.gate(token)) {
|
switch (session.gate(token)) {
|
||||||
case MediaGate.ok:
|
case MediaGate.ok:
|
||||||
break;
|
break;
|
||||||
case MediaGate.forbidden:
|
|
||||||
return _plain(403);
|
|
||||||
default:
|
default:
|
||||||
return _plain(401);
|
return _plain(401);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ MediaResolution resolveMediaRequest({
|
|||||||
return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0);
|
return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0);
|
||||||
case MediaGate.unauthorized:
|
case MediaGate.unauthorized:
|
||||||
return MediaResolution(status: 401, headers: base(contentType), start: 0, end: 0);
|
return MediaResolution(status: 401, headers: base(contentType), start: 0, end: 0);
|
||||||
case MediaGate.forbidden:
|
|
||||||
return MediaResolution(status: 403, headers: base(contentType), start: 0, end: 0);
|
|
||||||
case MediaGate.offline:
|
case MediaGate.offline:
|
||||||
return MediaResolution(
|
return MediaResolution(
|
||||||
status: 503,
|
status: 503,
|
||||||
|
|||||||
@@ -139,13 +139,6 @@ class ActiveRelay {
|
|||||||
if (ch != null) unawaited(_closeNotSource(ch));
|
if (ch != null) unawaited(_closeNotSource(ch));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Source member gone for good (kicked): stop its channel.
|
|
||||||
void dropChannel() {
|
|
||||||
final ch = _channel;
|
|
||||||
_channel = null;
|
|
||||||
if (ch != null) unawaited(_closeNotSource(ch));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _attachChannel(RelicWebSocket ws, List<int> sizes) {
|
void _attachChannel(RelicWebSocket ws, List<int> sizes) {
|
||||||
final old = _channel;
|
final old = _channel;
|
||||||
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
||||||
@@ -290,8 +283,6 @@ void mountRelayRoutes(
|
|||||||
switch (session.gate(token)) {
|
switch (session.gate(token)) {
|
||||||
case MediaGate.ok:
|
case MediaGate.ok:
|
||||||
break;
|
break;
|
||||||
case MediaGate.forbidden:
|
|
||||||
return _plain(403);
|
|
||||||
default:
|
default:
|
||||||
return _plain(401);
|
return _plain(401);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,9 @@ class ServerHub {
|
|||||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||||
|
|
||||||
/// The member a reconnecting client may take over with its old token:
|
/// The member a reconnecting client may take over with its old token:
|
||||||
/// still known and not kicked.
|
/// still known.
|
||||||
EngineMember? resumable(String token) {
|
EngineMember? resumable(String token) {
|
||||||
if (token.isEmpty) return null;
|
if (token.isEmpty || !session.tokens.containsKey(token)) return null;
|
||||||
final viewer = session.tokens[token];
|
|
||||||
if (viewer == null || viewer.kicked) return null;
|
|
||||||
return engine.members[token];
|
return engine.members[token];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,24 +148,25 @@ class ServerHub {
|
|||||||
|
|
||||||
void forgetSocket(String token) {
|
void forgetSocket(String token) {
|
||||||
engine.applyLeave(token);
|
engine.applyLeave(token);
|
||||||
final viewer = session.tokens[token];
|
session.tokens.remove(token);
|
||||||
if (viewer != null && !viewer.kicked) session.tokens.remove(token);
|
|
||||||
sockets.remove(token);
|
sockets.remove(token);
|
||||||
changed();
|
changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> kickById(String targetId, String reason) async {
|
/// Passes a nudge from [fromToken] to the member [targetId] only;
|
||||||
final member = engine.byId(targetId);
|
/// over the sender's rate cap, to itself or to nobody it is dropped.
|
||||||
if (member == null) throw const AuthException('unknown kick target');
|
void nudge(String fromToken, String targetId) {
|
||||||
if (session.tokens[member.token]?.isHost ?? false) {
|
final from = engine.members[fromToken];
|
||||||
throw const AuthException("can't kick the host");
|
final target = engine.byId(targetId);
|
||||||
}
|
final viewer = session.tokens[fromToken];
|
||||||
await session.kick(member.token, reason: reason);
|
if (from == null || target == null || viewer == null || identical(from, target)) return;
|
||||||
|
if (!viewer.takeNudge(engine.now)) return;
|
||||||
|
sockets[target.token]?.trySendText(jsonEncode(NudgeNotice(from: from.id, name: from.name).toJson()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
||||||
/// content hash, for members (valid, unkicked token) only. The bytes for a
|
/// content hash, for members (valid token) only. The bytes for a
|
||||||
/// hash never change, so clients may cache them for good.
|
/// hash never change, so clients may cache them for good.
|
||||||
void mountAvatarRoute(RelicApp app, ServerHub hub) =>
|
void mountAvatarRoute(RelicApp app, ServerHub hub) =>
|
||||||
_mountPictureRoute(app, hub, 'a', hub.engine.avatarFor);
|
_mountPictureRoute(app, hub, 'a', hub.engine.avatarFor);
|
||||||
@@ -188,8 +187,6 @@ void _mountPictureRoute(RelicApp app, ServerHub hub, String prefix, Uint8List? F
|
|||||||
switch (session.gate(token)) {
|
switch (session.gate(token)) {
|
||||||
case MediaGate.unauthorized:
|
case MediaGate.unauthorized:
|
||||||
return status(401);
|
return status(401);
|
||||||
case MediaGate.forbidden:
|
|
||||||
return status(403);
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -415,7 +412,7 @@ void mountSyncRoute(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final name = intent.name == null ? null : cleanMemberName(intent.name!);
|
final name = intent.name == null ? null : cleanMemberName(intent.name!);
|
||||||
// Chat and kick name the sender by its token's name.
|
// Chat names the sender by its token's name.
|
||||||
if (name != null) viewer.name = name;
|
if (name != null) viewer.name = name;
|
||||||
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
||||||
hub.changed();
|
hub.changed();
|
||||||
@@ -441,22 +438,14 @@ void mountSyncRoute(
|
|||||||
case 'chat':
|
case 'chat':
|
||||||
final chat = ChatMessage.fromJson(decoded);
|
final chat = ChatMessage.fromJson(decoded);
|
||||||
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
||||||
case 'kick':
|
case 'out':
|
||||||
final kickReq = KickRequest.fromJson(decoded);
|
if (token == null) return;
|
||||||
final member = hub.engine.byId(kickReq.target);
|
final intent = OutIntent.fromJson(decoded);
|
||||||
if (member == null) {
|
hub.engine.applyOut(token, intent.target, intent.out);
|
||||||
wsErr(ws, 'unknown kick target');
|
hub.changed();
|
||||||
return;
|
case 'nudge':
|
||||||
}
|
if (token == null) return;
|
||||||
if (session.tokens[member.token]?.isHost ?? false) {
|
hub.nudge(token, NudgeIntent.fromJson(decoded).target);
|
||||||
wsErr(ws, "can't kick the host");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final by = viewer?.name ?? '?';
|
|
||||||
unawaited(hub.session.kick(member.token,
|
|
||||||
reason: kickReq.reason.isEmpty
|
|
||||||
? 'kicked by $by'
|
|
||||||
: kickReq.reason));
|
|
||||||
}
|
}
|
||||||
} on ProtocolException catch (e) {
|
} on ProtocolException catch (e) {
|
||||||
wsErr(ws, 'bad message: ${e.message}');
|
wsErr(ws, 'bad message: ${e.message}');
|
||||||
@@ -544,13 +533,6 @@ void mountSyncRoute(
|
|||||||
hub.engine.members[token]?.host = isHost;
|
hub.engine.members[token]?.host = isHost;
|
||||||
final activeToken = token;
|
final activeToken = token;
|
||||||
hub.sockets[activeToken] = ws;
|
hub.sockets[activeToken] = ws;
|
||||||
session.tokens[activeToken]!.onKick = (reason) async {
|
|
||||||
if (hub.relay?.sourceToken == activeToken) hub.relay!.dropChannel();
|
|
||||||
ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson()));
|
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
|
||||||
await ws.tryClose();
|
|
||||||
hub.forgetSocketOf(activeToken, ws);
|
|
||||||
};
|
|
||||||
final media = session.media;
|
final media = session.media;
|
||||||
final relay = hub.relayAvailable;
|
final relay = hub.relayAvailable;
|
||||||
final relayed = hub.relay;
|
final relayed = hub.relay;
|
||||||
|
|||||||
@@ -3,21 +3,31 @@ import 'dart:math';
|
|||||||
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
enum MediaGate { ok, gone, unauthorized, forbidden, offline }
|
enum MediaGate { ok, gone, unauthorized, offline }
|
||||||
|
|
||||||
class ViewerToken {
|
class ViewerToken {
|
||||||
final String token;
|
final String token;
|
||||||
/// Cleaned ([cleanMemberName]); follows `profile` renames.
|
/// Cleaned ([cleanMemberName]); follows `profile` renames.
|
||||||
String name;
|
String name;
|
||||||
bool kicked = false;
|
|
||||||
/// Sent the server's host key in hello: the hosting app's own client.
|
/// Sent the server's host key in hello: the hosting app's own client.
|
||||||
bool isHost = false;
|
bool isHost = false;
|
||||||
int bytesServed = 0;
|
int bytesServed = 0;
|
||||||
DateTime lastSeen;
|
DateTime lastSeen;
|
||||||
Future<void> Function(String reason)? onKick;
|
/// When this member's recent nudges went out, for the rate cap.
|
||||||
|
final List<DateTime> nudges = [];
|
||||||
ViewerToken({required this.token, required this.name})
|
ViewerToken({required this.token, required this.name})
|
||||||
: lastSeen = DateTime.now().toUtc();
|
: lastSeen = DateTime.now().toUtc();
|
||||||
|
|
||||||
|
/// Takes one nudge at [now] if under the caps
|
||||||
|
/// ([RelayConstants.nudgesPerSec] / [RelayConstants.nudgesPerMin]).
|
||||||
|
bool takeNudge(DateTime now) {
|
||||||
|
nudges.removeWhere((t) => now.difference(t) >= const Duration(minutes: 1));
|
||||||
|
final lastSec = nudges.where((t) => now.difference(t) < const Duration(seconds: 1)).length;
|
||||||
|
if (nudges.length >= RelayConstants.nudgesPerMin || lastSec >= RelayConstants.nudgesPerSec) return false;
|
||||||
|
nudges.add(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool isStale(Duration timeout, [DateTime? now]) =>
|
bool isStale(Duration timeout, [DateTime? now]) =>
|
||||||
(now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout;
|
(now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout;
|
||||||
|
|
||||||
@@ -66,17 +76,9 @@ class Session {
|
|||||||
|
|
||||||
MediaGate gate(String? token) {
|
MediaGate gate(String? token) {
|
||||||
if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized;
|
if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized;
|
||||||
if (tokens[token]!.kicked) return MediaGate.forbidden;
|
|
||||||
return MediaGate.ok;
|
return MediaGate.ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> kick(String token, {String reason = ''}) async {
|
|
||||||
final viewer = tokens[token];
|
|
||||||
if (viewer == null) throw const AuthException('unknown token');
|
|
||||||
viewer.kicked = true;
|
|
||||||
await viewer.onKick?.call(reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
void addBytes(String? token, int n) {
|
void addBytes(String? token, int n) {
|
||||||
if (token != null) tokens[token]?.bytesServed += n;
|
if (token != null) tokens[token]?.bytesServed += n;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,29 +123,56 @@ void main() {
|
|||||||
await a.ws.close();
|
await a.ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('host (hostKey) cannot be kicked; loopback without it can', () async {
|
test('anyone sits anyone out (host too); nudges reach only the target, capped', () async {
|
||||||
await server.stop();
|
await server.stop();
|
||||||
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
||||||
await server.start();
|
await server.start();
|
||||||
final host =
|
final host =
|
||||||
await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk');
|
await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk');
|
||||||
final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
|
final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
|
||||||
final guesser =
|
final third = await _Peer.join('127.0.0.1', server.boundPort, 'third');
|
||||||
await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope');
|
|
||||||
String idOf(String name) =>
|
String idOf(String name) =>
|
||||||
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
|
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
|
||||||
friend.send(jsonEncode(KickRequest(target: idOf('host')).toJson()));
|
EngineMember of(String name) =>
|
||||||
final err = await friend.next('err');
|
server.hub.engine.members.values.firstWhere((m) => m.name == name);
|
||||||
expect(err['message'], "can't kick the host");
|
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson()));
|
||||||
expect(host.closed, isFalse);
|
var feed = await friend.next('feed');
|
||||||
await expectLater(
|
while (feed['action'] != 'out') {
|
||||||
server.hub.kickById(idOf('host'), 'x'), throwsA(isA<AuthException>()));
|
feed = await friend.next('feed');
|
||||||
// Same machine, no key (or a wrong one): an ordinary member.
|
}
|
||||||
host.send(jsonEncode(KickRequest(target: idOf('guesser')).toJson()));
|
expect([feed['actor'], feed['target']], ['friend', 'host']);
|
||||||
await guesser.next('kicked');
|
expect(of('host').out, isTrue);
|
||||||
await server.hub.kickById(idOf('friend'), 'x');
|
expect(of('host').inSet, isFalse);
|
||||||
await friend.next('kicked');
|
// Only oneself comes back.
|
||||||
|
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
|
||||||
|
host.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
expect(of('host').out, isFalse);
|
||||||
|
|
||||||
|
// 5 at once: 3 pass (per second), only to the target.
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
friend.send(jsonEncode(NudgeIntent(target: idOf('host')).toJson()));
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
final got = host.msgs.where((m) => m['t'] == 'nudge').toList();
|
||||||
|
expect(got, hasLength(3));
|
||||||
|
expect(got.first, {'t': 'nudge', 'from': idOf('friend'), 'name': 'friend'});
|
||||||
|
expect(third.msgs.where((m) => m['t'] == 'nudge'), isEmpty);
|
||||||
|
expect(friend.msgs.where((m) => m['t'] == 'nudge'), isEmpty);
|
||||||
await host.ws.close();
|
await host.ws.close();
|
||||||
|
await friend.ws.close();
|
||||||
|
await third.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nudge cap: 20 per minute', () {
|
||||||
|
final v = ViewerToken(token: 't', name: 'n');
|
||||||
|
final t0 = DateTime.utc(2026);
|
||||||
|
var passed = 0;
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
if (v.takeNudge(t0.add(Duration(milliseconds: 500 * i)))) passed++;
|
||||||
|
}
|
||||||
|
expect(passed, 20);
|
||||||
|
expect(v.takeNudge(t0.add(const Duration(seconds: 61))), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('state marks the host member for everyone', () async {
|
test('state marks the host member for everyone', () async {
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ void main() {
|
|||||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized)
|
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized)
|
||||||
.status,
|
.status,
|
||||||
401);
|
401);
|
||||||
expect(
|
|
||||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.forbidden).status,
|
|
||||||
403);
|
|
||||||
final off =
|
final off =
|
||||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline);
|
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline);
|
||||||
expect(off.status, 503);
|
expect(off.status, 503);
|
||||||
|
|||||||
@@ -133,189 +133,6 @@ void main() {
|
|||||||
await resp.drain();
|
await resp.drain();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('kick: WS drops and media goes 403', () async {
|
|
||||||
final ch = WebSocketChannel.connect(
|
|
||||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
|
||||||
final firstCompleter = Completer<String>();
|
|
||||||
var wsClosed = false;
|
|
||||||
final sub = ch.stream.listen(
|
|
||||||
(m) {
|
|
||||||
if (!firstCompleter.isCompleted) firstCompleter.complete('$m');
|
|
||||||
},
|
|
||||||
onDone: () => wsClosed = true,
|
|
||||||
);
|
|
||||||
final msg = HelloMessage(
|
|
||||||
proto: RelayConstants.protoVersion,
|
|
||||||
secret: 'shh',
|
|
||||||
user: const HelloUser(name: 'mallory'),
|
|
||||||
media:
|
|
||||||
const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson();
|
|
||||||
ch.sink.add(jsonEncode(msg));
|
|
||||||
final first =
|
|
||||||
await firstCompleter.future.timeout(const Duration(seconds: 5));
|
|
||||||
final token = (jsonDecode(first) as Map)['token'] as String;
|
|
||||||
|
|
||||||
await server.session.kick(token);
|
|
||||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
|
||||||
while (!wsClosed && DateTime.now().isBefore(deadline)) {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
}
|
|
||||||
expect(wsClosed, isTrue);
|
|
||||||
try {
|
|
||||||
await sub.cancel();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
await ch.sink.close();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
final name = file.path.split(Platform.pathSeparator).last;
|
|
||||||
final resp = await mediaGet('/m/default/$token/$name');
|
|
||||||
expect(resp.statusCode, 403);
|
|
||||||
await resp.drain();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('kick delivers reason then drops; instant rejoin works', () async {
|
|
||||||
final ch = WebSocketChannel.connect(
|
|
||||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
|
||||||
final messages = <String>[];
|
|
||||||
var wsClosed = false;
|
|
||||||
final sub = ch.stream.listen(
|
|
||||||
(m) => messages.add('$m'),
|
|
||||||
onDone: () => wsClosed = true,
|
|
||||||
);
|
|
||||||
HelloMessage helloFor(String who) => HelloMessage(
|
|
||||||
proto: RelayConstants.protoVersion,
|
|
||||||
secret: 'shh',
|
|
||||||
user: HelloUser(name: who),
|
|
||||||
media: const HelloMedia(
|
|
||||||
name: 'f.bin', size: 8192, duration: 10.0));
|
|
||||||
ch.sink.add(jsonEncode(helloFor(' sleepy').toJson()));
|
|
||||||
Map<String, Object?>? welcome;
|
|
||||||
final welcomeDeadline = DateTime.now().add(const Duration(seconds: 5));
|
|
||||||
while (DateTime.now().isBefore(welcomeDeadline)) {
|
|
||||||
for (final m in messages) {
|
|
||||||
final decoded = jsonDecode(m) as Map;
|
|
||||||
if (decoded['t'] == 'welcome') {
|
|
||||||
welcome = Map<String, Object?>.from(decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (welcome != null) break;
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
}
|
|
||||||
expect(welcome, isNotNull);
|
|
||||||
final token = welcome!['token'] as String;
|
|
||||||
|
|
||||||
await server.session.kick(token, reason: 'idle too long');
|
|
||||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
|
||||||
Map<String, Object?>? kicked;
|
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
|
||||||
for (final m in messages.skip(1)) {
|
|
||||||
final decoded = jsonDecode(m) as Map;
|
|
||||||
if (decoded['t'] == 'kicked') {
|
|
||||||
kicked = Map<String, Object?>.from(decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (wsClosed && kicked != null) break;
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
}
|
|
||||||
expect(wsClosed, isTrue);
|
|
||||||
expect(kicked, isNotNull);
|
|
||||||
expect(kicked!['reason'], 'idle too long');
|
|
||||||
try {
|
|
||||||
await sub.cancel();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
await ch.sink.close();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
final rejoin = await hello('shh');
|
|
||||||
final decoded = jsonDecode(rejoin) as Map;
|
|
||||||
expect(decoded['t'], 'welcome');
|
|
||||||
final token2 = decoded['token'] as String;
|
|
||||||
expect(token2, isNot(token));
|
|
||||||
final name = file.path.split(Platform.pathSeparator).last;
|
|
||||||
final resp = await mediaGet('/m/default/$token2/$name');
|
|
||||||
expect(resp.statusCode, 200);
|
|
||||||
await resp.drain();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('kick via socket: peer kicked, unknown target errors', () async {
|
|
||||||
Future<(WebSocketChannel, String, String, List<String>)> join(String who,
|
|
||||||
[String host = '127.0.0.1']) async {
|
|
||||||
final ch = WebSocketChannel.connect(
|
|
||||||
Uri.parse('ws://$host:${server.boundPort}/sync'));
|
|
||||||
final messages = <String>[];
|
|
||||||
ch.stream.listen((m) => messages.add('$m'));
|
|
||||||
final msg = HelloMessage(
|
|
||||||
proto: RelayConstants.protoVersion,
|
|
||||||
secret: 'shh',
|
|
||||||
user: HelloUser(name: who),
|
|
||||||
media: const HelloMedia(
|
|
||||||
name: 'f.bin', size: 8192, duration: 10.0)).toJson();
|
|
||||||
ch.sink.add(jsonEncode(msg));
|
|
||||||
Map<String, Object?>? welcome;
|
|
||||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
|
||||||
for (final m in messages) {
|
|
||||||
final decoded = jsonDecode(m) as Map;
|
|
||||||
if (decoded['t'] == 'welcome') {
|
|
||||||
welcome = Map<String, Object?>.from(decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (welcome != null) break;
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
}
|
|
||||||
expect(welcome, isNotNull,
|
|
||||||
reason: 'no welcome for $who: $messages');
|
|
||||||
final w = welcome!;
|
|
||||||
return (ch, w['token'] as String, w['memberId'] as String, messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?>? findType(List<String> messages, String type) {
|
|
||||||
for (final m in messages) {
|
|
||||||
final decoded = jsonDecode(m) as Map;
|
|
||||||
if (decoded['t'] == type) return Map<String, Object?>.from(decoded);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, Object?>?> waitType(
|
|
||||||
List<String> messages, String type, Duration timeout) async {
|
|
||||||
final deadline = DateTime.now().add(timeout);
|
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
|
||||||
final found = findType(messages, type);
|
|
||||||
if (found != null) return found;
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
}
|
|
||||||
return findType(messages, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
final (a, _, _, messagesA) = await join('alice');
|
|
||||||
final (b, tokenB, idB, messagesB) = await join('bob');
|
|
||||||
try {
|
|
||||||
a.sink.add(jsonEncode(
|
|
||||||
KickRequest(target: idB, reason: 'afk?').toJson()));
|
|
||||||
final kicked = await waitType(
|
|
||||||
messagesB, 'kicked', const Duration(seconds: 5));
|
|
||||||
expect(kicked, isNotNull);
|
|
||||||
expect(kicked!['reason'], 'afk?');
|
|
||||||
expect(server.session.gate(tokenB), MediaGate.forbidden);
|
|
||||||
|
|
||||||
a.sink.add(
|
|
||||||
jsonEncode(const KickRequest(target: 'm99').toJson()));
|
|
||||||
final err = await waitType(
|
|
||||||
messagesA, 'err', const Duration(seconds: 5));
|
|
||||||
expect(err, isNotNull);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await a.sink.close();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
await b.sink.close();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('silent socket dropped and member removed', () async {
|
test('silent socket dropped and member removed', () async {
|
||||||
final ch = WebSocketChannel.connect(
|
final ch = WebSocketChannel.connect(
|
||||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||||
@@ -557,11 +374,6 @@ void main() {
|
|||||||
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
|
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
|
||||||
final err = await waitFor((m) => m['t'] == 'err');
|
final err = await waitFor((m) => m['t'] == 'err');
|
||||||
expect(err['message'], 'rejected: avatar too large');
|
expect(err['message'], 'rejected: avatar too large');
|
||||||
|
|
||||||
await server.session.kick(token);
|
|
||||||
final kicked = await avatarGet(token, hash);
|
|
||||||
expect(kicked.statusCode, 403);
|
|
||||||
await kicked.drain();
|
|
||||||
} finally {
|
} finally {
|
||||||
await sub.cancel();
|
await sub.cancel();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,22 +21,6 @@ void main() {
|
|||||||
expect(s.gate(t), MediaGate.ok);
|
expect(s.gate(t), MediaGate.ok);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('kick unknown throws, kick known forbids + drops', () async {
|
|
||||||
final s = Session(id: 'default', secret: 'shh');
|
|
||||||
expect(() => s.kick('nope'), throwsA(isA<AuthException>()));
|
|
||||||
final t = s.issueToken('mallory', '');
|
|
||||||
var dropped = false;
|
|
||||||
String? gotReason;
|
|
||||||
s.tokens[t]!.onKick = (reason) async {
|
|
||||||
dropped = true;
|
|
||||||
gotReason = reason;
|
|
||||||
};
|
|
||||||
await s.kick(t, reason: 'idle');
|
|
||||||
expect(s.gate(t), MediaGate.forbidden);
|
|
||||||
expect(dropped, isTrue);
|
|
||||||
expect(gotReason, 'idle');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('oversize avatar rejected', () {
|
test('oversize avatar rejected', () {
|
||||||
final s = Session(id: 'default', secret: 'shh');
|
final s = Session(id: 'default', secret: 'shh');
|
||||||
final big = 'x' * (RelayConstants.maxAvatarBytes + 1);
|
final big = 'x' * (RelayConstants.maxAvatarBytes + 1);
|
||||||
@@ -122,18 +106,6 @@ void main() {
|
|||||||
expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA<ProtocolException>()));
|
expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA<ProtocolException>()));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('kicked + kick request round-trip', () {
|
|
||||||
const k = KickedMessage(reason: 'idle');
|
|
||||||
final back = KickedMessage.fromJson(Map<String, Object?>.from(k.toJson()));
|
|
||||||
expect(back.reason, 'idle');
|
|
||||||
const r = KickRequest(target: 'abc', reason: 'afk?');
|
|
||||||
final backR = KickRequest.fromJson(Map<String, Object?>.from(r.toJson()));
|
|
||||||
expect(backR.target, 'abc');
|
|
||||||
expect(backR.reason, 'afk?');
|
|
||||||
expect(() => KickedMessage.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
|
|
||||||
expect(() => KickRequest.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lastSeen touch + stale helper', () {
|
test('lastSeen touch + stale helper', () {
|
||||||
final s = Session(id: 'default', secret: 'shh');
|
final s = Session(id: 'default', secret: 'shh');
|
||||||
final t = s.issueToken('alice', '');
|
final t = s.issueToken('alice', '');
|
||||||
|
|||||||
@@ -127,17 +127,10 @@ void main() {
|
|||||||
await second.ws.close();
|
await second.ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unknown or kicked resume token joins as a new member', () async {
|
test('unknown resume token joins as a new member', () async {
|
||||||
final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
|
final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
|
||||||
expect(wa.token, isNot('nope'));
|
expect(wa.token, isNot('nope'));
|
||||||
final (b, wb) = await _Client.join(server.boundPort, 'b');
|
|
||||||
await server.hub.kickById(wb.memberId, 'bye');
|
|
||||||
final (c, wc) = await _Client.join(server.boundPort, 'b', resume: wb.token);
|
|
||||||
expect(wc.token, isNot(wb.token));
|
|
||||||
expect(wc.memberId, isNot(wb.memberId));
|
|
||||||
await a.ws.close();
|
await a.ws.close();
|
||||||
await b.ws.close();
|
|
||||||
await c.ws.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ping gets a pong with the client stamp and the server clock', () async {
|
test('ping gets a pong with the client stamp and the server clock', () async {
|
||||||
|
|||||||
Reference in new issue
Block a user