chore(orbit): configurable shuffle interval

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 14:13:32 +02:00
parent 5326011268
commit 089774dc3e
8 changed files with 126 additions and 14 deletions
+12 -1
View File
@@ -106,16 +106,27 @@ export interface OrbitState {
* Host-configurable rules. All default to `true`, i.e. the feature runs * Host-configurable rules. All default to `true`, i.e. the feature runs
* "all on" for new sessions. Toggled via the Orbit-bar settings popover. * "all on" for new sessions. Toggled via the Orbit-bar settings popover.
*/ */
/** Minute presets offered to the host in the Orbit settings popover. */
export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const;
export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number];
export interface OrbitSettings { export interface OrbitSettings {
/** Guest suggestions go straight into the host's play queue. */ /** Guest suggestions go straight into the host's play queue. */
autoApprove: boolean; autoApprove: boolean;
/** Queue is reshuffled every `ORBIT_SHUFFLE_INTERVAL_MS`. */ /** Whether the auto-shuffle cycle runs at all. */
autoShuffle: boolean; autoShuffle: boolean;
/**
* Minutes between each auto-shuffle cycle. Must be one of
* `ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN`. Older sessions that predate this
* field fall back to 15 via `effectiveShuffleIntervalMs`.
*/
shuffleIntervalMin?: OrbitShuffleIntervalMin;
} }
export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = { export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = {
autoApprove: true, autoApprove: true,
autoShuffle: true, autoShuffle: true,
shuffleIntervalMin: 15,
}; };
/** What the guest's outbox-playlist comment holds (heartbeat only, for now). */ /** What the guest's outbox-playlist comment holds (heartbeat only, for now). */
+2 -2
View File
@@ -8,8 +8,8 @@ import {
endOrbitSession, endOrbitSession,
leaveOrbitSession, leaveOrbitSession,
computeOrbitDriftMs, computeOrbitDriftMs,
effectiveShuffleIntervalMs,
} from '../utils/orbit'; } from '../utils/orbit';
import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit';
import { estimateLivePosition } from '../api/orbit'; import { estimateLivePosition } from '../api/orbit';
import OrbitParticipantsPopover from './OrbitParticipantsPopover'; import OrbitParticipantsPopover from './OrbitParticipantsPopover';
import OrbitExitModal from './OrbitExitModal'; import OrbitExitModal from './OrbitExitModal';
@@ -69,7 +69,7 @@ export default function OrbitSessionBar() {
<OrbitExitModal /> <OrbitExitModal />
); );
const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs); const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - nowMs);
// Guest-only: detect drift from the host's estimated live position. // Guest-only: detect drift from the host's estimated live position.
const guestPlayback = usePlayerStore.getState(); const guestPlayback = usePlayerStore.getState();
+32 -1
View File
@@ -4,7 +4,7 @@ import { Shuffle } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import { updateOrbitSettings, triggerOrbitShuffleNow } from '../utils/orbit'; import { updateOrbitSettings, triggerOrbitShuffleNow } from '../utils/orbit';
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit'; import { ORBIT_DEFAULT_SETTINGS, ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN, type OrbitShuffleIntervalMin } from '../api/orbit';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
interface Props { interface Props {
@@ -82,6 +82,37 @@ export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) {
</span> </span>
</label> </label>
<div className="orbit-settings-pop__row orbit-settings-pop__row--stacked">
<div className="orbit-settings-pop__text">
<div className="orbit-settings-pop__label">{t('orbit.settingShuffleInterval')}</div>
<div className="orbit-settings-pop__hint">{t('orbit.settingShuffleIntervalHint')}</div>
</div>
<div
className="orbit-settings-pop__preset-group"
role="radiogroup"
aria-label={t('orbit.settingShuffleInterval')}
>
{ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN.map(min => {
const active = (settings.shuffleIntervalMin ?? 15) === min;
return (
<button
key={min}
type="button"
role="radio"
aria-checked={active}
className={`orbit-settings-pop__preset${active ? ' is-active' : ''}`}
disabled={!settings.autoShuffle}
onClick={() => {
void updateOrbitSettings({ shuffleIntervalMin: min as OrbitShuffleIntervalMin });
}}
>
{t('orbit.settingShuffleIntervalValue', { count: min })}
</button>
);
})}
</div>
</div>
<button <button
type="button" type="button"
className="orbit-settings-pop__action" className="orbit-settings-pop__action"
+2 -2
View File
@@ -8,7 +8,7 @@ import {
sweepGuestOutboxes, sweepGuestOutboxes,
applyOutboxSnapshotsToState, applyOutboxSnapshotsToState,
maybeShuffleQueue, maybeShuffleQueue,
ORBIT_SHUFFLE_INTERVAL_MS, effectiveShuffleIntervalMs,
} from '../utils/orbit'; } from '../utils/orbit';
import { import {
orbitOutboxPlaylistName, orbitOutboxPlaylistName,
@@ -117,7 +117,7 @@ export function useOrbitHost(): void {
// mix the guests hear actually changes. `autoShuffle=false` skips // mix the guests hear actually changes. `autoShuffle=false` skips
// both. // both.
const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false
&& (Date.now() - afterSweep.lastShuffle >= ORBIT_SHUFFLE_INTERVAL_MS); && (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
const afterShuffle = maybeShuffleQueue(afterSweep); const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) { if (shouldShuffleNow) {
const before = usePlayerStore.getState().queue.length; const before = usePlayerStore.getState().queue.length;
+6 -2
View File
@@ -1513,8 +1513,12 @@ export const deTranslation = {
settingsTitle: 'Session-Einstellungen', settingsTitle: 'Session-Einstellungen',
settingAutoApprove: 'Vorschläge automatisch übernehmen', settingAutoApprove: 'Vorschläge automatisch übernehmen',
settingAutoApproveHint: 'Neue Gäste-Vorschläge landen sofort in deiner Warteschlange. Aus: sie bleiben in der Session-Liste, du musst sie später manuell übernehmen.', settingAutoApproveHint: 'Neue Gäste-Vorschläge landen sofort in deiner Warteschlange. Aus: sie bleiben in der Session-Liste, du musst sie später manuell übernehmen.',
settingAutoShuffle: 'Warteschlange alle 15 Minuten mischen', settingAutoShuffle: 'Automatisch mischen',
settingAutoShuffleHint: 'Automatischer Fisher-Yates-Shuffle. Aus: Reihenfolge wird nur verändert, wenn du selbst sortierst.', 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.',
settingShuffleIntervalValue_one: '{{count}} Min.',
settingShuffleIntervalValue_other: '{{count}} Min.',
settingShuffleNow: 'Jetzt mischen', settingShuffleNow: 'Jetzt mischen',
toastSuggested: '{{user}} hat „{{title}}" vorgeschlagen', toastSuggested: '{{user}} hat „{{title}}" vorgeschlagen',
toastSuggestedMany: '{{count}} neue Vorschläge in der Warteschlange', toastSuggestedMany: '{{count}} neue Vorschläge in der Warteschlange',
+6 -2
View File
@@ -1516,8 +1516,12 @@ export const enTranslation = {
settingsTitle: 'Session settings', settingsTitle: 'Session settings',
settingAutoApprove: 'Auto-approve suggestions', settingAutoApprove: 'Auto-approve suggestions',
settingAutoApproveHint: "Guest suggestions land in your queue instantly. Off: they stay in the session list for you to review later.", settingAutoApproveHint: "Guest suggestions land in your queue instantly. Off: they stay in the session list for you to review later.",
settingAutoShuffle: 'Reshuffle queue every 15 minutes', settingAutoShuffle: 'Automatic reshuffle',
settingAutoShuffleHint: "Automatic FisherYates shuffle. Off: the order only changes when you rearrange it.", settingAutoShuffleHint: "Periodic FisherYates 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.',
settingShuffleIntervalValue_one: '{{count}} min',
settingShuffleIntervalValue_other: '{{count}} min',
settingShuffleNow: 'Shuffle now', settingShuffleNow: 'Shuffle now',
toastSuggested: '{{user}} suggested "{{title}}"', toastSuggested: '{{user}} suggested "{{title}}"',
toastSuggestedMany: '{{count}} new suggestions in the queue', toastSuggestedMany: '{{count}} new suggestions in the queue',
+46
View File
@@ -12175,6 +12175,52 @@ html[data-psy-native-hidden="true"] *::after {
margin-top: 2px; margin-top: 2px;
} }
/* Row-variant for controls that want the control *under* the label rather
than beside it (preset pickers etc.). Non-interactive container: the
hover-background is suppressed here so the buttons keep focus. */
.orbit-settings-pop__row--stacked {
flex-direction: column;
align-items: stretch;
gap: 8px;
cursor: default;
}
.orbit-settings-pop__row--stacked:hover {
background: transparent;
}
.orbit-settings-pop__preset-group {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.orbit-settings-pop__preset {
flex: 1 1 0;
min-width: 44px;
padding: 6px 8px;
font-size: 11.5px;
font-weight: 600;
color: var(--text-secondary, var(--text-primary));
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.orbit-settings-pop__preset:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--text-primary);
}
.orbit-settings-pop__preset.is-active {
background: color-mix(in srgb, var(--accent) 22%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
color: var(--accent);
}
.orbit-settings-pop__preset:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.orbit-settings-pop__action { .orbit-settings-pop__action {
width: 100%; width: 100%;
display: inline-flex; display: inline-flex;
+20 -4
View File
@@ -700,9 +700,24 @@ export const ORBIT_HEARTBEAT_ALIVE_MS = 30_000;
*/ */
export const ORBIT_ORPHAN_TTL_MS = 60_000; export const ORBIT_ORPHAN_TTL_MS = 60_000;
/** Shuffle cadence — queue is reshuffled once every interval. */ /**
* Legacy / fallback shuffle cadence. New sessions store their own interval
* in `OrbitState.settings.shuffleIntervalMin`; `effectiveShuffleIntervalMs`
* resolves that against this constant for sessions created before the
* field existed.
*/
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000; export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
/**
* Resolve the active auto-shuffle cadence in ms. Reads the host's configured
* preset from `state.settings.shuffleIntervalMin`; older sessions that lack
* the field fall back to 15 min so their tick cadence is unchanged.
*/
export function effectiveShuffleIntervalMs(state: Pick<OrbitState, 'settings'>): number {
const min = state.settings?.shuffleIntervalMin;
return typeof min === 'number' ? min * 60_000 : ORBIT_SHUFFLE_INTERVAL_MS;
}
/** /**
* How long a soft-`removed` marker stays in the state blob. Long enough for * How long a soft-`removed` marker stays in the state blob. Long enough for
* the affected guest's 2.5 s read tick to surface the modal even after a * the affected guest's 2.5 s read tick to surface the modal even after a
@@ -718,10 +733,11 @@ export const ORBIT_REMOVED_TTL_MS = 60_000;
*/ */
export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState { export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState {
if (state.settings?.autoShuffle === false) return state; if (state.settings?.autoShuffle === false) return state;
if (nowMs - state.lastShuffle < ORBIT_SHUFFLE_INTERVAL_MS) return state; if (nowMs - state.lastShuffle < effectiveShuffleIntervalMs(state)) return state;
if (state.queue.length < 2) { if (state.queue.length < 2) {
// Still bump `lastShuffle` so the next eligible shuffle is 15 min away, // Still bump `lastShuffle` so the next eligible shuffle is one full
// preventing a tight retry loop right after a guest drops a single item in. // interval away, preventing a tight retry loop right after a guest
// drops a single item in.
return { ...state, lastShuffle: nowMs }; return { ...state, lastShuffle: nowMs };
} }
const shuffled = state.queue.slice(); const shuffled = state.queue.slice();