@behindthemusictree/app-kit-ui 29.0.1 → 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,30 +1901,233 @@ 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
  {
2117
+ variant: "floating",
1906
2118
  "aria-label": "Size nodes by",
1907
2119
  value,
1908
2120
  onChange,
1909
2121
  options: [
1910
- { value: "songs", label: /* @__PURE__ */ jsx27(MdMusicNote, { size: 18, "aria-hidden": true }), "aria-label": "Size by songs", title: "Node size by song count" },
1911
- { 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" }
1912
2124
  ]
1913
2125
  }
1914
2126
  );
1915
2127
  }
1916
2128
 
1917
2129
  // src/genre-tree/GenreTreeView.tsx
1918
- 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";
1919
2131
  var PREVIOUS_BUTTON_CSS = `
1920
2132
  .genre-tree-view .search-overlay .gtv-info-panel-previous { display: none; }
1921
2133
  .genre-tree-view:has(.search-overlay .gtv-info-panel-previous) .genre-search input { padding-left: 2.75rem; }
@@ -1959,6 +2171,13 @@ function GenreTreeView({
1959
2171
  artistSearch = true,
1960
2172
  onArtistSelect,
1961
2173
  artistSearchBaseUrl,
2174
+ songUuid: controlledSongUuid,
2175
+ onSongUuidChange,
2176
+ songExpanded: controlledSongExpanded,
2177
+ onSongExpandedChange,
2178
+ songSearch = true,
2179
+ onSongSelect,
2180
+ renderSongLink,
1962
2181
  renderSummary,
1963
2182
  renderEssentialTracks,
1964
2183
  renderSongLeading,
@@ -1975,10 +2194,17 @@ function GenreTreeView({
1975
2194
  }) {
1976
2195
  const getShareUrlRef = useRef4(getShareUrl);
1977
2196
  getShareUrlRef.current = getShareUrl;
1978
- const [internalArtistUuid, setInternalArtistUuid] = useState5(null);
2197
+ const [internalArtistUuid, setInternalArtistUuid] = useState7(null);
1979
2198
  const isArtistControlled = controlledArtistUuid !== void 0;
1980
2199
  const artistUuid = isArtistControlled ? controlledArtistUuid : internalArtistUuid;
1981
- 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");
1982
2208
  const sizeMetric = controlledSizeMetric ?? internalSizeMetric;
1983
2209
  const deferredSizeMetric = useDeferredValue(sizeMetric);
1984
2210
  const changeSizeMetric = (metric) => {
@@ -2017,7 +2243,7 @@ function GenreTreeView({
2017
2243
  artistUuid
2018
2244
  });
2019
2245
  const restoreRevision = viewportRestore?.revision;
2020
- const [stackedRestoreOwner, setStackedRestoreOwner] = useState5({ revision: restoreRevision, rootUuid: selectedGenrePlaylist?.root.uuid });
2246
+ const [stackedRestoreOwner, setStackedRestoreOwner] = useState7({ revision: restoreRevision, rootUuid: selectedGenrePlaylist?.root.uuid });
2021
2247
  let restoreRootUuid = stackedRestoreOwner.rootUuid;
2022
2248
  if (stackedRestoreOwner.revision !== restoreRevision) {
2023
2249
  restoreRootUuid = selectedGenrePlaylist?.root.uuid;
@@ -2041,27 +2267,72 @@ function GenreTreeView({
2041
2267
  const playback = useGenrePlaylistPlayback({ scope, genrePlaylists: visibleGenrePlaylists, artist: filterArtist });
2042
2268
  const openArtist = (uuid) => {
2043
2269
  if (selectedNodeId !== null) clearSelection();
2270
+ if (songUuid !== null) changeSong(null);
2044
2271
  changeArtist(uuid);
2045
2272
  };
2046
- 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);
2047
2285
  const artistBaseUrl = artistSearchBaseUrl ?? getBackendBaseUrl;
2286
+ const songBaseUrl = getBackendBaseUrl;
2048
2287
  const artistPanel = useArtistPanel({
2049
2288
  uuid: artistUuid,
2050
2289
  scope,
2051
2290
  getBackendBaseUrl: artistBaseUrl,
2052
2291
  getShareUrl: getShareUrlRef.current,
2053
- 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 })),
2054
2293
  expanded: controlledArtistExpanded ?? (artistUuid !== null && expandedArtistUuid === artistUuid),
2055
2294
  onExpandedChange: (on) => {
2056
2295
  if (controlledArtistExpanded === void 0) setExpandedArtistUuid(on ? artistUuid : null);
2057
2296
  onArtistExpandedChange?.(on);
2058
2297
  },
2298
+ renderSongLink: resolvedRenderSongLink,
2059
2299
  onClose: () => changeArtist(null)
2060
2300
  });
2061
- const builtInArtistPanel = overlayPanel || hasSelectedGenreNode ? void 0 : artistPanel;
2062
- const activeOverlayPanel = overlayPanel ?? (builtInArtistPanel && { ...builtInArtistPanel, hideTitle: true, hideClose: true });
2063
- const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState5(false);
2064
- 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);
2065
2336
  const isCollapsed = collapsed ?? uncontrolledCollapsed;
2066
2337
  const isExpanded = expanded ?? uncontrolledExpanded;
2067
2338
  const changeCollapsed = (next) => {
@@ -2074,7 +2345,7 @@ function GenreTreeView({
2074
2345
  };
2075
2346
  useEffect6(() => {
2076
2347
  setUncontrolledCollapsed(false);
2077
- }, [selectedNodeId, artistUuid]);
2348
+ }, [selectedNodeId, artistUuid, songUuid]);
2078
2349
  const isDetailHidden = isCollapsed || !hasSelectedGenreNode && !activeOverlayPanel;
2079
2350
  useLeftDetailPanelLayout(!isDetailHidden, placement === "regions");
2080
2351
  const detailOverlay = placement === "regions" && activeOverlayPanel?.onExpandedChange ? activeOverlayPanel : void 0;
@@ -2088,7 +2359,7 @@ function GenreTreeView({
2088
2359
  hideExpandButton: placement === "regions" ? true : void 0,
2089
2360
  overlayPanel: detailOverlay ? { ...detailOverlay, collapsed: isCollapsed, hideExpandButton: true } : activeOverlayPanel
2090
2361
  };
2091
- const [isWheelRevealed, setIsWheelRevealed] = useState5(false);
2362
+ const [isWheelRevealed, setIsWheelRevealed] = useState7(false);
2092
2363
  const usesHandoff = viewMode === "wheel" || viewMode === "pop-core";
2093
2364
  const isTreeReady = !isLoading && (!usesHandoff || isWheelRevealed);
2094
2365
  const rootRef = useRef4(null);
@@ -2105,21 +2376,22 @@ function GenreTreeView({
2105
2376
  centerChip.click();
2106
2377
  }, [hasSelectedGenreNode, isTreeReady, selectedGenrePlaylist, viewMode]);
2107
2378
  const { showPopup, hidePopup } = usePopup5();
2108
- const [searchText, setSearchText] = useState5("");
2379
+ const [searchText, setSearchText] = useState7("");
2109
2380
  const artistSearchResult = useArtistSearch(artistBaseUrl, artistSearch ? searchText : "");
2110
- const artistExtraResults = artistSearch ? {
2381
+ const artistGroup = artistSearch ? {
2382
+ key: "artists",
2111
2383
  label: "Artists",
2112
2384
  items: artistSearchResult.artists.map((artist) => ({
2113
2385
  key: artist.uuid,
2114
- content: /* @__PURE__ */ jsxs21(Fragment9, { children: [
2115
- /* @__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: [
2116
2388
  artist.name,
2117
- 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: [
2118
2390
  " \xB7 ",
2119
2391
  artist.disambiguation
2120
2392
  ] })
2121
2393
  ] }),
2122
- 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: [
2123
2395
  "also known as ",
2124
2396
  artist.matchedAlias
2125
2397
  ] })
@@ -2132,11 +2404,32 @@ function GenreTreeView({
2132
2404
  status: artistSearchResult.isError ? "Search failed" : artistSearchResult.settled && artistSearchResult.artists.length === 0 ? "No artists found" : "",
2133
2405
  loading: artistSearchResult.pending
2134
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);
2135
2426
  const handleSelectedNodeChange = (node) => {
2427
+ if (node !== null && songUuid !== null) changeSong(null);
2136
2428
  selectNode(node);
2137
2429
  };
2138
2430
  const handleGenreSearchClear = clearSelection;
2139
2431
  const handleGenreSearchSelect = (genrePlaylist) => {
2432
+ if (songUuid !== null) changeSong(null);
2140
2433
  selectGenrePlaylist(genrePlaylist);
2141
2434
  };
2142
2435
  const selectedUuid = selectedGenrePlaylist?.uuid;
@@ -2151,7 +2444,7 @@ function GenreTreeView({
2151
2444
  (node, { toolbar, ancestors, family }) => {
2152
2445
  const genrePlaylist = genrePlaylistByNodeId.get(node.id);
2153
2446
  const criteria = genrePlaylist?.criteria;
2154
- if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ jsxs21(Fragment9, { children: [
2447
+ if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ jsxs23(Fragment11, { children: [
2155
2448
  toolbar,
2156
2449
  family
2157
2450
  ] }), aside: ancestors };
@@ -2171,7 +2464,7 @@ function GenreTreeView({
2171
2464
  (sibling) => sibling.parent?.uuid === parentPlaylist.uuid && sibling.uuid !== genrePlaylist.uuid && sibling.criteria?.side === "pop"
2172
2465
  ) : void 0;
2173
2466
  return {
2174
- primary: /* @__PURE__ */ jsx28(
2467
+ primary: /* @__PURE__ */ jsx30(
2175
2468
  GenreInfoPanelBody,
2176
2469
  {
2177
2470
  toolbar,
@@ -2190,7 +2483,7 @@ function GenreTreeView({
2190
2483
  onAddChild: readOnly ? void 0 : () => handleGenreCreationAction(criteria),
2191
2484
  onRename: readOnly ? void 0 : () => handleGenreRenameAction(criteria),
2192
2485
  onChangeParent: readOnly ? void 0 : () => showPopup(
2193
- /* @__PURE__ */ jsx28(
2486
+ /* @__PURE__ */ jsx30(
2194
2487
  ChangeParentPopup,
2195
2488
  {
2196
2489
  genrePlaylist: { ...genrePlaylist, criteria },
@@ -2211,6 +2504,7 @@ function GenreTreeView({
2211
2504
  criteriaOverviewSchema,
2212
2505
  renderGenreDetailExtras,
2213
2506
  renderArtistLink,
2507
+ renderSongLink: resolvedRenderSongLink,
2214
2508
  artist: filterArtist,
2215
2509
  renderSummary,
2216
2510
  renderEssentialTracks,
@@ -2241,6 +2535,7 @@ function GenreTreeView({
2241
2535
  criteriaOverviewSchema,
2242
2536
  renderGenreDetailExtras,
2243
2537
  renderArtistLink,
2538
+ resolvedRenderSongLink,
2244
2539
  renderSummary,
2245
2540
  renderEssentialTracks,
2246
2541
  renderSongLeading,
@@ -2250,7 +2545,7 @@ function GenreTreeView({
2250
2545
  );
2251
2546
  const isRotationControlled = controlledAllowWheelRotation !== void 0;
2252
2547
  const allowWheelRotation = controlledAllowWheelRotation ?? internalAllowWheelRotation;
2253
- const detailTab = placement === "regions" && (hasSelectedGenreNode || detailOverlay) ? /* @__PURE__ */ jsx28(
2548
+ const detailTab = placement === "regions" && (hasSelectedGenreNode || detailOverlay) ? /* @__PURE__ */ jsx30(
2254
2549
  PanelEdgeTab,
2255
2550
  {
2256
2551
  side: "left",
@@ -2260,8 +2555,8 @@ function GenreTreeView({
2260
2555
  ) : null;
2261
2556
  const renderArtistFilter = () => {
2262
2557
  if (isLoading || !filterArtist) return null;
2263
- return /* @__PURE__ */ jsxs21("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
2264
- /* @__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(
2265
2560
  "button",
2266
2561
  {
2267
2562
  type: "button",
@@ -2273,7 +2568,7 @@ function GenreTreeView({
2273
2568
  children: filterArtist.name
2274
2569
  }
2275
2570
  ),
2276
- /* @__PURE__ */ jsx28(
2571
+ /* @__PURE__ */ jsx30(
2277
2572
  "button",
2278
2573
  {
2279
2574
  type: "button",
@@ -2285,22 +2580,22 @@ function GenreTreeView({
2285
2580
  )
2286
2581
  ] });
2287
2582
  };
2288
- const actions = /* @__PURE__ */ jsxs21(Fragment9, { children: [
2289
- !isLoading && /* @__PURE__ */ jsx28(
2583
+ const actions = /* @__PURE__ */ jsxs23(Fragment11, { children: [
2584
+ !isLoading && /* @__PURE__ */ jsx30(
2290
2585
  GenreSearch,
2291
2586
  {
2292
2587
  genrePlaylists: visibleGenrePlaylists,
2293
2588
  onSelect: handleGenreSearchSelect,
2294
- selectedName: selectedGenrePlaylist?.name ?? builtInArtistPanel?.title ?? null,
2295
- onClear: builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
2589
+ selectedName: selectedGenrePlaylist?.name ?? builtInPanel?.title ?? null,
2590
+ onClear: builtInSongPanel ? () => changeSong(null) : builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
2296
2591
  onPrevious: handlePrevious,
2297
- onQueryChange: artistSearch ? setSearchText : void 0,
2298
- extraResults: artistExtraResults
2592
+ onQueryChange: artistSearch || songSearch ? setSearchText : void 0,
2593
+ extraGroups
2299
2594
  }
2300
2595
  ),
2301
- !isLoading && filterArtist && /* @__PURE__ */ jsx28("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
2302
- /* @__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: [
2303
- !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(
2304
2599
  SegmentedControl2,
2305
2600
  {
2306
2601
  "aria-label": "Tree view mode",
@@ -2314,13 +2609,13 @@ function GenreTreeView({
2314
2609
  ]
2315
2610
  }
2316
2611
  ),
2317
- !isLoading && showDisplayOptions && /* @__PURE__ */ jsx28(
2612
+ !isLoading && showDisplayOptions && /* @__PURE__ */ jsx30(
2318
2613
  "div",
2319
2614
  {
2320
2615
  className: "flex items-center gap-1",
2321
2616
  role: "group",
2322
2617
  "aria-label": "Tree display options",
2323
- children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ jsx28(
2618
+ children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ jsx30(
2324
2619
  Button,
2325
2620
  {
2326
2621
  variant: allowWheelRotation ? "default" : "outline",
@@ -2331,7 +2626,7 @@ function GenreTreeView({
2331
2626
  )
2332
2627
  }
2333
2628
  ),
2334
- !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ jsx28(
2629
+ !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ jsx30(
2335
2630
  IconTextButton,
2336
2631
  {
2337
2632
  icon: Plus2,
@@ -2341,42 +2636,42 @@ function GenreTreeView({
2341
2636
  )
2342
2637
  ] })
2343
2638
  ] });
2344
- return /* @__PURE__ */ jsxs21(
2639
+ return /* @__PURE__ */ jsxs23(
2345
2640
  "div",
2346
2641
  {
2347
2642
  ref: rootRef,
2348
2643
  className: ["genre-tree-view relative flex flex-col h-full", placement === "regions" && REGIONS_CLASS, className2].filter(Boolean).join(" "),
2349
2644
  children: [
2350
- /* @__PURE__ */ jsx28("style", { children: PREVIOUS_BUTTON_CSS }),
2351
- /* @__PURE__ */ jsx28(
2645
+ /* @__PURE__ */ jsx30("style", { children: PREVIOUS_BUTTON_CSS }),
2646
+ /* @__PURE__ */ jsx30(
2352
2647
  "div",
2353
2648
  {
2354
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"}`,
2355
2650
  children: actions
2356
2651
  }
2357
2652
  ),
2358
- !isLoading && filterArtist && /* @__PURE__ */ jsx28(
2653
+ !isLoading && filterArtist && /* @__PURE__ */ jsx30(
2359
2654
  "div",
2360
2655
  {
2361
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"}`,
2362
2657
  children: renderArtistFilter()
2363
2658
  }
2364
2659
  ),
2365
- detailTab && /* @__PURE__ */ jsx28(
2660
+ detailTab && /* @__PURE__ */ jsx30(
2366
2661
  "div",
2367
2662
  {
2368
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))]"}`,
2369
2664
  children: detailTab
2370
2665
  }
2371
2666
  ),
2372
- /* @__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: [
2373
- !isLoading && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ jsx28("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
2374
- 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" ? (
2375
2670
  // The info panel's top edge aligns with the tree's; the search bar floats `--layout-panel-inset` below it,
2376
2671
  // and the header inset (the bar's height plus that inset) extends the header's fill behind the bar,
2377
2672
  // Google Maps style; the 8px bottom padding mirrors the inset above the bar, and the hidden title's height is zeroed.
2378
2673
  // The panel's own Zoom in button is hidden: the search bar carries it.
2379
- /* @__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(
2380
2675
  GenrePlaylistTreeWheel,
2381
2676
  {
2382
2677
  scope,
@@ -2395,9 +2690,9 @@ function GenreTreeView({
2395
2690
  allowWheelRotation,
2396
2691
  sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2397
2692
  busy,
2398
- 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: [
2399
2694
  floatingControlsLeading,
2400
- canSizeByViews && /* @__PURE__ */ jsx28(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2695
+ canSizeByViews && /* @__PURE__ */ jsx30(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2401
2696
  ] }) : void 0,
2402
2697
  defaultViewport,
2403
2698
  viewportRestore,
@@ -2407,7 +2702,7 @@ function GenreTreeView({
2407
2702
  ) }) })
2408
2703
  ) : viewMode === "pop-core" ? (
2409
2704
  // Same as the wheel: the header inset extends its fill behind the floating search bar, Google Maps style.
2410
- /* @__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(
2411
2706
  GenrePlaylistTreeWheelRadialPopCore,
2412
2707
  {
2413
2708
  scope,
@@ -2426,9 +2721,9 @@ function GenreTreeView({
2426
2721
  allowWheelRotation,
2427
2722
  sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2428
2723
  busy,
2429
- 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: [
2430
2725
  floatingControlsLeading,
2431
- canSizeByViews && /* @__PURE__ */ jsx28(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2726
+ canSizeByViews && /* @__PURE__ */ jsx30(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2432
2727
  ] }) : void 0,
2433
2728
  defaultViewport,
2434
2729
  viewportRestore,
@@ -2437,7 +2732,7 @@ function GenreTreeView({
2437
2732
  ...focus
2438
2733
  }
2439
2734
  ) }) })
2440
- ) : 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(
2441
2736
  GenrePlaylistTreeWheelRadialPopCore,
2442
2737
  {
2443
2738
  outline: true,
@@ -2456,13 +2751,13 @@ function GenreTreeView({
2456
2751
  readOnly,
2457
2752
  ...infoPanelCuration
2458
2753
  }
2459
- ) }) : /* @__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(
2460
2755
  ([uuid, genrePlaylistTreePerRoot], rootIndex) => {
2461
- return /* @__PURE__ */ jsx28(
2756
+ return /* @__PURE__ */ jsx30(
2462
2757
  "div",
2463
2758
  {
2464
2759
  className: "tree-per-root-container relative shrink-0 h-[28rem] mt-2 mr-16 p-2 bg-[var(--color-neutral-100)] rounded-lg",
2465
- 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(
2466
2761
  GenrePlaylistTreePerRoot,
2467
2762
  {
2468
2763
  scope,
@@ -2507,11 +2802,11 @@ import {
2507
2802
  TEXT_COLOR,
2508
2803
  tintSurface
2509
2804
  } from "@behindthemusictree/genre-tree-view";
2510
- import { Skeleton as Skeleton5 } from "@behindthemusictree/ui";
2805
+ import { Skeleton as Skeleton7 } from "@behindthemusictree/ui";
2511
2806
  import {
2512
2807
  useFetchGenreDetail
2513
2808
  } from "@behindthemusictree/app-kit/genre-tree";
2514
- import { jsx as jsx29, jsxs as jsxs22 } from "react/jsx-runtime";
2809
+ import { jsx as jsx31, jsxs as jsxs24 } from "react/jsx-runtime";
2515
2810
  var ITEM_COUNT_LABEL = "Songs";
2516
2811
  var RELATIVE_ITEM_COUNT = 1;
2517
2812
  function toNode(genre, parentId) {
@@ -2566,17 +2861,17 @@ function GenreDetailPanel({
2566
2861
  };
2567
2862
  }, [detail]);
2568
2863
  if (isError) {
2569
- 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." });
2570
2865
  }
2571
2866
  if (isPending || !detail || !view) {
2572
- return /* @__PURE__ */ jsxs22("div", { className: "flex flex-col gap-3 p-4", "aria-busy": "true", "data-testid": "genre-detail-panel-skeleton", children: [
2573
- /* @__PURE__ */ jsx29(Skeleton5, { className: "h-6 w-2/3" }),
2574
- /* @__PURE__ */ jsx29(Skeleton5, { className: "h-4 w-full" }),
2575
- /* @__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" })
2576
2871
  ] });
2577
2872
  }
2578
2873
  const renderBody = (node, sections) => ({
2579
- primary: /* @__PURE__ */ jsx29(
2874
+ primary: /* @__PURE__ */ jsx31(
2580
2875
  GenreInfoPanelBody,
2581
2876
  {
2582
2877
  toolbar: sections.toolbar,
@@ -2593,7 +2888,7 @@ function GenreDetailPanel({
2593
2888
  ),
2594
2889
  aside: null
2595
2890
  });
2596
- return /* @__PURE__ */ jsx29(
2891
+ return /* @__PURE__ */ jsx31(
2597
2892
  InfoPanel,
2598
2893
  {
2599
2894
  node: view.node,
@@ -2626,10 +2921,10 @@ import {
2626
2921
  // src/genre-tree/track-list-sidebar/TrackListSidebar.tsx
2627
2922
  import { useEffect as useEffect7, useLayoutEffect as useLayoutEffect2, useRef as useRef5 } from "react";
2628
2923
  import { ListMusic } from "lucide-react";
2629
- import { useTrackList as useTrackList5 } from "@behindthemusictree/app-kit/genre-tree";
2924
+ import { useTrackList as useTrackList6 } from "@behindthemusictree/app-kit/genre-tree";
2630
2925
  import { TrackListOriginType as TrackListOriginType2 } from "@behindthemusictree/app-kit/genre-tree";
2631
2926
  import { useTrackNoun as useTrackNoun6 } from "@behindthemusictree/app-kit/track-noun";
2632
- import { jsx as jsx30, jsxs as jsxs23 } from "react/jsx-runtime";
2927
+ import { jsx as jsx32, jsxs as jsxs25 } from "react/jsx-runtime";
2633
2928
  var useBrowserLayoutEffect = typeof window === "undefined" ? useEffect7 : useLayoutEffect2;
2634
2929
  function TrackListSidebar({
2635
2930
  className: className2 = "",
@@ -2639,9 +2934,10 @@ function TrackListSidebar({
2639
2934
  isCollapsed = false,
2640
2935
  onExpand,
2641
2936
  onMinimize,
2642
- renderArtistLink
2937
+ renderArtistLink,
2938
+ renderSongLink
2643
2939
  }) {
2644
- const { trackList, loadMore, autoAdvanceScrollRequest } = useTrackList5();
2940
+ const { trackList, loadMore, autoAdvanceScrollRequest } = useTrackList6();
2645
2941
  const trackNoun = useTrackNoun6();
2646
2942
  const trackListRef = useRef5(null);
2647
2943
  const previousOriginRef = useRef5(trackList?.origin ?? null);
@@ -2730,25 +3026,25 @@ function TrackListSidebar({
2730
3026
  }
2731
3027
  };
2732
3028
  const positionClasses = layout === "inline" ? "relative w-full flex h-full flex-col" : "fixed right-0 w-[50rem] flex flex-col";
2733
- return trackList ? /* @__PURE__ */ jsxs23(
3029
+ return trackList ? /* @__PURE__ */ jsxs25(
2734
3030
  "div",
2735
3031
  {
2736
3032
  "data-theme": "dark",
2737
3033
  className: `track-list-sidebar ${positionClasses} rounded-2xl bg-[var(--color-surface)] pb-1 ${className2}`,
2738
3034
  style: layout === "fixed" ? { top: "63.5px", maxHeight: "calc(100vh - 63.5px - 79px)" } : void 0,
2739
3035
  children: [
2740
- /* @__PURE__ */ jsxs23("div", { className: "header flex h-16 items-center px-4 py-2 text-[var(--color-text)]", children: [
2741
- /* @__PURE__ */ jsxs23("div", { className: "min-w-0 flex flex-1 flex-col justify-center", children: [
2742
- /* @__PURE__ */ jsxs23("div", { className: "origin flex text-xl", children: [
2743
- /* @__PURE__ */ jsx30("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
2744
- /* @__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 }) })
2745
3041
  ] }),
2746
- /* @__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: [
2747
3043
  trackList.origin.type === TrackListOriginType2.GENRE_PLAYLIST ? "Genre \u2022 " : trackList.origin.type === TrackListOriginType2.ARTIST ? "Artist \u2022 " : `${trackNoun.singular} playlist \u2022 `,
2748
3044
  `${trackList.total} ${trackList.total > 1 ? trackNoun.plural : trackNoun.singular}`
2749
3045
  ] })
2750
3046
  ] }),
2751
- (isCollapsed && onExpand || !isCollapsed && onMinimize) && /* @__PURE__ */ jsx30(
3047
+ (isCollapsed && onExpand || !isCollapsed && onMinimize) && /* @__PURE__ */ jsx32(
2752
3048
  "button",
2753
3049
  {
2754
3050
  type: "button",
@@ -2757,11 +3053,11 @@ function TrackListSidebar({
2757
3053
  "aria-expanded": !isCollapsed,
2758
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"}`,
2759
3055
  onClick: isCollapsed ? onExpand : onMinimize,
2760
- 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" })
2761
3057
  }
2762
3058
  )
2763
3059
  ] }),
2764
- !isCollapsed && /* @__PURE__ */ jsxs23(
3060
+ !isCollapsed && /* @__PURE__ */ jsxs25(
2765
3061
  "ul",
2766
3062
  {
2767
3063
  ref: trackListRef,
@@ -2771,17 +3067,18 @@ function TrackListSidebar({
2771
3067
  onPointerDown: markUserScrollIntent,
2772
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",
2773
3069
  children: [
2774
- 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(
2775
3071
  TrackItem,
2776
3072
  {
2777
3073
  track,
2778
3074
  position: index + 1,
2779
3075
  renderDuration,
2780
3076
  renderActions,
2781
- renderArtistLink
3077
+ renderArtistLink,
3078
+ renderSongLink
2782
3079
  }
2783
3080
  ) }, track.uuid)),
2784
- 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" })
2785
3082
  ]
2786
3083
  }
2787
3084
  )
@@ -2791,10 +3088,10 @@ function TrackListSidebar({
2791
3088
  }
2792
3089
 
2793
3090
  // src/genre-tree/PlayerPanel.tsx
2794
- import { useEffect as useEffect8, useState as useState6 } from "react";
3091
+ import { useEffect as useEffect8, useState as useState8 } from "react";
2795
3092
  import { twMerge as twMerge2 } from "tailwind-merge";
2796
3093
  import {
2797
- usePlayer as usePlayer10,
3094
+ usePlayer as usePlayer11,
2798
3095
  PlayerVideoSurface
2799
3096
  } from "@behindthemusictree/app-kit/player";
2800
3097
  import {
@@ -2802,10 +3099,10 @@ import {
2802
3099
  } from "@behindthemusictree/app-kit/genre-tree";
2803
3100
 
2804
3101
  // src/genre-tree/PlayerControls.tsx
2805
- import { Pause as Pause2, Play as Play3, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
2806
- import { formatTime, useTrackList as useTrackList6 } from "@behindthemusictree/app-kit/genre-tree";
2807
- import { PlayStates as PlayStates3, useCurrentTime, usePlayer as usePlayer9 } from "@behindthemusictree/app-kit/player";
2808
- 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";
2809
3106
  function PlayerControls() {
2810
3107
  const {
2811
3108
  duration,
@@ -2816,23 +3113,23 @@ function PlayerControls() {
2816
3113
  seekTo,
2817
3114
  setVolume,
2818
3115
  volume
2819
- } = usePlayer9();
2820
- const { hasNextTrack, hasPreviousTrack, playNextTrack, playPreviousTrack } = useTrackList6();
3116
+ } = usePlayer10();
3117
+ const { hasNextTrack, hasPreviousTrack, playNextTrack, playPreviousTrack } = useTrackList7();
2821
3118
  const currentTime = useCurrentTime();
2822
- const isBusy = isLoading || playState === PlayStates3.LOADING;
3119
+ const isBusy = isLoading || playState === PlayStates4.LOADING;
2823
3120
  const isReady = !!playerTrackObject?.isReady && !isBusy && !playerTrackObject.loadError;
2824
3121
  const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
2825
3122
  const safeCurrentTime = Number.isFinite(currentTime) ? Math.max(0, Math.min(currentTime, safeDuration)) : 0;
2826
- const isPlaying = playState === PlayStates3.PLAYING;
2827
- return /* @__PURE__ */ jsxs24(
3123
+ const isPlaying = playState === PlayStates4.PLAYING;
3124
+ return /* @__PURE__ */ jsxs26(
2828
3125
  "section",
2829
3126
  {
2830
3127
  "aria-label": "Player controls",
2831
3128
  className: "player-controls flex shrink-0 flex-col gap-2 border-b border-zinc-800 px-3 py-2 text-zinc-200",
2832
3129
  children: [
2833
- /* @__PURE__ */ jsxs24("div", { className: "flex min-w-0 items-center gap-2 text-xs tabular-nums text-zinc-400", children: [
2834
- /* @__PURE__ */ jsx31("span", { "aria-label": "Elapsed time", children: formatTime(safeCurrentTime) }),
2835
- /* @__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(
2836
3133
  "input",
2837
3134
  {
2838
3135
  "aria-label": "Seek",
@@ -2846,11 +3143,11 @@ function PlayerControls() {
2846
3143
  onChange: (event) => seekTo(Number(event.currentTarget.value))
2847
3144
  }
2848
3145
  ),
2849
- /* @__PURE__ */ jsx31("span", { "aria-label": "Duration", children: formatTime(safeDuration) })
3146
+ /* @__PURE__ */ jsx33("span", { "aria-label": "Duration", children: formatTime(safeDuration) })
2850
3147
  ] }),
2851
- /* @__PURE__ */ jsxs24("div", { className: "grid min-h-9 grid-cols-[1fr_auto_1fr] items-center gap-3", children: [
2852
- /* @__PURE__ */ jsxs24("div", { className: "col-start-2 flex items-center justify-center gap-2", children: [
2853
- /* @__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(
2854
3151
  "button",
2855
3152
  {
2856
3153
  type: "button",
@@ -2859,10 +3156,10 @@ function PlayerControls() {
2859
3156
  disabled: !isReady || !hasPreviousTrack,
2860
3157
  onClick: playPreviousTrack,
2861
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",
2862
- 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" })
2863
3160
  }
2864
3161
  ),
2865
- /* @__PURE__ */ jsx31(
3162
+ /* @__PURE__ */ jsx33(
2866
3163
  "button",
2867
3164
  {
2868
3165
  type: "button",
@@ -2871,10 +3168,10 @@ function PlayerControls() {
2871
3168
  disabled: !isReady,
2872
3169
  onClick: handlePlayPauseAction,
2873
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",
2874
- 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" })
2875
3172
  }
2876
3173
  ),
2877
- /* @__PURE__ */ jsx31(
3174
+ /* @__PURE__ */ jsx33(
2878
3175
  "button",
2879
3176
  {
2880
3177
  type: "button",
@@ -2883,14 +3180,14 @@ function PlayerControls() {
2883
3180
  disabled: !isReady || !hasNextTrack,
2884
3181
  onClick: playNextTrack,
2885
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",
2886
- 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" })
2887
3184
  }
2888
3185
  )
2889
3186
  ] }),
2890
- /* @__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: [
2891
- 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" }),
2892
- /* @__PURE__ */ jsx31("span", { className: "sr-only", children: "Volume" }),
2893
- /* @__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(
2894
3191
  "input",
2895
3192
  {
2896
3193
  "aria-label": "Volume",
@@ -2912,7 +3209,7 @@ function PlayerControls() {
2912
3209
  }
2913
3210
 
2914
3211
  // src/genre-tree/PlayerPanel.tsx
2915
- import { jsx as jsx32, jsxs as jsxs25 } from "react/jsx-runtime";
3212
+ import { jsx as jsx34, jsxs as jsxs27 } from "react/jsx-runtime";
2916
3213
  var PANEL_ID = "player-panel";
2917
3214
  var DEFAULT_EXTENDED_WIDTH = "calc(100vw - var(--layout-center-left) - var(--layout-gutter))";
2918
3215
  function PlayerPanel({
@@ -2921,16 +3218,17 @@ function PlayerPanel({
2921
3218
  expanded,
2922
3219
  onExpandedChange,
2923
3220
  extendedWidth = DEFAULT_EXTENDED_WIDTH,
2924
- renderArtistLink
3221
+ renderArtistLink,
3222
+ renderSongLink
2925
3223
  }) {
2926
- const { playerTrackObject } = usePlayer10();
3224
+ const { playerTrackObject } = usePlayer11();
2927
3225
  const { toggleTrackListSidebar, isTrackListSidebarVisible } = useTrackListSidebarVisibility();
2928
- const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState6("");
3226
+ const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState8("");
2929
3227
  const loadedTrackTitle = playerTrackObject?.track.title;
2930
3228
  useEffect8(() => {
2931
3229
  if (loadedTrackTitle) setLastLoadedTrackTitle(loadedTrackTitle);
2932
3230
  }, [loadedTrackTitle]);
2933
- const [uncontrolledExtended, setUncontrolledExtended] = useState6(false);
3231
+ const [uncontrolledExtended, setUncontrolledExtended] = useState8(false);
2934
3232
  const isExtendRequested = expanded ?? uncontrolledExtended;
2935
3233
  const setIsExtendRequested = (next) => {
2936
3234
  setUncontrolledExtended(next);
@@ -2951,7 +3249,7 @@ function PlayerPanel({
2951
3249
  onClick: () => setIsExtendRequested(true),
2952
3250
  expanded: false
2953
3251
  } : null;
2954
- return /* @__PURE__ */ jsxs25(
3252
+ return /* @__PURE__ */ jsxs27(
2955
3253
  "div",
2956
3254
  {
2957
3255
  id: PANEL_ID,
@@ -2964,7 +3262,7 @@ function PlayerPanel({
2964
3262
  ),
2965
3263
  style: isExtended ? { width: extendedWidth } : void 0,
2966
3264
  children: [
2967
- /* @__PURE__ */ jsx32(
3265
+ /* @__PURE__ */ jsx34(
2968
3266
  PanelEdgeTab,
2969
3267
  {
2970
3268
  side: "right",
@@ -2974,7 +3272,7 @@ function PlayerPanel({
2974
3272
  className: "absolute right-full top-1/2 -translate-y-1/2"
2975
3273
  }
2976
3274
  ),
2977
- /* @__PURE__ */ jsxs25(
3275
+ /* @__PURE__ */ jsxs27(
2978
3276
  "div",
2979
3277
  {
2980
3278
  className: twMerge2(
@@ -2982,15 +3280,15 @@ function PlayerPanel({
2982
3280
  isExtended ? "flex-row" : "flex-col"
2983
3281
  ),
2984
3282
  children: [
2985
- /* @__PURE__ */ jsxs25("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
2986
- /* @__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 }) }),
2987
- /* @__PURE__ */ jsxs25("div", { className: "relative w-full shrink-0", children: [
2988
- /* @__PURE__ */ jsx32(PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
2989
- 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 })
2990
3288
  ] }),
2991
- /* @__PURE__ */ jsx32(PlayerControls, {})
3289
+ /* @__PURE__ */ jsx34(PlayerControls, {})
2992
3290
  ] }),
2993
- /* @__PURE__ */ jsx32(
3291
+ /* @__PURE__ */ jsx34(
2994
3292
  TrackListSidebar,
2995
3293
  {
2996
3294
  layout: "inline",
@@ -3001,7 +3299,8 @@ function PlayerPanel({
3001
3299
  "min-h-0 min-w-0 flex-1",
3002
3300
  isExtended && "border-l border-zinc-800"
3003
3301
  ),
3004
- renderArtistLink
3302
+ renderArtistLink,
3303
+ renderSongLink
3005
3304
  }
3006
3305
  )
3007
3306
  ]
@@ -3016,11 +3315,11 @@ function PlayerPanel({
3016
3315
  import { v4 as uuidv4 } from "uuid";
3017
3316
  import { AiFillStar, AiOutlineStar } from "react-icons/ai";
3018
3317
  import { FORM_RATING_NULL_VALUE } from "@behindthemusictree/app-kit/genre-tree";
3019
- import { jsx as jsx33, jsxs as jsxs26 } from "react/jsx-runtime";
3318
+ import { jsx as jsx35, jsxs as jsxs28 } from "react/jsx-runtime";
3020
3319
  function Rating({ rating, handleChange }) {
3021
3320
  const instanceId = uuidv4();
3022
- 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: [
3023
- /* @__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(
3024
3323
  "input",
3025
3324
  {
3026
3325
  id: `star-${instanceId}-${i}`,
@@ -3032,16 +3331,16 @@ function Rating({ rating, handleChange }) {
3032
3331
  onChange: handleChange
3033
3332
  }
3034
3333
  ),
3035
- /* @__PURE__ */ jsx33("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ jsxs26("div", { className: "flex", children: [
3036
- /* @__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(
3037
3336
  AiOutlineStar,
3038
3337
  {
3039
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)]"}`
3040
3339
  }
3041
3340
  ),
3042
- /* @__PURE__ */ jsx33("span", { className: "flex-grow w-8" }),
3341
+ /* @__PURE__ */ jsx35("span", { className: "flex-grow w-8" }),
3043
3342
  " "
3044
- ] }) : /* @__PURE__ */ jsx33(
3343
+ ] }) : /* @__PURE__ */ jsx35(
3045
3344
  AiFillStar,
3046
3345
  {
3047
3346
  className: `w-7 h-7 absolute top-0 left-0 ${(rating ?? -1) >= i ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
@@ -3070,4 +3369,4 @@ export {
3070
3369
  GenreTreeViewSkeleton2 as GenreTreeViewSkeleton,
3071
3370
  GenreTreeOutlineSkeleton
3072
3371
  };
3073
- //# sourceMappingURL=chunk-TFLX6C5Q.js.map
3372
+ //# sourceMappingURL=chunk-2HE2HDFA.js.map