mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
+84
-7
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user