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
* "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 {
/** Guest suggestions go straight into the host's play queue. */
autoApprove: boolean;
/** Queue is reshuffled every `ORBIT_SHUFFLE_INTERVAL_MS`. */
/** Whether the auto-shuffle cycle runs at all. */
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 = {
autoApprove: true,
autoShuffle: true,
shuffleIntervalMin: 15,
};
/** What the guest's outbox-playlist comment holds (heartbeat only, for now). */
+2 -2
View File
@@ -8,8 +8,8 @@ import {
endOrbitSession,
leaveOrbitSession,
computeOrbitDriftMs,
effectiveShuffleIntervalMs,
} from '../utils/orbit';
import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit';
import { estimateLivePosition } from '../api/orbit';
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
import OrbitExitModal from './OrbitExitModal';
@@ -69,7 +69,7 @@ export default function OrbitSessionBar() {
<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.
const guestPlayback = usePlayerStore.getState();
+32 -1
View File
@@ -4,7 +4,7 @@ import { Shuffle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
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';
interface Props {
@@ -82,6 +82,37 @@ export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) {
</span>
</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
type="button"
className="orbit-settings-pop__action"
+2 -2
View File
@@ -8,7 +8,7 @@ import {
sweepGuestOutboxes,
applyOutboxSnapshotsToState,
maybeShuffleQueue,
ORBIT_SHUFFLE_INTERVAL_MS,
effectiveShuffleIntervalMs,
} from '../utils/orbit';
import {
orbitOutboxPlaylistName,
@@ -117,7 +117,7 @@ export function useOrbitHost(): void {
// mix the guests hear actually changes. `autoShuffle=false` skips
// both.
const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false
&& (Date.now() - afterSweep.lastShuffle >= ORBIT_SHUFFLE_INTERVAL_MS);
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) {
const before = usePlayerStore.getState().queue.length;
+6 -2
View File
@@ -1513,8 +1513,12 @@ export const deTranslation = {
settingsTitle: 'Session-Einstellungen',
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.',
settingAutoShuffle: 'Warteschlange alle 15 Minuten mischen',
settingAutoShuffleHint: 'Automatischer Fisher-Yates-Shuffle. Aus: Reihenfolge wird nur verändert, wenn du selbst sortierst.',
settingAutoShuffle: 'Automatisch mischen',
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',
toastSuggested: '{{user}} hat „{{title}}" vorgeschlagen',
toastSuggestedMany: '{{count}} neue Vorschläge in der Warteschlange',
+6 -2
View File
@@ -1516,8 +1516,12 @@ export const enTranslation = {
settingsTitle: 'Session settings',
settingAutoApprove: 'Auto-approve suggestions',
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',
settingAutoShuffleHint: "Automatic FisherYates shuffle. Off: the order only changes when you rearrange it.",
settingAutoShuffle: 'Automatic reshuffle',
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',
toastSuggested: '{{user}} suggested "{{title}}"',
toastSuggestedMany: '{{count}} new suggestions in the queue',
+46
View File
@@ -12175,6 +12175,52 @@ html[data-psy-native-hidden="true"] *::after {
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 {
width: 100%;
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;
/** 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;
/**
* 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
* 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 {
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) {
// Still bump `lastShuffle` so the next eligible shuffle is 15 min away,
// preventing a tight retry loop right after a guest drops a single item in.
// Still bump `lastShuffle` so the next eligible shuffle is one full
// interval away, preventing a tight retry loop right after a guest
// drops a single item in.
return { ...state, lastShuffle: nowMs };
}
const shuffled = state.queue.slice();