Protocol 11: sit out replaces kicking; nudge
Anyone can sit anyone out (host included); only you bring yourself back. Out members stay in chat and the party, never block or count, and load no media; a card with the live position and Join back (Space) replaces the video. The mini player's x sits you out; optionally idle does too. Nudge: the server forwards it to the target only (3/s, 20/min per sender); the target hears the nudge sound, gets a toast and a taskbar attention request. Kick, its messages, errors and dialog are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbfd351ee4
commit
8b81da4069
46 files changed
+855
-599
No files matched your search
@@ -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');
|
||||
}
|
||||
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),
|
||||
);
|
||||
_checkSeekLanded(r);
|
||||
unawaited(_tickJellyfin(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;
|
||||
sync.sendReady(false, source: 'auto');
|
||||
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;
|
||||
final out = coord.selfOut.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
});
|
||||
}
|
||||
untracked(() {
|
||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
||||
});
|
||||
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();
|
||||
}
|
||||
_show(PlayerMode.none);
|
||||
}
|
||||
|
||||
void _unmute() {
|
||||
final v = _mutedVolume;
|
||||
if (v == null) return;
|
||||
_mutedVolume = null;
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (!playback.alive.peek()) return;
|
||||
try {
|
||||
playback.player.setVolume(v);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _setWindowFullscreen(bool on) async {
|
||||
@@ -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') {
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
} else if (v == 'kick') {
|
||||
final ok = await showKickConfirmDialog(context, m.name);
|
||||
if (ok == true) coord.kick(m.id);
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'force':
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
case 'nudge':
|
||||
coord.nudgeMember(m.id);
|
||||
case 'out':
|
||||
coord.sitOut(m.id);
|
||||
case 'back':
|
||||
coord.joinBack();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self && !info.host)
|
||||
if (!m.out)
|
||||
PopupMenuItem(
|
||||
value: 'kick',
|
||||
child: Text(
|
||||
'Kick',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
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(
|
||||
key: ValueKey('sound-on-${kind.name}'),
|
||||
value: !off.contains(kind.name),
|
||||
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
|
||||
// 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 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../playback/media_marks.dart' show formatClock;
|
||||
import '../state/session_state.dart';
|
||||
import '../sync/state_diff.dart' show serverPositionAt;
|
||||
import 'now_playing_title.dart';
|
||||
import 'start_banner.dart';
|
||||
|
||||
/// In place of the video while self sits out: the media's start card
|
||||
/// (cover, title, episode), where the others are ("12:34 / 24:00",
|
||||
/// playing or paused) and "Join back" (or Space). [compact]: the mini
|
||||
/// player.
|
||||
class SitOutCard extends StatefulWidget {
|
||||
final bool compact;
|
||||
const SitOutCard({super.key, this.compact = false});
|
||||
|
||||
@override
|
||||
State<SitOutCard> createState() => _SitOutCardState();
|
||||
}
|
||||
|
||||
class _SitOutCardState extends State<SitOutCard> {
|
||||
// The position moves on while playing: redraw each second.
|
||||
late final Timer _tick;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tick.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final small = widget.compact;
|
||||
return ListenableBuilder(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||
final r = coord.serverState.value;
|
||||
final now = coord.nowPlaying.value;
|
||||
final playing = r?.transport == 'playing';
|
||||
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow());
|
||||
Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
|
||||
final where = r?.media == null
|
||||
? 'Nothing is up'
|
||||
: duration > 0
|
||||
? '${formatClock(secs(at.clamp(0, duration)))} / ${formatClock(secs(duration))}'
|
||||
: formatClock(secs(at));
|
||||
final text = TextStyle(color: Colors.white, fontSize: small ? 12 : 15);
|
||||
final panel = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("You're sitting out", style: text.copyWith(fontWeight: FontWeight.w700, fontSize: small ? 13 : 20)),
|
||||
SizedBox(height: small ? 4 : 8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (r?.media != null) ...[
|
||||
Icon(playing ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||
color: Colors.white70, size: small ? 14 : 18),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(where, key: const ValueKey('sit-out-position'), style: text.copyWith(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: small ? 8 : 16),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('join-back'),
|
||||
onPressed: coord.joinBack,
|
||||
icon: Icon(Icons.login_rounded, size: small ? 16 : 20),
|
||||
label: Text(small ? 'Join back' : 'Join back (Space)'),
|
||||
style: small
|
||||
? FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (now != null) StartCard(now: now) else const ColoredBox(color: Color(0xFF0C0D11)),
|
||||
// Small: the card's title sits at the bottom, so the panel goes up top.
|
||||
Align(
|
||||
alignment: small ? const Alignment(0, -0.35) : Alignment.center,
|
||||
child: panel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
? Icons.login
|
||||
: rest.startsWith('left')
|
||||
? Icons.logout
|
||||
: rest.startsWith('kicked')
|
||||
? Icons.gavel
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
: rest.startsWith('sat you out')
|
||||
? Icons.bedtime
|
||||
: rest.startsWith('nudged')
|
||||
? Icons.notifications_active_outlined
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ class ServerCrashed implements Exception {
|
||||
}
|
||||
|
||||
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
||||
/// 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user