@behindthemusictree/app-kit-ui 29.1.0 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import {
5
5
  } from "./chunk-DKQQLLMY.js";
6
6
 
7
7
  // src/genre-tree/GenreTreeView.tsx
8
- import { useCallback as useCallback6, useDeferredValue, useEffect as useEffect6, useMemo as useMemo7, useRef as useRef4, useState as useState5 } from "react";
8
+ import { useCallback as useCallback6, useDeferredValue, useEffect as useEffect6, useMemo as useMemo7, useRef as useRef4, useState as useState7 } from "react";
9
9
  import { useIsMutating } from "@tanstack/react-query";
10
10
  import { Plus as Plus2 } from "lucide-react";
11
11
  import { IconTextButton, Button, SegmentedControl as SegmentedControl2 } from "@behindthemusictree/ui";
@@ -14,7 +14,9 @@ import {
14
14
  GenreTreeViewSkeleton
15
15
  } from "@behindthemusictree/genre-tree-view";
16
16
  import {
17
- useArtistSearch
17
+ useArtistSearch,
18
+ useSongSearch,
19
+ isSongSearchResultPlayable
18
20
  } from "@behindthemusictree/app-kit/genre-tree";
19
21
  import { useGenreTreeViewState, useArtist as useArtist2, useUpdateGenre as useUpdateGenre2, genreUpdateMutationKey } from "@behindthemusictree/app-kit/genre-tree";
20
22
  import { usePopup as usePopup5 } from "@behindthemusictree/app-kit/popup";
@@ -499,10 +501,11 @@ function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
499
501
  }
500
502
 
501
503
  // src/genre-tree/GenreSearch.tsx
504
+ import { useState as useState2 } from "react";
502
505
  import { ArrowLeft, LoaderCircle, Search, X } from "lucide-react";
503
- import { Input } from "@behindthemusictree/ui";
506
+ import { Combobox } from "@behindthemusictree/ui";
504
507
  import { useGenreSearch } from "@behindthemusictree/app-kit/genre-tree";
505
- import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
508
+ import { Fragment, jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
506
509
  function GenreSearch({
507
510
  genrePlaylists,
508
511
  onSelect,
@@ -511,7 +514,7 @@ function GenreSearch({
511
514
  onPrevious,
512
515
  placeholder,
513
516
  onQueryChange,
514
- extraResults
517
+ extraGroups = []
515
518
  }) {
516
519
  const { query, setQuery, results, canClear, clear, select } = useGenreSearch({
517
520
  genrePlaylists,
@@ -519,11 +522,45 @@ function GenreSearch({
519
522
  selectedName,
520
523
  onClear
521
524
  });
522
- const extraItems = query !== "" && query !== selectedName ? extraResults?.items ?? [] : [];
523
- const label = extraResults ? "Search" : "Search a genre";
524
- const loading = query !== "" && query !== selectedName && (extraResults?.loading ?? false);
525
- const showList = results.length > 0 || extraItems.length > 0;
526
- const heading = "px-3 pt-2 pb-1 text-xs font-medium uppercase text-[var(--color-text-muted)]";
525
+ const [open, setOpen] = useState2(false);
526
+ const active = query !== "" && query !== selectedName;
527
+ const hasExtras = extraGroups.length > 0;
528
+ const label = hasExtras ? "Search" : "Search a genre";
529
+ const loading = active && extraGroups.some((group) => group.loading);
530
+ const status = active ? extraGroups.map((group) => group.status).filter(Boolean).join(". ") : "";
531
+ const groups = [
532
+ {
533
+ key: "genres",
534
+ label: hasExtras ? "Genres" : void 0,
535
+ options: results.map((genrePlaylist) => ({
536
+ key: genrePlaylist.uuid,
537
+ content: /* @__PURE__ */ jsxs2(Fragment, { children: [
538
+ genrePlaylist.name,
539
+ genrePlaylist.isUmbrella && /* @__PURE__ */ jsx5("span", { className: "ml-2 text-xs italic text-[var(--color-text-muted)]", children: "umbrella" })
540
+ ] })
541
+ }))
542
+ },
543
+ ...extraGroups.map((group) => ({
544
+ key: group.key,
545
+ label: group.label,
546
+ options: active ? group.items.map((item) => ({ key: item.key, content: item.content })) : []
547
+ }))
548
+ ];
549
+ const handlePick = (groupKey, optionKey) => {
550
+ setOpen(false);
551
+ if (groupKey === "genres") {
552
+ const genrePlaylist = results.find((result) => result.uuid === optionKey);
553
+ if (!genrePlaylist) return;
554
+ select(genrePlaylist);
555
+ onQueryChange?.("");
556
+ return;
557
+ }
558
+ const item = extraGroups.find((group) => group.key === groupKey)?.items.find((entry) => entry.key === optionKey);
559
+ if (!item) return;
560
+ setQuery("");
561
+ onQueryChange?.("");
562
+ item.onSelect();
563
+ };
527
564
  return /* @__PURE__ */ jsxs2("div", { className: "genre-search relative w-[calc(var(--layout-detail-panel-w,25.5rem)-2*var(--layout-panel-inset,1rem))] shrink-0 max-md:w-full", children: [
528
565
  onPrevious && /* @__PURE__ */ jsx5(
529
566
  "button",
@@ -537,47 +574,45 @@ function GenreSearch({
537
574
  }
538
575
  ),
539
576
  /* @__PURE__ */ jsx5(
540
- Input,
577
+ Combobox,
541
578
  {
542
- type: "text",
579
+ ariaLabel: label,
580
+ listLabel: `${label} results`,
543
581
  value: query,
544
- onChange: (event) => {
545
- setQuery(event.target.value);
546
- onQueryChange?.(event.target.value);
547
- },
548
582
  placeholder: placeholder ?? `${label}\u2026`,
549
- "aria-label": label,
550
- "aria-busy": loading,
551
- inputMode: "search",
552
- enterKeyHint: "search",
553
- autoComplete: "off",
554
- className: "h-[var(--layout-search-h,3rem)] pl-6 pr-20 text-base transition-[padding] duration-200 ease-out motion-reduce:transition-none bg-[var(--color-surface)] border-[var(--color-border)] rounded-full shadow-md"
583
+ groups,
584
+ open,
585
+ onOpenChange: setOpen,
586
+ onValueChange: (value) => {
587
+ setQuery(value);
588
+ onQueryChange?.(value);
589
+ },
590
+ onPick: handlePick,
591
+ status,
592
+ loading: loading ? "Searching\u2026" : void 0,
593
+ inputClassName: "h-[var(--layout-search-h,3rem)] pl-6 pr-20 text-base transition-[padding] duration-200 ease-out motion-reduce:transition-none bg-[var(--color-surface)] border-[var(--color-border)] rounded-full shadow-md",
594
+ className: "genre-search-results z-40 rounded-xl shadow-md",
595
+ listClassName: "max-h-[min(20rem,50dvh)]",
596
+ optionClassName: "[@media(pointer:coarse)]:min-h-[var(--layout-touch-target)]"
555
597
  }
556
598
  ),
557
599
  /* @__PURE__ */ jsxs2("div", { className: "absolute right-3 top-1/2 flex items-center -translate-y-1/2", children: [
558
- /* @__PURE__ */ jsxs2(
559
- "span",
560
- {
561
- "data-loading": loading,
562
- className: "relative p-2 text-[var(--color-text-muted)] pointer-events-none",
563
- children: [
564
- /* @__PURE__ */ jsx5(
565
- Search,
566
- {
567
- "aria-hidden": "true",
568
- className: `w-6 h-6 transition-opacity duration-200 motion-reduce:transition-none ${loading ? "opacity-0" : ""}`
569
- }
570
- ),
571
- /* @__PURE__ */ jsx5(
572
- LoaderCircle,
573
- {
574
- "aria-hidden": "true",
575
- className: `absolute inset-2 w-6 h-6 transition-opacity duration-200 motion-reduce:transition-none ${loading ? "opacity-100 delay-300 animate-spin motion-reduce:animate-none" : "opacity-0"}`
576
- }
577
- )
578
- ]
579
- }
580
- ),
600
+ /* @__PURE__ */ jsxs2("span", { "data-loading": loading, className: "relative p-2 text-[var(--color-text-muted)] pointer-events-none", children: [
601
+ /* @__PURE__ */ jsx5(
602
+ Search,
603
+ {
604
+ "aria-hidden": "true",
605
+ className: `w-6 h-6 transition-opacity duration-200 motion-reduce:transition-none ${loading ? "opacity-0" : ""}`
606
+ }
607
+ ),
608
+ /* @__PURE__ */ jsx5(
609
+ LoaderCircle,
610
+ {
611
+ "aria-hidden": "true",
612
+ className: `absolute inset-2 w-6 h-6 transition-opacity duration-200 motion-reduce:transition-none ${loading ? "opacity-100 delay-300 animate-spin motion-reduce:animate-none" : "opacity-0"}`
613
+ }
614
+ )
615
+ ] }),
581
616
  canClear && /* @__PURE__ */ jsx5(
582
617
  "button",
583
618
  {
@@ -591,39 +626,6 @@ function GenreSearch({
591
626
  children: /* @__PURE__ */ jsx5(X, { "aria-hidden": "true", className: "w-6 h-6" })
592
627
  }
593
628
  )
594
- ] }),
595
- extraResults?.status && /* @__PURE__ */ jsx5("p", { role: "status", "aria-live": "polite", className: "sr-only", children: extraResults.status }),
596
- showList && /* @__PURE__ */ jsxs2("ul", { className: "genre-search-results absolute z-40 w-full max-h-[min(16rem,50dvh)] mt-1 overflow-y-auto bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl shadow-md", children: [
597
- extraResults && results.length > 0 && /* @__PURE__ */ jsx5("li", { className: heading, children: "Genres" }),
598
- results.map((genrePlaylist) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsxs2(
599
- "button",
600
- {
601
- type: "button",
602
- className: "w-full px-3 py-1.5 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] text-left hover:bg-[var(--color-surface-muted)]",
603
- onClick: () => {
604
- select(genrePlaylist);
605
- onQueryChange?.("");
606
- },
607
- children: [
608
- genrePlaylist.name,
609
- genrePlaylist.isUmbrella && /* @__PURE__ */ jsx5("span", { className: "ml-2 text-xs italic text-[var(--color-text-muted)]", children: "umbrella" })
610
- ]
611
- }
612
- ) }, genrePlaylist.uuid)),
613
- extraResults && extraItems.length > 0 && /* @__PURE__ */ jsx5("li", { className: heading, children: extraResults.label }),
614
- extraItems.map((item) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsx5(
615
- "button",
616
- {
617
- type: "button",
618
- className: "w-full px-3 py-1.5 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] text-left hover:bg-[var(--color-surface-muted)]",
619
- onClick: () => {
620
- setQuery("");
621
- onQueryChange?.("");
622
- item.onSelect();
623
- },
624
- children: item.content
625
- }
626
- ) }, item.key))
627
629
  ] })
628
630
  ] });
629
631
  }
@@ -639,16 +641,16 @@ import { Share2 as Share22 } from "lucide-react";
639
641
  import { usePopup as usePopup3 } from "@behindthemusictree/app-kit/popup";
640
642
 
641
643
  // src/genre-tree/SharePopup.tsx
642
- import { useCallback as useCallback5, useEffect as useEffect2, useMemo as useMemo5, useState as useState2 } from "react";
644
+ import { useCallback as useCallback5, useEffect as useEffect2, useMemo as useMemo5, useState as useState3 } from "react";
643
645
  import { Link2, Share2 } from "lucide-react";
644
646
  import { FaWhatsapp } from "react-icons/fa";
645
647
  import { SiGmail } from "react-icons/si";
646
648
 
647
649
  // src/genre-tree/ShareDestinationButton.tsx
648
- import { Fragment, jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
650
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
649
651
  var className = "group flex min-w-0 flex-1 flex-col items-center justify-center gap-1.5 rounded-xl px-2 py-2 text-xs text-[var(--color-text)] transition-colors hover:bg-[var(--color-surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50";
650
652
  function Contents({ icon, label }) {
651
- return /* @__PURE__ */ jsxs3(Fragment, { children: [
653
+ return /* @__PURE__ */ jsxs3(Fragment2, { children: [
652
654
  /* @__PURE__ */ jsx6("span", { className: "flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-surface-muted)] transition-colors group-hover:bg-[var(--color-border)]", children: icon }),
653
655
  /* @__PURE__ */ jsx6("span", { className: "max-w-full truncate", children: label })
654
656
  ] });
@@ -668,11 +670,11 @@ function ShareDestinationButton({
668
670
  }
669
671
 
670
672
  // src/genre-tree/SharePreviewCard.tsx
671
- import { Music2, UserRound } from "lucide-react";
673
+ import { Music, Music2, UserRound } from "lucide-react";
672
674
  import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
673
675
  function SharePreviewCard({ target, url }) {
674
- const Icon = target.kind === "genre" ? Music2 : UserRound;
675
- const typeLabel = target.kind === "genre" ? "Genre" : "Artist";
676
+ const Icon = { genre: Music2, artist: UserRound, song: Music }[target.kind];
677
+ const typeLabel = { genre: "Genre", artist: "Artist", song: "Song" }[target.kind];
676
678
  return /* @__PURE__ */ jsx7("div", { className: "rounded-3xl bg-[var(--color-surface-muted)] p-4", children: /* @__PURE__ */ jsxs4("div", { className: "flex min-h-32 overflow-hidden rounded-2xl bg-[var(--color-surface)] shadow-lg", children: [
677
679
  /* @__PURE__ */ jsx7("div", { className: "flex w-28 shrink-0 items-center justify-center bg-[var(--color-primary)]/15 text-[var(--color-primary)]", children: /* @__PURE__ */ jsx7(Icon, { "aria-hidden": "true", className: "h-10 w-10" }) }),
678
680
  /* @__PURE__ */ jsxs4("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-1 p-4", children: [
@@ -684,7 +686,7 @@ function SharePreviewCard({ target, url }) {
684
686
  }
685
687
 
686
688
  // src/genre-tree/SharePopup.tsx
687
- import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
689
+ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
688
690
  function resolveAbsoluteUrl(url) {
689
691
  try {
690
692
  const parsed = new URL(url, window.location.href);
@@ -717,11 +719,11 @@ async function copyText(text) {
717
719
  }
718
720
  }
719
721
  function SharePopup({ target, url, onClose }) {
720
- const [absoluteUrl, setAbsoluteUrl] = useState2(url);
721
- const [copyStatus, setCopyStatus] = useState2("idle");
722
- const [canUseNativeShare, setCanUseNativeShare] = useState2(false);
723
- const [shareError, setShareError] = useState2(null);
724
- const [toastId, setToastId] = useState2(0);
722
+ const [absoluteUrl, setAbsoluteUrl] = useState3(url);
723
+ const [copyStatus, setCopyStatus] = useState3("idle");
724
+ const [canUseNativeShare, setCanUseNativeShare] = useState3(false);
725
+ const [shareError, setShareError] = useState3(null);
726
+ const [toastId, setToastId] = useState3(0);
725
727
  useEffect2(() => {
726
728
  setAbsoluteUrl(resolveAbsoluteUrl(url) ?? "");
727
729
  setCopyStatus("idle");
@@ -759,7 +761,7 @@ function SharePopup({ target, url, onClose }) {
759
761
  setShareError("Could not open the share options.");
760
762
  }
761
763
  };
762
- return /* @__PURE__ */ jsxs5(Fragment2, { children: [
764
+ return /* @__PURE__ */ jsxs5(Fragment3, { children: [
763
765
  /* @__PURE__ */ jsx8(
764
766
  BasePopup_default,
765
767
  {
@@ -954,18 +956,18 @@ import { Skeleton as Skeleton2 } from "@behindthemusictree/ui";
954
956
  import { useFetchGenreOverview } from "@behindthemusictree/app-kit/genre-tree";
955
957
 
956
958
  // src/genre-tree/ClampedText.tsx
957
- import { useLayoutEffect, useRef as useRef2, useState as useState3 } from "react";
958
- import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
959
+ import { useLayoutEffect, useRef as useRef2, useState as useState4 } from "react";
960
+ import { Fragment as Fragment4, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
959
961
  function ClampedText({ children, className: className2 = "" }) {
960
962
  const ref = useRef2(null);
961
- const [expanded, setExpanded] = useState3(false);
962
- const [overflows, setOverflows] = useState3(false);
963
+ const [expanded, setExpanded] = useState4(false);
964
+ const [overflows, setOverflows] = useState4(false);
963
965
  useLayoutEffect(() => {
964
966
  const element = ref.current;
965
967
  if (!element || expanded) return;
966
968
  setOverflows(element.scrollHeight > element.clientHeight);
967
969
  }, [children, expanded]);
968
- return /* @__PURE__ */ jsxs8(Fragment3, { children: [
970
+ return /* @__PURE__ */ jsxs8(Fragment4, { children: [
969
971
  /* @__PURE__ */ jsx11("p", { ref, className: `${className2} ${expanded ? "" : "line-clamp-3"}`, children }),
970
972
  (overflows || expanded) && /* @__PURE__ */ jsx11(
971
973
  "button",
@@ -1001,14 +1003,14 @@ import { useTrackList as useTrackList2 } from "@behindthemusictree/app-kit/genre
1001
1003
 
1002
1004
  // src/genre-tree/MusicBrainzRecordingLink.tsx
1003
1005
  import { musicBrainzRecordingUrl } from "@behindthemusictree/app-kit/genre-tree";
1004
- import { Fragment as Fragment4, jsx as jsx12 } from "react/jsx-runtime";
1006
+ import { Fragment as Fragment5, jsx as jsx12 } from "react/jsx-runtime";
1005
1007
  function MusicBrainzRecordingLink({
1006
1008
  song,
1007
1009
  children,
1008
1010
  className: className2 = ""
1009
1011
  }) {
1010
1012
  const url = musicBrainzRecordingUrl(song);
1011
- if (!url) return /* @__PURE__ */ jsx12(Fragment4, { children });
1013
+ if (!url) return /* @__PURE__ */ jsx12(Fragment5, { children });
1012
1014
  return /* @__PURE__ */ jsx12(
1013
1015
  "a",
1014
1016
  {
@@ -1049,6 +1051,7 @@ function GenrePlaylistTracks({
1049
1051
  scope,
1050
1052
  getBackendBaseUrl,
1051
1053
  renderArtistLink,
1054
+ renderSongLink,
1052
1055
  renderSongLeading,
1053
1056
  renderSongActions,
1054
1057
  renderTracksToolbar,
@@ -1119,7 +1122,7 @@ function GenrePlaylistTracks({
1119
1122
  const isCurrent = playerTrackObject?.track.id === track.uuid;
1120
1123
  const isPlaying = isCurrent && playState === PlayStates.PLAYING;
1121
1124
  const text = /* @__PURE__ */ jsxs9("span", { className: TEXT_CELL, children: [
1122
- /* @__PURE__ */ jsx13("span", { className: TITLE_CELL, children: isSong(track) ? /* @__PURE__ */ jsx13(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }),
1125
+ /* @__PURE__ */ jsx13("span", { className: TITLE_CELL, children: renderSongLink ? renderSongLink(track) : isSong(track) ? /* @__PURE__ */ jsx13(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }),
1123
1126
  track.artists?.length ? /* @__PURE__ */ jsx13("span", { className: SEPARATOR, children: " \u2014 " }) : null,
1124
1127
  /* @__PURE__ */ jsx13("span", { className: COLUMN_CELL, children: track.artists?.length ? /* @__PURE__ */ jsx13(ArtistNames, { artists: track.artists, renderArtistLink }) : null }),
1125
1128
  track.album && /* @__PURE__ */ jsx13("span", { className: ALBUM_CELL, children: track.album.name })
@@ -1176,7 +1179,7 @@ function GenrePlaylistTracks({
1176
1179
  // src/genre-tree/GenreDetailExtras.tsx
1177
1180
  import { useTrackNoun as useTrackNoun5 } from "@behindthemusictree/app-kit/track-noun";
1178
1181
  import { SiWikidata, SiWikipedia } from "react-icons/si";
1179
- import { Fragment as Fragment5, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
1182
+ import { Fragment as Fragment6, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
1180
1183
  function GenreDetailExtras({
1181
1184
  genreUuid,
1182
1185
  genrePlaylist,
@@ -1186,6 +1189,7 @@ function GenreDetailExtras({
1186
1189
  criteriaOverviewSchema,
1187
1190
  renderGenreDetailExtras,
1188
1191
  renderArtistLink,
1192
+ renderSongLink,
1189
1193
  renderSummary,
1190
1194
  renderEssentialTracks,
1191
1195
  family,
@@ -1199,7 +1203,7 @@ function GenreDetailExtras({
1199
1203
  criteriaOverviewSchema
1200
1204
  );
1201
1205
  if (isPending) {
1202
- return /* @__PURE__ */ jsxs10(Fragment5, { children: [
1206
+ return /* @__PURE__ */ jsxs10(Fragment6, { children: [
1203
1207
  /* @__PURE__ */ jsxs10("div", { className: "gtv-info-panel-children", "aria-busy": "true", "data-testid": "genre-detail-extras-skeleton", children: [
1204
1208
  /* @__PURE__ */ jsx14("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
1205
1209
  /* @__PURE__ */ jsx14(Skeleton2, { className: "h-4 w-3/4" })
@@ -1207,10 +1211,10 @@ function GenreDetailExtras({
1207
1211
  family
1208
1212
  ] });
1209
1213
  }
1210
- if (!overview) return /* @__PURE__ */ jsx14(Fragment5, { children: family });
1214
+ if (!overview) return /* @__PURE__ */ jsx14(Fragment6, { children: family });
1211
1215
  const { summary, summarySourceUrl, essentialSongs } = overview;
1212
1216
  const wikidataUrl = wikidataId ? `https://www.wikidata.org/wiki/${encodeURIComponent(wikidataId)}` : null;
1213
- return /* @__PURE__ */ jsxs10(Fragment5, { children: [
1217
+ return /* @__PURE__ */ jsxs10(Fragment6, { children: [
1214
1218
  (summarySourceUrl || wikidataUrl) && /* @__PURE__ */ jsxs10("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
1215
1219
  /* @__PURE__ */ jsx14("span", { className: "gtv-info-panel-children-title", children: "Links" }),
1216
1220
  /* @__PURE__ */ jsxs10("div", { className: "mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs", children: [
@@ -1263,6 +1267,7 @@ function GenreDetailExtras({
1263
1267
  scope,
1264
1268
  getBackendBaseUrl,
1265
1269
  renderArtistLink,
1270
+ renderSongLink,
1266
1271
  ...trackSlots
1267
1272
  }
1268
1273
  )
@@ -1270,7 +1275,7 @@ function GenreDetailExtras({
1270
1275
  }
1271
1276
 
1272
1277
  // src/genre-tree/GenreInfoPanelBody.tsx
1273
- import { Fragment as Fragment6, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
1278
+ import { Fragment as Fragment7, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
1274
1279
  import { createElement } from "react";
1275
1280
  function GenreNodeActionBand({ genrePlaylist, playback, ...bandProps }) {
1276
1281
  const { isPlaying, setIsPlaying } = usePlayer5();
@@ -1305,7 +1310,7 @@ function GenreInfoPanelBody({
1305
1310
  band,
1306
1311
  extras
1307
1312
  }) {
1308
- return /* @__PURE__ */ jsxs11(Fragment6, { children: [
1313
+ return /* @__PURE__ */ jsxs11(Fragment7, { children: [
1309
1314
  toolbar,
1310
1315
  /* @__PURE__ */ jsx15(GenreNodeActionBand, { ...band, genrePlaylist, playback }),
1311
1316
  /* @__PURE__ */ createElement(
@@ -1611,7 +1616,7 @@ function ArtistDiscography({ albums }) {
1611
1616
  }
1612
1617
 
1613
1618
  // src/genre-tree/artist/ArtistSongs.tsx
1614
- import { useState as useState4 } from "react";
1619
+ import { useState as useState5 } from "react";
1615
1620
  import { keepPreviousData } from "@tanstack/react-query";
1616
1621
  import { Play as Play2 } from "lucide-react";
1617
1622
  import { Pagination, PanelSection as PanelSection3, Skeleton as Skeleton3 } from "@behindthemusictree/ui";
@@ -1689,7 +1694,8 @@ function TrackItem({
1689
1694
  position,
1690
1695
  renderDuration,
1691
1696
  renderActions,
1692
- renderArtistLink
1697
+ renderArtistLink,
1698
+ renderSongLink
1693
1699
  }) {
1694
1700
  const { handlePlayPauseAction, playerTrackObject, loadTrackForPlayer } = usePlayer7();
1695
1701
  const { trackList, selectedTrack, toTrackAtPosition, runtimeUnplayableTrackIds } = useTrackList3();
@@ -1729,7 +1735,7 @@ function TrackItem({
1729
1735
  }
1730
1736
  ),
1731
1737
  /* @__PURE__ */ jsxs18("div", { className: "title-artist-container flex flex-col items-start justify-center min-w-0 flex-1", children: [
1732
- /* @__PURE__ */ jsx24("div", { className: "flex max-w-full items-center gap-2", children: /* @__PURE__ */ jsx24("div", { className: "title text-lg font-bold text-[var(--color-text)] max-w-full truncate", children: isSong2(track) ? /* @__PURE__ */ jsx24(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }) }),
1738
+ /* @__PURE__ */ jsx24("div", { className: "flex max-w-full items-center gap-2", children: /* @__PURE__ */ jsx24("div", { className: "title text-lg font-bold text-[var(--color-text)] max-w-full truncate", children: renderSongLink ? renderSongLink(track) : isSong2(track) ? /* @__PURE__ */ jsx24(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }) }),
1733
1739
  track.artists && track.artists.length > 0 ? /* @__PURE__ */ jsx24("div", { className: "artist text-base max-w-full truncate text-[var(--color-text-muted)]", children: /* @__PURE__ */ jsx24(ArtistNames2, { artists: track.artists, renderArtistLink }) }) : ""
1734
1740
  ] }),
1735
1741
  /* @__PURE__ */ jsx24("div", { className: "genre-name-container flex items-center justify-start w-[21.6%] min-w-0 shrink-0", children: track.genre ? /* @__PURE__ */ jsx24("div", { className: "genre-name max-w-full whitespace-normal break-words p-1 text-xs font-bold", children: track.genre.name }) : "" }),
@@ -1748,15 +1754,15 @@ function TrackItem({
1748
1754
  }
1749
1755
 
1750
1756
  // src/genre-tree/artist/ArtistSongs.tsx
1751
- import { Fragment as Fragment7, jsx as jsx25, jsxs as jsxs19 } from "react/jsx-runtime";
1757
+ import { Fragment as Fragment8, jsx as jsx25, jsxs as jsxs19 } from "react/jsx-runtime";
1752
1758
  var SongPageSchema2 = PaginatedResponseSchema2(SongDetailedSchema2);
1753
1759
  var SORTS = [
1754
1760
  { ordering: "title", label: "Title" },
1755
1761
  { ordering: "-albumYear", label: "Year" }
1756
1762
  ];
1757
- function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1758
- const [page, setPage] = useState4(1);
1759
- const [ordering, setOrdering] = useState4("title");
1763
+ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink, renderSongLink }) {
1764
+ const [page, setPage] = useState5(1);
1765
+ const [ordering, setOrdering] = useState5("title");
1760
1766
  const { fetch } = useFetchWrapper2(getBackendBaseUrl);
1761
1767
  const { playNewTrackListFromArtist } = useTrackList4();
1762
1768
  const { playerTrackObject } = usePlayer8();
@@ -1781,7 +1787,7 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1781
1787
  event.stopPropagation();
1782
1788
  void play(song.uuid);
1783
1789
  };
1784
- return /* @__PURE__ */ jsx25(PanelSection3, { title: "Songs", children: isPending ? /* @__PURE__ */ jsx25(Skeleton3, { className: "h-14", "aria-busy": "true" }) : isError ? /* @__PURE__ */ jsx25("p", { className: "text-red-600", children: "Could not load songs." }) : data.results.length === 0 ? /* @__PURE__ */ jsx25("p", { className: "text-[var(--color-text-muted)]", children: "No songs yet." }) : /* @__PURE__ */ jsxs19(Fragment7, { children: [
1790
+ return /* @__PURE__ */ jsx25(PanelSection3, { title: "Songs", children: isPending ? /* @__PURE__ */ jsx25(Skeleton3, { className: "h-14", "aria-busy": "true" }) : isError ? /* @__PURE__ */ jsx25("p", { className: "text-red-600", children: "Could not load songs." }) : data.results.length === 0 ? /* @__PURE__ */ jsx25("p", { className: "text-[var(--color-text-muted)]", children: "No songs yet." }) : /* @__PURE__ */ jsxs19(Fragment8, { children: [
1785
1791
  /* @__PURE__ */ jsxs19("div", { className: "flex flex-wrap items-center justify-between gap-2 mb-2", children: [
1786
1792
  /* @__PURE__ */ jsxs19(
1787
1793
  "button",
@@ -1815,7 +1821,8 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1815
1821
  {
1816
1822
  track: song,
1817
1823
  position: (page - 1) * data.pageSize + index + 1,
1818
- renderArtistLink
1824
+ renderArtistLink,
1825
+ renderSongLink
1819
1826
  }
1820
1827
  ) }, song.uuid)) }),
1821
1828
  /* @__PURE__ */ jsx25(Pagination, { currentPage: page, totalPages: data.totalPages, onPageChange: setPage })
@@ -1823,13 +1830,14 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1823
1830
  }
1824
1831
 
1825
1832
  // src/genre-tree/artist/useArtistPanel.tsx
1826
- import { Fragment as Fragment8, jsx as jsx26, jsxs as jsxs20 } from "react/jsx-runtime";
1833
+ import { Fragment as Fragment9, jsx as jsx26, jsxs as jsxs20 } from "react/jsx-runtime";
1827
1834
  function useArtistPanel({
1828
1835
  uuid,
1829
1836
  scope,
1830
1837
  getBackendBaseUrl,
1831
1838
  getShareUrl,
1832
1839
  renderArtistLink,
1840
+ renderSongLink,
1833
1841
  expanded,
1834
1842
  onExpandedChange,
1835
1843
  onClose
@@ -1871,7 +1879,7 @@ function useArtistPanel({
1871
1879
  return {
1872
1880
  ...panel,
1873
1881
  title: artist.name,
1874
- content: /* @__PURE__ */ jsxs20(Fragment8, { children: [
1882
+ content: /* @__PURE__ */ jsxs20(Fragment9, { children: [
1875
1883
  shareUrl && /* @__PURE__ */ jsx26("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: /* @__PURE__ */ jsx26("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: shareTarget && /* @__PURE__ */ jsx26(ShareActionButton, { target: shareTarget, url: shareUrl }) }) }),
1876
1884
  artist.image && /* @__PURE__ */ jsx26(ArtistPhoto, { image: artist.image, name: artist.name }),
1877
1885
  /* @__PURE__ */ jsx26(ArtistHeader, { artist }),
@@ -1882,7 +1890,8 @@ function useArtistPanel({
1882
1890
  {
1883
1891
  artist: { uuid: artist.uuid, name: artist.name },
1884
1892
  getBackendBaseUrl,
1885
- renderArtistLink
1893
+ renderArtistLink,
1894
+ renderSongLink
1886
1895
  }
1887
1896
  )
1888
1897
  ] }),
@@ -1892,15 +1901,217 @@ function useArtistPanel({
1892
1901
  }
1893
1902
  }
1894
1903
 
1904
+ // src/genre-tree/song/useSongPanel.tsx
1905
+ import { Skeleton as Skeleton6 } from "@behindthemusictree/ui";
1906
+ import {
1907
+ useSong
1908
+ } from "@behindthemusictree/app-kit/genre-tree";
1909
+
1910
+ // src/genre-tree/song/SongPanelContent.tsx
1911
+ import { useState as useState6 } from "react";
1912
+ import { Pause as Pause2, Play as Play3 } from "lucide-react";
1913
+ import { FieldList as FieldList2, PanelSection as PanelSection4, Skeleton as Skeleton5 } from "@behindthemusictree/ui";
1914
+ import { PlayStates as PlayStates3, usePlayer as usePlayer9 } from "@behindthemusictree/app-kit/player";
1915
+ import {
1916
+ ArtistNames as ArtistNames3,
1917
+ musicBrainzRecordingUrl as musicBrainzRecordingUrl2,
1918
+ songUnplayableReason as songUnplayableReason4,
1919
+ useSongHistory,
1920
+ useTrackList as useTrackList5
1921
+ } from "@behindthemusictree/app-kit/genre-tree";
1922
+ import { Fragment as Fragment10, jsx as jsx27, jsxs as jsxs21 } from "react/jsx-runtime";
1923
+ var numberFormat = new Intl.NumberFormat("en");
1924
+ var dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium" });
1925
+ var externalLink = "text-[var(--color-primary)] hover:underline focus-visible:underline";
1926
+ function SongHistory({ uuid, getBackendBaseUrl }) {
1927
+ const [open, setOpen] = useState6(false);
1928
+ const history = useSongHistory(uuid, getBackendBaseUrl, open);
1929
+ let body = null;
1930
+ if (history.status === "loading") body = /* @__PURE__ */ jsx27(Skeleton5, { className: "h-10", "aria-busy": "true" });
1931
+ else if (history.status === "error") body = /* @__PURE__ */ jsx27("p", { className: "text-red-600", children: "Could not load the history." });
1932
+ else if (history.status === "success")
1933
+ body = history.entries.length === 0 ? /* @__PURE__ */ jsx27("p", { className: "text-[var(--color-text-muted)]", children: "No changes yet." }) : /* @__PURE__ */ jsx27("ul", { className: "divide-y divide-[var(--color-border)]", children: history.entries.map((entry) => /* @__PURE__ */ jsxs21("li", { className: "py-1.5 text-sm", children: [
1934
+ /* @__PURE__ */ jsx27("span", { className: "font-medium", children: entry.action }),
1935
+ entry.oldValue !== null || entry.newValue !== null ? /* @__PURE__ */ jsxs21("span", { className: "text-[var(--color-text-muted)]", children: [
1936
+ " ",
1937
+ "\u2014 ",
1938
+ entry.oldValue ?? "none",
1939
+ " \u2192 ",
1940
+ entry.newValue ?? "none"
1941
+ ] }) : null,
1942
+ /* @__PURE__ */ jsxs21("span", { className: "block text-xs text-[var(--color-text-muted)]", children: [
1943
+ entry.actorPseudo ?? "Anonymous",
1944
+ " \xB7 ",
1945
+ dateFormat.format(new Date(entry.createdOn))
1946
+ ] })
1947
+ ] }, entry.uuid)) });
1948
+ return /* @__PURE__ */ jsxs21(
1949
+ "details",
1950
+ {
1951
+ onToggle: (event) => setOpen(event.currentTarget.open),
1952
+ className: "px-6 pb-2.5 mt-2.5 border-t border-[var(--color-border)]",
1953
+ children: [
1954
+ /* @__PURE__ */ jsx27("summary", { className: "mt-2.5 cursor-pointer text-[0.6875rem] font-semibold uppercase text-[var(--color-text-muted)]", children: "History" }),
1955
+ /* @__PURE__ */ jsx27("div", { className: "mt-1.5", children: body })
1956
+ ]
1957
+ }
1958
+ );
1959
+ }
1960
+ function SongPanelContent({
1961
+ song,
1962
+ scope,
1963
+ getBackendBaseUrl,
1964
+ shareUrl,
1965
+ renderArtistLink,
1966
+ genrePath,
1967
+ onSelectGenre
1968
+ }) {
1969
+ const { playNewTrackListFromTrackUuid } = useTrackList5();
1970
+ const { playerTrackObject, playState, handlePlayPauseAction } = usePlayer9();
1971
+ const unplayableReason = songUnplayableReason4(song);
1972
+ const isCurrent = playerTrackObject?.track.id === song.uuid;
1973
+ const isPlaying = isCurrent && playState === PlayStates3.PLAYING;
1974
+ const shareTarget = { kind: "song", uuid: song.uuid, name: song.title, scope };
1975
+ const musicBrainzUrl = musicBrainzRecordingUrl2(song);
1976
+ const fields = [];
1977
+ if (song.genre) {
1978
+ const path = genrePath.length > 0 ? genrePath : null;
1979
+ fields.push({
1980
+ label: "Genre",
1981
+ value: path ? /* @__PURE__ */ jsx27("span", { className: "flex flex-wrap items-center gap-x-1", children: path.map((genrePlaylist, index) => /* @__PURE__ */ jsxs21("span", { children: [
1982
+ index > 0 && /* @__PURE__ */ jsx27("span", { "aria-hidden": "true", children: "\u203A " }),
1983
+ /* @__PURE__ */ jsx27("button", { type: "button", onClick: () => onSelectGenre(genrePlaylist), className: "hover:underline", children: genrePlaylist.name })
1984
+ ] }, genrePlaylist.uuid)) }) : song.genre.name
1985
+ });
1986
+ } else {
1987
+ fields.push({ label: "Genre", value: /* @__PURE__ */ jsx27("span", { className: "text-[var(--color-text-muted)]", children: "No genre" }) });
1988
+ }
1989
+ if (song.firstReleaseYear) fields.push({ label: "First released", value: song.firstReleaseYear });
1990
+ const viewCount = song.youtubeVideo?.viewCount;
1991
+ if (viewCount !== null && viewCount !== void 0)
1992
+ fields.push({ label: "YouTube views", value: numberFormat.format(viewCount) });
1993
+ fields.push({ label: "Plays", value: numberFormat.format(song.playCount) });
1994
+ const links = [];
1995
+ if (musicBrainzUrl)
1996
+ links.push(
1997
+ /* @__PURE__ */ jsx27("a", { href: musicBrainzUrl, target: "_blank", rel: "noopener noreferrer", className: externalLink, children: "MusicBrainz" }, "mb")
1998
+ );
1999
+ if (song.youtubeVideo)
2000
+ links.push(
2001
+ /* @__PURE__ */ jsx27(
2002
+ "a",
2003
+ {
2004
+ href: `https://www.youtube.com/watch?v=${encodeURIComponent(song.youtubeVideo.id)}`,
2005
+ target: "_blank",
2006
+ rel: "noopener noreferrer",
2007
+ className: externalLink,
2008
+ children: "YouTube"
2009
+ },
2010
+ "yt"
2011
+ )
2012
+ );
2013
+ if (links.length > 0)
2014
+ fields.push({
2015
+ label: "Links",
2016
+ value: /* @__PURE__ */ jsx27("span", { className: "flex gap-3", children: links })
2017
+ });
2018
+ return /* @__PURE__ */ jsxs21(Fragment10, { children: [
2019
+ /* @__PURE__ */ jsx27("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: /* @__PURE__ */ jsxs21("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: [
2020
+ unplayableReason ? /* @__PURE__ */ jsx27("p", { className: "flex items-center px-2.5 text-sm text-[var(--color-text-muted)]", children: unplayableReason }) : /* @__PURE__ */ jsxs21(
2021
+ "button",
2022
+ {
2023
+ type: "button",
2024
+ onClick: () => isCurrent ? handlePlayPauseAction() : playNewTrackListFromTrackUuid(song, scope),
2025
+ className: "group flex flex-col items-center gap-1 w-16 text-xs text-center text-[var(--color-text)] focus-visible:outline-none",
2026
+ children: [
2027
+ /* @__PURE__ */ jsx27("span", { className: "flex items-center justify-center w-10 h-10 rounded-full group-focus-visible:ring-2 group-focus-visible:ring-[var(--color-focus-ring)] bg-[var(--color-surface-muted)] transition-colors group-hover:bg-[var(--color-border)]", children: isPlaying ? /* @__PURE__ */ jsx27(Pause2, { "aria-hidden": "true", className: "w-5 h-5" }) : /* @__PURE__ */ jsx27(Play3, { "aria-hidden": "true", className: "w-5 h-5" }) }),
2028
+ isPlaying ? "Pause" : "Play"
2029
+ ]
2030
+ }
2031
+ ),
2032
+ shareUrl && /* @__PURE__ */ jsx27(ShareActionButton, { target: shareTarget, url: shareUrl })
2033
+ ] }) }),
2034
+ song.artists && song.artists.length > 0 && /* @__PURE__ */ jsx27("p", { className: "px-6 pt-2.5 text-[var(--color-text-muted)]", children: /* @__PURE__ */ jsx27(ArtistNames3, { artists: song.artists, renderArtistLink }) }),
2035
+ /* @__PURE__ */ jsx27(FieldList2, { items: fields }),
2036
+ song.essentialGenres && song.essentialGenres.length > 0 && /* @__PURE__ */ jsx27(PanelSection4, { title: "Essential in", children: /* @__PURE__ */ jsx27("ul", { children: song.essentialGenres.map((genre) => /* @__PURE__ */ jsx27("li", { className: "py-0.5", children: genre.name }, genre.uuid)) }) }),
2037
+ /* @__PURE__ */ jsx27(SongHistory, { uuid: song.uuid, getBackendBaseUrl })
2038
+ ] });
2039
+ }
2040
+
2041
+ // src/genre-tree/song/useSongPanel.tsx
2042
+ import { jsx as jsx28, jsxs as jsxs22 } from "react/jsx-runtime";
2043
+ function useSongPanel({
2044
+ uuid,
2045
+ scope,
2046
+ getBackendBaseUrl,
2047
+ getShareUrl,
2048
+ renderArtistLink,
2049
+ getGenrePath,
2050
+ onSelectGenre,
2051
+ expanded,
2052
+ onExpandedChange,
2053
+ onClose
2054
+ }) {
2055
+ const result = useSong(uuid, getBackendBaseUrl);
2056
+ const panel = { expanded, onExpandedChange, onClose };
2057
+ switch (result.status) {
2058
+ case "idle":
2059
+ return void 0;
2060
+ case "loading":
2061
+ return {
2062
+ ...panel,
2063
+ title: "Song",
2064
+ content: /* @__PURE__ */ jsxs22("div", { "aria-busy": "true", className: "flex flex-col gap-2.5 px-6 py-2.5", children: [
2065
+ /* @__PURE__ */ jsx28(Skeleton6, { className: "h-3 w-16" }),
2066
+ /* @__PURE__ */ jsx28(Skeleton6, { className: "h-4 w-32" }),
2067
+ /* @__PURE__ */ jsx28(Skeleton6, { className: "h-3 w-16" }),
2068
+ /* @__PURE__ */ jsx28(Skeleton6, { className: "h-4 w-24" })
2069
+ ] })
2070
+ };
2071
+ case "not-found":
2072
+ return {
2073
+ ...panel,
2074
+ title: "Song not found",
2075
+ content: /* @__PURE__ */ jsx28("p", { className: "px-6 py-2.5 text-[var(--color-text-muted)]", children: "This song does not exist." })
2076
+ };
2077
+ case "error":
2078
+ return {
2079
+ ...panel,
2080
+ title: "Song",
2081
+ content: /* @__PURE__ */ jsx28("p", { className: "px-6 py-2.5 text-red-600", children: "Could not load this song." })
2082
+ };
2083
+ case "success": {
2084
+ const { song } = result;
2085
+ const shareUrl = getShareUrl?.({ kind: "song", uuid: song.uuid, name: song.title, scope });
2086
+ return {
2087
+ ...panel,
2088
+ title: song.title,
2089
+ content: /* @__PURE__ */ jsx28(
2090
+ SongPanelContent,
2091
+ {
2092
+ song,
2093
+ scope,
2094
+ getBackendBaseUrl,
2095
+ shareUrl: shareUrl ?? void 0,
2096
+ renderArtistLink,
2097
+ genrePath: song.genre ? getGenrePath(song.genre) : [],
2098
+ onSelectGenre
2099
+ }
2100
+ )
2101
+ };
2102
+ }
2103
+ }
2104
+ }
2105
+
1895
2106
  // src/genre-tree/SizeMetricToggle.tsx
1896
2107
  import { MdMusicNote, MdVisibility } from "react-icons/md";
1897
2108
  import { SegmentedControl } from "@behindthemusictree/ui";
1898
- import { jsx as jsx27 } from "react/jsx-runtime";
2109
+ import { jsx as jsx29 } from "react/jsx-runtime";
1899
2110
  function SizeMetricToggle({
1900
2111
  value,
1901
2112
  onChange
1902
2113
  }) {
1903
- return /* @__PURE__ */ jsx27(
2114
+ return /* @__PURE__ */ jsx29(
1904
2115
  SegmentedControl,
1905
2116
  {
1906
2117
  variant: "floating",
@@ -1908,15 +2119,15 @@ function SizeMetricToggle({
1908
2119
  value,
1909
2120
  onChange,
1910
2121
  options: [
1911
- { value: "songs", label: /* @__PURE__ */ jsx27(MdMusicNote, { size: 18, "aria-hidden": true }), "aria-label": "Size by songs", title: "Node size by song count" },
1912
- { value: "views", label: /* @__PURE__ */ jsx27(MdVisibility, { size: 18, "aria-hidden": true }), "aria-label": "Size by views", title: "Node size by song views" }
2122
+ { value: "songs", label: /* @__PURE__ */ jsx29(MdMusicNote, { size: 18, "aria-hidden": true }), "aria-label": "Size by songs", title: "Node size by song count" },
2123
+ { value: "views", label: /* @__PURE__ */ jsx29(MdVisibility, { size: 18, "aria-hidden": true }), "aria-label": "Size by views", title: "Node size by song views" }
1913
2124
  ]
1914
2125
  }
1915
2126
  );
1916
2127
  }
1917
2128
 
1918
2129
  // src/genre-tree/GenreTreeView.tsx
1919
- import { Fragment as Fragment9, jsx as jsx28, jsxs as jsxs21 } from "react/jsx-runtime";
2130
+ import { Fragment as Fragment11, jsx as jsx30, jsxs as jsxs23 } from "react/jsx-runtime";
1920
2131
  var PREVIOUS_BUTTON_CSS = `
1921
2132
  .genre-tree-view .search-overlay .gtv-info-panel-previous { display: none; }
1922
2133
  .genre-tree-view:has(.search-overlay .gtv-info-panel-previous) .genre-search input { padding-left: 2.75rem; }
@@ -1960,6 +2171,13 @@ function GenreTreeView({
1960
2171
  artistSearch = true,
1961
2172
  onArtistSelect,
1962
2173
  artistSearchBaseUrl,
2174
+ songUuid: controlledSongUuid,
2175
+ onSongUuidChange,
2176
+ songExpanded: controlledSongExpanded,
2177
+ onSongExpandedChange,
2178
+ songSearch = true,
2179
+ onSongSelect,
2180
+ renderSongLink,
1963
2181
  renderSummary,
1964
2182
  renderEssentialTracks,
1965
2183
  renderSongLeading,
@@ -1976,10 +2194,17 @@ function GenreTreeView({
1976
2194
  }) {
1977
2195
  const getShareUrlRef = useRef4(getShareUrl);
1978
2196
  getShareUrlRef.current = getShareUrl;
1979
- const [internalArtistUuid, setInternalArtistUuid] = useState5(null);
2197
+ const [internalArtistUuid, setInternalArtistUuid] = useState7(null);
1980
2198
  const isArtistControlled = controlledArtistUuid !== void 0;
1981
2199
  const artistUuid = isArtistControlled ? controlledArtistUuid : internalArtistUuid;
1982
- const [internalSizeMetric, setInternalSizeMetric] = useState5("songs");
2200
+ const [internalSongUuid, setInternalSongUuid] = useState7(null);
2201
+ const isSongControlled = controlledSongUuid !== void 0;
2202
+ const songUuid = isSongControlled ? controlledSongUuid : internalSongUuid;
2203
+ const changeSong = (uuid) => {
2204
+ if (!isSongControlled) setInternalSongUuid(uuid);
2205
+ onSongUuidChange?.(uuid);
2206
+ };
2207
+ const [internalSizeMetric, setInternalSizeMetric] = useState7("songs");
1983
2208
  const sizeMetric = controlledSizeMetric ?? internalSizeMetric;
1984
2209
  const deferredSizeMetric = useDeferredValue(sizeMetric);
1985
2210
  const changeSizeMetric = (metric) => {
@@ -2018,7 +2243,7 @@ function GenreTreeView({
2018
2243
  artistUuid
2019
2244
  });
2020
2245
  const restoreRevision = viewportRestore?.revision;
2021
- const [stackedRestoreOwner, setStackedRestoreOwner] = useState5({ revision: restoreRevision, rootUuid: selectedGenrePlaylist?.root.uuid });
2246
+ const [stackedRestoreOwner, setStackedRestoreOwner] = useState7({ revision: restoreRevision, rootUuid: selectedGenrePlaylist?.root.uuid });
2022
2247
  let restoreRootUuid = stackedRestoreOwner.rootUuid;
2023
2248
  if (stackedRestoreOwner.revision !== restoreRevision) {
2024
2249
  restoreRootUuid = selectedGenrePlaylist?.root.uuid;
@@ -2042,27 +2267,72 @@ function GenreTreeView({
2042
2267
  const playback = useGenrePlaylistPlayback({ scope, genrePlaylists: visibleGenrePlaylists, artist: filterArtist });
2043
2268
  const openArtist = (uuid) => {
2044
2269
  if (selectedNodeId !== null) clearSelection();
2270
+ if (songUuid !== null) changeSong(null);
2045
2271
  changeArtist(uuid);
2046
2272
  };
2047
- const [expandedArtistUuid, setExpandedArtistUuid] = useState5(null);
2273
+ const openSong = (uuid) => {
2274
+ if (selectedNodeId !== null) clearSelection();
2275
+ changeSong(uuid);
2276
+ };
2277
+ const openSongRef = useRef4(openSong);
2278
+ openSongRef.current = openSong;
2279
+ const resolvedRenderSongLink = useCallback6(
2280
+ (song) => renderSongLink ? renderSongLink(song) : /* @__PURE__ */ jsx30("button", { type: "button", onClick: () => openSongRef.current(song.uuid), className: "hover:underline", children: song.title }),
2281
+ [renderSongLink]
2282
+ );
2283
+ const [expandedSongUuid, setExpandedSongUuid] = useState7(null);
2284
+ const [expandedArtistUuid, setExpandedArtistUuid] = useState7(null);
2048
2285
  const artistBaseUrl = artistSearchBaseUrl ?? getBackendBaseUrl;
2286
+ const songBaseUrl = getBackendBaseUrl;
2049
2287
  const artistPanel = useArtistPanel({
2050
2288
  uuid: artistUuid,
2051
2289
  scope,
2052
2290
  getBackendBaseUrl: artistBaseUrl,
2053
2291
  getShareUrl: getShareUrlRef.current,
2054
- renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ jsx28("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
2292
+ renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ jsx30("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
2055
2293
  expanded: controlledArtistExpanded ?? (artistUuid !== null && expandedArtistUuid === artistUuid),
2056
2294
  onExpandedChange: (on) => {
2057
2295
  if (controlledArtistExpanded === void 0) setExpandedArtistUuid(on ? artistUuid : null);
2058
2296
  onArtistExpandedChange?.(on);
2059
2297
  },
2298
+ renderSongLink: resolvedRenderSongLink,
2060
2299
  onClose: () => changeArtist(null)
2061
2300
  });
2062
- const builtInArtistPanel = overlayPanel || hasSelectedGenreNode ? void 0 : artistPanel;
2063
- const activeOverlayPanel = overlayPanel ?? (builtInArtistPanel && { ...builtInArtistPanel, hideTitle: true, hideClose: true });
2064
- const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState5(false);
2065
- const [uncontrolledExpanded, setUncontrolledExpanded] = useState5(false);
2301
+ const resolvedRenderArtistLink = renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ jsx30("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name }));
2302
+ const getGenrePath = (genre) => {
2303
+ const path = [];
2304
+ let current = visibleGenrePlaylists.find((playlist) => playlist.criteria?.uuid === genre.uuid);
2305
+ while (current) {
2306
+ path.unshift(current);
2307
+ const parentUuid = current.parent?.uuid;
2308
+ current = parentUuid ? visibleGenrePlaylists.find((playlist) => playlist.uuid === parentUuid) : void 0;
2309
+ }
2310
+ return path;
2311
+ };
2312
+ const songPanel = useSongPanel({
2313
+ uuid: songUuid,
2314
+ scope,
2315
+ getBackendBaseUrl: songBaseUrl,
2316
+ getShareUrl: getShareUrlRef.current,
2317
+ renderArtistLink: resolvedRenderArtistLink,
2318
+ getGenrePath,
2319
+ onSelectGenre: (genrePlaylist) => {
2320
+ changeSong(null);
2321
+ selectGenrePlaylist(genrePlaylist);
2322
+ },
2323
+ expanded: controlledSongExpanded ?? (songUuid !== null && expandedSongUuid === songUuid),
2324
+ onExpandedChange: (on) => {
2325
+ if (controlledSongExpanded === void 0) setExpandedSongUuid(on ? songUuid : null);
2326
+ onSongExpandedChange?.(on);
2327
+ },
2328
+ onClose: () => changeSong(null)
2329
+ });
2330
+ const builtInSongPanel = overlayPanel || hasSelectedGenreNode ? void 0 : songPanel;
2331
+ const builtInArtistPanel = overlayPanel || hasSelectedGenreNode || builtInSongPanel ? void 0 : artistPanel;
2332
+ const builtInPanel = builtInSongPanel ?? builtInArtistPanel;
2333
+ const activeOverlayPanel = overlayPanel ?? (builtInPanel && { ...builtInPanel, hideTitle: true, hideClose: true });
2334
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState7(false);
2335
+ const [uncontrolledExpanded, setUncontrolledExpanded] = useState7(false);
2066
2336
  const isCollapsed = collapsed ?? uncontrolledCollapsed;
2067
2337
  const isExpanded = expanded ?? uncontrolledExpanded;
2068
2338
  const changeCollapsed = (next) => {
@@ -2075,7 +2345,7 @@ function GenreTreeView({
2075
2345
  };
2076
2346
  useEffect6(() => {
2077
2347
  setUncontrolledCollapsed(false);
2078
- }, [selectedNodeId, artistUuid]);
2348
+ }, [selectedNodeId, artistUuid, songUuid]);
2079
2349
  const isDetailHidden = isCollapsed || !hasSelectedGenreNode && !activeOverlayPanel;
2080
2350
  useLeftDetailPanelLayout(!isDetailHidden, placement === "regions");
2081
2351
  const detailOverlay = placement === "regions" && activeOverlayPanel?.onExpandedChange ? activeOverlayPanel : void 0;
@@ -2089,7 +2359,7 @@ function GenreTreeView({
2089
2359
  hideExpandButton: placement === "regions" ? true : void 0,
2090
2360
  overlayPanel: detailOverlay ? { ...detailOverlay, collapsed: isCollapsed, hideExpandButton: true } : activeOverlayPanel
2091
2361
  };
2092
- const [isWheelRevealed, setIsWheelRevealed] = useState5(false);
2362
+ const [isWheelRevealed, setIsWheelRevealed] = useState7(false);
2093
2363
  const usesHandoff = viewMode === "wheel" || viewMode === "pop-core";
2094
2364
  const isTreeReady = !isLoading && (!usesHandoff || isWheelRevealed);
2095
2365
  const rootRef = useRef4(null);
@@ -2106,21 +2376,22 @@ function GenreTreeView({
2106
2376
  centerChip.click();
2107
2377
  }, [hasSelectedGenreNode, isTreeReady, selectedGenrePlaylist, viewMode]);
2108
2378
  const { showPopup, hidePopup } = usePopup5();
2109
- const [searchText, setSearchText] = useState5("");
2379
+ const [searchText, setSearchText] = useState7("");
2110
2380
  const artistSearchResult = useArtistSearch(artistBaseUrl, artistSearch ? searchText : "");
2111
- const artistExtraResults = artistSearch ? {
2381
+ const artistGroup = artistSearch ? {
2382
+ key: "artists",
2112
2383
  label: "Artists",
2113
2384
  items: artistSearchResult.artists.map((artist) => ({
2114
2385
  key: artist.uuid,
2115
- content: /* @__PURE__ */ jsxs21(Fragment9, { children: [
2116
- /* @__PURE__ */ jsxs21("span", { className: "block truncate font-medium", children: [
2386
+ content: /* @__PURE__ */ jsxs23(Fragment11, { children: [
2387
+ /* @__PURE__ */ jsxs23("span", { className: "block truncate font-medium", children: [
2117
2388
  artist.name,
2118
- artist.disambiguation && /* @__PURE__ */ jsxs21("span", { className: "font-normal text-[var(--color-neutral-500)]", children: [
2389
+ artist.disambiguation && /* @__PURE__ */ jsxs23("span", { className: "font-normal text-[var(--color-neutral-500)]", children: [
2119
2390
  " \xB7 ",
2120
2391
  artist.disambiguation
2121
2392
  ] })
2122
2393
  ] }),
2123
- artist.matchedAlias && /* @__PURE__ */ jsxs21("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [
2394
+ artist.matchedAlias && /* @__PURE__ */ jsxs23("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [
2124
2395
  "also known as ",
2125
2396
  artist.matchedAlias
2126
2397
  ] })
@@ -2133,11 +2404,32 @@ function GenreTreeView({
2133
2404
  status: artistSearchResult.isError ? "Search failed" : artistSearchResult.settled && artistSearchResult.artists.length === 0 ? "No artists found" : "",
2134
2405
  loading: artistSearchResult.pending
2135
2406
  } : void 0;
2407
+ const songSearchResult = useSongSearch(songBaseUrl, songSearch ? searchText : "");
2408
+ const songGroup = songSearch ? {
2409
+ key: "songs",
2410
+ label: "Songs",
2411
+ items: songSearchResult.songs.map((song) => ({
2412
+ key: song.uuid,
2413
+ content: /* @__PURE__ */ jsxs23("span", { className: isSongSearchResultPlayable(song) ? void 0 : "opacity-60", children: [
2414
+ /* @__PURE__ */ jsx30("span", { className: "block truncate font-medium", children: song.title }),
2415
+ /* @__PURE__ */ jsx30("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [song.artists.map((artist) => artist.name).join(", "), song.genre?.name ?? "No genre"].filter(Boolean).join(" \xB7 ") })
2416
+ ] }),
2417
+ onSelect: () => {
2418
+ openSong(song.uuid);
2419
+ onSongSelect?.(song);
2420
+ }
2421
+ })),
2422
+ status: songSearchResult.isError ? "Search failed" : songSearchResult.settled && songSearchResult.songs.length === 0 ? "No songs found" : "",
2423
+ loading: songSearchResult.pending
2424
+ } : void 0;
2425
+ const extraGroups = [artistGroup, songGroup].filter((group) => group !== void 0);
2136
2426
  const handleSelectedNodeChange = (node) => {
2427
+ if (node !== null && songUuid !== null) changeSong(null);
2137
2428
  selectNode(node);
2138
2429
  };
2139
2430
  const handleGenreSearchClear = clearSelection;
2140
2431
  const handleGenreSearchSelect = (genrePlaylist) => {
2432
+ if (songUuid !== null) changeSong(null);
2141
2433
  selectGenrePlaylist(genrePlaylist);
2142
2434
  };
2143
2435
  const selectedUuid = selectedGenrePlaylist?.uuid;
@@ -2152,7 +2444,7 @@ function GenreTreeView({
2152
2444
  (node, { toolbar, ancestors, family }) => {
2153
2445
  const genrePlaylist = genrePlaylistByNodeId.get(node.id);
2154
2446
  const criteria = genrePlaylist?.criteria;
2155
- if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ jsxs21(Fragment9, { children: [
2447
+ if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ jsxs23(Fragment11, { children: [
2156
2448
  toolbar,
2157
2449
  family
2158
2450
  ] }), aside: ancestors };
@@ -2172,7 +2464,7 @@ function GenreTreeView({
2172
2464
  (sibling) => sibling.parent?.uuid === parentPlaylist.uuid && sibling.uuid !== genrePlaylist.uuid && sibling.criteria?.side === "pop"
2173
2465
  ) : void 0;
2174
2466
  return {
2175
- primary: /* @__PURE__ */ jsx28(
2467
+ primary: /* @__PURE__ */ jsx30(
2176
2468
  GenreInfoPanelBody,
2177
2469
  {
2178
2470
  toolbar,
@@ -2191,7 +2483,7 @@ function GenreTreeView({
2191
2483
  onAddChild: readOnly ? void 0 : () => handleGenreCreationAction(criteria),
2192
2484
  onRename: readOnly ? void 0 : () => handleGenreRenameAction(criteria),
2193
2485
  onChangeParent: readOnly ? void 0 : () => showPopup(
2194
- /* @__PURE__ */ jsx28(
2486
+ /* @__PURE__ */ jsx30(
2195
2487
  ChangeParentPopup,
2196
2488
  {
2197
2489
  genrePlaylist: { ...genrePlaylist, criteria },
@@ -2212,6 +2504,7 @@ function GenreTreeView({
2212
2504
  criteriaOverviewSchema,
2213
2505
  renderGenreDetailExtras,
2214
2506
  renderArtistLink,
2507
+ renderSongLink: resolvedRenderSongLink,
2215
2508
  artist: filterArtist,
2216
2509
  renderSummary,
2217
2510
  renderEssentialTracks,
@@ -2242,6 +2535,7 @@ function GenreTreeView({
2242
2535
  criteriaOverviewSchema,
2243
2536
  renderGenreDetailExtras,
2244
2537
  renderArtistLink,
2538
+ resolvedRenderSongLink,
2245
2539
  renderSummary,
2246
2540
  renderEssentialTracks,
2247
2541
  renderSongLeading,
@@ -2251,7 +2545,7 @@ function GenreTreeView({
2251
2545
  );
2252
2546
  const isRotationControlled = controlledAllowWheelRotation !== void 0;
2253
2547
  const allowWheelRotation = controlledAllowWheelRotation ?? internalAllowWheelRotation;
2254
- const detailTab = placement === "regions" && (hasSelectedGenreNode || detailOverlay) ? /* @__PURE__ */ jsx28(
2548
+ const detailTab = placement === "regions" && (hasSelectedGenreNode || detailOverlay) ? /* @__PURE__ */ jsx30(
2255
2549
  PanelEdgeTab,
2256
2550
  {
2257
2551
  side: "left",
@@ -2261,8 +2555,8 @@ function GenreTreeView({
2261
2555
  ) : null;
2262
2556
  const renderArtistFilter = () => {
2263
2557
  if (isLoading || !filterArtist) return null;
2264
- return /* @__PURE__ */ jsxs21("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
2265
- /* @__PURE__ */ jsx28(
2558
+ return /* @__PURE__ */ jsxs23("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
2559
+ /* @__PURE__ */ jsx30(
2266
2560
  "button",
2267
2561
  {
2268
2562
  type: "button",
@@ -2274,7 +2568,7 @@ function GenreTreeView({
2274
2568
  children: filterArtist.name
2275
2569
  }
2276
2570
  ),
2277
- /* @__PURE__ */ jsx28(
2571
+ /* @__PURE__ */ jsx30(
2278
2572
  "button",
2279
2573
  {
2280
2574
  type: "button",
@@ -2286,22 +2580,22 @@ function GenreTreeView({
2286
2580
  )
2287
2581
  ] });
2288
2582
  };
2289
- const actions = /* @__PURE__ */ jsxs21(Fragment9, { children: [
2290
- !isLoading && /* @__PURE__ */ jsx28(
2583
+ const actions = /* @__PURE__ */ jsxs23(Fragment11, { children: [
2584
+ !isLoading && /* @__PURE__ */ jsx30(
2291
2585
  GenreSearch,
2292
2586
  {
2293
2587
  genrePlaylists: visibleGenrePlaylists,
2294
2588
  onSelect: handleGenreSearchSelect,
2295
- selectedName: selectedGenrePlaylist?.name ?? builtInArtistPanel?.title ?? null,
2296
- onClear: builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
2589
+ selectedName: selectedGenrePlaylist?.name ?? builtInPanel?.title ?? null,
2590
+ onClear: builtInSongPanel ? () => changeSong(null) : builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
2297
2591
  onPrevious: handlePrevious,
2298
- onQueryChange: artistSearch ? setSearchText : void 0,
2299
- extraResults: artistExtraResults
2592
+ onQueryChange: artistSearch || songSearch ? setSearchText : void 0,
2593
+ extraGroups
2300
2594
  }
2301
2595
  ),
2302
- !isLoading && filterArtist && /* @__PURE__ */ jsx28("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
2303
- /* @__PURE__ */ jsxs21("div", { className: "contents max-md:flex max-md:w-full max-md:items-center max-md:gap-2 max-md:overflow-x-auto max-md:[scrollbar-width:none] max-md:[&>*]:shrink-0", children: [
2304
- !isLoading && !isControlled && /* @__PURE__ */ jsx28(
2596
+ !isLoading && filterArtist && /* @__PURE__ */ jsx30("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
2597
+ /* @__PURE__ */ jsxs23("div", { className: "contents max-md:flex max-md:w-full max-md:items-center max-md:gap-2 max-md:overflow-x-auto max-md:[scrollbar-width:none] max-md:[&>*]:shrink-0", children: [
2598
+ !isLoading && !isControlled && /* @__PURE__ */ jsx30(
2305
2599
  SegmentedControl2,
2306
2600
  {
2307
2601
  "aria-label": "Tree view mode",
@@ -2315,13 +2609,13 @@ function GenreTreeView({
2315
2609
  ]
2316
2610
  }
2317
2611
  ),
2318
- !isLoading && showDisplayOptions && /* @__PURE__ */ jsx28(
2612
+ !isLoading && showDisplayOptions && /* @__PURE__ */ jsx30(
2319
2613
  "div",
2320
2614
  {
2321
2615
  className: "flex items-center gap-1",
2322
2616
  role: "group",
2323
2617
  "aria-label": "Tree display options",
2324
- children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ jsx28(
2618
+ children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ jsx30(
2325
2619
  Button,
2326
2620
  {
2327
2621
  variant: allowWheelRotation ? "default" : "outline",
@@ -2332,7 +2626,7 @@ function GenreTreeView({
2332
2626
  )
2333
2627
  }
2334
2628
  ),
2335
- !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ jsx28(
2629
+ !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ jsx30(
2336
2630
  IconTextButton,
2337
2631
  {
2338
2632
  icon: Plus2,
@@ -2342,42 +2636,42 @@ function GenreTreeView({
2342
2636
  )
2343
2637
  ] })
2344
2638
  ] });
2345
- return /* @__PURE__ */ jsxs21(
2639
+ return /* @__PURE__ */ jsxs23(
2346
2640
  "div",
2347
2641
  {
2348
2642
  ref: rootRef,
2349
2643
  className: ["genre-tree-view relative flex flex-col h-full", placement === "regions" && REGIONS_CLASS, className2].filter(Boolean).join(" "),
2350
2644
  children: [
2351
- /* @__PURE__ */ jsx28("style", { children: PREVIOUS_BUTTON_CSS }),
2352
- /* @__PURE__ */ jsx28(
2645
+ /* @__PURE__ */ jsx30("style", { children: PREVIOUS_BUTTON_CSS }),
2646
+ /* @__PURE__ */ jsx30(
2353
2647
  "div",
2354
2648
  {
2355
2649
  className: `actions-container absolute z-30 flex items-center gap-2 ${placement === "regions" ? "left-[calc(var(--layout-gutter)+var(--layout-panel-inset))] top-[calc(var(--layout-gutter)+var(--layout-panel-inset))] max-md:right-[calc(var(--layout-gutter)+var(--layout-panel-inset))] max-md:top-[var(--layout-band-top)] max-md:flex-col max-md:items-stretch" : "left-5 top-5"}`,
2356
2650
  children: actions
2357
2651
  }
2358
2652
  ),
2359
- !isLoading && filterArtist && /* @__PURE__ */ jsx28(
2653
+ !isLoading && filterArtist && /* @__PURE__ */ jsx30(
2360
2654
  "div",
2361
2655
  {
2362
2656
  className: `absolute left-1/2 z-30 hidden -translate-x-1/2 md:block ${placement === "regions" ? "top-[calc(var(--layout-gutter)+var(--layout-panel-inset))]" : "top-5"}`,
2363
2657
  children: renderArtistFilter()
2364
2658
  }
2365
2659
  ),
2366
- detailTab && /* @__PURE__ */ jsx28(
2660
+ detailTab && /* @__PURE__ */ jsx30(
2367
2661
  "div",
2368
2662
  {
2369
2663
  className: `absolute top-1/2 z-30 -translate-y-1/2 max-md:hidden transition-[opacity,visibility] duration-150 ease-out ${isTreeReady ? "opacity-100" : "pointer-events-none invisible opacity-0"} ${detailExpanded ? "right-[calc(var(--layout-center-right)-var(--layout-edge-tab-w))]" : isCollapsed ? "left-0" : "left-[calc(var(--layout-gutter)+var(--layout-left-w))]"}`,
2370
2664
  children: detailTab
2371
2665
  }
2372
2666
  ),
2373
- /* @__PURE__ */ jsx28("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ jsxs21("div", { className: "tree-view-container relative flex-1 min-w-0 flex flex-col h-full", children: [
2374
- !isLoading && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ jsx28("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
2375
- isLoading ? /* @__PURE__ */ jsx28(GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
2667
+ /* @__PURE__ */ jsx30("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ jsxs23("div", { className: "tree-view-container relative flex-1 min-w-0 flex flex-col h-full", children: [
2668
+ !isLoading && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ jsx30("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
2669
+ isLoading ? /* @__PURE__ */ jsx30(GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
2376
2670
  // The info panel's top edge aligns with the tree's; the search bar floats `--layout-panel-inset` below it,
2377
2671
  // and the header inset (the bar's height plus that inset) extends the header's fill behind the bar,
2378
2672
  // Google Maps style; the 8px bottom padding mirrors the inset above the bar, and the hidden title's height is zeroed.
2379
2673
  // The panel's own Zoom in button is hidden: the search bar carries it.
2380
- /* @__PURE__ */ jsx28("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx28(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx28(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx28(
2674
+ /* @__PURE__ */ jsx30("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx30(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx30(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx30(
2381
2675
  GenrePlaylistTreeWheel,
2382
2676
  {
2383
2677
  scope,
@@ -2396,9 +2690,9 @@ function GenreTreeView({
2396
2690
  allowWheelRotation,
2397
2691
  sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2398
2692
  busy,
2399
- floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs21("div", { className: "flex items-center gap-2", children: [
2693
+ floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs23("div", { className: "flex items-center gap-2", children: [
2400
2694
  floatingControlsLeading,
2401
- canSizeByViews && /* @__PURE__ */ jsx28(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2695
+ canSizeByViews && /* @__PURE__ */ jsx30(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2402
2696
  ] }) : void 0,
2403
2697
  defaultViewport,
2404
2698
  viewportRestore,
@@ -2408,7 +2702,7 @@ function GenreTreeView({
2408
2702
  ) }) })
2409
2703
  ) : viewMode === "pop-core" ? (
2410
2704
  // Same as the wheel: the header inset extends its fill behind the floating search bar, Google Maps style.
2411
- /* @__PURE__ */ jsx28("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden [&_.gtv-info-panel-zoom-in]:hidden", children: /* @__PURE__ */ jsx28(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx28(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx28(
2705
+ /* @__PURE__ */ jsx30("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden [&_.gtv-info-panel-zoom-in]:hidden", children: /* @__PURE__ */ jsx30(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx30(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx30(
2412
2706
  GenrePlaylistTreeWheelRadialPopCore,
2413
2707
  {
2414
2708
  scope,
@@ -2427,9 +2721,9 @@ function GenreTreeView({
2427
2721
  allowWheelRotation,
2428
2722
  sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2429
2723
  busy,
2430
- floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs21("div", { className: "flex items-center gap-2", children: [
2724
+ floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs23("div", { className: "flex items-center gap-2", children: [
2431
2725
  floatingControlsLeading,
2432
- canSizeByViews && /* @__PURE__ */ jsx28(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2726
+ canSizeByViews && /* @__PURE__ */ jsx30(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2433
2727
  ] }) : void 0,
2434
2728
  defaultViewport,
2435
2729
  viewportRestore,
@@ -2438,7 +2732,7 @@ function GenreTreeView({
2438
2732
  ...focus
2439
2733
  }
2440
2734
  ) }) })
2441
- ) : viewMode === "outline" ? /* @__PURE__ */ jsx28("div", { className: "tree-container flex-1 min-h-0 w-full overflow-y-auto relative [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem)+0.75rem)] [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx28(
2735
+ ) : viewMode === "outline" ? /* @__PURE__ */ jsx30("div", { className: "tree-container flex-1 min-h-0 w-full overflow-y-auto relative [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem)+0.75rem)] [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx30(
2442
2736
  GenrePlaylistTreeWheelRadialPopCore,
2443
2737
  {
2444
2738
  outline: true,
@@ -2457,13 +2751,13 @@ function GenreTreeView({
2457
2751
  readOnly,
2458
2752
  ...infoPanelCuration
2459
2753
  }
2460
- ) }) : /* @__PURE__ */ jsx28("div", { className: "tree-container relative flex flex-col gap-4 w-full pt-14 overflow-x-auto overflow-y-auto text-[var(--color-text)] [&_.gtv-toolbar]:hidden", children: Object.entries(groupedGenrePlaylistsByRoot).map(
2754
+ ) }) : /* @__PURE__ */ jsx30("div", { className: "tree-container relative flex flex-col gap-4 w-full pt-14 overflow-x-auto overflow-y-auto text-[var(--color-text)] [&_.gtv-toolbar]:hidden", children: Object.entries(groupedGenrePlaylistsByRoot).map(
2461
2755
  ([uuid, genrePlaylistTreePerRoot], rootIndex) => {
2462
- return /* @__PURE__ */ jsx28(
2756
+ return /* @__PURE__ */ jsx30(
2463
2757
  "div",
2464
2758
  {
2465
2759
  className: "tree-per-root-container relative shrink-0 h-[28rem] mt-2 mr-16 p-2 bg-[var(--color-neutral-100)] rounded-lg",
2466
- children: /* @__PURE__ */ jsx28("div", { className: "graph-container relative z-10 h-full", children: /* @__PURE__ */ jsx28(
2760
+ children: /* @__PURE__ */ jsx30("div", { className: "graph-container relative z-10 h-full", children: /* @__PURE__ */ jsx30(
2467
2761
  GenrePlaylistTreePerRoot,
2468
2762
  {
2469
2763
  scope,
@@ -2508,11 +2802,11 @@ import {
2508
2802
  TEXT_COLOR,
2509
2803
  tintSurface
2510
2804
  } from "@behindthemusictree/genre-tree-view";
2511
- import { Skeleton as Skeleton5 } from "@behindthemusictree/ui";
2805
+ import { Skeleton as Skeleton7 } from "@behindthemusictree/ui";
2512
2806
  import {
2513
2807
  useFetchGenreDetail
2514
2808
  } from "@behindthemusictree/app-kit/genre-tree";
2515
- import { jsx as jsx29, jsxs as jsxs22 } from "react/jsx-runtime";
2809
+ import { jsx as jsx31, jsxs as jsxs24 } from "react/jsx-runtime";
2516
2810
  var ITEM_COUNT_LABEL = "Songs";
2517
2811
  var RELATIVE_ITEM_COUNT = 1;
2518
2812
  function toNode(genre, parentId) {
@@ -2567,17 +2861,17 @@ function GenreDetailPanel({
2567
2861
  };
2568
2862
  }, [detail]);
2569
2863
  if (isError) {
2570
- return /* @__PURE__ */ jsx29("div", { role: "alert", className: "p-4 text-sm text-[var(--color-text)]", children: "This genre could not be loaded." });
2864
+ return /* @__PURE__ */ jsx31("div", { role: "alert", className: "p-4 text-sm text-[var(--color-text)]", children: "This genre could not be loaded." });
2571
2865
  }
2572
2866
  if (isPending || !detail || !view) {
2573
- return /* @__PURE__ */ jsxs22("div", { className: "flex flex-col gap-3 p-4", "aria-busy": "true", "data-testid": "genre-detail-panel-skeleton", children: [
2574
- /* @__PURE__ */ jsx29(Skeleton5, { className: "h-6 w-2/3" }),
2575
- /* @__PURE__ */ jsx29(Skeleton5, { className: "h-4 w-full" }),
2576
- /* @__PURE__ */ jsx29(Skeleton5, { className: "h-4 w-3/4" })
2867
+ return /* @__PURE__ */ jsxs24("div", { className: "flex flex-col gap-3 p-4", "aria-busy": "true", "data-testid": "genre-detail-panel-skeleton", children: [
2868
+ /* @__PURE__ */ jsx31(Skeleton7, { className: "h-6 w-2/3" }),
2869
+ /* @__PURE__ */ jsx31(Skeleton7, { className: "h-4 w-full" }),
2870
+ /* @__PURE__ */ jsx31(Skeleton7, { className: "h-4 w-3/4" })
2577
2871
  ] });
2578
2872
  }
2579
2873
  const renderBody = (node, sections) => ({
2580
- primary: /* @__PURE__ */ jsx29(
2874
+ primary: /* @__PURE__ */ jsx31(
2581
2875
  GenreInfoPanelBody,
2582
2876
  {
2583
2877
  toolbar: sections.toolbar,
@@ -2594,7 +2888,7 @@ function GenreDetailPanel({
2594
2888
  ),
2595
2889
  aside: null
2596
2890
  });
2597
- return /* @__PURE__ */ jsx29(
2891
+ return /* @__PURE__ */ jsx31(
2598
2892
  InfoPanel,
2599
2893
  {
2600
2894
  node: view.node,
@@ -2627,10 +2921,10 @@ import {
2627
2921
  // src/genre-tree/track-list-sidebar/TrackListSidebar.tsx
2628
2922
  import { useEffect as useEffect7, useLayoutEffect as useLayoutEffect2, useRef as useRef5 } from "react";
2629
2923
  import { ListMusic } from "lucide-react";
2630
- import { useTrackList as useTrackList5 } from "@behindthemusictree/app-kit/genre-tree";
2924
+ import { useTrackList as useTrackList6 } from "@behindthemusictree/app-kit/genre-tree";
2631
2925
  import { TrackListOriginType as TrackListOriginType2 } from "@behindthemusictree/app-kit/genre-tree";
2632
2926
  import { useTrackNoun as useTrackNoun6 } from "@behindthemusictree/app-kit/track-noun";
2633
- import { jsx as jsx30, jsxs as jsxs23 } from "react/jsx-runtime";
2927
+ import { jsx as jsx32, jsxs as jsxs25 } from "react/jsx-runtime";
2634
2928
  var useBrowserLayoutEffect = typeof window === "undefined" ? useEffect7 : useLayoutEffect2;
2635
2929
  function TrackListSidebar({
2636
2930
  className: className2 = "",
@@ -2640,9 +2934,10 @@ function TrackListSidebar({
2640
2934
  isCollapsed = false,
2641
2935
  onExpand,
2642
2936
  onMinimize,
2643
- renderArtistLink
2937
+ renderArtistLink,
2938
+ renderSongLink
2644
2939
  }) {
2645
- const { trackList, loadMore, autoAdvanceScrollRequest } = useTrackList5();
2940
+ const { trackList, loadMore, autoAdvanceScrollRequest } = useTrackList6();
2646
2941
  const trackNoun = useTrackNoun6();
2647
2942
  const trackListRef = useRef5(null);
2648
2943
  const previousOriginRef = useRef5(trackList?.origin ?? null);
@@ -2731,25 +3026,25 @@ function TrackListSidebar({
2731
3026
  }
2732
3027
  };
2733
3028
  const positionClasses = layout === "inline" ? "relative w-full flex h-full flex-col" : "fixed right-0 w-[50rem] flex flex-col";
2734
- return trackList ? /* @__PURE__ */ jsxs23(
3029
+ return trackList ? /* @__PURE__ */ jsxs25(
2735
3030
  "div",
2736
3031
  {
2737
3032
  "data-theme": "dark",
2738
3033
  className: `track-list-sidebar ${positionClasses} rounded-2xl bg-[var(--color-surface)] pb-1 ${className2}`,
2739
3034
  style: layout === "fixed" ? { top: "63.5px", maxHeight: "calc(100vh - 63.5px - 79px)" } : void 0,
2740
3035
  children: [
2741
- /* @__PURE__ */ jsxs23("div", { className: "header flex h-16 items-center px-4 py-2 text-[var(--color-text)]", children: [
2742
- /* @__PURE__ */ jsxs23("div", { className: "min-w-0 flex flex-1 flex-col justify-center", children: [
2743
- /* @__PURE__ */ jsxs23("div", { className: "origin flex text-xl", children: [
2744
- /* @__PURE__ */ jsx30("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
2745
- /* @__PURE__ */ jsx30("div", { className: "name-container flex flex-col justify-center items-center text-[var(--color-text)] font-bold pr-2 max-w-[27.5rem]", children: /* @__PURE__ */ jsx30("div", { className: "name max-w-full truncate", children: trackList.origin.label }) })
3036
+ /* @__PURE__ */ jsxs25("div", { className: "header flex h-16 items-center px-4 py-2 text-[var(--color-text)]", children: [
3037
+ /* @__PURE__ */ jsxs25("div", { className: "min-w-0 flex flex-1 flex-col justify-center", children: [
3038
+ /* @__PURE__ */ jsxs25("div", { className: "origin flex text-xl", children: [
3039
+ /* @__PURE__ */ jsx32("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
3040
+ /* @__PURE__ */ jsx32("div", { className: "name-container flex flex-col justify-center items-center text-[var(--color-text)] font-bold pr-2 max-w-[27.5rem]", children: /* @__PURE__ */ jsx32("div", { className: "name max-w-full truncate", children: trackList.origin.label }) })
2746
3041
  ] }),
2747
- /* @__PURE__ */ jsxs23("div", { className: "info flex items-center text-sm text-[var(--color-text-muted)]", children: [
3042
+ /* @__PURE__ */ jsxs25("div", { className: "info flex items-center text-sm text-[var(--color-text-muted)]", children: [
2748
3043
  trackList.origin.type === TrackListOriginType2.GENRE_PLAYLIST ? "Genre \u2022 " : trackList.origin.type === TrackListOriginType2.ARTIST ? "Artist \u2022 " : `${trackNoun.singular} playlist \u2022 `,
2749
3044
  `${trackList.total} ${trackList.total > 1 ? trackNoun.plural : trackNoun.singular}`
2750
3045
  ] })
2751
3046
  ] }),
2752
- (isCollapsed && onExpand || !isCollapsed && onMinimize) && /* @__PURE__ */ jsx30(
3047
+ (isCollapsed && onExpand || !isCollapsed && onMinimize) && /* @__PURE__ */ jsx32(
2753
3048
  "button",
2754
3049
  {
2755
3050
  type: "button",
@@ -2758,11 +3053,11 @@ function TrackListSidebar({
2758
3053
  "aria-expanded": !isCollapsed,
2759
3054
  className: `ml-2 flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-[var(--color-surface-muted)] ${isCollapsed ? "text-[var(--color-text-muted)]" : "text-white"}`,
2760
3055
  onClick: isCollapsed ? onExpand : onMinimize,
2761
- children: /* @__PURE__ */ jsx30(ListMusic, { "aria-hidden": "true", className: "h-5 w-5" })
3056
+ children: /* @__PURE__ */ jsx32(ListMusic, { "aria-hidden": "true", className: "h-5 w-5" })
2762
3057
  }
2763
3058
  )
2764
3059
  ] }),
2765
- !isCollapsed && /* @__PURE__ */ jsxs23(
3060
+ !isCollapsed && /* @__PURE__ */ jsxs25(
2766
3061
  "ul",
2767
3062
  {
2768
3063
  ref: trackListRef,
@@ -2772,17 +3067,18 @@ function TrackListSidebar({
2772
3067
  onPointerDown: markUserScrollIntent,
2773
3068
  className: layout === "inline" ? "track-list flex-1 min-h-0 overflow-auto list-none p-0 m-0" : "track-list flex-1 min-h-0 overflow-auto list-none p-0 m-0",
2774
3069
  children: [
2775
- trackList.tracks.map((track, index) => /* @__PURE__ */ jsx30("li", { "data-track-uuid": track.uuid, children: /* @__PURE__ */ jsx30(
3070
+ trackList.tracks.map((track, index) => /* @__PURE__ */ jsx32("li", { "data-track-uuid": track.uuid, children: /* @__PURE__ */ jsx32(
2776
3071
  TrackItem,
2777
3072
  {
2778
3073
  track,
2779
3074
  position: index + 1,
2780
3075
  renderDuration,
2781
3076
  renderActions,
2782
- renderArtistLink
3077
+ renderArtistLink,
3078
+ renderSongLink
2783
3079
  }
2784
3080
  ) }, track.uuid)),
2785
- hasMore && /* @__PURE__ */ jsx30("li", { ref: loadMoreSentinelRef, "aria-hidden": "true", className: "h-px" })
3081
+ hasMore && /* @__PURE__ */ jsx32("li", { ref: loadMoreSentinelRef, "aria-hidden": "true", className: "h-px" })
2786
3082
  ]
2787
3083
  }
2788
3084
  )
@@ -2792,10 +3088,10 @@ function TrackListSidebar({
2792
3088
  }
2793
3089
 
2794
3090
  // src/genre-tree/PlayerPanel.tsx
2795
- import { useEffect as useEffect8, useState as useState6 } from "react";
3091
+ import { useEffect as useEffect8, useState as useState8 } from "react";
2796
3092
  import { twMerge as twMerge2 } from "tailwind-merge";
2797
3093
  import {
2798
- usePlayer as usePlayer10,
3094
+ usePlayer as usePlayer11,
2799
3095
  PlayerVideoSurface
2800
3096
  } from "@behindthemusictree/app-kit/player";
2801
3097
  import {
@@ -2803,10 +3099,10 @@ import {
2803
3099
  } from "@behindthemusictree/app-kit/genre-tree";
2804
3100
 
2805
3101
  // src/genre-tree/PlayerControls.tsx
2806
- import { Pause as Pause2, Play as Play3, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
2807
- import { formatTime, useTrackList as useTrackList6 } from "@behindthemusictree/app-kit/genre-tree";
2808
- import { PlayStates as PlayStates3, useCurrentTime, usePlayer as usePlayer9 } from "@behindthemusictree/app-kit/player";
2809
- import { jsx as jsx31, jsxs as jsxs24 } from "react/jsx-runtime";
3102
+ import { Pause as Pause3, Play as Play4, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
3103
+ import { formatTime, useTrackList as useTrackList7 } from "@behindthemusictree/app-kit/genre-tree";
3104
+ import { PlayStates as PlayStates4, useCurrentTime, usePlayer as usePlayer10 } from "@behindthemusictree/app-kit/player";
3105
+ import { jsx as jsx33, jsxs as jsxs26 } from "react/jsx-runtime";
2810
3106
  function PlayerControls() {
2811
3107
  const {
2812
3108
  duration,
@@ -2817,23 +3113,23 @@ function PlayerControls() {
2817
3113
  seekTo,
2818
3114
  setVolume,
2819
3115
  volume
2820
- } = usePlayer9();
2821
- const { hasNextTrack, hasPreviousTrack, playNextTrack, playPreviousTrack } = useTrackList6();
3116
+ } = usePlayer10();
3117
+ const { hasNextTrack, hasPreviousTrack, playNextTrack, playPreviousTrack } = useTrackList7();
2822
3118
  const currentTime = useCurrentTime();
2823
- const isBusy = isLoading || playState === PlayStates3.LOADING;
3119
+ const isBusy = isLoading || playState === PlayStates4.LOADING;
2824
3120
  const isReady = !!playerTrackObject?.isReady && !isBusy && !playerTrackObject.loadError;
2825
3121
  const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
2826
3122
  const safeCurrentTime = Number.isFinite(currentTime) ? Math.max(0, Math.min(currentTime, safeDuration)) : 0;
2827
- const isPlaying = playState === PlayStates3.PLAYING;
2828
- return /* @__PURE__ */ jsxs24(
3123
+ const isPlaying = playState === PlayStates4.PLAYING;
3124
+ return /* @__PURE__ */ jsxs26(
2829
3125
  "section",
2830
3126
  {
2831
3127
  "aria-label": "Player controls",
2832
3128
  className: "player-controls flex shrink-0 flex-col gap-2 border-b border-zinc-800 px-3 py-2 text-zinc-200",
2833
3129
  children: [
2834
- /* @__PURE__ */ jsxs24("div", { className: "flex min-w-0 items-center gap-2 text-xs tabular-nums text-zinc-400", children: [
2835
- /* @__PURE__ */ jsx31("span", { "aria-label": "Elapsed time", children: formatTime(safeCurrentTime) }),
2836
- /* @__PURE__ */ jsx31(
3130
+ /* @__PURE__ */ jsxs26("div", { className: "flex min-w-0 items-center gap-2 text-xs tabular-nums text-zinc-400", children: [
3131
+ /* @__PURE__ */ jsx33("span", { "aria-label": "Elapsed time", children: formatTime(safeCurrentTime) }),
3132
+ /* @__PURE__ */ jsx33(
2837
3133
  "input",
2838
3134
  {
2839
3135
  "aria-label": "Seek",
@@ -2847,11 +3143,11 @@ function PlayerControls() {
2847
3143
  onChange: (event) => seekTo(Number(event.currentTarget.value))
2848
3144
  }
2849
3145
  ),
2850
- /* @__PURE__ */ jsx31("span", { "aria-label": "Duration", children: formatTime(safeDuration) })
3146
+ /* @__PURE__ */ jsx33("span", { "aria-label": "Duration", children: formatTime(safeDuration) })
2851
3147
  ] }),
2852
- /* @__PURE__ */ jsxs24("div", { className: "grid min-h-9 grid-cols-[1fr_auto_1fr] items-center gap-3", children: [
2853
- /* @__PURE__ */ jsxs24("div", { className: "col-start-2 flex items-center justify-center gap-2", children: [
2854
- /* @__PURE__ */ jsx31(
3148
+ /* @__PURE__ */ jsxs26("div", { className: "grid min-h-9 grid-cols-[1fr_auto_1fr] items-center gap-3", children: [
3149
+ /* @__PURE__ */ jsxs26("div", { className: "col-start-2 flex items-center justify-center gap-2", children: [
3150
+ /* @__PURE__ */ jsx33(
2855
3151
  "button",
2856
3152
  {
2857
3153
  type: "button",
@@ -2860,10 +3156,10 @@ function PlayerControls() {
2860
3156
  disabled: !isReady || !hasPreviousTrack,
2861
3157
  onClick: playPreviousTrack,
2862
3158
  className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-zinc-300 hover:bg-zinc-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-40",
2863
- children: /* @__PURE__ */ jsx31(SkipBack, { "aria-hidden": "true", className: "h-4 w-4" })
3159
+ children: /* @__PURE__ */ jsx33(SkipBack, { "aria-hidden": "true", className: "h-4 w-4" })
2864
3160
  }
2865
3161
  ),
2866
- /* @__PURE__ */ jsx31(
3162
+ /* @__PURE__ */ jsx33(
2867
3163
  "button",
2868
3164
  {
2869
3165
  type: "button",
@@ -2872,10 +3168,10 @@ function PlayerControls() {
2872
3168
  disabled: !isReady,
2873
3169
  onClick: handlePlayPauseAction,
2874
3170
  className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white text-zinc-950 hover:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-40",
2875
- children: isPlaying ? /* @__PURE__ */ jsx31(Pause2, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" }) : /* @__PURE__ */ jsx31(Play3, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" })
3171
+ children: isPlaying ? /* @__PURE__ */ jsx33(Pause3, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" }) : /* @__PURE__ */ jsx33(Play4, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" })
2876
3172
  }
2877
3173
  ),
2878
- /* @__PURE__ */ jsx31(
3174
+ /* @__PURE__ */ jsx33(
2879
3175
  "button",
2880
3176
  {
2881
3177
  type: "button",
@@ -2884,14 +3180,14 @@ function PlayerControls() {
2884
3180
  disabled: !isReady || !hasNextTrack,
2885
3181
  onClick: playNextTrack,
2886
3182
  className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-zinc-300 hover:bg-zinc-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-40",
2887
- children: /* @__PURE__ */ jsx31(SkipForward, { "aria-hidden": "true", className: "h-4 w-4" })
3183
+ children: /* @__PURE__ */ jsx33(SkipForward, { "aria-hidden": "true", className: "h-4 w-4" })
2888
3184
  }
2889
3185
  )
2890
3186
  ] }),
2891
- /* @__PURE__ */ jsxs24("label", { className: "col-start-3 flex w-[4.2rem] shrink-0 items-center gap-[0.35rem] justify-self-end text-zinc-400 sm:w-[4.9rem]", children: [
2892
- volume === 0 ? /* @__PURE__ */ jsx31(VolumeX, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }) : /* @__PURE__ */ jsx31(Volume2, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }),
2893
- /* @__PURE__ */ jsx31("span", { className: "sr-only", children: "Volume" }),
2894
- /* @__PURE__ */ jsx31(
3187
+ /* @__PURE__ */ jsxs26("label", { className: "col-start-3 flex w-[4.2rem] shrink-0 items-center gap-[0.35rem] justify-self-end text-zinc-400 sm:w-[4.9rem]", children: [
3188
+ volume === 0 ? /* @__PURE__ */ jsx33(VolumeX, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }) : /* @__PURE__ */ jsx33(Volume2, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }),
3189
+ /* @__PURE__ */ jsx33("span", { className: "sr-only", children: "Volume" }),
3190
+ /* @__PURE__ */ jsx33(
2895
3191
  "input",
2896
3192
  {
2897
3193
  "aria-label": "Volume",
@@ -2913,7 +3209,7 @@ function PlayerControls() {
2913
3209
  }
2914
3210
 
2915
3211
  // src/genre-tree/PlayerPanel.tsx
2916
- import { jsx as jsx32, jsxs as jsxs25 } from "react/jsx-runtime";
3212
+ import { jsx as jsx34, jsxs as jsxs27 } from "react/jsx-runtime";
2917
3213
  var PANEL_ID = "player-panel";
2918
3214
  var DEFAULT_EXTENDED_WIDTH = "calc(100vw - var(--layout-center-left) - var(--layout-gutter))";
2919
3215
  function PlayerPanel({
@@ -2922,16 +3218,17 @@ function PlayerPanel({
2922
3218
  expanded,
2923
3219
  onExpandedChange,
2924
3220
  extendedWidth = DEFAULT_EXTENDED_WIDTH,
2925
- renderArtistLink
3221
+ renderArtistLink,
3222
+ renderSongLink
2926
3223
  }) {
2927
- const { playerTrackObject } = usePlayer10();
3224
+ const { playerTrackObject } = usePlayer11();
2928
3225
  const { toggleTrackListSidebar, isTrackListSidebarVisible } = useTrackListSidebarVisibility();
2929
- const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState6("");
3226
+ const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState8("");
2930
3227
  const loadedTrackTitle = playerTrackObject?.track.title;
2931
3228
  useEffect8(() => {
2932
3229
  if (loadedTrackTitle) setLastLoadedTrackTitle(loadedTrackTitle);
2933
3230
  }, [loadedTrackTitle]);
2934
- const [uncontrolledExtended, setUncontrolledExtended] = useState6(false);
3231
+ const [uncontrolledExtended, setUncontrolledExtended] = useState8(false);
2935
3232
  const isExtendRequested = expanded ?? uncontrolledExtended;
2936
3233
  const setIsExtendRequested = (next) => {
2937
3234
  setUncontrolledExtended(next);
@@ -2952,7 +3249,7 @@ function PlayerPanel({
2952
3249
  onClick: () => setIsExtendRequested(true),
2953
3250
  expanded: false
2954
3251
  } : null;
2955
- return /* @__PURE__ */ jsxs25(
3252
+ return /* @__PURE__ */ jsxs27(
2956
3253
  "div",
2957
3254
  {
2958
3255
  id: PANEL_ID,
@@ -2965,7 +3262,7 @@ function PlayerPanel({
2965
3262
  ),
2966
3263
  style: isExtended ? { width: extendedWidth } : void 0,
2967
3264
  children: [
2968
- /* @__PURE__ */ jsx32(
3265
+ /* @__PURE__ */ jsx34(
2969
3266
  PanelEdgeTab,
2970
3267
  {
2971
3268
  side: "right",
@@ -2975,7 +3272,7 @@ function PlayerPanel({
2975
3272
  className: "absolute right-full top-1/2 -translate-y-1/2"
2976
3273
  }
2977
3274
  ),
2978
- /* @__PURE__ */ jsxs25(
3275
+ /* @__PURE__ */ jsxs27(
2979
3276
  "div",
2980
3277
  {
2981
3278
  className: twMerge2(
@@ -2983,15 +3280,15 @@ function PlayerPanel({
2983
3280
  isExtended ? "flex-row" : "flex-col"
2984
3281
  ),
2985
3282
  children: [
2986
- /* @__PURE__ */ jsxs25("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
2987
- /* @__PURE__ */ jsx32("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-zinc-800 p-2", children: /* @__PURE__ */ jsx32("span", { className: "min-w-0 truncate text-sm font-semibold", children: loadedTrackTitle || lastLoadedTrackTitle }) }),
2988
- /* @__PURE__ */ jsxs25("div", { className: "relative w-full shrink-0", children: [
2989
- /* @__PURE__ */ jsx32(PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
2990
- playerTrackObject?.loadError && /* @__PURE__ */ jsx32("span", { className: "absolute bottom-2 left-2 right-2 text-xs text-red-400", children: playerTrackObject.loadError })
3283
+ /* @__PURE__ */ jsxs27("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
3284
+ /* @__PURE__ */ jsx34("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-zinc-800 p-2", children: /* @__PURE__ */ jsx34("span", { className: "min-w-0 truncate text-sm font-semibold", children: loadedTrackTitle || lastLoadedTrackTitle }) }),
3285
+ /* @__PURE__ */ jsxs27("div", { className: "relative w-full shrink-0", children: [
3286
+ /* @__PURE__ */ jsx34(PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
3287
+ playerTrackObject?.loadError && /* @__PURE__ */ jsx34("span", { className: "absolute bottom-2 left-2 right-2 text-xs text-red-400", children: playerTrackObject.loadError })
2991
3288
  ] }),
2992
- /* @__PURE__ */ jsx32(PlayerControls, {})
3289
+ /* @__PURE__ */ jsx34(PlayerControls, {})
2993
3290
  ] }),
2994
- /* @__PURE__ */ jsx32(
3291
+ /* @__PURE__ */ jsx34(
2995
3292
  TrackListSidebar,
2996
3293
  {
2997
3294
  layout: "inline",
@@ -3002,7 +3299,8 @@ function PlayerPanel({
3002
3299
  "min-h-0 min-w-0 flex-1",
3003
3300
  isExtended && "border-l border-zinc-800"
3004
3301
  ),
3005
- renderArtistLink
3302
+ renderArtistLink,
3303
+ renderSongLink
3006
3304
  }
3007
3305
  )
3008
3306
  ]
@@ -3017,11 +3315,11 @@ function PlayerPanel({
3017
3315
  import { v4 as uuidv4 } from "uuid";
3018
3316
  import { AiFillStar, AiOutlineStar } from "react-icons/ai";
3019
3317
  import { FORM_RATING_NULL_VALUE } from "@behindthemusictree/app-kit/genre-tree";
3020
- import { jsx as jsx33, jsxs as jsxs26 } from "react/jsx-runtime";
3318
+ import { jsx as jsx35, jsxs as jsxs28 } from "react/jsx-runtime";
3021
3319
  function Rating({ rating, handleChange }) {
3022
3320
  const instanceId = uuidv4();
3023
- return /* @__PURE__ */ jsx33("div", { className: "flex items-center", children: [...Array(6)].map((_, i) => /* @__PURE__ */ jsxs26("div", { className: `relative ${i === 0 ? "mr-4" : "mr-1"}`, children: [
3024
- /* @__PURE__ */ jsx33(
3321
+ return /* @__PURE__ */ jsx35("div", { className: "flex items-center", children: [...Array(6)].map((_, i) => /* @__PURE__ */ jsxs28("div", { className: `relative ${i === 0 ? "mr-4" : "mr-1"}`, children: [
3322
+ /* @__PURE__ */ jsx35(
3025
3323
  "input",
3026
3324
  {
3027
3325
  id: `star-${instanceId}-${i}`,
@@ -3033,16 +3331,16 @@ function Rating({ rating, handleChange }) {
3033
3331
  onChange: handleChange
3034
3332
  }
3035
3333
  ),
3036
- /* @__PURE__ */ jsx33("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ jsxs26("div", { className: "flex", children: [
3037
- /* @__PURE__ */ jsx33(
3334
+ /* @__PURE__ */ jsx35("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ jsxs28("div", { className: "flex", children: [
3335
+ /* @__PURE__ */ jsx35(
3038
3336
  AiOutlineStar,
3039
3337
  {
3040
3338
  className: `w-7 h-7 absolute top-0 left-0 ${rating === FORM_RATING_NULL_VALUE ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
3041
3339
  }
3042
3340
  ),
3043
- /* @__PURE__ */ jsx33("span", { className: "flex-grow w-8" }),
3341
+ /* @__PURE__ */ jsx35("span", { className: "flex-grow w-8" }),
3044
3342
  " "
3045
- ] }) : /* @__PURE__ */ jsx33(
3343
+ ] }) : /* @__PURE__ */ jsx35(
3046
3344
  AiFillStar,
3047
3345
  {
3048
3346
  className: `w-7 h-7 absolute top-0 left-0 ${(rating ?? -1) >= i ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
@@ -3071,4 +3369,4 @@ export {
3071
3369
  GenreTreeViewSkeleton2 as GenreTreeViewSkeleton,
3072
3370
  GenreTreeOutlineSkeleton
3073
3371
  };
3074
- //# sourceMappingURL=chunk-36YVZ6JR.js.map
3372
+ //# sourceMappingURL=chunk-2HE2HDFA.js.map