diff --git a/src/api/orbit.ts b/src/api/orbit.ts index 1031c6c2..55732fb7 100644 --- a/src/api/orbit.ts +++ b/src/api/orbit.ts @@ -100,6 +100,12 @@ export interface OrbitState { ended?: boolean; /** Host-settable session rules; absent on older clients — treat missing as all-defaults. */ settings?: OrbitSettings; + /** + * Usernames muted by the host: their outbox is still polled (so heartbeats + * keep them visible as participants) but new track suggestions are dropped + * before they reach the approval list. Symmetric — host can re-enable. + */ + suggestionBlocked?: string[]; } /** @@ -121,6 +127,13 @@ export interface OrbitSettings { * field fall back to 15 via `effectiveShuffleIntervalMs`. */ shuffleIntervalMin?: OrbitShuffleIntervalMin; + /** + * Cap on simultaneously-pending guest suggestions. 0 = unlimited. + * When the cap is reached, the host's outbox sweep drops new suggestions + * until existing ones are approved or declined; the guest UI surfaces + * the count so users know to wait. + */ + maxPending?: number; } export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = { @@ -128,6 +141,8 @@ export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = { autoApprove: false, autoShuffle: true, shuffleIntervalMin: 15, + // 0 = unlimited; host opts in via the session-settings popover. + maxPending: 0, }; /** What the guest's outbox-playlist comment holds (heartbeat only, for now). */ @@ -176,6 +191,7 @@ export function makeInitialOrbitState(args: { participants: [], kicked: [], removed: [], + suggestionBlocked: [], playQueue: [], playQueueTotal: 0, settings: { ...ORBIT_DEFAULT_SETTINGS }, @@ -201,6 +217,8 @@ export function parseOrbitState(raw: unknown): OrbitState | null { // the attribution UI, not correctness. // `removed` is optional (older hosts won't write it); coerce to [] if absent or malformed. if (!Array.isArray(s.removed)) s.removed = []; + // `suggestionBlocked` is optional too — older hosts predate the mute feature. + if (!Array.isArray(s.suggestionBlocked)) s.suggestionBlocked = []; // `playQueue` / `playQueueTotal` are optional (older hosts won't write them). if (!Array.isArray(s.playQueue)) s.playQueue = []; if (typeof s.playQueueTotal !== 'number') s.playQueueTotal = (s.playQueue?.length ?? 0); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 63ebe189..a8aae593 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,7 +1,12 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; import { useOrbitStore } from '../store/orbitStore'; -import { suggestOrbitTrack, hostEnqueueToOrbit } from '../utils/orbit'; +import { + suggestOrbitTrack, + hostEnqueueToOrbit, + evaluateOrbitSuggestGate, + OrbitSuggestBlockedError, +} from '../utils/orbit'; import LastfmIcon from './LastfmIcon'; import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; @@ -1446,15 +1451,38 @@ export default function ContextMenu() {
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
- {orbitRole === 'guest' && ( -
handleAction(() => { - suggestOrbitTrack(song.id) - .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) - .catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error')); - })}> - {t('orbit.ctxAddToSession')} -
- )} + {orbitRole === 'guest' && (() => { + const gate = evaluateOrbitSuggestGate(); + const muted = gate.reason === 'muted'; + const capReached = gate.reason === 'cap-reached'; + const disabled = muted || capReached; + const tooltip = muted + ? t('orbit.suggestBlockedMuted') + : capReached ? t('orbit.suggestBlockedCap') : ''; + return ( +
handleAction(() => { + if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; } + if (capReached) { showToast(t('orbit.suggestBlockedCap'), 3500, 'info'); return; } + suggestOrbitTrack(song.id) + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(err => { + if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + } else if (err instanceof OrbitSuggestBlockedError && err.reason === 'cap-reached') { + showToast(t('orbit.suggestBlockedCap'), 3500, 'info'); + } else { + showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); + } + }); + })} + > + {t('orbit.ctxAddToSession')} +
+ ); + })()} {orbitRole === 'host' && (
handleAction(() => { hostEnqueueToOrbit(song.id) @@ -1596,15 +1624,38 @@ export default function ContextMenu() {
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
- {orbitRole === 'guest' && ( -
handleAction(() => { - suggestOrbitTrack(song.id) - .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) - .catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error')); - })}> - {t('orbit.ctxAddToSession')} -
- )} + {orbitRole === 'guest' && (() => { + const gate = evaluateOrbitSuggestGate(); + const muted = gate.reason === 'muted'; + const capReached = gate.reason === 'cap-reached'; + const disabled = muted || capReached; + const tooltip = muted + ? t('orbit.suggestBlockedMuted') + : capReached ? t('orbit.suggestBlockedCap') : ''; + return ( +
handleAction(() => { + if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; } + if (capReached) { showToast(t('orbit.suggestBlockedCap'), 3500, 'info'); return; } + suggestOrbitTrack(song.id) + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(err => { + if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + } else if (err instanceof OrbitSuggestBlockedError && err.reason === 'cap-reached') { + showToast(t('orbit.suggestBlockedCap'), 3500, 'info'); + } else { + showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); + } + }); + })} + > + {t('orbit.ctxAddToSession')} +
+ ); + })()} {orbitRole === 'host' && (
handleAction(() => { hostEnqueueToOrbit(song.id) diff --git a/src/components/OrbitParticipantsPopover.tsx b/src/components/OrbitParticipantsPopover.tsx index b7d4457b..db8404a3 100644 --- a/src/components/OrbitParticipantsPopover.tsx +++ b/src/components/OrbitParticipantsPopover.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Crown, User, UserMinus, ShieldOff } from 'lucide-react'; +import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; -import { kickOrbitParticipant, removeOrbitParticipant } from '../utils/orbit'; +import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '../utils/orbit'; import ConfirmModal from './ConfirmModal'; interface Props { @@ -90,35 +90,50 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
{t('orbit.participantsEmpty')}
)} - {state.participants.map(p => ( -
- - {p.user} - {joinedFor(p.joinedAt, nowMs)} - {role === 'host' && ( -
- - -
- )} -
- ))} + {state.participants.map(p => { + const isMuted = state.suggestionBlocked?.includes(p.user) ?? false; + return ( +
+ + {p.user} + {joinedFor(p.joinedAt, nowMs)} + {role === 'host' && ( +
+ + + +
+ )} +
+ ); + })}
p.user)]; const showPresence = role === 'guest' && state.positionAt > 0; const hostAway = showPresence && (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS; + const cap = state.settings?.maxPending ?? 0; + // Approximate visible-pending count — same heuristic as evaluateOrbitSuggestGate. + // Conservative for guests (over-counts declined entries) but the host's + // own queue view sees the exact number because it has the merged/declined + // sets locally; we don't bother surfacing the exact count here either way + // since guests just need to know if they're near the cap. + const pendingCount = cap > 0 + ? state.queue.filter(q => q.addedBy !== state.host).length + : 0; return (
@@ -54,6 +63,15 @@ export default function OrbitQueueHead({ state }: Props) {
{names.join(', ')} + {cap > 0 && ( + = cap ? ' is-full' : ''}`} + data-tooltip={t('orbit.pendingCounterTooltip')} + > + + {t('orbit.pendingCounter', { count: pendingCount, max: cap })} + + )}
); diff --git a/src/components/OrbitSettingsPopover.tsx b/src/components/OrbitSettingsPopover.tsx index 2070052f..9f00f321 100644 --- a/src/components/OrbitSettingsPopover.tsx +++ b/src/components/OrbitSettingsPopover.tsx @@ -82,6 +82,27 @@ export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) { + +
{t('orbit.settingShuffleInterval')}
diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index 4a2c0daa..49978a6b 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -87,7 +87,12 @@ export function useOrbitHost(): void { let afterSweep = base; try { const snaps = await sweepGuestOutboxes(base.sid, base.host); - afterSweep = applyOutboxSnapshotsToState(base, snaps); + // Hand the cap-check the host's local merged/declined sets so it can + // tell which `state.queue` items are still actually awaiting approval. + afterSweep = applyOutboxSnapshotsToState(base, snaps, Date.now(), { + mergedKeys: new Set(store.mergedSuggestionKeys), + declinedKeys: new Set(store.declinedSuggestionKeys), + }); } catch { /* best-effort; keep old participants and queue */ } // 2) Merge newly-suggested items into the host's local play queue so diff --git a/src/hooks/useOrbitSongRowBehavior.ts b/src/hooks/useOrbitSongRowBehavior.ts index 20d38785..df6cb26a 100644 --- a/src/hooks/useOrbitSongRowBehavior.ts +++ b/src/hooks/useOrbitSongRowBehavior.ts @@ -1,7 +1,12 @@ import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; -import { suggestOrbitTrack, hostEnqueueToOrbit } from '../utils/orbit'; +import { + suggestOrbitTrack, + hostEnqueueToOrbit, + evaluateOrbitSuggestGate, + OrbitSuggestBlockedError, +} from '../utils/orbit'; import { showToast } from '../utils/toast'; /** @@ -43,9 +48,26 @@ export function useOrbitSongRowBehavior() { clickTimerRef.current = null; } if (orbitRole === 'guest') { + const gate = evaluateOrbitSuggestGate(); + if (!gate.allowed && gate.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + return; + } + if (!gate.allowed && gate.reason === 'cap-reached') { + showToast(t('orbit.suggestBlockedCap'), 3500, 'info'); + return; + } suggestOrbitTrack(songId) .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) - .catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error')); + .catch(err => { + if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + } else if (err instanceof OrbitSuggestBlockedError && err.reason === 'cap-reached') { + showToast(t('orbit.suggestBlockedCap'), 3500, 'info'); + } else { + showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); + } + }); } else if (orbitRole === 'host') { hostEnqueueToOrbit(songId) .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) diff --git a/src/locales/de.ts b/src/locales/de.ts index ff65ed11..368c2e4b 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1580,6 +1580,10 @@ export const deTranslation = { participantsRemoveAria: '{{user}} aus dieser Session entfernen', participantsBanTooltip: 'Permanent bannen', participantsBanAria: '{{user}} permanent bannen', + participantsMuteTooltip: 'Vorschläge sperren', + participantsUnmuteTooltip: 'Vorschläge erlauben', + participantsMuteAria: 'Vorschläge von {{user}} sperren', + participantsUnmuteAria: 'Vorschläge von {{user}} wieder erlauben', confirmRemoveTitle: 'Aus Session entfernen?', confirmRemoveBody: '{{user}} wird aus der Session geworfen, kann aber jederzeit über deinen Einladungslink wieder beitreten.', confirmRemoveConfirm: 'Entfernen', @@ -1605,6 +1609,12 @@ export const deTranslation = { settingAutoShuffleHint: 'Periodischer Fisher-Yates-Shuffle der kommenden Warteschlange. Aus: Reihenfolge wird nur verändert, wenn du selbst sortierst.', settingShuffleInterval: 'Alle', settingShuffleIntervalHint: 'Wie oft die kommende Warteschlange während der Session neu gemischt wird.', + settingMaxPending: 'Max offene Vorschläge', + settingMaxPendingHint: 'Begrenzt, wie viele Gast-Vorschläge gleichzeitig auf Bestätigung warten dürfen. 0 = unbegrenzt.', + pendingCounter: '{{count}} / {{max}} offen', + pendingCounterTooltip: 'Wartende Gast-Vorschläge / Limit', + suggestBlockedMuted: 'Der Host hat dich für diese Session gesperrt — keine Vorschläge möglich.', + suggestBlockedCap: 'Approval-Liste ist voll — warte bis der Host welche durchwinkt.', settingShuffleIntervalValue_one: '{{count}} Min.', settingShuffleIntervalValue_other: '{{count}} Min.', settingShuffleNow: 'Jetzt mischen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 4413a6ff..3fb4a6f5 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1584,6 +1584,10 @@ export const enTranslation = { participantsRemoveAria: 'Remove {{user}} from this session', participantsBanTooltip: 'Ban permanently', participantsBanAria: 'Permanently ban {{user}}', + participantsMuteTooltip: 'Mute suggestions', + participantsUnmuteTooltip: 'Allow suggestions', + participantsMuteAria: 'Mute suggestions from {{user}}', + participantsUnmuteAria: 'Allow suggestions from {{user}}', confirmRemoveTitle: 'Remove from session?', confirmRemoveBody: '{{user}} will be dropped from the session, but can re-join via your invite link.', confirmRemoveConfirm: 'Remove', @@ -1609,6 +1613,12 @@ export const enTranslation = { settingAutoShuffleHint: "Periodic Fisher–Yates shuffle of the upcoming queue. Off: order only changes when you rearrange it.", settingShuffleInterval: 'Reshuffle every', settingShuffleIntervalHint: 'How often the upcoming queue gets reshuffled while the session runs.', + settingMaxPending: 'Max pending suggestions', + settingMaxPendingHint: 'Cap on how many guest suggestions can wait for approval. 0 = unlimited.', + pendingCounter: '{{count}} / {{max}} pending', + pendingCounterTooltip: 'Pending guest suggestions / cap', + suggestBlockedMuted: 'The host muted you for this session — suggestions are off.', + suggestBlockedCap: 'Approval queue is full — wait for the host to clear some.', settingShuffleIntervalValue_one: '{{count}} min', settingShuffleIntervalValue_other: '{{count}} min', settingShuffleNow: 'Shuffle now', diff --git a/src/locales/es.ts b/src/locales/es.ts index d35494b3..02a0dda9 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1567,6 +1567,10 @@ export const esTranslation = { participantsRemoveAria: 'Quitar a {{user}} de esta sesión', participantsBanTooltip: 'Banear permanentemente', participantsBanAria: 'Banear permanentemente a {{user}}', + participantsMuteTooltip: 'Silenciar sugerencias', + participantsUnmuteTooltip: 'Permitir sugerencias', + participantsMuteAria: 'Silenciar las sugerencias de {{user}}', + participantsUnmuteAria: 'Volver a permitir las sugerencias de {{user}}', confirmRemoveTitle: '¿Quitar de la sesión?', confirmRemoveBody: '{{user}} será expulsado de la sesión, pero puede volver a unirse por tu enlace de invitación.', confirmRemoveConfirm: 'Quitar', @@ -1592,6 +1596,12 @@ export const esTranslation = { settingAutoShuffleHint: 'Mezclado Fisher-Yates periódico de la cola próxima. Desactivado: el orden solo cambia cuando lo reorganizas.', settingShuffleInterval: 'Remezclar cada', settingShuffleIntervalHint: 'Con qué frecuencia se remezcla la cola próxima durante la sesión.', + settingMaxPending: 'Máx. sugerencias pendientes', + settingMaxPendingHint: 'Límite de sugerencias de invitados que pueden esperar aprobación a la vez. 0 = ilimitado.', + pendingCounter: '{{count}} / {{max}} pendientes', + pendingCounterTooltip: 'Sugerencias de invitados pendientes / límite', + suggestBlockedMuted: 'El anfitrión te silenció en esta sesión — sugerencias desactivadas.', + suggestBlockedCap: 'La cola de aprobación está llena — espera a que el anfitrión despeje algunas.', settingShuffleIntervalValue_one: '{{count}} min', settingShuffleIntervalValue_other: '{{count}} min', settingShuffleNow: 'Mezclar ahora', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 5dc79899..931bbd0d 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1562,6 +1562,10 @@ export const frTranslation = { participantsRemoveAria: 'Retirer {{user}} de cette session', participantsBanTooltip: 'Bannir définitivement', participantsBanAria: 'Bannir définitivement {{user}}', + participantsMuteTooltip: 'Bloquer les suggestions', + participantsUnmuteTooltip: 'Autoriser les suggestions', + participantsMuteAria: 'Bloquer les suggestions de {{user}}', + participantsUnmuteAria: 'Autoriser à nouveau les suggestions de {{user}}', confirmRemoveTitle: 'Retirer de la session ?', confirmRemoveBody: '{{user}} sera retiré de la session mais peut rejoindre via ton lien d\'invitation.', confirmRemoveConfirm: 'Retirer', @@ -1587,6 +1591,12 @@ export const frTranslation = { settingAutoShuffleHint: 'Mélange Fisher-Yates périodique de la file à venir. Désactivé : l\'ordre ne change que lorsque tu le réorganises.', settingShuffleInterval: 'Remélanger toutes les', settingShuffleIntervalHint: 'Fréquence de remélange de la file pendant la session.', + settingMaxPending: 'Max suggestions en attente', + settingMaxPendingHint: 'Limite le nombre de suggestions invitées qui attendent ton approbation. 0 = illimité.', + pendingCounter: '{{count}} / {{max}} en attente', + pendingCounterTooltip: 'Suggestions invitées en attente / limite', + suggestBlockedMuted: 'L\'hôte t\'a coupé le micro pour cette session — suggestions désactivées.', + suggestBlockedCap: 'La file d\'approbation est pleine — attends que l\'hôte en traite.', settingShuffleIntervalValue_one: '{{count}} min', settingShuffleIntervalValue_other: '{{count}} min', settingShuffleNow: 'Mélanger maintenant', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index a64f9e31..47911b5a 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -1561,6 +1561,10 @@ export const nbTranslation = { participantsRemoveAria: 'Fjern {{user}} fra denne økten', participantsBanTooltip: 'Uteste permanent', participantsBanAria: 'Uteste {{user}} permanent', + participantsMuteTooltip: 'Blokker forslag', + participantsUnmuteTooltip: 'Tillat forslag', + participantsMuteAria: 'Blokker forslag fra {{user}}', + participantsUnmuteAria: 'Tillat forslag fra {{user}} igjen', confirmRemoveTitle: 'Fjerne fra økten?', confirmRemoveBody: '{{user}} fjernes fra økten, men kan bli med igjen via invitasjonslenken din.', confirmRemoveConfirm: 'Fjern', @@ -1586,6 +1590,12 @@ export const nbTranslation = { settingAutoShuffleHint: 'Periodisk Fisher-Yates-stokking av kommende kø. Av: rekkefølgen endres bare når du omorganiserer den.', settingShuffleInterval: 'Stokk på nytt hver', settingShuffleIntervalHint: 'Hvor ofte kommende kø stokkes på nytt mens økten kjører.', + settingMaxPending: 'Maks ventende forslag', + settingMaxPendingHint: 'Hvor mange gjesteforslag som samtidig kan vente på godkjenning. 0 = ubegrenset.', + pendingCounter: '{{count}} / {{max}} venter', + pendingCounterTooltip: 'Ventende gjesteforslag / grense', + suggestBlockedMuted: 'Verten har blokkert deg for denne økten — forslag er av.', + suggestBlockedCap: 'Godkjenningskøen er full — vent til verten tar unna noen.', settingShuffleIntervalValue_one: '{{count}} min', settingShuffleIntervalValue_other: '{{count}} min', settingShuffleNow: 'Stokk nå', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 4dc3fffd..76686010 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1561,6 +1561,10 @@ export const nlTranslation = { participantsRemoveAria: '{{user}} uit deze sessie verwijderen', participantsBanTooltip: 'Permanent bannen', participantsBanAria: '{{user}} permanent bannen', + participantsMuteTooltip: 'Voorstellen blokkeren', + participantsUnmuteTooltip: 'Voorstellen toestaan', + participantsMuteAria: 'Voorstellen van {{user}} blokkeren', + participantsUnmuteAria: 'Voorstellen van {{user}} weer toestaan', confirmRemoveTitle: 'Uit sessie verwijderen?', confirmRemoveBody: '{{user}} wordt uit de sessie verwijderd, maar kan opnieuw deelnemen via je uitnodigingslink.', confirmRemoveConfirm: 'Verwijderen', @@ -1586,6 +1590,12 @@ export const nlTranslation = { settingAutoShuffleHint: 'Periodieke Fisher-Yates-shuffle van de komende wachtrij. Uit: de volgorde verandert alleen wanneer je zelf herschikt.', settingShuffleInterval: 'Opnieuw shufflen elke', settingShuffleIntervalHint: 'Hoe vaak de komende wachtrij tijdens de sessie opnieuw wordt geshuffled.', + settingMaxPending: 'Max openstaande voorstellen', + settingMaxPendingHint: 'Hoeveel gastvoorstellen tegelijk op goedkeuring mogen wachten. 0 = onbeperkt.', + pendingCounter: '{{count}} / {{max}} open', + pendingCounterTooltip: 'Wachtende gastvoorstellen / limiet', + suggestBlockedMuted: 'De host heeft je voor deze sessie geblokkeerd — voorstellen uitgeschakeld.', + suggestBlockedCap: 'De goedkeuringslijst is vol — wacht tot de host er een paar afhandelt.', settingShuffleIntervalValue_one: '{{count}} min', settingShuffleIntervalValue_other: '{{count}} min', settingShuffleNow: 'Nu shufflen', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index a2a65043..e0ec42d7 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1641,6 +1641,10 @@ export const ruTranslation = { participantsRemoveAria: 'Удалить {{user}} из этой сессии', participantsBanTooltip: 'Забанить навсегда', participantsBanAria: 'Забанить {{user}} навсегда', + participantsMuteTooltip: 'Запретить предлагать треки', + participantsUnmuteTooltip: 'Разрешить предлагать треки', + participantsMuteAria: 'Запретить {{user}} предлагать треки', + participantsUnmuteAria: 'Снова разрешить {{user}} предлагать треки', confirmRemoveTitle: 'Удалить из сессии?', confirmRemoveBody: '{{user}} будет удалён из сессии, но может присоединиться снова по твоей ссылке-приглашению.', confirmRemoveConfirm: 'Удалить', @@ -1666,6 +1670,12 @@ export const ruTranslation = { settingAutoShuffleHint: 'Периодическое Fisher-Yates перемешивание предстоящей очереди. Выкл.: порядок меняется только при ручной перестановке.', settingShuffleInterval: 'Перемешивать каждые', settingShuffleIntervalHint: 'Как часто предстоящая очередь перемешивается во время сессии.', + settingMaxPending: 'Макс. ожидающих заявок', + settingMaxPendingHint: 'Сколько гостевых предложений могут одновременно ждать одобрения. 0 = без лимита.', + pendingCounter: '{{count}} / {{max}} в ожидании', + pendingCounterTooltip: 'Ожидающие гостевые заявки / лимит', + suggestBlockedMuted: 'Хост отключил тебе предложение треков на этой сессии.', + suggestBlockedCap: 'Очередь одобрений переполнена — подожди, пока хост её разгребёт.', settingShuffleIntervalValue_one: '{{count}} мин', settingShuffleIntervalValue_few: '{{count}} мин', settingShuffleIntervalValue_many: '{{count}} мин', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 3aa516cc..94a49e35 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1554,6 +1554,10 @@ export const zhTranslation = { participantsRemoveAria: '从此会话中移除 {{user}}', participantsBanTooltip: '永久封禁', participantsBanAria: '永久封禁 {{user}}', + participantsMuteTooltip: '禁止投歌', + participantsUnmuteTooltip: '允许投歌', + participantsMuteAria: '禁止 {{user}} 投歌', + participantsUnmuteAria: '允许 {{user}} 重新投歌', confirmRemoveTitle: '从会话中移除?', confirmRemoveBody: '{{user}} 将从会话中移除,但可以通过你的邀请链接重新加入。', confirmRemoveConfirm: '移除', @@ -1579,6 +1583,12 @@ export const zhTranslation = { settingAutoShuffleHint: '即将播放队列的定期 Fisher-Yates 洗牌。关闭:顺序仅在你手动重新排列时改变。', settingShuffleInterval: '洗牌间隔', settingShuffleIntervalHint: '会话运行时即将播放队列的重新洗牌频率。', + settingMaxPending: '最大待审投歌数', + settingMaxPendingHint: '同时等待审核的访客投歌上限。0 = 不限制。', + pendingCounter: '{{count}} / {{max}} 待审', + pendingCounterTooltip: '等待中的访客投歌 / 上限', + suggestBlockedMuted: '主持人已禁止你在本场会话投歌。', + suggestBlockedCap: '审核队列已满,等主持人处理一些再继续。', settingShuffleIntervalValue_one: '{{count}} 分钟', settingShuffleIntervalValue_other: '{{count}} 分钟', settingShuffleNow: '立即洗牌', diff --git a/src/styles/components.css b/src/styles/components.css index 81e3cd37..3c59482f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11847,6 +11847,11 @@ html[data-psy-native-hidden="true"] *::after { background: color-mix(in srgb, var(--ctp-red, #f38ba8) 12%, transparent); border-color: color-mix(in srgb, var(--ctp-red, #f38ba8) 35%, transparent); } +.orbit-participants-pop__kick.is-active { + color: var(--ctp-peach, #fab387); + background: color-mix(in srgb, var(--ctp-peach, #fab387) 16%, transparent); + border-color: color-mix(in srgb, var(--ctp-peach, #fab387) 45%, transparent); +} /* ── Exit modal ────────────────────────────────────────────────────── */ .orbit-exit-overlay { @@ -12383,6 +12388,22 @@ html[data-psy-native-hidden="true"] *::after { .orbit-settings-pop__row--stacked:hover { background: transparent; } +.orbit-settings-pop__number { + width: 64px; + padding: 4px 6px; + border-radius: 6px; + background: var(--ctp-surface0, rgba(127, 127, 127, 0.12)); + border: 1px solid color-mix(in srgb, var(--text-secondary) 18%, transparent); + color: var(--text-primary); + font-size: 12px; + font-variant-numeric: tabular-nums; + text-align: right; + flex-shrink: 0; +} +.orbit-settings-pop__number:focus { + outline: none; + border-color: var(--accent); +} .orbit-settings-pop__preset-group { display: flex; @@ -12519,6 +12540,26 @@ html[data-psy-native-hidden="true"] *::after { white-space: nowrap; min-width: 0; } +.orbit-queue-head__pending { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; + padding: 1px 6px; + border-radius: 9px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + color: var(--text-secondary); + font-size: 10px; + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} +.orbit-queue-head__pending.is-full { + color: var(--ctp-red, #f38ba8); + background: color-mix(in srgb, var(--ctp-red, #f38ba8) 14%, transparent); + border-color: color-mix(in srgb, var(--ctp-red, #f38ba8) 40%, transparent); +} +.orbit-queue-head__pending svg { color: inherit; } .orbit-guest-queue__current { position: relative; diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 39f39253..1ce70a02 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -456,7 +456,49 @@ export async function leaveOrbitSession(): Promise { * 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) { + super(`Suggestion blocked: ${reason}`); + this.name = 'OrbitSuggestBlockedError'; + } +} + export async function suggestOrbitTrack(trackId: string): Promise { + 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 { } 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 { + 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; declinedKeys?: ReadonlySet }, ): 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( - state.queue.map(q => `${q.addedBy}${q.trackId}`), + state.queue.map(q => `${q.addedBy} ${q.trackId}`), ); if (state.currentTrack) { - existingKeys.add(`${state.currentTrack.addedBy}${state.currentTrack.trackId}`); + existingKeys.add(`${state.currentTrack.addedBy} ${state.currentTrack.trackId}`); } + + // Compute remaining slots under the host's pending-approval cap. Items the + // host has merged or declined have left the approval queue and don't + // occupy a slot any more — without those sets we conservatively count + // every non-host queue entry as pending. Plus drop any new suggestion + // from a user the host has muted. + const blocked = new Set(state.suggestionBlocked ?? []); + const cap = state.settings?.maxPending ?? 0; + const merged = opts?.mergedKeys ?? new Set(); + const declined = opts?.declinedKeys ?? new Set(); + const pendingCount = cap > 0 + ? state.queue.filter(q => + q.addedBy !== state.host + && !merged.has(suggestionKey(q)) + && !declined.has(suggestionKey(q)) + ).length + : 0; + let remaining = cap > 0 ? Math.max(0, cap - pendingCount) : Infinity; + const newItems: OrbitQueueItem[] = []; + outer: for (const snap of snapshots) { + if (blocked.has(snap.user)) continue; for (const trackId of snap.trackIds) { - const key = `${snap.user}${trackId}`; + if (remaining <= 0) break outer; + const key = `${snap.user} ${trackId}`; if (existingKeys.has(key)) continue; existingKeys.add(key); newItems.push({ trackId, addedBy: snap.user, addedAt: nowMs }); + remaining--; } }