import { RockskyClient } from "client"; import { Box, Text, useInput } from "ink"; import { useAtom, useAtomValue } from "jotai"; import React, { useState } from "react"; import { Cell, Ell } from "./Columns"; import { fmtDuration } from "./format"; import { List } from "./List"; import { exportQueue } from "./navidrome"; import { jumpTo } from "./playback"; import { playerController } from "./player"; import { queryClient } from "./queryClient"; import { authAtom, playerStatusAtom, queueOpenAtom, queueVersionAtom } from "./store"; import { BLUE, TEAL, VIOLET } from "./theme"; export function QueueView({ height }: { height: number }) { const [, setOpen] = useAtom(queueOpenAtom); const token = useAtomValue(authAtom); const status = useAtomValue(playerStatusAtom); useAtomValue(queueVersionAtom); // re-render on queue changes const items = playerController.queueItems; const playingIndex = status?.index ?? -1; const [selected, setSelected] = useState( playingIndex >= 0 ? playingIndex : 0, ); const [exporting, setExporting] = useState(false); const [name, setName] = useState(""); const [note, setNote] = useState(""); async function saveAsPlaylist(playlistName: string) { try { // Resolve every queue item to its Subsonic song id (tracks.xata_id). // Items played this session already carry `trackId`; older ones restored // from a saved session may only have `uploadId`, so map those via the // uploads API. const trackIds = items.map((i) => i.trackId).filter(Boolean) as string[]; const unresolvable = items.filter((i) => !i.trackId && !i.uploadId).length; const needResolve = items.filter((i) => !i.trackId && i.uploadId); let missed = 0; if (needResolve.length > 0 && token) { setNote("Resolving tracks…"); const wanted = new Set(needResolve.map((i) => i.uploadId)); const client = new RockskyClient(token); let offset = 0; while (wanted.size > 0 && offset < 5000) { const rows: any[] = await client.getUploads({ skip: offset, limit: 200 }); if (!rows.length) break; for (const r of rows) { if (wanted.has(r.upload.id)) { trackIds.push(r.track.id); wanted.delete(r.upload.id); } } offset += rows.length; if (rows.length < 200) break; } missed = wanted.size; } const songIds = [...new Set(trackIds)]; // dedupe repeated tracks if (songIds.length === 0) { setNote("No exportable tracks in the queue."); return; } setNote(`Saving "${playlistName}"…`); if (!token) { setNote("Sign in to save playlists."); return; } const { added, failed } = await exportQueue(token, playlistName, songIds); queryClient.invalidateQueries({ queryKey: ["playlists"] }); const skipped = failed + missed + unresolvable; setNote( skipped > 0 ? `Saved "${playlistName}" — ${added} tracks (${skipped} skipped)` : `Saved "${playlistName}" (${added} tracks)`, ); } catch (e: any) { setNote(`Error: ${e.message}`); } } useInput((input, key) => { if (exporting) { if (key.escape) { setExporting(false); setName(""); return; } if (key.return) { const n = name.trim(); setExporting(false); setName(""); if (n) void saveAsPlaylist(n); return; } if (key.backspace || key.delete) return setName((s) => s.slice(0, -1)); if (input && input >= " " && !key.ctrl && !key.meta) setName((s) => s + input); return; } if (key.escape || input === "Q") return setOpen(false); if (key.upArrow || input === "k") return setSelected((s) => Math.max(0, s - 1)); if (key.downArrow || input === "j") return setSelected((s) => Math.min(items.length - 1, s + 1)); if (input === "d" || key.delete || key.backspace) { playerController.removeAt(selected); // Clamp selection to the shortened queue (also forces a re-render). setSelected((s) => Math.max(0, Math.min(s, items.length - 2))); return; } if (input === "s") { setExporting(true); setName(""); return; } if (key.return) { if (token) void jumpTo(token, selected); setOpen(false); } }); if (exporting) { return ( {" Save queue as playlist "} {"name "} {name} ▏ {`${items.length} tracks · Enter to save · Esc to cancel`} ); } return ( {" Current Queue "} {` ${items.length} track${items.length === 1 ? "" : "s"} · Enter jump · d remove · s save · Esc close`} { const isPlaying = idx === playingIndex; return ( <> {isPlaying ? "♪" : active ? "›" : " "} {t.title} {t.artist} {t.duration ? fmtDuration(t.duration) : ""} ); }} /> {note ? {note} : null} ); }