feat(orbit): show track attribution in host queue + host label in guest queue

Before: the guest queue already labelled guest-suggested tracks with
"Suggested by {user}", but host picks had no label — and the host's own
queue had no attribution at all. The host couldn't tell which upcoming
rows came from guests without checking the pending-approvals list.

- Guest queue: host-pick rows now show "Added by host" instead of hiding
  the attribution line.
- Host queue: each row (and the current track) shows "Added by you" or
  "Added by {user}" while an Orbit session is active. Lookup uses the
  existing OrbitState.queue / currentTrack addedBy field, so no new
  protocol fields.

New i18n keys (en+de+fr+nl+zh+nb+ru+es):
  orbit.queueAddedByHost · queueAddedByYou · queueAddedByUser

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-25 00:28:46 +02:00
parent 2b1124d14a
commit a84be98140
11 changed files with 79 additions and 5 deletions
+5 -5
View File
@@ -169,11 +169,11 @@ export default function OrbitGuestQueue() {
<div className="orbit-guest-queue__track-artist"> <div className="orbit-guest-queue__track-artist">
{song?.artist ?? ''} {song?.artist ?? ''}
</div> </div>
{!isHostPick && ( <div className="orbit-guest-queue__submitter">
<div className="orbit-guest-queue__submitter"> {isHostPick
{t('orbit.guestSubmitter', { user: q.addedBy })} ? t('orbit.queueAddedByHost')
</div> : t('orbit.guestSubmitter', { user: q.addedBy })}
)} </div>
</div> </div>
</div> </div>
); );
+30
View File
@@ -251,6 +251,28 @@ function QueuePanelHostOrSolo() {
const navigate = useNavigate(); const navigate = useNavigate();
const orbitRole = useOrbitStore(s => s.role); const orbitRole = useOrbitStore(s => s.role);
const orbitState = useOrbitStore(s => s.state); const orbitState = useOrbitStore(s => s.state);
/** trackId → addedBy (host username or guest username) — only populated while
* hosting an Orbit session, so the queue rows can surface attribution. */
const orbitAddedByByTrack = useMemo(() => {
const map = new Map<string, string>();
if (orbitRole !== 'host' || !orbitState) return map;
if (orbitState.currentTrack) {
map.set(orbitState.currentTrack.trackId, orbitState.currentTrack.addedBy);
}
for (const q of orbitState.queue) map.set(q.trackId, q.addedBy);
return map;
}, [orbitRole, orbitState]);
const orbitHostUsername = orbitState?.host ?? '';
/** Attribution label for a queue row / current track while hosting. Null when
* not hosting, when the track isn't part of the Orbit state, or when it
* isn't relevant for this user (non-host and we haven't mapped the added-by). */
const orbitAttributionLabel = (trackId: string): string | null => {
if (orbitRole !== 'host') return null;
const addedBy = orbitAddedByByTrack.get(trackId);
if (!addedBy) return null;
if (addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
return t('orbit.queueAddedByUser', { user: addedBy });
};
const queue = usePlayerStore(s => s.queue); const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex); const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
@@ -571,6 +593,10 @@ function QueuePanelHostOrSolo() {
{currentTrack.year && ( {currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div> <div className="queue-current-sub">{currentTrack.year}</div>
)} )}
{(() => {
const label = orbitAttributionLabel(currentTrack.id);
return label ? <div className="queue-current-sub queue-current-attribution">{label}</div> : null;
})()}
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)} {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
</div> </div>
</div> </div>
@@ -748,6 +774,10 @@ function QueuePanelHostOrSolo() {
<span className="truncate">{track.title}</span> <span className="truncate">{track.title}</span>
</div> </div>
<div className="queue-item-artist truncate">{track.artist}</div> <div className="queue-item-artist truncate">{track.artist}</div>
{(() => {
const label = orbitAttributionLabel(track.id);
return label ? <div className="queue-item-attribution truncate">{label}</div> : null;
})()}
</div> </div>
<div className="queue-item-duration"> <div className="queue-item-duration">
{formatTime(track.duration)} {formatTime(track.duration)}
+3
View File
@@ -1650,6 +1650,9 @@ export const deTranslation = {
approvalDecline: 'Ablehnen', approvalDecline: 'Ablehnen',
guestUpNextMore: '+ {{count}} weitere in der Host-Warteschlange', guestUpNextMore: '+ {{count}} weitere in der Host-Warteschlange',
guestSubmitter: 'von {{user}}', guestSubmitter: 'von {{user}}',
queueAddedByHost: 'Vom Host hinzugefügt',
queueAddedByYou: 'Von dir hinzugefügt',
queueAddedByUser: 'Hinzugefügt 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".',
guestFooter: 'Die Wiedergabe wird vom Gastgeber gesteuert — du kannst die Liste nicht ändern.', guestFooter: 'Die Wiedergabe wird vom Gastgeber gesteuert — du kannst die Liste nicht ändern.',
exitKickedTitle: 'Du wurdest aus der Session gebannt', exitKickedTitle: 'Du wurdest aus der Session gebannt',
+3
View File
@@ -1654,6 +1654,9 @@ export const enTranslation = {
approvalDecline: 'Decline', approvalDecline: 'Decline',
guestUpNextMore: '+ {{count}} more in the host\'s queue', guestUpNextMore: '+ {{count}} more in the host\'s queue',
guestSubmitter: 'by {{user}}', guestSubmitter: 'by {{user}}',
queueAddedByHost: 'Added by host',
queueAddedByYou: 'Added by you',
queueAddedByUser: 'Added 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".',
guestFooter: "The host controls playback — you can't change the list.", guestFooter: "The host controls playback — you can't change the list.",
exitKickedTitle: 'You were banned from the session', exitKickedTitle: 'You were banned from the session',
+3
View File
@@ -1637,6 +1637,9 @@ export const esTranslation = {
approvalDecline: 'Rechazar', approvalDecline: 'Rechazar',
guestUpNextMore: '+ {{count}} más en la cola del anfitrión', guestUpNextMore: '+ {{count}} más en la cola del anfitrión',
guestSubmitter: 'de {{user}}', guestSubmitter: 'de {{user}}',
queueAddedByHost: 'Añadido por el anfitrión',
queueAddedByYou: 'Añadido por ti',
queueAddedByUser: 'Añadido por {{user}}',
guestEmpty: 'Aún no hay sugerencias. Abre el menú contextual de una canción y elige "Añadir a la sesión Orbit".', guestEmpty: 'Aún no hay sugerencias. Abre el menú contextual de una canción y elige "Añadir a la sesión Orbit".',
guestFooter: 'El anfitrión controla la reproducción — no puedes cambiar la lista.', guestFooter: 'El anfitrión controla la reproducción — no puedes cambiar la lista.',
exitKickedTitle: 'Has sido baneado de la sesión', exitKickedTitle: 'Has sido baneado de la sesión',
+3
View File
@@ -1632,6 +1632,9 @@ export const frTranslation = {
approvalDecline: 'Refuser', approvalDecline: 'Refuser',
guestUpNextMore: '+ {{count}} autres dans la file de l\'hôte', guestUpNextMore: '+ {{count}} autres dans la file de l\'hôte',
guestSubmitter: 'par {{user}}', guestSubmitter: 'par {{user}}',
queueAddedByHost: 'Ajouté par l\'hôte',
queueAddedByYou: 'Ajouté par toi',
queueAddedByUser: 'Ajouté par {{user}}',
guestEmpty: 'Aucune suggestion. Ouvre le menu contextuel d\'un morceau et choisis « Ajouter à la session Orbit ».', guestEmpty: 'Aucune suggestion. Ouvre le menu contextuel d\'un morceau et choisis « Ajouter à la session Orbit ».',
guestFooter: 'L\'hôte contrôle la lecture — tu ne peux pas modifier la liste.', guestFooter: 'L\'hôte contrôle la lecture — tu ne peux pas modifier la liste.',
exitKickedTitle: 'Tu as été banni de la session', exitKickedTitle: 'Tu as été banni de la session',
+3
View File
@@ -1631,6 +1631,9 @@ export const nbTranslation = {
approvalDecline: 'Avvis', approvalDecline: 'Avvis',
guestUpNextMore: '+ {{count}} flere i vertens kø', guestUpNextMore: '+ {{count}} flere i vertens kø',
guestSubmitter: 'av {{user}}', guestSubmitter: 'av {{user}}',
queueAddedByHost: 'Lagt til av verten',
queueAddedByYou: 'Lagt til av deg',
queueAddedByUser: 'Lagt til av {{user}}',
guestEmpty: 'Ingen forslag ennå. Åpne et spors kontekstmeny og velg "Legg til i Orbit-økt".', guestEmpty: 'Ingen forslag ennå. Åpne et spors kontekstmeny og velg "Legg til i Orbit-økt".',
guestFooter: 'Verten kontrollerer avspillingen — du kan ikke endre listen.', guestFooter: 'Verten kontrollerer avspillingen — du kan ikke endre listen.',
exitKickedTitle: 'Du ble utestengt fra økten', exitKickedTitle: 'Du ble utestengt fra økten',
+3
View File
@@ -1631,6 +1631,9 @@ export const nlTranslation = {
approvalDecline: 'Weigeren', approvalDecline: 'Weigeren',
guestUpNextMore: '+ {{count}} meer in de wachtrij van de host', guestUpNextMore: '+ {{count}} meer in de wachtrij van de host',
guestSubmitter: 'door {{user}}', guestSubmitter: 'door {{user}}',
queueAddedByHost: 'Toegevoegd door host',
queueAddedByYou: 'Toegevoegd door jou',
queueAddedByUser: 'Toegevoegd door {{user}}',
guestEmpty: 'Nog geen suggesties. Open het contextmenu van een nummer en kies "Aan Orbit-sessie toevoegen".', guestEmpty: 'Nog geen suggesties. Open het contextmenu van een nummer en kies "Aan Orbit-sessie toevoegen".',
guestFooter: 'De host bedient de weergave — je kunt de lijst niet wijzigen.', guestFooter: 'De host bedient de weergave — je kunt de lijst niet wijzigen.',
exitKickedTitle: 'Je bent uit de sessie gebannen', exitKickedTitle: 'Je bent uit de sessie gebannen',
+3
View File
@@ -1713,6 +1713,9 @@ export const ruTranslation = {
approvalDecline: 'Отклонить', approvalDecline: 'Отклонить',
guestUpNextMore: '+ ещё {{count}} в очереди хоста', guestUpNextMore: '+ ещё {{count}} в очереди хоста',
guestSubmitter: 'от {{user}}', guestSubmitter: 'от {{user}}',
queueAddedByHost: 'Добавил хост',
queueAddedByYou: 'Добавил(а) ты',
queueAddedByUser: 'Добавил {{user}}',
guestEmpty: 'Предложений пока нет. Открой контекстное меню трека и выбери «Добавить в сессию Orbit».', guestEmpty: 'Предложений пока нет. Открой контекстное меню трека и выбери «Добавить в сессию Orbit».',
guestFooter: 'Хост управляет воспроизведением — ты не можешь изменять список.', guestFooter: 'Хост управляет воспроизведением — ты не можешь изменять список.',
exitKickedTitle: 'Тебя забанили в сессии', exitKickedTitle: 'Тебя забанили в сессии',
+3
View File
@@ -1624,6 +1624,9 @@ export const zhTranslation = {
approvalDecline: '拒绝', approvalDecline: '拒绝',
guestUpNextMore: '+ 主持人队列中还有 {{count}} 首', guestUpNextMore: '+ 主持人队列中还有 {{count}} 首',
guestSubmitter: '来自 {{user}}', guestSubmitter: '来自 {{user}}',
queueAddedByHost: '由主持人添加',
queueAddedByYou: '由你添加',
queueAddedByUser: '由 {{user}} 添加',
guestEmpty: '还没有建议。打开曲目的上下文菜单并选择"添加到 Orbit 会话"。', guestEmpty: '还没有建议。打开曲目的上下文菜单并选择"添加到 Orbit 会话"。',
guestFooter: '主持人控制播放 — 你无法更改列表。', guestFooter: '主持人控制播放 — 你无法更改列表。',
exitKickedTitle: '你已被会话封禁', exitKickedTitle: '你已被会话封禁',
+20
View File
@@ -2160,6 +2160,26 @@ html[data-platform="windows"] .player-bar.floating {
opacity: 0.8; opacity: 0.8;
} }
.queue-item-attribution,
.queue-current-attribution {
font-size: 10px;
color: var(--text-muted);
opacity: 0.75;
margin-top: 1px;
font-style: italic;
}
.queue-item:hover .queue-item-attribution,
.queue-item.context-active .queue-item-attribution {
color: var(--text-secondary);
opacity: 0.9;
}
.queue-item.active .queue-item-attribution {
color: inherit;
opacity: 0.7;
}
/* /*
MOBILE LAYOUT (< 800px) MOBILE LAYOUT (< 800px)
Controller: data-mobile attribute set by useIsMobile hook Controller: data-mobile attribute set by useIsMobile hook