chore(orbit): session-start polish

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 10:35:50 +02:00
parent fe7dc7af54
commit f8bcd57ed4
7 changed files with 100 additions and 3 deletions
+19 -1
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import { suggestOrbitTrack } from '../utils/orbit'; import { suggestOrbitTrack, hostEnqueueToOrbit } from '../utils/orbit';
import LastfmIcon from './LastfmIcon'; import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating'; import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
@@ -1455,6 +1455,15 @@ export default function ContextMenu() {
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')} <OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
</div> </div>
)} )}
{orbitRole === 'host' && (
<div className="context-menu-item" onClick={() => handleAction(() => {
hostEnqueueToOrbit(song.id)
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
})}>
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
</div>
)}
<div <div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`} className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id} data-playlist-trigger-id={song.id}
@@ -1596,6 +1605,15 @@ export default function ContextMenu() {
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')} <OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
</div> </div>
)} )}
{orbitRole === 'host' && (
<div className="context-menu-item" onClick={() => handleAction(() => {
hostEnqueueToOrbit(song.id)
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
})}>
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
</div>
)}
<div <div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`} className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id} data-playlist-trigger-id={song.id}
+20
View File
@@ -13,6 +13,7 @@ import {
} from '../utils/orbit'; } from '../utils/orbit';
import { randomOrbitSessionName } from '../utils/orbitNames'; import { randomOrbitSessionName } from '../utils/orbitNames';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { isLanUrl } from '../hooks/useConnectionStatus'; import { isLanUrl } from '../hooks/useConnectionStatus';
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit'; import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
@@ -35,6 +36,7 @@ export default function OrbitStartModal({ onClose }: Props) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [hasCopied, setHasCopied] = useState(false); const [hasCopied, setHasCopied] = useState(false);
const [clearQueue, setClearQueue] = useState(false);
const server = useAuthStore.getState().getActiveServer(); const server = useAuthStore.getState().getActiveServer();
const serverBase = server?.url ?? ''; const serverBase = server?.url ?? '';
@@ -76,6 +78,7 @@ export default function OrbitStartModal({ onClose }: Props) {
setBusy(true); setBusy(true);
try { try {
if (clearQueue) usePlayerStore.getState().clearQueue();
await startOrbitSession({ name: trimmed, maxUsers, sid }); await startOrbitSession({ name: trimmed, maxUsers, sid });
onClose(); onClose();
} catch (e) { } catch (e) {
@@ -182,6 +185,23 @@ export default function OrbitStartModal({ onClose }: Props) {
<div className="orbit-start-modal__helper">{t('orbit.helperMax')}</div> <div className="orbit-start-modal__helper">{t('orbit.helperMax')}</div>
</div> </div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__toggle-row">
<div className="orbit-start-modal__toggle-text">
<div className="orbit-start-modal__label">{t('orbit.labelClearQueue')}</div>
<div className="orbit-start-modal__helper">{t('orbit.helperClearQueue')}</div>
</div>
<span className="toggle-switch">
<input
type="checkbox"
checked={clearQueue}
onChange={e => setClearQueue(e.target.checked)}
/>
<span className="toggle-track" />
</span>
</label>
</div>
<div className="orbit-start-modal__field"> <div className="orbit-start-modal__field">
<label className="orbit-start-modal__label">{t('orbit.labelLink')}</label> <label className="orbit-start-modal__label">{t('orbit.labelLink')}</label>
<div className="orbit-start-modal__link"> <div className="orbit-start-modal__link">
+5 -1
View File
@@ -165,8 +165,12 @@ export function useOrbitHost(): void {
const settings = useOrbitStore.getState().state?.settings; const settings = useOrbitStore.getState().state?.settings;
if (settings && settings.autoApprove === false) return; if (settings && settings.autoApprove === false) return;
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
// must not flow through the merge pipeline again — otherwise the tick
// would duplicate the track into the upcoming queue.
const hostUser = useOrbitStore.getState().state?.host;
const merged = mergedSuggestionsRef.current; const merged = mergedSuggestionsRef.current;
const pending = items.filter(q => !merged.has(suggestionKey(q))); const pending = items.filter(q => q.addedBy !== hostUser && !merged.has(suggestionKey(q)));
if (pending.length === 0) return; if (pending.length === 0) return;
// Resolve in parallel — Navidrome is fine with concurrent getSong calls. // Resolve in parallel — Navidrome is fine with concurrent getSong calls.
+5
View File
@@ -1471,6 +1471,8 @@ export const deTranslation = {
tooltipCopy: 'Kopieren', tooltipCopy: 'Kopieren',
ariaCopyLink: 'Link kopieren', ariaCopyLink: 'Link kopieren',
helperLink: 'Schicke diesen Link deinen Gästen. Sie fügen ihn mit Strg+V irgendwo in Psysonic ein.', helperLink: 'Schicke diesen Link deinen Gästen. Sie fügen ihn mit Strg+V irgendwo in Psysonic ein.',
labelClearQueue: 'Eigene Warteschlange vorher leeren',
helperClearQueue: 'Mit leerer Warteschlange in die Session starten. Aus: bereits aufgereihte Titel bleiben und werden mit den Gästen geteilt.',
btnCancel: 'Abbrechen', btnCancel: 'Abbrechen',
btnStarting: 'Starte…', btnStarting: 'Starte…',
btnStart: 'Orbit starten', btnStart: 'Orbit starten',
@@ -1532,6 +1534,9 @@ export const deTranslation = {
ctxAddToSession: 'Zur Orbit-Session hinzufügen', ctxAddToSession: 'Zur Orbit-Session hinzufügen',
ctxSuggestedToast: 'An Session vorgeschlagen', ctxSuggestedToast: 'An Session vorgeschlagen',
ctxSuggestFailed: 'Vorschlag fehlgeschlagen — nicht beigetreten', ctxSuggestFailed: 'Vorschlag fehlgeschlagen — nicht beigetreten',
ctxAddToSessionHost: 'Zur Orbit-Session hinzufügen',
ctxAddedHostToast: 'Zur Session hinzugefügt',
ctxAddHostFailed: 'Hinzufügen fehlgeschlagen',
guestLive: 'Live', guestLive: 'Live',
guestPlaying: 'Läuft gerade', guestPlaying: 'Läuft gerade',
guestPaused: 'Pausiert', guestPaused: 'Pausiert',
+5
View File
@@ -1474,6 +1474,8 @@ export const enTranslation = {
tooltipCopy: 'Copy', tooltipCopy: 'Copy',
ariaCopyLink: 'Copy invite link', ariaCopyLink: 'Copy invite link',
helperLink: 'Send this link to your guests. They can paste it anywhere in Psysonic with Ctrl+V.', helperLink: 'Send this link to your guests. They can paste it anywhere in Psysonic with Ctrl+V.',
labelClearQueue: 'Clear my queue first',
helperClearQueue: 'Start the session with a fresh, empty queue. Off: the tracks you already have queued stay and get shared with guests.',
btnCancel: 'Cancel', btnCancel: 'Cancel',
btnStarting: 'Starting…', btnStarting: 'Starting…',
btnStart: 'Start Orbit', btnStart: 'Start Orbit',
@@ -1535,6 +1537,9 @@ export const enTranslation = {
ctxAddToSession: 'Add to Orbit session', ctxAddToSession: 'Add to Orbit session',
ctxSuggestedToast: 'Suggested to the session', ctxSuggestedToast: 'Suggested to the session',
ctxSuggestFailed: "Couldn't suggest — not joined", ctxSuggestFailed: "Couldn't suggest — not joined",
ctxAddToSessionHost: 'Add to Orbit session',
ctxAddedHostToast: 'Added to the session',
ctxAddHostFailed: "Couldn't add to session",
guestLive: 'Live', guestLive: 'Live',
guestPlaying: 'Playing now', guestPlaying: 'Playing now',
guestPaused: 'Paused', guestPaused: 'Paused',
+17
View File
@@ -12015,6 +12015,23 @@ html[data-psy-native-hidden="true"] *::after {
line-height: 1.4; line-height: 1.4;
} }
.orbit-start-modal__toggle-row {
display: flex;
align-items: center;
gap: 14px;
cursor: pointer;
}
.orbit-start-modal__toggle-text {
flex: 1;
min-width: 0;
}
.orbit-start-modal__toggle-row .orbit-start-modal__label {
margin-bottom: 2px;
}
.orbit-start-modal__toggle-row .orbit-start-modal__helper {
margin-top: 0;
}
.orbit-start-modal__note { .orbit-start-modal__note {
display: grid; display: grid;
grid-template-columns: 18px 1fr; grid-template-columns: 18px 1fr;
+29 -1
View File
@@ -5,10 +5,11 @@ import {
deletePlaylist, deletePlaylist,
getPlaylist, getPlaylist,
getPlaylists, getPlaylists,
getSong,
} from '../api/subsonic'; } from '../api/subsonic';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { import {
makeInitialOrbitState, makeInitialOrbitState,
orbitOutboxPlaylistName, orbitOutboxPlaylistName,
@@ -505,6 +506,33 @@ export async function suggestOrbitTrack(trackId: string): Promise<void> {
await updatePlaylist(outboxPlaylistId, nextIds, songs.length); await updatePlaylist(outboxPlaylistId, nextIds, songs.length);
} }
/**
* Host: add a track to the active Orbit session directly, skipping the
* outbox/approval loop guests go through. The track lands in the host's
* own play queue immediately and is attributed to the host in the
* session's suggestion history. Host-authored queue items are filtered
* out of the tick-merge pipeline so the host-tick doesn't re-insert the
* same track once it notices the new entry in `OrbitState.queue`.
*/
export async function hostEnqueueToOrbit(trackId: string): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) {
throw new Error('Not hosting an active Orbit session');
}
const song = await getSong(trackId);
if (!song) throw new Error('Track not found');
const track = songToTrack(song);
usePlayerStore.getState().enqueue([track]);
const item: OrbitQueueItem = { trackId, addedBy: store.state.host, addedAt: Date.now() };
const next: OrbitState = { ...store.state, queue: [...store.state.queue, item] };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push the merged state anyway */ }
}
// ── Host-side outbox sweep ────────────────────────────────────────────── // ── Host-side outbox sweep ──────────────────────────────────────────────
interface OutboxSnapshot { interface OutboxSnapshot {