chore(orbit): manual approval flow for guest suggestions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 17:36:44 +02:00
parent e6d15bf9ce
commit 17bcac7155
9 changed files with 347 additions and 27 deletions
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, X, Inbox } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import {
approveOrbitSuggestion,
declineOrbitSuggestion,
suggestionKey,
} from '../utils/orbit';
import {
getSong,
buildCoverArtUrl,
coverArtCacheKey,
type SubsonicSong,
} from '../api/subsonic';
import CachedImage from './CachedImage';
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
/**
* Host-only approval strip. Renders directly below the OrbitQueueHead
* when `autoApprove === false` and at least one guest suggestion is
* waiting. Shows each pending track with Approve / Decline controls.
*
* Only rendered by the host-side render path (QueuePanel); guests never
* see this section — they watch their own pending list.
*/
export default function HostApprovalQueue() {
const { t } = useTranslation();
const role = useOrbitStore(s => s.role);
const state = useOrbitStore(s => s.state);
const mergedKeys = useOrbitStore(s => s.mergedSuggestionKeys);
const declinedKeys = useOrbitStore(s => s.declinedSuggestionKeys);
const settings = state?.settings ?? ORBIT_DEFAULT_SETTINGS;
const autoApproveOff = settings.autoApprove === false;
// Pending = everything in the session's suggestion history that isn't
// host-authored, isn't already merged, and hasn't been declined.
const pendingItems = useMemo(() => {
if (!state) return [];
const mergedSet = new Set(mergedKeys);
const declinedSet = new Set(declinedKeys);
return state.queue.filter(q =>
q.addedBy !== state.host
&& !mergedSet.has(suggestionKey(q))
&& !declinedSet.has(suggestionKey(q))
);
}, [state, mergedKeys, declinedKeys]);
// Track-metadata cache (title/artist/cover) for the pending items.
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
const wantedKey = useMemo(
() => Array.from(new Set(pendingItems.map(q => q.trackId))).sort().join('|'),
[pendingItems],
);
useEffect(() => {
const wanted = wantedKey ? wantedKey.split('|') : [];
const missing = wanted.filter(id => id && !songs[id]);
if (missing.length === 0) return;
let cancelled = false;
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
.then(results => {
if (cancelled) return;
setSongs(prev => {
const next = { ...prev };
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
return next;
});
});
return () => { cancelled = true; };
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (role !== 'host' || !state || !autoApproveOff || pendingItems.length === 0) {
return null;
}
return (
<div className="host-approval">
<div className="host-approval__head">
<Inbox size={12} />
<span>{t('orbit.approvalTitle')}</span>
<span className="host-approval__count">{pendingItems.length}</span>
</div>
<div className="host-approval__list">
{pendingItems.map(q => {
const song = songs[q.trackId];
const key = suggestionKey(q);
return (
<div key={key} className="host-approval__item">
{song?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(song.coverArt, 48)}
cacheKey={coverArtCacheKey(song.coverArt, 48)}
alt=""
className="host-approval__cover"
/>
) : (
<div className="host-approval__cover host-approval__cover--ph" />
)}
<div className="host-approval__info">
<div className="host-approval__title">{song?.title ?? '…'}</div>
<div className="host-approval__artist">{song?.artist ?? ''}</div>
<div className="host-approval__submitter">
{t('orbit.approvalFrom', { user: q.addedBy })}
</div>
</div>
<div className="host-approval__actions">
<button
type="button"
className="host-approval__btn host-approval__btn--approve"
onClick={() => { void approveOrbitSuggestion(q); }}
data-tooltip={t('orbit.approvalAccept')}
aria-label={t('orbit.approvalAccept')}
>
<Check size={13} />
</button>
<button
type="button"
className="host-approval__btn host-approval__btn--decline"
onClick={() => { declineOrbitSuggestion(q); }}
data-tooltip={t('orbit.approvalDecline')}
aria-label={t('orbit.approvalDecline')}
>
<X size={13} />
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
+5 -1
View File
@@ -3,6 +3,7 @@ import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import OrbitGuestQueue from './OrbitGuestQueue'; import OrbitGuestQueue from './OrbitGuestQueue';
import OrbitQueueHead from './OrbitQueueHead'; import OrbitQueueHead from './OrbitQueueHead';
import HostApprovalQueue from './HostApprovalQueue';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
@@ -469,7 +470,10 @@ function QueuePanelHostOrSolo() {
}} }}
> >
{orbitRole === 'host' && orbitState && ( {orbitRole === 'host' && orbitState && (
<OrbitQueueHead state={orbitState} /> <>
<OrbitQueueHead state={orbitState} />
<HostApprovalQueue />
</>
)} )}
<QueueHeader <QueueHeader
queue={queue} queue={queue}
+7 -4
View File
@@ -129,12 +129,15 @@ export function useOrbitGuest(): void {
useOrbitStore.getState().setState(state); useOrbitStore.getState().setState(state);
// Reconcile pending guest suggestions against the host's shared state. // Reconcile pending guest suggestions against the host's *playable*
// Once a suggested trackId shows up in state.queue or state.currentTrack, // queue — NOT `state.queue`, which is the suggestion history (every
// the host has merged it and we can drop it from the "pending" list. // submission lands there immediately, even under manual-approval mode
// where the host hasn't actually accepted the track yet).
// `state.playQueue` is the host's real upcoming queue, so a trackId
// appearing there (or as `currentTrack`) means the host has merged it.
if (useOrbitStore.getState().pendingSuggestions.length > 0) { if (useOrbitStore.getState().pendingSuggestions.length > 0) {
const landed = new Set<string>(); const landed = new Set<string>();
for (const q of state.queue) landed.add(q.trackId); for (const q of (state.playQueue ?? [])) landed.add(q.trackId);
if (state.currentTrack) landed.add(state.currentTrack.trackId); if (state.currentTrack) landed.add(state.currentTrack.trackId);
useOrbitStore.getState().reconcilePendingSuggestions(landed); useOrbitStore.getState().reconcilePendingSuggestions(landed);
} }
+16 -21
View File
@@ -9,6 +9,7 @@ import {
applyOutboxSnapshotsToState, applyOutboxSnapshotsToState,
maybeShuffleQueue, maybeShuffleQueue,
effectiveShuffleIntervalMs, effectiveShuffleIntervalMs,
suggestionKey,
} from '../utils/orbit'; } from '../utils/orbit';
import { import {
orbitOutboxPlaylistName, orbitOutboxPlaylistName,
@@ -19,9 +20,6 @@ import {
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import i18n from '../i18n'; import i18n from '../i18n';
/** Stable per-suggestion key — survives reshuffles since all three fields are immutable. */
const suggestionKey = (q: OrbitQueueItem): string => `${q.addedBy}:${q.addedAt}:${q.trackId}`;
/** /**
* Orbit — host-side tick hook. * Orbit — host-side tick hook.
* *
@@ -54,22 +52,11 @@ export function useOrbitHost(): void {
// recompute against, no need to subscribe to every playerStore tick. // recompute against, no need to subscribe to every playerStore tick.
const lastPushedAtRef = useRef(0); const lastPushedAtRef = useRef(0);
/**
* Tracks which guest-submitted queue items we've already appended to the
* local playerStore queue. Prevents duplicate enqueues on every tick and
* when `maybeShuffleQueue` reorders `state.queue` without adding items.
* Reset on session start / end via the `active` effect below.
*/
const mergedSuggestionsRef = useRef<Set<string>>(new Set());
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId; const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
useEffect(() => { useEffect(() => {
if (!active || !sessionPlaylistId) return; if (!active || !sessionPlaylistId) return;
// Fresh session → nothing has been merged yet.
mergedSuggestionsRef.current = new Set();
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => { const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
const p = usePlayerStore.getState(); const p = usePlayerStore.getState();
const now = Date.now(); const now = Date.now();
@@ -162,15 +149,22 @@ export function useOrbitHost(): void {
// Opt-out: host turned auto-approve off. Items still accumulate in // Opt-out: host turned auto-approve off. Items still accumulate in
// `OrbitState.queue` and show up in the guest view / approval list — // `OrbitState.queue` and show up in the guest view / approval list —
// they just don't flow into the host's actual play queue yet. // they just don't flow into the host's actual play queue yet.
const settings = useOrbitStore.getState().state?.settings; const store = useOrbitStore.getState();
const settings = store.state?.settings;
if (settings && settings.autoApprove === false) return; if (settings && settings.autoApprove === false) return;
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and // Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
// must not flow through the merge pipeline again — otherwise the tick // must not flow through the merge pipeline again — otherwise the tick
// would duplicate the track into the upcoming queue. // would duplicate the track into the upcoming queue. Declined items
const hostUser = useOrbitStore.getState().state?.host; // stay out too; merged items are the existing dedup anchor.
const merged = mergedSuggestionsRef.current; const hostUser = store.state?.host;
const pending = items.filter(q => q.addedBy !== hostUser && !merged.has(suggestionKey(q))); const mergedKeys = new Set(store.mergedSuggestionKeys);
const declinedKeys = new Set(store.declinedSuggestionKeys);
const pending = items.filter(q =>
q.addedBy !== hostUser
&& !mergedKeys.has(suggestionKey(q))
&& !declinedKeys.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.
@@ -184,10 +178,11 @@ export function useOrbitHost(): void {
})); }));
const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType<typeof songToTrack> } => r !== null); const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType<typeof songToTrack> } => r !== null);
const markAllAsMerged = () => pending.forEach(q => store.addMergedSuggestion(suggestionKey(q)));
if (toEnqueue.length === 0) { if (toEnqueue.length === 0) {
// Mark the failed lookups as seen anyway so we don't keep retrying // Mark the failed lookups as seen anyway so we don't keep retrying
// every tick for a track the server can't serve. // every tick for a track the server can't serve.
pending.forEach(q => merged.add(suggestionKey(q))); markAllAsMerged();
return; return;
} }
@@ -203,7 +198,7 @@ export function useOrbitHost(): void {
const pos = from + Math.floor(Math.random() * span); const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos); player.enqueueAt([track], pos);
} }
pending.forEach(q => merged.add(suggestionKey(q))); markAllAsMerged();
// Friendly nudge per sweep, not per track — bundled toast if >1. // Friendly nudge per sweep, not per track — bundled toast if >1.
if (toEnqueue.length === 1) { if (toEnqueue.length === 1) {
+4
View File
@@ -1555,6 +1555,10 @@ export const deTranslation = {
guestUpNextEmpty: 'Nichts in der Warteschlange. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen", um etwas vorzuschlagen.', guestUpNextEmpty: 'Nichts in der Warteschlange. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen", um etwas vorzuschlagen.',
guestPendingTitle: 'Wartet auf Host', guestPendingTitle: 'Wartet auf Host',
guestPendingHint: 'Vorgeschlagen — taucht in der Warteschlange auf, sobald der Host sie übernimmt.', guestPendingHint: 'Vorgeschlagen — taucht in der Warteschlange auf, sobald der Host sie übernimmt.',
approvalTitle: 'Zur Freigabe',
approvalFrom: 'Vorgeschlagen von {{user}}',
approvalAccept: 'Übernehmen',
approvalDecline: 'Ablehnen',
guestUpNextMore: '+ {{count}} weitere in der Host-Warteschlange', guestUpNextMore: '+ {{count}} weitere in der Host-Warteschlange',
guestSubmitter: 'von {{user}}', guestSubmitter: 'von {{user}}',
guestEmpty: 'Noch keine Vorschläge. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen".', guestEmpty: 'Noch keine Vorschläge. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen".',
+4
View File
@@ -1558,6 +1558,10 @@ export const enTranslation = {
guestUpNextEmpty: 'Nothing queued. Open a track\'s context menu and pick "Add to Orbit session" to suggest one.', guestUpNextEmpty: 'Nothing queued. Open a track\'s context menu and pick "Add to Orbit session" to suggest one.',
guestPendingTitle: 'Waiting for host', guestPendingTitle: 'Waiting for host',
guestPendingHint: 'Suggested — will appear in the queue when the host picks it up.', guestPendingHint: 'Suggested — will appear in the queue when the host picks it up.',
approvalTitle: 'Pending approvals',
approvalFrom: 'Suggested by {{user}}',
approvalAccept: 'Accept',
approvalDecline: 'Decline',
guestUpNextMore: '+ {{count}} more in the host\'s queue', guestUpNextMore: '+ {{count}} more in the host\'s queue',
guestSubmitter: 'by {{user}}', guestSubmitter: 'by {{user}}',
guestEmpty: 'No suggestions yet. Open a track\'s context menu and pick "Add to Orbit session".', guestEmpty: 'No suggestions yet. Open a track\'s context menu and pick "Add to Orbit session".',
+31 -1
View File
@@ -62,6 +62,19 @@ interface OrbitStore {
* the host's next sweep anyway. * the host's next sweep anyway.
*/ */
pendingSuggestions: string[]; pendingSuggestions: string[];
/**
* Host-only: suggestionKeys (see suggestionKey() in utils/orbit) that
* the host has already merged into the play queue whether via
* auto-approve or an explicit Approve button. Stops the host tick
* from re-inserting the same item on every sweep.
*/
mergedSuggestionKeys: string[];
/**
* Host-only: suggestionKeys that the host explicitly declined. Keeps
* them out of the merge pipeline AND out of the pending-approvals UI
* so a declined suggestion doesn't keep begging for attention.
*/
declinedSuggestionKeys: string[];
// ── Setters (Phase 1 scaffolding; later phases add real actions) ──────── // ── Setters (Phase 1 scaffolding; later phases add real actions) ────────
setPhase: (phase: OrbitPhase) => void; setPhase: (phase: OrbitPhase) => void;
@@ -76,6 +89,10 @@ interface OrbitStore {
addPendingSuggestion: (trackId: string) => void; addPendingSuggestion: (trackId: string) => void;
/** Keep only the pending ids that are NOT yet observable in the shared queue. */ /** Keep only the pending ids that are NOT yet observable in the shared queue. */
reconcilePendingSuggestions: (landedTrackIds: Set<string>) => void; reconcilePendingSuggestions: (landedTrackIds: Set<string>) => void;
/** Host: mark a suggestion as merged so the tick stops re-proposing it. */
addMergedSuggestion: (key: string) => void;
/** Host: mark a suggestion as declined so the approval UI and tick ignore it. */
addDeclinedSuggestion: (key: string) => void;
/** Tear down the session locally. Does NOT clean up remote playlists. */ /** Tear down the session locally. Does NOT clean up remote playlists. */
reset: () => void; reset: () => void;
} }
@@ -90,9 +107,12 @@ const initialState = {
errorMessage: null, errorMessage: null,
joinedAt: null, joinedAt: null,
pendingSuggestions: [] as string[], pendingSuggestions: [] as string[],
mergedSuggestionKeys: [] as string[],
declinedSuggestionKeys: [] as string[],
} satisfies Omit<OrbitStore, } satisfies Omit<OrbitStore,
| 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError' | 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError'
| 'addPendingSuggestion' | 'reconcilePendingSuggestions' | 'reset' | 'addPendingSuggestion' | 'reconcilePendingSuggestions'
| 'addMergedSuggestion' | 'addDeclinedSuggestion' | 'reset'
>; >;
export const useOrbitStore = create<OrbitStore>()((set) => ({ export const useOrbitStore = create<OrbitStore>()((set) => ({
@@ -113,5 +133,15 @@ export const useOrbitStore = create<OrbitStore>()((set) => ({
const next = s.pendingSuggestions.filter(id => !landedTrackIds.has(id)); const next = s.pendingSuggestions.filter(id => !landedTrackIds.has(id));
return next.length === s.pendingSuggestions.length ? s : { pendingSuggestions: next }; return next.length === s.pendingSuggestions.length ? s : { pendingSuggestions: next };
}), }),
addMergedSuggestion: (key) => set(s => (
s.mergedSuggestionKeys.includes(key)
? s
: { mergedSuggestionKeys: [...s.mergedSuggestionKeys, key] }
)),
addDeclinedSuggestion: (key) => set(s => (
s.declinedSuggestionKeys.includes(key)
? s
: { declinedSuggestionKeys: [...s.declinedSuggestionKeys, key] }
)),
reset: () => set({ ...initialState }), reset: () => set({ ...initialState }),
})); }));
+112
View File
@@ -12465,6 +12465,118 @@ html[data-psy-native-hidden="true"] *::after {
font-weight: 600; font-weight: 600;
} }
/* Host manual-approval strip rendered right below OrbitQueueHead when
autoApprove is off and guest suggestions are waiting. Prominent accent
colour so the host can't miss queued approvals. */
.host-approval {
margin: 0 10px 10px;
padding: 10px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
border-radius: var(--radius-md, 10px);
}
.host-approval__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
.host-approval__head svg { color: var(--accent); flex-shrink: 0; }
.host-approval__count {
margin-left: auto;
padding: 1px 7px;
background: var(--accent);
color: var(--bg-primary, #0b0b13);
border-radius: 999px;
font-size: 10px;
letter-spacing: 0;
text-transform: none;
}
.host-approval__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.host-approval__item {
display: flex;
align-items: center;
gap: 9px;
padding: 6px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
}
.host-approval__cover {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.host-approval__cover--ph {
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
}
.host-approval__info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.host-approval__title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-approval__artist {
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-approval__submitter {
margin-top: 2px;
font-size: 10.5px;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-approval__actions {
display: inline-flex;
gap: 4px;
flex-shrink: 0;
}
.host-approval__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid color-mix(in srgb, var(--text-primary) 18%, transparent);
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.host-approval__btn--approve:hover {
background: color-mix(in srgb, var(--ctp-green, #a6e3a1) 28%, transparent);
border-color: var(--ctp-green, #a6e3a1);
color: var(--ctp-green, #a6e3a1);
}
.host-approval__btn--decline:hover {
background: color-mix(in srgb, var(--danger, #f38ba8) 28%, transparent);
border-color: var(--danger, #f38ba8);
color: var(--danger, #f38ba8);
}
/* Pending suggestions tracks the guest has submitted but the host /* Pending suggestions tracks the guest has submitted but the host
hasn't merged yet. Looks like the regular queue section, with a subtle hasn't merged yet. Looks like the regular queue section, with a subtle
yellow accent and a clock icon in the heading. */ yellow accent and a clock icon in the heading. */
+35
View File
@@ -511,6 +511,41 @@ export async function suggestOrbitTrack(trackId: string): Promise<void> {
useOrbitStore.getState().addPendingSuggestion(trackId); useOrbitStore.getState().addPendingSuggestion(trackId);
} }
/**
* Stable per-suggestion key across reshuffles `addedBy`, `addedAt` and
* `trackId` are all immutable once the host sweep has written them.
* Shared between the host tick and the manual-approval UI.
*/
export const suggestionKey = (q: OrbitQueueItem): string =>
`${q.addedBy}:${q.addedAt}:${q.trackId}`;
/**
* Host: accept a guest suggestion and route it into the live play queue.
* No-op outside host role. Uses the shared `mergedSuggestionKeys` store
* slot so the tick doesn't re-process the same item.
*/
export async function approveOrbitSuggestion(q: OrbitQueueItem): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state) return;
try {
const song = await getSong(q.trackId);
if (!song) return;
const track = songToTrack(song);
usePlayerStore.getState().enqueue([track]);
store.addMergedSuggestion(suggestionKey(q));
} catch { /* silent */ }
}
/**
* Host: reject a guest suggestion. It stays in `OrbitState.queue` as
* history but is filtered out of the approval UI and the merge tick.
*/
export function declineOrbitSuggestion(q: OrbitQueueItem): void {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
store.addDeclinedSuggestion(suggestionKey(q));
}
/** /**
* Host: add a track to the active Orbit session directly, skipping the * 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 * outbox/approval loop guests go through. The track lands in the host's