diff --git a/desktop/src/atoms/writable.ts b/desktop/src/atoms/writable.ts new file mode 100644 index 00000000..ae289464 --- /dev/null +++ b/desktop/src/atoms/writable.ts @@ -0,0 +1,16 @@ +import { atom } from "jotai"; +import type { NfcWriteTarget } from "../components/WriteToNfcMenuItem"; + +/** + * What the page on screen would write to a tag or card, if asked. + * + * The context menus name their subject by being attached to it; a keyboard + * shortcut has no such anchor, so the page publishes what it is showing and the + * global handler reads it. Null on a page with nothing writable, which is what + * makes the shortcut a no-op there rather than acting on a stale subject. + */ +export const writableTargetAtom = atom<{ + target: NfcWriteTarget; + label: string; + sublabel?: string; +} | null>(null); diff --git a/desktop/src/components/KeyboardShortcuts/KeyboardShortcuts.tsx b/desktop/src/components/KeyboardShortcuts/KeyboardShortcuts.tsx index 3b71a37b..77ae5503 100644 --- a/desktop/src/components/KeyboardShortcuts/KeyboardShortcuts.tsx +++ b/desktop/src/components/KeyboardShortcuts/KeyboardShortcuts.tsx @@ -17,6 +17,9 @@ import { fullscreenPlayerAtom } from "../../atoms/fullscreenPlayer"; import { nowPlayingAtom } from "../../atoms/nowpaying"; import { rightPaneHiddenAtom } from "../../atoms/rightPane"; import { shortcutsHelpOpenAtom } from "../../atoms/shortcuts"; +import { writableTargetAtom } from "../../atoms/writable"; +import { nfcWriteTargetAtom } from "../../atoms/nfc"; +import { payloadsForTarget } from "../WriteToNfcMenuItem"; import { themeAtom } from "../../atoms/theme"; // ── Shortcut catalogue (also drives the help modal) ───────────────────────── @@ -48,6 +51,10 @@ const GROUPS: Group[] = [ { keys: ["?"], label: "Show this help" }, { keys: ["Esc"], label: "Close dialog / search" }, { keys: ["t"], label: "Toggle light / dark theme" }, + { + keys: ["T"], + label: "Write this album, playlist or favorites to an NFC tag or card", + }, { keys: ["\\"], label: "Show / hide the side panel" }, { keys: ["f"], label: "Toggle the fullscreen player" }, { keys: ["e"], label: "Equalizer (audio settings)" }, @@ -195,6 +202,9 @@ function KeyboardShortcuts() { const [libraryPlaylistOpen, setLibraryPlaylistOpen] = useAtom( libraryPlaylistModalOpenAtom, ); + // What the page on screen can write, and where a write is parked once armed. + const writable = useAtomValue(writableTargetAtom); + const setNfcWriteTarget = useSetAtom(nfcWriteTargetAtom); // The keydown listener is installed once; read the latest values through a // ref so it never closes over stale state. @@ -219,6 +229,8 @@ function KeyboardShortcuts() { setCreatePlaylistOpen, libraryPlaylistOpen, setLibraryPlaylistOpen, + writable, + setNfcWriteTarget, }); stateRef.current = { navigate, @@ -241,6 +253,8 @@ function KeyboardShortcuts() { setCreatePlaylistOpen, libraryPlaylistOpen, setLibraryPlaylistOpen, + writable, + setNfcWriteTarget, }; useEffect(() => { @@ -337,6 +351,27 @@ function KeyboardShortcuts() { e.preventDefault(); toggleTheme(); break; + // Shift+T → write what this page is showing to a tag or card. The + // pages publish their subject (see useWritable); a page with nothing + // writable leaves this inert rather than acting on a stale one. + // + // One key rather than the TUI's two: an armed write here is taken by + // whichever medium turns up, so a tap and an insert are the same + // gesture as far as the app is concerned. + case "T": { + const w = s.writable; + if (!w) break; + e.preventDefault(); + const { payloads, portable } = payloadsForTarget(w.target); + if (!payloads.length) break; + s.setNfcWriteTarget({ + payloads, + label: w.label, + sublabel: w.sublabel, + portable, + }); + break; + } case "\\": e.preventDefault(); s.setPaneHidden(!s.paneHidden); diff --git a/desktop/src/components/WriteToNfcMenuItem.tsx b/desktop/src/components/WriteToNfcMenuItem.tsx index 5af4dce4..8863efa7 100644 --- a/desktop/src/components/WriteToNfcMenuItem.tsx +++ b/desktop/src/components/WriteToNfcMenuItem.tsx @@ -53,6 +53,26 @@ export type NfcWriteTarget = } | { kind: "favorites"; did: string }; +/** + * The records a target goes on a tag or card as, and whether it will work + * outside the owner's library. Shared with the T shortcut so a keyboard write + * and a menu write cannot drift apart. + */ +export function payloadsForTarget(target: NfcWriteTarget): { + payloads: string[]; + portable: boolean; +} { + // A favorites target names a person, not a server row, so it travels with + // them: portable in the same sense a record URI is. + if (target.kind === "favorites") { + return { payloads: nfcFavoritesPayloads(target.did), portable: true }; + } + return { + payloads: nfcPayloadsFor(target.kind, { uri: target.uri, id: target.id }), + portable: isPortableRef({ uri: target.uri }), + }; +} + export function WriteToNfcMenuItem({ target, label, @@ -67,14 +87,7 @@ export function WriteToNfcMenuItem({ const setTarget = useSetAtom(nfcWriteTargetAtom); const { ready, reason } = useNfcReady(); - // A favorites tag or card names a person, not a server row, so it travels - // with them: portable in the same sense a record URI is. - const payloads = - target.kind === "favorites" - ? nfcFavoritesPayloads(target.did) - : nfcPayloadsFor(target.kind, { uri: target.uri, id: target.id }); - const portable = - target.kind === "favorites" ? true : isPortableRef({ uri: target.uri }); + const { payloads, portable } = payloadsForTarget(target); const unwritable = payloads.length === 0 ? "Nothing to write yet" : null; diff --git a/desktop/src/hooks/useWritable.ts b/desktop/src/hooks/useWritable.ts new file mode 100644 index 00000000..7f9bb292 --- /dev/null +++ b/desktop/src/hooks/useWritable.ts @@ -0,0 +1,31 @@ +import { useSetAtom } from "jotai"; +import { useEffect } from "react"; +import type { NfcWriteTarget } from "../components/WriteToNfcMenuItem"; +import { writableTargetAtom } from "../atoms/writable"; + +/** + * Publish what this page would write to a tag or card, for the T shortcut. + * + * Cleared on unmount, so leaving a page takes its subject with it and the + * shortcut cannot act on something no longer on screen. + * + * `writable` is expected to be null while the page is still loading; passing it + * through unchanged is what keeps the shortcut inert until there is something + * real to write. + */ +export function useWritable( + writable: { target: NfcWriteTarget; label: string; sublabel?: string } | null, +) { + const publish = useSetAtom(writableTargetAtom); + + // Depend on the parts rather than the object: callers build it inline, so a + // fresh identity every render would republish on every render. + const { target, label, sublabel } = writable ?? {}; + const key = target ? JSON.stringify(target) : null; + + useEffect(() => { + publish(target && label ? { target, label, sublabel } : null); + return () => publish(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, label, sublabel, publish]); +} diff --git a/desktop/src/pages/library/Library.tsx b/desktop/src/pages/library/Library.tsx index c0af1037..1c5b7f16 100644 --- a/desktop/src/pages/library/Library.tsx +++ b/desktop/src/pages/library/Library.tsx @@ -49,6 +49,7 @@ import { DropdownPortal } from "../../components/DropdownPortal"; import { AddToPlaylistMenu } from "../../components/AddToPlaylistMenu"; import PlaylistSearch from "../../components/PlaylistSearch"; import { WriteToNfcMenuItem } from "../../components/WriteToNfcMenuItem"; +import { useWritable } from "../../hooks/useWritable"; import TrackArtMosaic from "../../components/TrackArtMosaic"; import { IconPlaylist, IconPlus } from "@tabler/icons-react"; import { useAtomValue, useSetAtom } from "jotai"; @@ -1104,6 +1105,15 @@ export default function Library() { if (search.tab) setActiveKey(tabKeyFor(search.tab)); }, [search.tab]); + // Shift+T on the Favorites tab writes the favorites themselves. The other + // tabs are lists rather than a single subject, so it stays inert there — + // those rows have their own context-menu entry. + useWritable( + did && activeKey === tabKeyFor("favorites") + ? { target: { kind: "favorites", did }, label: "Favorites" } + : null, + ); + const [openPlaylistMenuId, setOpenPlaylistMenuId] = useState(null); const [playlistMenuAnchor, setPlaylistMenuAnchor] = useState(null); const [openMenuId, setOpenMenuId] = useState(null); diff --git a/desktop/src/pages/library/LibraryAlbum.tsx b/desktop/src/pages/library/LibraryAlbum.tsx index db03da7e..cf8ab271 100644 --- a/desktop/src/pages/library/LibraryAlbum.tsx +++ b/desktop/src/pages/library/LibraryAlbum.tsx @@ -26,6 +26,7 @@ import Main from "../../layouts/Main"; import { DropdownPortal } from "../../components/DropdownPortal"; import { AddToPlaylistMenu } from "../../components/AddToPlaylistMenu"; import { WriteToNfcMenuItem } from "../../components/WriteToNfcMenuItem"; +import { useWritable } from "../../hooks/useWritable"; // --------------------------------------------------------------------------- // Helpers @@ -517,6 +518,17 @@ export default function LibraryAlbum() { const songs: NavidromeSong[] = album?.song ?? []; + // What Shift+T writes on this page. + useWritable( + album?.id + ? { + target: { kind: "album", id: album.id, uri: album.uri }, + label: album.name, + sublabel: songs[0]?.albumArtist ?? songs[0]?.artist, + } + : null, + ); + // idx is the position in the flat list, which is what the queue and the // context menu index into — grouping must not renumber it. const discs = useMemo(() => { diff --git a/desktop/src/pages/library/LibraryPlaylist.tsx b/desktop/src/pages/library/LibraryPlaylist.tsx index e52d2738..93c18df0 100644 --- a/desktop/src/pages/library/LibraryPlaylist.tsx +++ b/desktop/src/pages/library/LibraryPlaylist.tsx @@ -33,6 +33,7 @@ import Main from "../../layouts/Main"; import { DropdownPortal } from "../../components/DropdownPortal"; import { AddToPlaylistMenu } from "../../components/AddToPlaylistMenu"; import { WriteToNfcMenuItem } from "../../components/WriteToNfcMenuItem"; +import { useWritable } from "../../hooks/useWritable"; import PlaylistSearch from "../../components/PlaylistSearch"; import TrackArtMosaic from "../../components/TrackArtMosaic"; import { useSetAtom } from "jotai"; @@ -558,6 +559,17 @@ export default function LibraryPlaylist() { const setEditingPlaylist = useSetAtom(editingLibraryPlaylistAtom); const setAddSongsTarget = useSetAtom(addLibrarySongsTargetAtom); + // What Shift+T writes on this page. + useWritable( + playlist?.id + ? { + target: { kind: "playlist", id: playlist.id, uri: playlist.uri }, + label: playlist.name, + sublabel: `${playlist.songCount} tracks`, + } + : null, + ); + const [openMenuIdx, setOpenMenuIdx] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); const [filter, setFilter] = useState("");