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 {