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
+847
-591
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
|
||||
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,
|
||||
dimmed reconnecting; a crown on whoever hosts, a tower on whoever
|
||||
passes the video on). Click the avatars for the member list (force ready,
|
||||
kick, "Copy invite address"; when you host it lists this computer's
|
||||
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
|
||||
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
corner; double-click or ⤢ makes it big again, × stops watching (you stay in
|
||||
the server, muted) and the strip's screen button brings it back. F or
|
||||
corner; double-click or ⤢ makes it big again, × sits you out (below) and
|
||||
× 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
|
||||
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
|
||||
@@ -76,11 +76,23 @@ hidden, as a small chip top right.
|
||||
Your name and picture change for everyone in the server right away; the
|
||||
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 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
|
||||
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
|
||||
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
|
||||
@@ -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 |
|
||||
|
||||
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
|
||||
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
||||
`/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
|
||||
/// 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
|
||||
/// late joiner never saw that swap), so all clients agree.
|
||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||
final eligible = [
|
||||
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;
|
||||
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;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Set when an attempt ends in a kick, so the shell shows the removal
|
||||
/// dialog.
|
||||
void Function(String reason)? onKicked;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
@@ -110,12 +106,7 @@ class ConnectFlow extends ChangeNotifier {
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
final kick = _session.coordinator.kickReason.peek();
|
||||
if (kick != null && kick.isNotEmpty) {
|
||||
onKicked?.call(kick);
|
||||
} else {
|
||||
errors[mode] = CardError('Could not connect', '$e');
|
||||
}
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../jellyfin/session_media.dart';
|
||||
import '../jellyfin/track_prefs.dart';
|
||||
import '../playback/media_marks.dart';
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../platform/attention.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../schedule/anilist.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 _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
||||
const _toastActions = {'join', 'leave', 'swap', 'chat'};
|
||||
|
||||
/// "Unready when the video changes": a state with a newer [mediaId] than
|
||||
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
||||
@@ -180,7 +181,6 @@ class SessionCoordinator {
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
final reconnecting = signal(false);
|
||||
final kickReason = signal<String?>(null);
|
||||
final starving = signal<String?>(null);
|
||||
|
||||
/// The current media's Jellyfin ref (null for links and files).
|
||||
@@ -226,10 +226,16 @@ class SessionCoordinator {
|
||||
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;
|
||||
StreamSubscription? _stateSub;
|
||||
StreamSubscription? _feedSub;
|
||||
StreamSubscription? _kickSub;
|
||||
StreamSubscription? _nudgeSub;
|
||||
StreamSubscription? _errSub;
|
||||
StreamSubscription? _statusSub;
|
||||
|
||||
@@ -242,6 +248,13 @@ class SessionCoordinator {
|
||||
// Set after a reconnect until the server echoes the restored ready, so
|
||||
// the fresh member's ready=false doesn't overwrite [_lastReady].
|
||||
bool _restoringReady = false;
|
||||
// Last 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
|
||||
// reconnect, so only a real swap counts as a new video.
|
||||
int _seenMediaId = -1;
|
||||
@@ -372,7 +385,6 @@ class SessionCoordinator {
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
_params = params;
|
||||
_manualClose = false;
|
||||
kickReason.value = null;
|
||||
_wsHost = params.host;
|
||||
_wsPort = params.port;
|
||||
diff.reset();
|
||||
@@ -380,6 +392,8 @@ class SessionCoordinator {
|
||||
_openUrl = null;
|
||||
_lastReady = false;
|
||||
_restoringReady = false;
|
||||
_lastOut = false;
|
||||
_joinBackAt = null;
|
||||
_seenMediaId = -1;
|
||||
_wasPlayAllowed = false;
|
||||
_everConnected = false;
|
||||
@@ -443,12 +457,12 @@ class SessionCoordinator {
|
||||
void _subscribe() {
|
||||
_stateSub ??= sync.states.listen(_onState);
|
||||
_feedSub ??= sync.feeds.listen(_onFeed);
|
||||
_kickSub ??= sync.kicked.listen(_onKicked);
|
||||
_nudgeSub ??= sync.nudges.listen(_onNudge);
|
||||
_errSub ??= sync.errors.listen((msg) {
|
||||
health.value = HealthStatus(HealthSeverity.warn, msg);
|
||||
});
|
||||
_statusSub ??= sync.status.listen((s) {
|
||||
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
|
||||
if (s == SyncStatus.closed && !_manualClose) {
|
||||
health.value = const HealthStatus(
|
||||
HealthSeverity.warn, 'connection lost — retrying…');
|
||||
_soundLost();
|
||||
@@ -613,6 +627,12 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> _applyState(StateBroadcast r) async {
|
||||
_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);
|
||||
final newEvent = r.eventId != _lastEventId;
|
||||
_lastEventId = r.eventId;
|
||||
@@ -740,6 +760,7 @@ class SessionCoordinator {
|
||||
} else {
|
||||
_lastReady = self.ready;
|
||||
}
|
||||
_lastOut = self.out;
|
||||
final last = _seenMediaId;
|
||||
_seenMediaId = r.mediaId;
|
||||
// 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) {
|
||||
if (r.transport == 'playing') {
|
||||
starving.value = null;
|
||||
@@ -773,14 +815,15 @@ class SessionCoordinator {
|
||||
}
|
||||
final self = selfMember.peek();
|
||||
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))
|
||||
.map((m) => m.name)
|
||||
.toList();
|
||||
if (waiters.isNotEmpty) {
|
||||
final readyCount = r.members.where((m) => m.ready).length;
|
||||
final readyCount = present.where((m) => m.ready).length;
|
||||
starving.value =
|
||||
'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)';
|
||||
'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)';
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -809,9 +852,15 @@ class SessionCoordinator {
|
||||
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);
|
||||
// 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) {
|
||||
var readyIndex = 0;
|
||||
if (f.action == 'ready') {
|
||||
@@ -827,8 +876,6 @@ class SessionCoordinator {
|
||||
return '${f.actor} joined';
|
||||
case 'leave':
|
||||
return '${f.actor} left';
|
||||
case 'kick':
|
||||
return '${f.actor} kicked ${f.target ?? ''}';
|
||||
case 'swap':
|
||||
return '${f.actor} changed media';
|
||||
default:
|
||||
@@ -836,26 +883,13 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onKicked(String reason) async {
|
||||
kickReason.value = reason;
|
||||
_manualClose = true;
|
||||
_epoch++;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
reconnecting.value = false;
|
||||
_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');
|
||||
/// Someone poked this member: sound (even when its switch is off; the
|
||||
/// volume still applies), a toast, and the window asks for attention.
|
||||
void _onNudge(NudgeNotice n) {
|
||||
final name = n.name.isEmpty ? 'Someone' : n.name;
|
||||
_pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
|
||||
unawaited(requestAttention());
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
@@ -871,20 +905,22 @@ class SessionCoordinator {
|
||||
if (sync.token.isNotEmpty) sync.sendPing();
|
||||
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
||||
// 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(
|
||||
position: playback.positionSecs,
|
||||
buffered: playback.bufferedSecsAhead,
|
||||
position: _lastOut ? 0 : playback.positionSecs,
|
||||
buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
|
||||
eventId: r.eventId,
|
||||
duration: known ? playback.durationSecs : 0,
|
||||
mediaId: known ? r.mediaId : 0,
|
||||
jf: _jfAccessFor(r),
|
||||
);
|
||||
if (!_lastOut) {
|
||||
_checkSeekLanded(r);
|
||||
unawaited(_tickJellyfin(r));
|
||||
}
|
||||
}
|
||||
if (!_manualClose &&
|
||||
kickReason.peek() == null &&
|
||||
_lastStateAt != null &&
|
||||
now.difference(_lastStateAt!).inSeconds >= 5) {
|
||||
if (serverState.peek() != null) {
|
||||
@@ -905,6 +941,8 @@ class SessionCoordinator {
|
||||
// No warning when there is nothing to lose (unready), and none while
|
||||
// PLAYING: the ready flag gates nothing then, and watching counts as
|
||||
// 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,
|
||||
playing: serverState.peek()?.transport == 'playing',
|
||||
selfReady: selfMember.peek()?.ready ?? false);
|
||||
@@ -915,18 +953,46 @@ class SessionCoordinator {
|
||||
...toasts.peek(),
|
||||
ToastItem(
|
||||
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)
|
||||
];
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||
case IdleEvent.unready:
|
||||
_lastReady = false;
|
||||
if (sitOutWhenIdle) {
|
||||
sitOut();
|
||||
} else {
|
||||
sync.sendReady(false, source: 'auto');
|
||||
}
|
||||
case IdleEvent.none:
|
||||
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() {
|
||||
_pendingSeekEvent = null;
|
||||
_pendingSeekAt = null;
|
||||
@@ -993,6 +1059,10 @@ class SessionCoordinator {
|
||||
|
||||
void toggleReady() {
|
||||
final self = selfMember.peek();
|
||||
if (self?.out ?? false) {
|
||||
joinBack();
|
||||
return;
|
||||
}
|
||||
// Ready but out of the set (dropped out while stale) gates like
|
||||
// unready, so Space puts self back in instead of unreadying.
|
||||
final next = !((self?.ready ?? false) && (self?.inSet ?? false));
|
||||
@@ -1080,12 +1150,43 @@ class SessionCoordinator {
|
||||
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();
|
||||
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) {
|
||||
// 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);
|
||||
sync.sendSwap(media);
|
||||
}
|
||||
@@ -1266,11 +1367,11 @@ class SessionCoordinator {
|
||||
/// results), shown like the session's own.
|
||||
void showToast(String text) => _pushToast(text);
|
||||
|
||||
void _pushToast(String text) {
|
||||
void _pushToast(String text, {String? actor}) {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...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));
|
||||
}
|
||||
|
||||
/// Leave / kick / window close: Stopped, bounded so leaving never hangs.
|
||||
/// Leave / window close: Stopped, bounded so leaving never hangs.
|
||||
Future<void> _stopJellyfin() async {
|
||||
final reporter = _reporter;
|
||||
final snap = _snapNow();
|
||||
@@ -1592,7 +1693,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void _scheduleReconnect({bool manual = false}) {
|
||||
if (_manualClose || kickReason.peek() != null || _params == null) return;
|
||||
if (_manualClose || _params == null) return;
|
||||
if (!manual && !_everConnected) return;
|
||||
if (reconnecting.peek()) return;
|
||||
reconnecting.value = true;
|
||||
@@ -1607,14 +1708,15 @@ class SessionCoordinator {
|
||||
/// would kill the newer connection and leave a ghost member).
|
||||
Future<void> _reconnect(int epoch) async {
|
||||
_reconnectTimer = null;
|
||||
bool stale() => epoch != _epoch || kickReason.peek() != null;
|
||||
bool stale() => epoch != _epoch;
|
||||
final params = _params;
|
||||
if (stale() || params == null) return;
|
||||
if (_manualClose) {
|
||||
reconnecting.value = false;
|
||||
return;
|
||||
}
|
||||
var wantReady = _lastReady;
|
||||
final wasOut = _lastOut;
|
||||
var wantReady = _lastReady && !wasOut;
|
||||
final seenBefore = _seenMediaId;
|
||||
try {
|
||||
health.value = const HealthStatus(
|
||||
@@ -1655,6 +1757,10 @@ class SessionCoordinator {
|
||||
wantReady = 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) {
|
||||
_lastReady = true;
|
||||
_restoringReady = true;
|
||||
@@ -1700,7 +1806,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void retry() {
|
||||
if (kickReason.peek() != null || _params == null) return;
|
||||
if (_params == null) return;
|
||||
_invalidateAttempts();
|
||||
_manualClose = false;
|
||||
reconnecting.value = false;
|
||||
@@ -1761,7 +1867,7 @@ class SessionCoordinator {
|
||||
}
|
||||
await _stateSub?.cancel();
|
||||
await _feedSub?.cancel();
|
||||
await _kickSub?.cancel();
|
||||
await _nudgeSub?.cancel();
|
||||
await _errSub?.cancel();
|
||||
await _statusSub?.cancel();
|
||||
_aniList?.close();
|
||||
|
||||
@@ -8,6 +8,7 @@ enum SfxKind {
|
||||
forceReady,
|
||||
join,
|
||||
leave,
|
||||
/// Someone sat you out (played by the session, not from a feed action).
|
||||
kick,
|
||||
/// A new video or episode is put up.
|
||||
swap,
|
||||
@@ -31,7 +32,6 @@ SfxKind? sfxForFeedAction(String action) => switch (action) {
|
||||
'ready' => SfxKind.ready,
|
||||
'unready' => SfxKind.unready,
|
||||
'force-ready' => SfxKind.forceReady,
|
||||
'kick' => SfxKind.kick,
|
||||
'join' => SfxKind.join,
|
||||
'leave' => SfxKind.leave,
|
||||
'swap' => SfxKind.swap,
|
||||
|
||||
@@ -258,11 +258,11 @@ const soundInfo = {
|
||||
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
|
||||
SfxKind.join: ('Joined', 'Someone joins the server'),
|
||||
SfxKind.leave: ('Left', 'Someone leaves'),
|
||||
SfxKind.kick: ('Removed', 'Someone is kicked'),
|
||||
SfxKind.kick: ('Sat out', 'Someone sits you out'),
|
||||
SfxKind.swap: ('New video', 'A different video or episode is put up'),
|
||||
SfxKind.chat: ('Message', 'A chat message from someone else'),
|
||||
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
|
||||
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
|
||||
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
|
||||
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).
|
||||
bool _autoFull = false;
|
||||
int _lastMediaId = -1;
|
||||
double? _mutedVolume;
|
||||
bool _dialogOpen = false;
|
||||
String? _lastKickShown;
|
||||
final _stops = <void Function()>[];
|
||||
// Rebuild the shell on these flips, not on every state broadcast.
|
||||
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.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
_flow.onKicked = _showKicked;
|
||||
HardwareKeyboard.instance.addHandler(_onKey);
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
@@ -181,17 +178,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
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(() {
|
||||
final kick = coord.kickReason.value;
|
||||
if (kick == null) {
|
||||
_lastKickShown = null;
|
||||
return;
|
||||
}
|
||||
untracked(() {
|
||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
||||
final out = coord.selfOut.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
});
|
||||
if (kick.isNotEmpty) _later(() => _showKicked(kick));
|
||||
}
|
||||
wasOut = out;
|
||||
}));
|
||||
// The hosted server died mid-session (SessionState leaves): say so.
|
||||
_stops.add(effect(() {
|
||||
@@ -242,7 +239,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}
|
||||
|
||||
void _wentOffline() {
|
||||
_mutedVolume = null;
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() {
|
||||
_chatOpen = false;
|
||||
@@ -258,7 +254,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
Future<void> _show(PlayerMode m) async {
|
||||
if (m == _mode) return;
|
||||
final seq = ++_modeSeq;
|
||||
if (m != PlayerMode.none) _unmute();
|
||||
setState(() {
|
||||
_from = _shown;
|
||||
_mode = m;
|
||||
@@ -282,27 +277,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_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() {
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (playback.alive.peek()) {
|
||||
try {
|
||||
_mutedVolume = playback.player.state.volume;
|
||||
playback.player.setVolume(0);
|
||||
} catch (_) {}
|
||||
}
|
||||
final coord = SessionState.instance.coordinator;
|
||||
if (coord.selfOut.peek()) {
|
||||
_show(PlayerMode.none);
|
||||
} else {
|
||||
coord.sitOut();
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -520,12 +503,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
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 {
|
||||
if (!mounted || _dialogOpen) return;
|
||||
_dialogOpen = true;
|
||||
|
||||
@@ -17,6 +17,8 @@ class AvatarInfo {
|
||||
final Color color;
|
||||
final ImageProvider? image;
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -28,6 +30,7 @@ class AvatarInfo {
|
||||
this.stale = false,
|
||||
this.host = 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).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -86,6 +89,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +111,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
// knows before the first state.
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
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
|
||||
/// 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 {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -176,7 +182,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Opacity(
|
||||
opacity: info.stale ? 0.4 : 1,
|
||||
opacity: info.stale || info.out ? 0.4 : 1,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
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)
|
||||
Positioned(
|
||||
right: -3,
|
||||
@@ -240,7 +257,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 visible = sorted.take(widget.shown).toList();
|
||||
final extra = sorted.length - visible.length;
|
||||
|
||||
@@ -232,9 +232,11 @@ class ActivityRow extends StatelessWidget {
|
||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||
case 'stale':
|
||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||
case 'kick':
|
||||
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
|
||||
case 'out':
|
||||
final who = f.target;
|
||||
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:
|
||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.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 '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/dialogs.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
@@ -337,7 +336,8 @@ class StripTab extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 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>(
|
||||
context,
|
||||
at: const Alignment(0.7, -0.84),
|
||||
@@ -469,6 +469,8 @@ class _MemberTile extends StatelessWidget {
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
? 'sitting out'
|
||||
: info.buffering
|
||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||
: m.ready
|
||||
@@ -494,27 +496,30 @@ class _MemberTile extends StatelessWidget {
|
||||
trailing: PopupMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) async {
|
||||
if (v == 'force') {
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'force':
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
} else if (v == 'kick') {
|
||||
final ok = await showKickConfirmDialog(context, m.name);
|
||||
if (ok == true) coord.kick(m.id);
|
||||
case 'nudge':
|
||||
coord.nudgeMember(m.id);
|
||||
case 'out':
|
||||
coord.sitOut(m.id);
|
||||
case 'back':
|
||||
coord.joinBack();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (!m.out)
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self && !info.host)
|
||||
PopupMenuItem(
|
||||
value: 'kick',
|
||||
child: Text(
|
||||
'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';
|
||||
|
||||
/// 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 whole pill).
|
||||
@@ -59,16 +59,10 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
String? kickReason,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
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) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
@@ -82,6 +76,8 @@ StatusView? playerStatusFor({
|
||||
}
|
||||
final r = state;
|
||||
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') {
|
||||
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",
|
||||
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 = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
@@ -117,7 +114,6 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
return playerStatusFor(
|
||||
health: c.health.value,
|
||||
reconnecting: c.reconnecting.value,
|
||||
kickReason: c.kickReason.value,
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
@@ -258,7 +254,6 @@ class _StatusLine extends StatelessWidget {
|
||||
Widget? _lead() {
|
||||
final v = view;
|
||||
switch (v.kind) {
|
||||
case StatusKind.kicked:
|
||||
case StatusKind.problem:
|
||||
final error = v.tone == HealthSeverity.error;
|
||||
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 color = _p.color.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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -166,7 +167,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
||||
Pill(online ? 'in a server' : 'offline',
|
||||
online ? Colors.greenAccent : Colors.grey),
|
||||
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: const EdgeInsets.symmetric(vertical: 6),
|
||||
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(
|
||||
Icons.extension_outlined,
|
||||
'Jellyfin & integrations',
|
||||
|
||||
@@ -30,6 +30,7 @@ import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
import 'sit_out_card.dart';
|
||||
import 'start_banner.dart';
|
||||
|
||||
/// 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()],
|
||||
] else
|
||||
_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
|
||||
/// bar at the bottom. A thin progress line stays while not hovered.
|
||||
Widget _miniOverlay() {
|
||||
@@ -823,7 +859,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
return [
|
||||
@@ -510,8 +523,9 @@ class NotificationsInfo extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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'),
|
||||
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('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)),
|
||||
],
|
||||
),
|
||||
Switch(
|
||||
// A nudge always plays (an explicit poke): no switch,
|
||||
// its place kept so the rows line up.
|
||||
Visibility.maintain(
|
||||
visible: kind != SfxKind.nudge,
|
||||
child: Switch(
|
||||
key: ValueKey('sound-on-${kind.name}'),
|
||||
value: !off.contains(kind.name),
|
||||
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
|
||||
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,8 +34,10 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
? Icons.login
|
||||
: rest.startsWith('left')
|
||||
? Icons.logout
|
||||
: rest.startsWith('kicked')
|
||||
? Icons.gavel
|
||||
: rest.startsWith('sat you out')
|
||||
? Icons.bedtime
|
||||
: rest.startsWith('nudged')
|
||||
? Icons.notifications_active_outlined
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
|
||||
@@ -144,7 +144,7 @@ class ServerCrashed implements Exception {
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
final r = Random.secure();
|
||||
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 SfxService sfx = SynthSfx(
|
||||
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,
|
||||
);
|
||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||
@@ -183,7 +184,6 @@ class SessionState {
|
||||
|
||||
@visibleForTesting
|
||||
ServerIsolate? get hostedServer => _server;
|
||||
void Function()? _stopKickWatch;
|
||||
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
||||
// next step and unwinds instead of connecting.
|
||||
int _op = 0;
|
||||
@@ -309,7 +309,6 @@ class SessionState {
|
||||
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
||||
rethrow;
|
||||
}
|
||||
_watchKick();
|
||||
route.value = AppRoute.session;
|
||||
} finally {
|
||||
await _saveFormQuietly();
|
||||
@@ -353,15 +352,6 @@ class SessionState {
|
||||
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
|
||||
/// wants the form back.
|
||||
Future<void> _saveFormQuietly() async {
|
||||
@@ -387,8 +377,6 @@ class SessionState {
|
||||
|
||||
Future<void> stopServer() async {
|
||||
coordinator.hostFile = null;
|
||||
_stopKickWatch?.call();
|
||||
_stopKickWatch = null;
|
||||
final server = _server;
|
||||
_server = null;
|
||||
await server?.stop();
|
||||
|
||||
@@ -25,6 +25,7 @@ const _keySoundsOff = 'soundsOff';
|
||||
const _keySoundPreset = 'soundPreset';
|
||||
const _keySoundPresetFor = 'soundPresetFor';
|
||||
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||
const _keyIdleSitsOut = 'idleSitsOut';
|
||||
|
||||
enum HwdecMode { auto, off, custom }
|
||||
|
||||
@@ -39,6 +40,8 @@ class AppSettings {
|
||||
/// Session: send ready=false when the video changes (a swap keeps the
|
||||
/// server's ready flags).
|
||||
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
|
||||
/// standard ones (media_kit's with our bars). Applies live.
|
||||
final mpvControls = signal(false);
|
||||
@@ -95,6 +98,8 @@ class AppSettings {
|
||||
if (advance is bool) autoAdvance.value = advance;
|
||||
final unready = box.get(_keyUnreadyOnSwap);
|
||||
if (unready is bool) unreadyOnSwap.value = unready;
|
||||
final idleOut = box.get(_keyIdleSitsOut);
|
||||
if (idleOut is bool) idleSitsOut.value = idleOut;
|
||||
final mpv = box.get(_keyMpvControls);
|
||||
if (mpv is bool) mpvControls.value = mpv;
|
||||
final hideRe = box.get(_keyHideReReleases);
|
||||
@@ -156,6 +161,11 @@ class AppSettings {
|
||||
_put(_keyUnreadyOnSwap, on);
|
||||
}
|
||||
|
||||
void setIdleSitsOut(bool on) {
|
||||
idleSitsOut.value = on;
|
||||
_put(_keyIdleSitsOut, on);
|
||||
}
|
||||
|
||||
void setMpvControls(bool on) {
|
||||
mpvControls.value = on;
|
||||
_put(_keyMpvControls, on);
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:convert';
|
||||
import 'package:sync_relay_core/core.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 {
|
||||
/// Local clock; injectable so tests can run a client whose clock is off.
|
||||
@@ -23,13 +23,14 @@ class SyncClient {
|
||||
StreamSubscription? _sub;
|
||||
final StreamController<StateBroadcast> _states = 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<SyncStatus> _status = StreamController.broadcast();
|
||||
|
||||
Stream<StateBroadcast> get states => _states.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<SyncStatus> get status => _status.stream;
|
||||
|
||||
@@ -153,14 +154,10 @@ class SyncClient {
|
||||
// one toast, one sfx.
|
||||
if (f.action != 'chat') _feeds.add(f);
|
||||
} catch (_) {}
|
||||
case 'kicked':
|
||||
case 'nudge':
|
||||
try {
|
||||
final msg = KickedMessage.fromJson(decoded);
|
||||
_kicked.add(msg.reason);
|
||||
_status.add(SyncStatus.kicked);
|
||||
} catch (_) {
|
||||
_kicked.add('');
|
||||
}
|
||||
_nudges.add(NudgeNotice.fromJson(decoded));
|
||||
} catch (_) {}
|
||||
case 'pong':
|
||||
try {
|
||||
final pong = PongMessage.fromJson(decoded);
|
||||
@@ -255,8 +252,14 @@ class SyncClient {
|
||||
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
||||
}
|
||||
|
||||
void sendKick(String target, {String reason = ''}) {
|
||||
_send(KickRequest(target: target, reason: reason).toJson());
|
||||
/// Sits the member [target] out, or with [out] false brings this
|
||||
/// 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 {
|
||||
@@ -283,7 +286,7 @@ class SyncClient {
|
||||
await disconnect();
|
||||
await _states.close();
|
||||
await _feeds.close();
|
||||
await _kicked.close();
|
||||
await _nudges.close();
|
||||
await _errors.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 {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
// "starsync/window" (lib/src/platform/attention.dart).
|
||||
FlMethodChannel* window_channel;
|
||||
};
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
// "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.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
@@ -88,6 +113,15 @@ static void my_application_activate(GApplication* application) {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -134,6 +168,7 @@ static void my_application_shutdown(GApplication* application) {
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
g_clear_object(&self->window_channel);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
|
||||
@@ -296,10 +296,10 @@ void main() {
|
||||
findsOneWidget);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')),
|
||||
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.pumpAndSettle();
|
||||
expect(find.text('Kick'), findsNothing);
|
||||
expect(find.text('Sit out'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
|
||||
|
||||
@@ -26,8 +26,9 @@ final png = Uint8List.fromList(const [
|
||||
0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) =>
|
||||
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale);
|
||||
MemberView mv(String id, String name,
|
||||
{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(
|
||||
@@ -55,17 +56,8 @@ void main() {
|
||||
group('status', () {
|
||||
final me = mv('me', 'Me', ready: true);
|
||||
|
||||
test('problems first: removed, reconnecting, health', () {
|
||||
var v = playerStatusFor(
|
||||
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…'),
|
||||
test('problems first: reconnecting, health', () {
|
||||
var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
|
||||
reconnecting: true);
|
||||
expect(v!.text, 'Reconnecting…');
|
||||
expect(v.action, StatusAction.cancel);
|
||||
@@ -109,11 +101,14 @@ void main() {
|
||||
expect(v.self, isTrue);
|
||||
final w = playerStatusFor(
|
||||
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)!;
|
||||
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');
|
||||
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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
MediaKit.ensureInitialized();
|
||||
Hive.init(
|
||||
@@ -131,16 +131,16 @@ void main() {
|
||||
final hostId = ((state['members'] as List)
|
||||
.cast<Map>()
|
||||
.firstWhere((m) => m['name'] == 'host'))['id'];
|
||||
friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson()));
|
||||
final err = await friend.next('err');
|
||||
expect(err['message'], "can't kick the host");
|
||||
// Anyone may sit the host out; its player unloads, the server serves on.
|
||||
friend.ws.add(jsonEncode(OutIntent(target: '$hostId', out: true).toJson()));
|
||||
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)
|
||||
.cast<Map>()
|
||||
.firstWhere((m) => m['name'] == 'friend'))['id'];
|
||||
session.coordinator.kick('$friendId');
|
||||
await friend.next('kicked');
|
||||
session.coordinator.nudgeMember('$friendId');
|
||||
expect((await friend.next('nudge'))['name'], 'host');
|
||||
|
||||
session.hostedServer!.isolate.kill(priority: Isolate.immediate);
|
||||
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('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.pumpAndSettle();
|
||||
await tester.tap(find.text('Kick'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Remove Bob?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
expect(find.text('Nudge'), findsOneWidget);
|
||||
expect(find.text('Sit out'), findsOneWidget);
|
||||
expect(find.text('Kick'), findsNothing);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(40, 700));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
@@ -25,6 +27,24 @@ bool FlutterWindow::OnCreate() {
|
||||
return false;
|
||||
}
|
||||
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());
|
||||
|
||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||
@@ -40,6 +60,7 @@ bool FlutterWindow::OnCreate() {
|
||||
}
|
||||
|
||||
void FlutterWindow::OnDestroy() {
|
||||
window_channel_ = nullptr;
|
||||
if (flutter_controller_) {
|
||||
flutter_controller_ = nullptr;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#include <flutter/dart_project.h>
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/encodable_value.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
@@ -28,6 +30,10 @@ class FlutterWindow : public Win32Window {
|
||||
|
||||
// The Flutter instance hosted by this window.
|
||||
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_
|
||||
+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
|
||||
dies anyway, the session shows "server crashed: ..." and leaves; the
|
||||
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.
|
||||
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||
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
|
||||
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
|
||||
comes from plays no part (so a reverse proxy or tunnel on the same machine
|
||||
does not make everyone the host). The dedicated server has no key, so it
|
||||
has no host. Kick = revoke token, send `kicked`, close; the person may
|
||||
rejoin at once with a new token.
|
||||
key matches is the host (`members[].host`: the crown, art rights). The
|
||||
address a connection comes from plays no part (so a reverse proxy or tunnel
|
||||
on the same machine does not make everyone the host). The dedicated server
|
||||
has no key, so it has no host. Nobody removes anyone: members sit each
|
||||
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
|
||||
|
||||
@@ -94,8 +95,22 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
|
||||
default off, sends `ready=false` itself on a new `mediaId`; not for the
|
||||
first state of a session, and a reconnect does not restore a ready that
|
||||
a swap meanwhile would have dropped). Stale, leave and kick remove from
|
||||
the set.
|
||||
a swap meanwhile would have dropped). Stale, leave and sitting out
|
||||
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
|
||||
`need` buffered or stale flips the server to HELD (feed action `pause` by
|
||||
`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
|
||||
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
|
||||
dead socket is closed without removing the member. An unknown or kicked
|
||||
token joins as a new member.
|
||||
dead socket is closed without removing the member. An unknown token joins
|
||||
as a new member.
|
||||
- **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
|
||||
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.
|
||||
Closing the window leaves gracefully (2 s timeout).
|
||||
- **Idle** (`IdleTracker`): measured from the later of the last input and the
|
||||
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.
|
||||
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
|
||||
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).
|
||||
@@ -192,7 +217,7 @@ Range table (`resolveMediaRequest`, tested byte-exact):
|
||||
| `bytes=-n` | 206 last n bytes |
|
||||
| unsatisfiable | 416, `Content-Range: bytes */size` |
|
||||
| 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
|
||||
(`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
|
||||
`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
|
||||
`Authorization: MediaBrowser` built from exactly the configured fields
|
||||
(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
|
||||
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
|
||||
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 =
|
||||
duration, so Jellyfin marks it played); seeking back from the end sends
|
||||
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 n/m" (hidden under 1100 px), chat with an unread badge, and Leave
|
||||
(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`
|
||||
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
|
||||
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
|
||||
picture with a gap in the background colour (both scale with the size):
|
||||
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
|
||||
passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
|
||||
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.
|
||||
Nothing happens while a dialog or popover is over the shell.
|
||||
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
|
||||
unready, force-ready, join, leave, kick, swap and others' chat (feed
|
||||
events), the idle warning, and connection lost / back (once per drop).
|
||||
unready, force-ready, join, leave, swap and others' chat (feed
|
||||
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).
|
||||
The sounds are synthesized, not sampled: `synth.dart` renders voices
|
||||
(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
|
||||
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
|
||||
nor remounted. A tab sends the full player to mini; × on the mini closes
|
||||
it (muted, still in the server) and the strip brings it back. Fullscreen
|
||||
nor remounted. A tab sends the full player to mini; × on the mini sits
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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").
|
||||
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`,
|
||||
@@ -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 roles, friends trust model: simplest thing that fits the use.
|
||||
- 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: a Flutter binary cannot run headless, so a dedicated server needs
|
||||
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
|
||||
takes over your old place (same member, ready flag and video).
|
||||
|
||||
There are no roles: anyone can seek, swap, force-ready others and kick
|
||||
anyone except the host.
|
||||
There are no roles: anyone can seek, swap, force-ready others, nudge
|
||||
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
|
||||
|
||||
|
||||
+25
-10
@@ -43,7 +43,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
|
||||
{"t":"ping","c":1767225600000}
|
||||
{"t":"chat","text":"..."}
|
||||
{"t":"kick","target":"<memberId>","reason":""}
|
||||
{"t":"out","target":"<memberId>","out":true}
|
||||
{"t":"nudge","target":"<memberId>"}
|
||||
{"t":"profile","name":"...","avatar":"<base64>"}
|
||||
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
|
||||
```
|
||||
@@ -54,11 +55,11 @@ Field notes:
|
||||
- `hello.hostKey` (optional, omitted when empty): the hosting app
|
||||
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
|
||||
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.
|
||||
- `hello.resume` (optional, omitted when empty): the token from this
|
||||
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
|
||||
there is no `join`/`leave` feed. Otherwise it is ignored and a new member
|
||||
joins.
|
||||
@@ -81,6 +82,19 @@ Field notes:
|
||||
`rejected: avatar too large` (the socket stays open, nothing changes).
|
||||
- `art`: pictures of the current media (see "Media art").
|
||||
- `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.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
||||
@@ -99,12 +113,12 @@ Field notes:
|
||||
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
||||
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
|
||||
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
|
||||
"host"?}],
|
||||
"host"?,"out"?}],
|
||||
"seekBy":"m1","seekSeq":3,"relay":true}
|
||||
{"t":"pong","c":1767225600000,"s":1767225600012}
|
||||
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
|
||||
{"t":"chat","text":"...","from":"<name>"}
|
||||
{"t":"kicked","reason":""}
|
||||
{"t":"nudge","from":"m2","name":"Alice"}
|
||||
{"t":"err","message":""}
|
||||
```
|
||||
|
||||
@@ -130,12 +144,13 @@ Field notes:
|
||||
`report.jf`, omitted when it never sent one. `members[].avatarHash`
|
||||
(omitted when the member has no picture) names the picture's bytes (see
|
||||
"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
|
||||
(the clock `serverTime` uses). With `t3` the local receive time, the offset
|
||||
is `s - (c + t3) / 2`, accurate to half the round trip.
|
||||
- `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`
|
||||
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
|
||||
`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,
|
||||
`cache-control: private, max-age=31536000, immutable` (a hash names fixed
|
||||
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,
|
||||
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
|
||||
as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and
|
||||
`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
|
||||
credential. Bytes count toward `bytesServed`.
|
||||
- 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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -101,7 +101,7 @@ class AppConfig {
|
||||
/// client dialled.
|
||||
final String advertiseHost;
|
||||
/// 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;
|
||||
/// Optional login for relaying Jellyfin media to guests; null = no relay.
|
||||
final JellyfinCredential? jellyfin;
|
||||
|
||||
@@ -50,4 +50,8 @@ abstract class RelayConstants {
|
||||
static const int maxBannerBytes = 768 * 1024;
|
||||
static const int maxNameLength = 32;
|
||||
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 {
|
||||
final String reason;
|
||||
const KickedMessage({this.reason = ''});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'kicked', 'reason': reason};
|
||||
|
||||
factory KickedMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'kicked') throw const ProtocolException('not a kicked message');
|
||||
return KickedMessage(reason: (json['reason'] as String?) ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
class KickRequest {
|
||||
/// Sits [target] (a member id) out, or with [out] false brings oneself
|
||||
/// back. Out: connected, but not in the ready gate and not playing.
|
||||
class OutIntent {
|
||||
final String target;
|
||||
final String reason;
|
||||
const KickRequest({required this.target, this.reason = ''});
|
||||
final bool out;
|
||||
const OutIntent({required this.target, required this.out});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'kick', 'target': target, 'reason': reason};
|
||||
Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
|
||||
|
||||
factory KickRequest.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'kick') throw const ProtocolException('not a kick request');
|
||||
return KickRequest(
|
||||
target: (json['target'] as String?) ?? '',
|
||||
reason: (json['reason'] as String?) ?? '',
|
||||
);
|
||||
factory OutIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'out') throw const ProtocolException('not an out intent');
|
||||
return OutIntent(target: (json['target'] as String?) ?? '', out: (json['out'] as bool?) ?? true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
const NudgeIntent({required this.target});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'nudge', 'target': target};
|
||||
|
||||
factory NudgeIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'nudge') throw const ProtocolException('not a nudge intent');
|
||||
return NudgeIntent(target: (json['target'] 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;
|
||||
/// The hosting app's own member (sent the server's host key).
|
||||
final bool host;
|
||||
/// Sitting out: connected, outside the ready gate, not playing.
|
||||
final bool out;
|
||||
const MemberView({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -546,6 +563,7 @@ class MemberView {
|
||||
required this.stale,
|
||||
this.jf = const {},
|
||||
this.host = false,
|
||||
this.out = false,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -558,6 +576,7 @@ class MemberView {
|
||||
'stale': stale,
|
||||
if (jf.isNotEmpty) 'jf': jf,
|
||||
if (host) 'host': true,
|
||||
if (out) 'out': true,
|
||||
};
|
||||
|
||||
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
||||
@@ -570,6 +589,7 @@ class MemberView {
|
||||
stale: (json['stale'] as bool?) ?? false,
|
||||
jf: jfAccessFromJson(json['jf']) ?? const {},
|
||||
host: json['host'] == true,
|
||||
out: json['out'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ class EngineMember {
|
||||
Map<String, bool> jf = const {};
|
||||
/// The hosting app's own member (set by the server from the host key).
|
||||
bool host = false;
|
||||
/// Sitting out: never in the set, never ready, its buffer ignored.
|
||||
bool out = false;
|
||||
DateTime lastSeen;
|
||||
EngineMember({
|
||||
required this.id,
|
||||
@@ -94,6 +96,7 @@ class EngineMember {
|
||||
stale: stale,
|
||||
jf: jf,
|
||||
host: host,
|
||||
out: out,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,7 +196,8 @@ class SyncEngine {
|
||||
void _seen(EngineMember member) {
|
||||
final wasStale = isStale(member);
|
||||
member.lastSeen = now;
|
||||
if (!member.inSet &&
|
||||
if (!member.out &&
|
||||
!member.inSet &&
|
||||
(member.ready || (wasStale && transport != Transport.playing))) {
|
||||
member.inSet = true;
|
||||
}
|
||||
@@ -301,6 +305,8 @@ class SyncEngine {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
_seen(member);
|
||||
// Sitting out: back first ([applyOut]), then ready.
|
||||
if (member.out) return;
|
||||
if (member.ready == ready && member.inSet) {
|
||||
evaluate();
|
||||
return;
|
||||
@@ -320,12 +326,13 @@ class SyncEngine {
|
||||
final actorName = actor?.name ?? '?';
|
||||
if (target == 'all') {
|
||||
for (final member in members.values) {
|
||||
if (member.out) continue;
|
||||
member.ready = ready;
|
||||
if (ready) member.inSet = true;
|
||||
}
|
||||
} else {
|
||||
final member = byId(target);
|
||||
if (member == null) return;
|
||||
if (member == null || member.out) return;
|
||||
member.ready = ready;
|
||||
if (ready) member.inSet = true;
|
||||
feed.add(FeedMessage(
|
||||
@@ -343,6 +350,28 @@ class SyncEngine {
|
||||
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}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
@@ -379,7 +408,7 @@ class SyncEngine {
|
||||
// (a stale member rejoins the set with its next frame if still ready).
|
||||
final t = now;
|
||||
for (final member in members.values) {
|
||||
member.inSet = !isStale(member, t);
|
||||
member.inSet = !member.out && !isStale(member, t);
|
||||
member.buffered = 0;
|
||||
}
|
||||
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
||||
@@ -393,7 +422,7 @@ class SyncEngine {
|
||||
if (!reportPosition.isFinite) return;
|
||||
_seen(member);
|
||||
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;
|
||||
// First report of the length wins; only for the media the client has open.
|
||||
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', () {
|
||||
test('join while held lands in the set, playback stays held', () {
|
||||
join('a', 'alice');
|
||||
|
||||
@@ -53,8 +53,6 @@ void mountJellyfinRelayRoute(
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.ok:
|
||||
break;
|
||||
case MediaGate.forbidden:
|
||||
return _plain(403);
|
||||
default:
|
||||
return _plain(401);
|
||||
}
|
||||
|
||||
@@ -58,8 +58,6 @@ MediaResolution resolveMediaRequest({
|
||||
return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0);
|
||||
case MediaGate.unauthorized:
|
||||
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:
|
||||
return MediaResolution(
|
||||
status: 503,
|
||||
|
||||
@@ -139,13 +139,6 @@ class ActiveRelay {
|
||||
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) {
|
||||
final old = _channel;
|
||||
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
||||
@@ -290,8 +283,6 @@ void mountRelayRoutes(
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.ok:
|
||||
break;
|
||||
case MediaGate.forbidden:
|
||||
return _plain(403);
|
||||
default:
|
||||
return _plain(401);
|
||||
}
|
||||
|
||||
@@ -66,11 +66,9 @@ class ServerHub {
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
/// still known and not kicked.
|
||||
/// still known.
|
||||
EngineMember? resumable(String token) {
|
||||
if (token.isEmpty) return null;
|
||||
final viewer = session.tokens[token];
|
||||
if (viewer == null || viewer.kicked) return null;
|
||||
if (token.isEmpty || !session.tokens.containsKey(token)) return null;
|
||||
return engine.members[token];
|
||||
}
|
||||
|
||||
@@ -150,24 +148,25 @@ class ServerHub {
|
||||
|
||||
void forgetSocket(String token) {
|
||||
engine.applyLeave(token);
|
||||
final viewer = session.tokens[token];
|
||||
if (viewer != null && !viewer.kicked) session.tokens.remove(token);
|
||||
session.tokens.remove(token);
|
||||
sockets.remove(token);
|
||||
changed();
|
||||
}
|
||||
|
||||
Future<void> kickById(String targetId, String reason) async {
|
||||
final member = engine.byId(targetId);
|
||||
if (member == null) throw const AuthException('unknown kick target');
|
||||
if (session.tokens[member.token]?.isHost ?? false) {
|
||||
throw const AuthException("can't kick the host");
|
||||
}
|
||||
await session.kick(member.token, reason: reason);
|
||||
/// Passes a nudge from [fromToken] to the member [targetId] only;
|
||||
/// over the sender's rate cap, to itself or to nobody it is dropped.
|
||||
void nudge(String fromToken, String targetId) {
|
||||
final from = engine.members[fromToken];
|
||||
final target = engine.byId(targetId);
|
||||
final viewer = session.tokens[fromToken];
|
||||
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
|
||||
/// 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.
|
||||
void mountAvatarRoute(RelicApp app, ServerHub hub) =>
|
||||
_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)) {
|
||||
case MediaGate.unauthorized:
|
||||
return status(401);
|
||||
case MediaGate.forbidden:
|
||||
return status(403);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -415,7 +412,7 @@ void mountSyncRoute(
|
||||
return;
|
||||
}
|
||||
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;
|
||||
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
||||
hub.changed();
|
||||
@@ -441,22 +438,14 @@ void mountSyncRoute(
|
||||
case 'chat':
|
||||
final chat = ChatMessage.fromJson(decoded);
|
||||
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
||||
case 'kick':
|
||||
final kickReq = KickRequest.fromJson(decoded);
|
||||
final member = hub.engine.byId(kickReq.target);
|
||||
if (member == null) {
|
||||
wsErr(ws, 'unknown kick target');
|
||||
return;
|
||||
}
|
||||
if (session.tokens[member.token]?.isHost ?? false) {
|
||||
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));
|
||||
case 'out':
|
||||
if (token == null) return;
|
||||
final intent = OutIntent.fromJson(decoded);
|
||||
hub.engine.applyOut(token, intent.target, intent.out);
|
||||
hub.changed();
|
||||
case 'nudge':
|
||||
if (token == null) return;
|
||||
hub.nudge(token, NudgeIntent.fromJson(decoded).target);
|
||||
}
|
||||
} on ProtocolException catch (e) {
|
||||
wsErr(ws, 'bad message: ${e.message}');
|
||||
@@ -544,13 +533,6 @@ void mountSyncRoute(
|
||||
hub.engine.members[token]?.host = isHost;
|
||||
final activeToken = token;
|
||||
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 relay = hub.relayAvailable;
|
||||
final relayed = hub.relay;
|
||||
|
||||
@@ -3,21 +3,31 @@ import 'dart:math';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
enum MediaGate { ok, gone, unauthorized, forbidden, offline }
|
||||
enum MediaGate { ok, gone, unauthorized, offline }
|
||||
|
||||
class ViewerToken {
|
||||
final String token;
|
||||
/// Cleaned ([cleanMemberName]); follows `profile` renames.
|
||||
String name;
|
||||
bool kicked = false;
|
||||
/// Sent the server's host key in hello: the hosting app's own client.
|
||||
bool isHost = false;
|
||||
int bytesServed = 0;
|
||||
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})
|
||||
: 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]) =>
|
||||
(now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout;
|
||||
|
||||
@@ -66,17 +76,9 @@ class Session {
|
||||
|
||||
MediaGate gate(String? token) {
|
||||
if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized;
|
||||
if (tokens[token]!.kicked) return MediaGate.forbidden;
|
||||
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) {
|
||||
if (token != null) tokens[token]?.bytesServed += n;
|
||||
}
|
||||
|
||||
@@ -123,29 +123,56 @@ void main() {
|
||||
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();
|
||||
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
||||
await server.start();
|
||||
final host =
|
||||
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 guesser =
|
||||
await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope');
|
||||
final third = await _Peer.join('127.0.0.1', server.boundPort, 'third');
|
||||
String idOf(String name) =>
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
|
||||
friend.send(jsonEncode(KickRequest(target: idOf('host')).toJson()));
|
||||
final err = await friend.next('err');
|
||||
expect(err['message'], "can't kick the host");
|
||||
expect(host.closed, isFalse);
|
||||
await expectLater(
|
||||
server.hub.kickById(idOf('host'), 'x'), throwsA(isA<AuthException>()));
|
||||
// Same machine, no key (or a wrong one): an ordinary member.
|
||||
host.send(jsonEncode(KickRequest(target: idOf('guesser')).toJson()));
|
||||
await guesser.next('kicked');
|
||||
await server.hub.kickById(idOf('friend'), 'x');
|
||||
await friend.next('kicked');
|
||||
EngineMember of(String name) =>
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name);
|
||||
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson()));
|
||||
var feed = await friend.next('feed');
|
||||
while (feed['action'] != 'out') {
|
||||
feed = await friend.next('feed');
|
||||
}
|
||||
expect([feed['actor'], feed['target']], ['friend', 'host']);
|
||||
expect(of('host').out, isTrue);
|
||||
expect(of('host').inSet, isFalse);
|
||||
// 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 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 {
|
||||
|
||||
@@ -54,9 +54,6 @@ void main() {
|
||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized)
|
||||
.status,
|
||||
401);
|
||||
expect(
|
||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.forbidden).status,
|
||||
403);
|
||||
final off =
|
||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline);
|
||||
expect(off.status, 503);
|
||||
|
||||
@@ -133,189 +133,6 @@ void main() {
|
||||
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 {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
@@ -557,11 +374,6 @@ void main() {
|
||||
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
|
||||
final err = await waitFor((m) => m['t'] == 'err');
|
||||
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 {
|
||||
await sub.cancel();
|
||||
try {
|
||||
|
||||
@@ -21,22 +21,6 @@ void main() {
|
||||
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', () {
|
||||
final s = Session(id: 'default', secret: 'shh');
|
||||
final big = 'x' * (RelayConstants.maxAvatarBytes + 1);
|
||||
@@ -122,18 +106,6 @@ void main() {
|
||||
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', () {
|
||||
final s = Session(id: 'default', secret: 'shh');
|
||||
final t = s.issueToken('alice', '');
|
||||
|
||||
@@ -127,17 +127,10 @@ void main() {
|
||||
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');
|
||||
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 b.ws.close();
|
||||
await c.ws.close();
|
||||
});
|
||||
|
||||
test('ping gets a pong with the client stamp and the server clock', () async {
|
||||
|
||||
Reference in new issue
Block a user