mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
chore(orbit): session-start polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user