mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(orbit): per-guest suggestion mute + global pending-cap setting
Two anti-spam knobs the host can dial during a live session: 1. Per-guest suggestion mute — Mic / MicOff toggle next to the kick/ban buttons in the participants popover. Symmetric (re-enable later). State lives in OrbitState.suggestionBlocked: string[]; the guest reads it and disables its own Suggest controls so the user sees a clear "muted" state instead of silent failures. Host-side sweep also drops their outbox entries as a safety net. 2. Max pending approvals cap — number input in the session-settings popover, default 0 (= unlimited so existing sessions are unaffected). When set, the host sweep stops folding new outbox entries into the approval list once the cap is reached. The OrbitQueueHead surfaces "X / Y pending" so guests can see when they're getting close. State changes are additive on the wire — both fields are optional, with parseOrbitState defaulting them, so older clients keep working. evaluateOrbitSuggestGate() centralises the guest-side allow/block check shared between useOrbitSongRowBehavior and the ContextMenu Add-to-Session items, plus suggestOrbitTrack as a defensive last line. i18n: en + de + fr + nl + zh + nb + ru + es. Also fixes an earlier dedupe-key collision: the (user, trackId) cache keys were missing their separator (NULL byte slipped in during the previous patch), so two tracks could share a key and one of them silently overwrite the other. Restored the space separator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+103
-3
@@ -456,7 +456,49 @@ export async function leaveOrbitSession(): Promise<void> {
|
||||
* consume it and publish the authoritative queue update in the state blob.
|
||||
* No state mutation here — the guest never touches canonical state.
|
||||
*/
|
||||
/** Why a guest's suggestion would be blocked, in priority order. `null` means
|
||||
* the suggestion can proceed. */
|
||||
export type OrbitSuggestGateReason = 'not-guest' | 'muted' | 'cap-reached' | null;
|
||||
|
||||
/**
|
||||
* Evaluate whether the local guest is allowed to send a new suggestion right
|
||||
* now — used by both the UI (to disable buttons / show toasts) and
|
||||
* {@link suggestOrbitTrack} as a defensive check.
|
||||
*
|
||||
* Mute check uses the host-pushed `state.suggestionBlocked` list. Cap check
|
||||
* is approximate from the guest's POV: it counts every non-host entry in
|
||||
* `state.queue` plus the local `pendingSuggestions` list that hasn't been
|
||||
* reconciled yet. We can't subtract host-side merged/declined sets — the
|
||||
* guest never sees them — so the count is conservative (over-, not under-).
|
||||
*/
|
||||
export function evaluateOrbitSuggestGate(): { allowed: boolean; reason: OrbitSuggestGateReason } {
|
||||
const { role, state, pendingSuggestions } = useOrbitStore.getState();
|
||||
if (role !== 'guest' || !state) return { allowed: false, reason: 'not-guest' };
|
||||
const username = useAuthStore.getState().getActiveServer()?.username ?? '';
|
||||
if (state.suggestionBlocked?.includes(username)) {
|
||||
return { allowed: false, reason: 'muted' };
|
||||
}
|
||||
const cap = state.settings?.maxPending ?? 0;
|
||||
if (cap > 0) {
|
||||
const inState = state.queue.filter(q => q.addedBy !== state.host).length;
|
||||
const total = inState + pendingSuggestions.length;
|
||||
if (total >= cap) return { allowed: false, reason: 'cap-reached' };
|
||||
}
|
||||
return { allowed: true, reason: null };
|
||||
}
|
||||
|
||||
export class OrbitSuggestBlockedError extends Error {
|
||||
constructor(public readonly reason: Exclude<OrbitSuggestGateReason, null>) {
|
||||
super(`Suggestion blocked: ${reason}`);
|
||||
this.name = 'OrbitSuggestBlockedError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggestOrbitTrack(trackId: string): Promise<void> {
|
||||
const gate = evaluateOrbitSuggestGate();
|
||||
if (!gate.allowed && gate.reason && gate.reason !== 'not-guest') {
|
||||
throw new OrbitSuggestBlockedError(gate.reason);
|
||||
}
|
||||
const { role, outboxPlaylistId, sessionId } = useOrbitStore.getState();
|
||||
if (role !== 'guest') throw new Error('Not joined to a session as a guest');
|
||||
if (!outboxPlaylistId || !sessionId) throw new Error('No outbox bound');
|
||||
@@ -853,6 +895,40 @@ export async function removeOrbitParticipant(username: string): Promise<void> {
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Host: mute/unmute a participant's track suggestions.
|
||||
*
|
||||
* Symmetric — pass `blocked: true` to add the username to
|
||||
* `state.suggestionBlocked`, `false` to remove it. The participant remains
|
||||
* in the session and continues to appear in the participants list; only new
|
||||
* outbox entries are silently dropped during the host's sweep. The guest UI
|
||||
* reads the same flag and disables its own Suggest controls so the user
|
||||
* sees a clear "muted" state instead of silent failures.
|
||||
*
|
||||
* No-op outside host role, when the session isn't active, when the target
|
||||
* is the host themselves, or when the toggle wouldn't change anything.
|
||||
*/
|
||||
export async function setOrbitSuggestionBlocked(username: string, blocked: boolean): Promise<void> {
|
||||
const store = useOrbitStore.getState();
|
||||
if (store.role !== 'host') return;
|
||||
const state = store.state;
|
||||
const sessionPlaylistId = store.sessionPlaylistId;
|
||||
if (!state || !sessionPlaylistId) return;
|
||||
if (username === state.host) return;
|
||||
|
||||
const current = state.suggestionBlocked ?? [];
|
||||
const isBlocked = current.includes(username);
|
||||
if (blocked === isBlocked) return;
|
||||
|
||||
const nextList = blocked
|
||||
? [...current, username]
|
||||
: current.filter(u => u !== username);
|
||||
const nextState: OrbitState = { ...state, suggestionBlocked: nextList };
|
||||
useOrbitStore.getState().setState(nextState);
|
||||
try { await writeOrbitState(sessionPlaylistId, nextState); }
|
||||
catch { /* best-effort; next host tick will re-push state */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold sweep results into an updated `OrbitState`.
|
||||
*
|
||||
@@ -868,6 +944,7 @@ export function applyOutboxSnapshotsToState(
|
||||
state: OrbitState,
|
||||
snapshots: OutboxSnapshot[],
|
||||
nowMs: number = Date.now(),
|
||||
opts?: { mergedKeys?: ReadonlySet<string>; declinedKeys?: ReadonlySet<string> },
|
||||
): OrbitState {
|
||||
// ── Queue additions ──
|
||||
// Guest outboxes are append-only from the host's POV — the host reads the
|
||||
@@ -877,18 +954,41 @@ export function applyOutboxSnapshotsToState(
|
||||
// balloons indefinitely. A user re-suggesting the same track after it
|
||||
// lands/plays is a rare enough case to live with for now.
|
||||
const existingKeys = new Set<string>(
|
||||
state.queue.map(q => `${q.addedBy} | ||||