mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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
|
* 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). */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 Fisher–Yates shuffle. Off: the order only changes when you rearrange it.",
|
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',
|
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',
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user