exp(orbit): host merge, guest queue view, settings popover, i18n

Guest suggestions now auto-merge into the host's play queue at random
positions inside the upcoming range, so they actually surface alongside
host-picked tracks instead of piling up at the end. Per-item dedupe via
addedBy:addedAt:trackId keys survives reshuffles.

Guests get a read-only mirror of the session in place of their queue
panel — live-card for the host's current track, suggestion list with
submitter attribution, and a footer reminding them the host owns
playback. Track metadata is resolved lazily and cached locally.

New host-only settings popover (gear in the bar) with auto-approve and
auto-shuffle toggles plus a "Shuffle now" action. Settings live in the
OrbitState blob and push immediately to Navidrome; missing fields on
older blobs default to "both on".

15-min shuffle now touches the real playerStore queue via a new
shuffleUpcomingQueue action — previous behaviour only reshuffled the
guest-facing suggestion history. A manual shuffle is available from the
settings popover so the interval can be verified without waiting.

Start-modal reworked into a single step: share-link is visible and
copy-able from the start, auto-copied on Start if the host hasn't
already hit Copy. Link carries a live name-derived slug (parser strips
it, SID is authoritative). LAN/remote-server hint above the facts.
Random session-name suggester pulls from three pattern pools for ~10k
unique combinations.

All user-visible Orbit strings moved to a dedicated orbit i18n
namespace (en + de); other locales fall back to en via i18next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 02:49:42 +02:00
parent 7fe492d233
commit 2b97a7b01e
19 changed files with 1647 additions and 213 deletions
+84 -7
View File
@@ -8,6 +8,7 @@ import {
} from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';
import {
makeInitialOrbitState,
orbitOutboxPlaylistName,
@@ -36,12 +37,27 @@ import {
// ── ID generation ───────────────────────────────────────────────────────
/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */
function generateSessionId(): string {
export function generateSessionId(): string {
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Turn a human session name into a URL-safe slug. Ignores non-ASCII
* characters so the output is stable across locales and safe in a
* `psysonic2://` link. Returns an empty string for names that slugify
* to nothing — callers should fall back to a slug-less link in that case.
*/
export function slugifyOrbitName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
// ── Serialisation ───────────────────────────────────────────────────────
/**
@@ -125,6 +141,12 @@ export interface StartOrbitArgs {
name: string;
/** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */
maxUsers?: number;
/**
* Pre-generated session id. Lets the caller (e.g. the start modal) show a
* stable share-link *before* the session is actually created. Falls back
* to a fresh id when omitted.
*/
sid?: string;
}
/**
@@ -151,7 +173,7 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
let sessionPlaylistId: string | null = null;
let outboxPlaylistId: string | null = null;
try {
const sid = generateSessionId();
const sid = args.sid ?? generateSessionId();
const sessionName = orbitSessionPlaylistName(sid);
const outboxName = orbitOutboxPlaylistName(sid, username);
@@ -236,6 +258,51 @@ export function patchOrbitState(patch: Partial<OrbitState>): OrbitState | null {
return next;
}
/**
* Host-only: update the session settings and immediately push to Navidrome
* so guests see the change on their next poll. No-op unless the caller is
* the current host with an active session.
*/
/**
* Host-only: force an immediate shuffle of the upcoming play queue, bump
* `lastShuffle` so the automatic 15-min timer resets, and push the new
* state to Navidrome. Ignores the `autoShuffle` setting — this is an
* explicit user action.
*/
export async function triggerOrbitShuffleNow(): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
// 1) Shuffle the host's real play queue (upcoming only).
usePlayerStore.getState().shuffleUpcomingQueue();
// 2) Shuffle the OrbitState.queue (guest-facing suggestion history) +
// bump lastShuffle so the auto-shuffle timer restarts.
const now = Date.now();
const shuffled = store.state.queue.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const next: OrbitState = { ...store.state, queue: shuffled, lastShuffle: now };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push */ }
}
export async function updateOrbitSettings(patch: Partial<import('../api/orbit').OrbitSettings>): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
const mergedSettings: import('../api/orbit').OrbitSettings = {
...(store.state.settings ?? { autoApprove: true, autoShuffle: true }),
...patch,
};
const next: OrbitState = { ...store.state, settings: mergedSettings };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push the current state anyway */ }
}
// ── Share link ──────────────────────────────────────────────────────────
export const ORBIT_SHARE_SCHEME = 'psysonic2://orbit/';
@@ -262,8 +329,12 @@ export function parseOrbitShareLink(url: string): OrbitShareLink | null {
const slash = stripped.indexOf('/');
if (slash <= 0) return null;
const serverB64 = stripped.slice(0, slash);
const sid = stripped.slice(slash + 1).replace(/\/+$/, '');
if (!/^[0-9a-f]{8}$/i.test(sid)) return null;
const tail = stripped.slice(slash + 1).replace(/\/+$/, '');
// Tail is either `<sid>` or `<slug>-<sid>` — the SID is always the
// terminal 8-hex group. The slug is purely cosmetic for the sender.
const m = tail.match(/(?:^|-)([0-9a-f]{8})$/i);
if (!m) return null;
const sid = m[1].toLowerCase();
let serverBase: string;
try {
serverBase = atob(serverB64);
@@ -272,9 +343,14 @@ export function parseOrbitShareLink(url: string): OrbitShareLink | null {
return { serverBase, sid };
}
/** Build a share link for a live session. */
export function buildOrbitShareLink(serverBase: string, sid: string): string {
return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${sid}`;
/**
* Build a share link for a live session. When `slug` is provided (and
* non-empty) it is prepended to the SID for a friendlier-looking URL
* — the parser strips it on the receiving side.
*/
export function buildOrbitShareLink(serverBase: string, sid: string, slug?: string): string {
const tail = slug && slug.length > 0 ? `${slug}-${sid}` : sid;
return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${tail}`;
}
// ── Playlist lookup ─────────────────────────────────────────────────────
@@ -522,6 +598,7 @@ export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
* `currentTrack` is never touched.
*/
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 (state.queue.length < 2) {
// Still bump `lastShuffle` so the next eligible shuffle is 15 min away,
+85
View File
@@ -0,0 +1,85 @@
/**
* Orbit — random session-name suggester.
*
* Combinatorial generator over three patterns:
* 1. `${adj} ${noun}` (e.g. "Velvet Orbit")
* 2. `${phrase} ${noun}` (e.g. "Late Night Rooftop")
* 3. `${noun} ${suffix}` (e.g. "Rooftop Sessions")
*
* Pools are hand-curated so that random combinations almost always still
* read as a coherent, music-themed session name. Total addressable name
* space is in the tens of thousands — you'd have to reshuffle very hard
* to see the same one twice.
*/
const ADJECTIVES: readonly string[] = [
'Velvet', 'Neon', 'Midnight', 'Golden', 'Static', 'Cosmic',
'Late', 'Lost', 'Deep', 'Slow', 'Quiet', 'Electric',
'Analog', 'Distant', 'Hidden', 'Frozen', 'Warm', 'Wild',
'Silver', 'Amber', 'Crystal', 'Endless', 'Hazy', 'Drifting',
'Restless', 'Secret', 'Weightless', 'Echoing', 'Low', 'Bright',
'Soft', 'Dusty', 'Foggy', 'Smoky', 'Twilight', 'Dawning',
'Infinite', 'Eternal', 'Vintage', 'Smooth', 'Silent', 'Faint',
'Bold', 'Sharp', 'Tender', 'Savage', 'Gentle', 'Reckless',
'Chill', 'Steaming', 'Burning', 'Icy', 'Muted', 'Vivid',
'Prismatic', 'Shadowy', 'Liminal', 'Spectral', 'Faded', 'Sleepy',
'Wandering', 'Roaming', 'Dreaming', 'Floating', 'Buzzing', 'Rolling',
'Hushed', 'Broken', 'Wired', 'Outer', 'Moonlit', 'Sunlit',
'Firelit', 'Candlelit', 'Quiet', 'Howling', 'Whispered', 'Shimmering',
'Dusky', 'Drowsy', 'Plush', 'Opalescent', 'Silken',
];
const NOUNS: readonly string[] = [
// places
'Rooftop', 'Kitchen', 'Basement', 'Garage', 'Lounge', 'Diner',
'Cinema', 'Harbor', 'Highway', 'Hotel', 'Parlor', 'Attic',
'Balcony', 'Terrace', 'Patio', 'Studio', 'Warehouse', 'Pier',
'Terminal', 'Platform', 'Corner', 'Boulevard', 'Tower', 'Lighthouse',
'Chapel', 'Bunker', 'Courtyard', 'Observatory', 'Arcade', 'Alleyway',
// media / musical
'Tape', 'Radio', 'Session', 'Rotation', 'Mixtape', 'Transmission',
'Frequency', 'Broadcast', 'Channel', 'Cassette', 'Reel', 'Loop',
'Vinyl', 'Sleeve', 'Waveform', 'Echo', 'Reverb', 'Bassline',
'Bridge', 'Interlude', 'Mix', 'Playlist', 'Chord', 'Groove',
'Encore', 'Setlist', 'Tracklist', 'Dub', 'Bootleg',
// celestial / orbit-y
'Orbit', 'Galaxy', 'Nebula', 'Comet', 'Horizon', 'Signal',
'Drift', 'Satellite', 'Atmosphere', 'Starfield', 'Eclipse', 'Nova',
'Moon', 'Void', 'Prism', 'Meteor', 'Solstice', 'Equinox',
'Zenith', 'Apogee', 'Pulsar', 'Quasar', 'Aurora', 'Supernova',
];
const PHRASES: readonly string[] = [
'Late Night', 'Deep Space', 'Low-Fi', 'Low-Key', 'Slow Burn',
'Afterhour', 'Golden Hour', 'Blue Hour', 'Off-Grid', 'Outer Space',
'Northern Light', 'Velvet Night', 'Neon Drive', 'Midnight Drive',
'Quiet Storm', 'Slow Motion', 'Electric Dream', 'Analog Dream',
'Hidden Track', 'Side-B', 'Dark-Side', 'Back-Room', 'Morning-After',
'Last-Call', 'First-Light', 'Long-Play', 'Cold-Start', 'Warm-Up',
'Sunset', 'Afterparty',
];
const SUFFIXES: readonly string[] = [
'Sessions', 'Radio', 'Tapes', 'Transmissions', 'Rotations',
'Mixes', 'Broadcasts', 'Frequencies', 'Interludes', 'Playback',
'Nights', 'Hours', 'Signals', 'Takes', 'Bootlegs',
];
function pickRandom<T>(list: readonly T[]): T {
return list[Math.floor(Math.random() * list.length)];
}
/** Returns a fresh combinatorial suggestion. */
export function randomOrbitSessionName(): string {
const r = Math.random();
if (r < 0.20) {
// "Rooftop Sessions"
return `${pickRandom(NOUNS)} ${pickRandom(SUFFIXES)}`;
}
if (r < 0.40) {
// "Late Night Rooftop"
return `${pickRandom(PHRASES)} ${pickRandom(NOUNS)}`;
}
// Default: "Velvet Orbit"
return `${pickRandom(ADJECTIVES)} ${pickRandom(NOUNS)}`;
}