mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
chore(orbit): configurable shuffle interval
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+12
-1
@@ -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). */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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 Fisher–Yates shuffle. Off: the order only changes when you rearrange it.",
|
||||
settingAutoShuffle: 'Automatic reshuffle',
|
||||
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.',
|
||||
settingShuffleIntervalValue_one: '{{count}} min',
|
||||
settingShuffleIntervalValue_other: '{{count}} min',
|
||||
settingShuffleNow: 'Shuffle now',
|
||||
toastSuggested: '{{user}} suggested "{{title}}"',
|
||||
toastSuggestedMany: '{{count}} new suggestions in the queue',
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user