diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 5409bfe5..63ebe189 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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() { {t('orbit.ctxAddToSession')} )} + {orbitRole === 'host' && ( +
handleAction(() => { + hostEnqueueToOrbit(song.id) + .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); + })}> + {t('orbit.ctxAddToSessionHost')} +
+ )}
{t('orbit.ctxAddToSession')}
)} + {orbitRole === 'host' && ( +
handleAction(() => { + hostEnqueueToOrbit(song.id) + .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); + })}> + {t('orbit.ctxAddToSessionHost')} +
+ )}
(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) {
{t('orbit.helperMax')}
+
+ +
+
diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index d9ce77df..86f30dad 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -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. diff --git a/src/locales/de.ts b/src/locales/de.ts index 713664c4..42476b35 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 129adbb3..ca6fc677 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/styles/components.css b/src/styles/components.css index 6d2780c9..9bd9ffdd 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index c95c828b..086213b3 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -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 { 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 { + 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 {