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 { 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 { suggestOrbitTrack } from '../utils/orbit';
|
||||
import { suggestOrbitTrack, hostEnqueueToOrbit } from '../utils/orbit';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import StarRating from './StarRating';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
@@ -1455,6 +1455,15 @@ export default function ContextMenu() {
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||
</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
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
@@ -1596,6 +1605,15 @@ export default function ContextMenu() {
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||
</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
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../utils/orbit';
|
||||
import { randomOrbitSessionName } from '../utils/orbitNames';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { isLanUrl } from '../hooks/useConnectionStatus';
|
||||
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 [copied, setCopied] = useState(false);
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
const [clearQueue, setClearQueue] = useState(false);
|
||||
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
const serverBase = server?.url ?? '';
|
||||
@@ -76,6 +78,7 @@ export default function OrbitStartModal({ onClose }: Props) {
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
if (clearQueue) usePlayerStore.getState().clearQueue();
|
||||
await startOrbitSession({ name: trimmed, maxUsers, sid });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -182,6 +185,23 @@ export default function OrbitStartModal({ onClose }: Props) {
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperMax')}</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">
|
||||
<label className="orbit-start-modal__label">{t('orbit.labelLink')}</label>
|
||||
<div className="orbit-start-modal__link">
|
||||
|
||||
@@ -165,8 +165,12 @@ export function useOrbitHost(): void {
|
||||
const settings = useOrbitStore.getState().state?.settings;
|
||||
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 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;
|
||||
|
||||
// Resolve in parallel — Navidrome is fine with concurrent getSong calls.
|
||||
|
||||
@@ -1471,6 +1471,8 @@ export const deTranslation = {
|
||||
tooltipCopy: 'Kopieren',
|
||||
ariaCopyLink: 'Link kopieren',
|
||||
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',
|
||||
btnStarting: 'Starte…',
|
||||
btnStart: 'Orbit starten',
|
||||
@@ -1532,6 +1534,9 @@ export const deTranslation = {
|
||||
ctxAddToSession: 'Zur Orbit-Session hinzufügen',
|
||||
ctxSuggestedToast: 'An Session vorgeschlagen',
|
||||
ctxSuggestFailed: 'Vorschlag fehlgeschlagen — nicht beigetreten',
|
||||
ctxAddToSessionHost: 'Zur Orbit-Session hinzufügen',
|
||||
ctxAddedHostToast: 'Zur Session hinzugefügt',
|
||||
ctxAddHostFailed: 'Hinzufügen fehlgeschlagen',
|
||||
guestLive: 'Live',
|
||||
guestPlaying: 'Läuft gerade',
|
||||
guestPaused: 'Pausiert',
|
||||
|
||||
@@ -1474,6 +1474,8 @@ export const enTranslation = {
|
||||
tooltipCopy: 'Copy',
|
||||
ariaCopyLink: 'Copy invite link',
|
||||
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',
|
||||
btnStarting: 'Starting…',
|
||||
btnStart: 'Start Orbit',
|
||||
@@ -1535,6 +1537,9 @@ export const enTranslation = {
|
||||
ctxAddToSession: 'Add to Orbit session',
|
||||
ctxSuggestedToast: 'Suggested to the session',
|
||||
ctxSuggestFailed: "Couldn't suggest — not joined",
|
||||
ctxAddToSessionHost: 'Add to Orbit session',
|
||||
ctxAddedHostToast: 'Added to the session',
|
||||
ctxAddHostFailed: "Couldn't add to session",
|
||||
guestLive: 'Live',
|
||||
guestPlaying: 'Playing now',
|
||||
guestPaused: 'Paused',
|
||||
|
||||
@@ -12015,6 +12015,23 @@ html[data-psy-native-hidden="true"] *::after {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr;
|
||||
|
||||
+29
-1
@@ -5,10 +5,11 @@ import {
|
||||
deletePlaylist,
|
||||
getPlaylist,
|
||||
getPlaylists,
|
||||
getSong,
|
||||
} from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import {
|
||||
makeInitialOrbitState,
|
||||
orbitOutboxPlaylistName,
|
||||
@@ -505,6 +506,33 @@ export async function suggestOrbitTrack(trackId: string): Promise<void> {
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
interface OutboxSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user