@behindthemusictree/app-kit-ui 29.1.0 → 31.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,20 +5,52 @@ 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 useEffect5, 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
- import { IconTextButton, Button, SegmentedControl as SegmentedControl2 } from "@behindthemusictree/ui";
11
+ import { Maximize2, Minimize2 } from "lucide-react";
12
+ import { IconTextButton, IconButton, Button, SegmentedControl as SegmentedControl2, DetailPanel, useObscuredInset } from "@behindthemusictree/ui";
12
13
  import {
14
+ GenreNodeAncestors,
15
+ GenreNodeFamily,
13
16
  GenreTreeWheelSkeleton,
14
- GenreTreeViewSkeleton
17
+ GenreTreeViewSkeleton,
18
+ useGenreNodeRelations
15
19
  } from "@behindthemusictree/genre-tree-view";
16
20
  import {
17
- useArtistSearch
21
+ useArtistSearch,
22
+ useSongSearch,
23
+ isSongSearchResultPlayable
18
24
  } from "@behindthemusictree/app-kit/genre-tree";
19
25
  import { useGenreTreeViewState, useArtist as useArtist2, useUpdateGenre as useUpdateGenre2, genreUpdateMutationKey } from "@behindthemusictree/app-kit/genre-tree";
20
26
  import { usePopup as usePopup5 } from "@behindthemusictree/app-kit/popup";
21
27
 
28
+ // src/genre-tree/playlist-tree/size-metric.ts
29
+ var hasViewsCount = (genrePlaylists) => genrePlaylists.every((genrePlaylist) => genrePlaylist.viewsCount !== void 0);
30
+ var genrePlaylistSize = (genrePlaylist, metric) => {
31
+ if (metric === "songs") return genrePlaylist.tracksCount;
32
+ if (genrePlaylist.viewsCount === void 0) {
33
+ throw new Error(`Genre playlist ${genrePlaylist.uuid} has no viewsCount to size by views`);
34
+ }
35
+ return genrePlaylist.viewsCount;
36
+ };
37
+
38
+ // src/genre-tree/playlist-tree/toTreeNodes.ts
39
+ function toTreeNodes(genrePlaylists, sizeMetric) {
40
+ return genrePlaylists.map((genrePlaylist) => ({
41
+ id: genrePlaylist.uuid,
42
+ parentId: genrePlaylist.parent?.uuid ?? null,
43
+ name: genrePlaylist.name,
44
+ itemCount: genrePlaylistSize(genrePlaylist, sizeMetric),
45
+ actionable: Boolean(genrePlaylist.criteria),
46
+ detached: !genrePlaylist.criteria,
47
+ side: genrePlaylist.criteria?.side ?? void 0,
48
+ wikidataId: genrePlaylist.criteria?.wikidataId ?? null,
49
+ umbrella: genrePlaylist.isUmbrella,
50
+ scene: genrePlaylist.scene ?? void 0
51
+ }));
52
+ }
53
+
22
54
  // src/genre-tree/playlist-tree/TreePerRoot.tsx
23
55
  import { useCallback as useCallback2, useMemo as useMemo2 } from "react";
24
56
  import {
@@ -61,7 +93,6 @@ function useGenrePlaylistPlayback({
61
93
  }
62
94
 
63
95
  // src/genre-tree/playlist-tree/TreePerRoot.tsx
64
- import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";
65
96
  import { jsx } from "react/jsx-runtime";
66
97
  function GenrePlaylistTreePerRoot({
67
98
  scope,
@@ -73,24 +104,19 @@ function GenrePlaylistTreePerRoot({
73
104
  additionalActions,
74
105
  onNodeClick,
75
106
  onNodeHover,
76
- renderInfoPanelBody,
77
107
  selectedNodeId,
78
108
  onSelectedNodeChange,
79
- hideInfoPanelClose,
80
- hideInfoPanelTitle,
81
109
  readOnly = false,
82
- showToolbar,
83
110
  wheelZoom,
84
111
  defaultViewport,
85
112
  viewportRestore,
86
113
  onViewportChange,
87
114
  playback,
88
- ...infoPanelCuration
115
+ ...hostPanel
89
116
  }) {
90
117
  const { isPlaying, setIsPlaying } = usePlayer();
91
118
  const defaultPlayback = useGenrePlaylistPlayback({ scope, genrePlaylists: genrePlaylistTreePerRoot });
92
119
  const { playingNodeId, play } = playback ?? defaultPlayback;
93
- const trackNoun = useTrackNoun();
94
120
  const { showPopup } = usePopup();
95
121
  const nodes = useMemo2(
96
122
  () => genrePlaylistTreePerRoot.map((genrePlaylist) => ({
@@ -155,7 +181,6 @@ function GenrePlaylistTreePerRoot({
155
181
  {
156
182
  className: className2,
157
183
  nodes,
158
- itemCountLabel: `${capitalize(trackNoun.singular)} count`,
159
184
  rootColor: getGenreTreeColor(rootUuid),
160
185
  playingNodeId,
161
186
  playState: isPlaying ? "playing" : "paused",
@@ -166,18 +191,13 @@ function GenrePlaylistTreePerRoot({
166
191
  additionalActions,
167
192
  onNodeClick,
168
193
  onNodeHover,
169
- renderInfoPanelBody,
170
194
  selectedNodeId,
171
195
  onSelectedNodeChange,
172
- hideInfoPanelClose,
173
- hideInfoPanelTitle,
174
- showToolbar,
175
196
  wheelZoom,
176
197
  defaultViewport,
177
198
  viewportRestore,
178
199
  onViewportChange,
179
- ...infoPanelCuration,
180
- readOnly
200
+ ...hostPanel
181
201
  }
182
202
  );
183
203
  }
@@ -190,19 +210,6 @@ import {
190
210
  import { usePlayer as usePlayer2 } from "@behindthemusictree/app-kit/player";
191
211
  import { usePopup as usePopup2 } from "@behindthemusictree/app-kit/popup";
192
212
  import { ErrorCode as ErrorCode2 } from "@behindthemusictree/app-kit/transport";
193
-
194
- // src/genre-tree/playlist-tree/size-metric.ts
195
- var hasViewsCount = (genrePlaylists) => genrePlaylists.every((genrePlaylist) => genrePlaylist.viewsCount !== void 0);
196
- var genrePlaylistSize = (genrePlaylist, metric) => {
197
- if (metric === "songs") return genrePlaylist.tracksCount;
198
- if (genrePlaylist.viewsCount === void 0) {
199
- throw new Error(`Genre playlist ${genrePlaylist.uuid} has no viewsCount to size by views`);
200
- }
201
- return genrePlaylist.viewsCount;
202
- };
203
-
204
- // src/genre-tree/playlist-tree/TreeWheel.tsx
205
- import { capitalize as capitalize2, useTrackNoun as useTrackNoun2 } from "@behindthemusictree/app-kit/track-noun";
206
213
  import { jsx as jsx2 } from "react/jsx-runtime";
207
214
  function GenrePlaylistTreeWheel({
208
215
  scope,
@@ -213,14 +220,10 @@ function GenrePlaylistTreeWheel({
213
220
  additionalActions,
214
221
  onNodeClick,
215
222
  onNodeHover,
216
- renderInfoPanelBody,
217
223
  selectedNodeId,
218
224
  onSelectedNodeChange,
219
- hideInfoPanelClose,
220
- hideInfoPanelTitle,
221
225
  readOnly = false,
222
226
  allowWheelRotation,
223
- showToolbar,
224
227
  sizeMetric = "songs",
225
228
  floatingControlsLeading,
226
229
  busy,
@@ -228,12 +231,11 @@ function GenrePlaylistTreeWheel({
228
231
  viewportRestore,
229
232
  onViewportChange,
230
233
  playback,
231
- ...infoPanelCuration
234
+ ...hostPanel
232
235
  }) {
233
236
  const { isPlaying, setIsPlaying } = usePlayer2();
234
237
  const defaultPlayback = useGenrePlaylistPlayback({ scope, genrePlaylists });
235
238
  const { playingNodeId, play } = playback ?? defaultPlayback;
236
- const trackNoun = useTrackNoun2();
237
239
  const { showPopup } = usePopup2();
238
240
  const nodes = useMemo3(
239
241
  () => genrePlaylists.map((genrePlaylist) => ({
@@ -297,7 +299,6 @@ function GenrePlaylistTreeWheel({
297
299
  {
298
300
  className: className2,
299
301
  nodes,
300
- itemCountLabel: sizeMetric === "views" ? "Views" : `${capitalize2(trackNoun.singular)} count`,
301
302
  playingNodeId,
302
303
  playState: isPlaying ? "playing" : "paused",
303
304
  onPlayPause: handlePlayPause,
@@ -307,20 +308,15 @@ function GenrePlaylistTreeWheel({
307
308
  additionalActions,
308
309
  onNodeClick,
309
310
  onNodeHover,
310
- renderInfoPanelBody,
311
311
  selectedNodeId,
312
312
  onSelectedNodeChange,
313
- hideInfoPanelClose,
314
- hideInfoPanelTitle,
315
313
  allowWheelRotation,
316
- showToolbar,
317
314
  floatingControlsLeading,
318
315
  busy,
319
316
  defaultViewport,
320
317
  viewportRestore,
321
318
  onViewportChange,
322
- ...infoPanelCuration,
323
- readOnly
319
+ ...hostPanel
324
320
  }
325
321
  );
326
322
  }
@@ -332,7 +328,6 @@ import {
332
328
  GenreTreeWheelRadialPopCore
333
329
  } from "@behindthemusictree/genre-tree-view";
334
330
  import { usePlayer as usePlayer3 } from "@behindthemusictree/app-kit/player";
335
- import { capitalize as capitalize3, useTrackNoun as useTrackNoun3 } from "@behindthemusictree/app-kit/track-noun";
336
331
  import { jsx as jsx3 } from "react/jsx-runtime";
337
332
  function GenrePlaylistTreeWheelRadialPopCore(props) {
338
333
  const {
@@ -344,14 +339,10 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
344
339
  additionalActions,
345
340
  onNodeClick,
346
341
  onNodeHover,
347
- renderInfoPanelBody,
348
342
  selectedNodeId,
349
343
  onSelectedNodeChange,
350
- hideInfoPanelClose,
351
- hideInfoPanelTitle,
352
344
  readOnly = false,
353
345
  allowWheelRotation,
354
- showToolbar,
355
346
  sizeMetric = "songs",
356
347
  floatingControlsLeading,
357
348
  busy,
@@ -362,33 +353,14 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
362
353
  playback
363
354
  } = props;
364
355
  const focus = props;
365
- const infoPanelCuration = {
366
- renderDangerZone: props.renderDangerZone,
367
- expanded: props.expanded,
368
- onExpandedChange: props.onExpandedChange,
369
- collapsed: props.collapsed,
370
- hideExpandButton: props.hideExpandButton,
371
- overlayPanel: props.overlayPanel
356
+ const hostPanel = {
357
+ obscuredInset: props.obscuredInset,
358
+ onNodeStyleResolverChange: props.onNodeStyleResolverChange
372
359
  };
373
360
  const { isPlaying, setIsPlaying } = usePlayer3();
374
361
  const defaultPlayback = useGenrePlaylistPlayback({ scope, genrePlaylists });
375
362
  const { playingNodeId, play } = playback ?? defaultPlayback;
376
- const trackNoun = useTrackNoun3();
377
- const nodes = useMemo4(
378
- () => genrePlaylists.map((genrePlaylist) => ({
379
- id: genrePlaylist.uuid,
380
- parentId: genrePlaylist.parent?.uuid ?? null,
381
- name: genrePlaylist.name,
382
- itemCount: genrePlaylistSize(genrePlaylist, sizeMetric),
383
- actionable: Boolean(genrePlaylist.criteria),
384
- detached: !genrePlaylist.criteria,
385
- side: genrePlaylist.criteria?.side ?? void 0,
386
- wikidataId: genrePlaylist.criteria?.wikidataId ?? null,
387
- umbrella: genrePlaylist.isUmbrella,
388
- scene: genrePlaylist.scene ?? void 0
389
- })),
390
- [genrePlaylists, sizeMetric]
391
- );
363
+ const nodes = useMemo4(() => toTreeNodes(genrePlaylists, sizeMetric), [genrePlaylists, sizeMetric]);
392
364
  const handlePlayPause = useCallback4(
393
365
  (nodeId) => {
394
366
  const genrePlaylist = genrePlaylists.find((g) => g.uuid === nodeId);
@@ -429,7 +401,6 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
429
401
  const treeProps = {
430
402
  className: className2,
431
403
  nodes,
432
- itemCountLabel: sizeMetric === "views" ? "Views" : `${capitalize3(trackNoun.singular)} count`,
433
404
  playingNodeId,
434
405
  playState: isPlaying ? "playing" : "paused",
435
406
  onPlayPause: handlePlayPause,
@@ -439,14 +410,9 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
439
410
  additionalActions,
440
411
  onNodeClick,
441
412
  onNodeHover,
442
- renderInfoPanelBody,
443
413
  selectedNodeId,
444
414
  onSelectedNodeChange,
445
- hideInfoPanelClose,
446
- hideInfoPanelTitle,
447
- showToolbar,
448
- ...infoPanelCuration,
449
- readOnly
415
+ ...hostPanel
450
416
  };
451
417
  return outline ? /* @__PURE__ */ jsx3(GenreTreeOutline, { ...treeProps }) : /* @__PURE__ */ jsx3(
452
418
  GenreTreeWheelRadialPopCore,
@@ -499,10 +465,11 @@ function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
499
465
  }
500
466
 
501
467
  // src/genre-tree/GenreSearch.tsx
468
+ import { useState as useState2 } from "react";
502
469
  import { ArrowLeft, LoaderCircle, Search, X } from "lucide-react";
503
- import { Input } from "@behindthemusictree/ui";
470
+ import { Combobox } from "@behindthemusictree/ui";
504
471
  import { useGenreSearch } from "@behindthemusictree/app-kit/genre-tree";
505
- import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
472
+ import { Fragment, jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
506
473
  function GenreSearch({
507
474
  genrePlaylists,
508
475
  onSelect,
@@ -511,7 +478,7 @@ function GenreSearch({
511
478
  onPrevious,
512
479
  placeholder,
513
480
  onQueryChange,
514
- extraResults
481
+ extraGroups = []
515
482
  }) {
516
483
  const { query, setQuery, results, canClear, clear, select } = useGenreSearch({
517
484
  genrePlaylists,
@@ -519,11 +486,45 @@ function GenreSearch({
519
486
  selectedName,
520
487
  onClear
521
488
  });
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)]";
489
+ const [open, setOpen] = useState2(false);
490
+ const active = query !== "" && query !== selectedName;
491
+ const hasExtras = extraGroups.length > 0;
492
+ const label = hasExtras ? "Search" : "Search a genre";
493
+ const loading = active && extraGroups.some((group) => group.loading);
494
+ const status = active ? extraGroups.map((group) => group.status).filter(Boolean).join(". ") : "";
495
+ const groups = [
496
+ {
497
+ key: "genres",
498
+ label: hasExtras ? "Genres" : void 0,
499
+ options: results.map((genrePlaylist) => ({
500
+ key: genrePlaylist.uuid,
501
+ content: /* @__PURE__ */ jsxs2(Fragment, { children: [
502
+ genrePlaylist.name,
503
+ genrePlaylist.isUmbrella && /* @__PURE__ */ jsx5("span", { className: "ml-2 text-xs italic text-[var(--color-text-muted)]", children: "umbrella" })
504
+ ] })
505
+ }))
506
+ },
507
+ ...extraGroups.map((group) => ({
508
+ key: group.key,
509
+ label: group.label,
510
+ options: active ? group.items.map((item) => ({ key: item.key, content: item.content })) : []
511
+ }))
512
+ ];
513
+ const handlePick = (groupKey, optionKey) => {
514
+ setOpen(false);
515
+ if (groupKey === "genres") {
516
+ const genrePlaylist = results.find((result) => result.uuid === optionKey);
517
+ if (!genrePlaylist) return;
518
+ select(genrePlaylist);
519
+ onQueryChange?.("");
520
+ return;
521
+ }
522
+ const item = extraGroups.find((group) => group.key === groupKey)?.items.find((entry) => entry.key === optionKey);
523
+ if (!item) return;
524
+ setQuery("");
525
+ onQueryChange?.("");
526
+ item.onSelect();
527
+ };
527
528
  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
529
  onPrevious && /* @__PURE__ */ jsx5(
529
530
  "button",
@@ -531,53 +532,51 @@ function GenreSearch({
531
532
  type: "button",
532
533
  "aria-label": "Previous",
533
534
  title: "Previous",
534
- className: "genre-search-previous absolute left-2 top-1/2 z-10 hidden items-center justify-center p-1.5 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] [@media(pointer:coarse)]:min-w-[var(--layout-touch-target)] text-[var(--color-text-muted)] rounded-full -translate-y-1/2 hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
535
+ className: "genre-search-previous absolute left-2 top-1/2 z-10 flex items-center justify-center p-1.5 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] [@media(pointer:coarse)]:min-w-[var(--layout-touch-target)] text-[var(--color-text-muted)] rounded-full -translate-y-1/2 hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
535
536
  onClick: onPrevious,
536
537
  children: /* @__PURE__ */ jsx5(ArrowLeft, { "aria-hidden": "true", className: "w-4 h-4" })
537
538
  }
538
539
  ),
539
540
  /* @__PURE__ */ jsx5(
540
- Input,
541
+ Combobox,
541
542
  {
542
- type: "text",
543
+ ariaLabel: label,
544
+ listLabel: `${label} results`,
543
545
  value: query,
544
- onChange: (event) => {
545
- setQuery(event.target.value);
546
- onQueryChange?.(event.target.value);
547
- },
548
546
  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"
547
+ groups,
548
+ open,
549
+ onOpenChange: setOpen,
550
+ onValueChange: (value) => {
551
+ setQuery(value);
552
+ onQueryChange?.(value);
553
+ },
554
+ onPick: handlePick,
555
+ status,
556
+ loading: loading ? "Searching\u2026" : void 0,
557
+ inputClassName: `h-[var(--layout-search-h,3rem)] ${onPrevious ? "pl-11" : "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`,
558
+ className: "genre-search-results z-40 rounded-xl shadow-md",
559
+ listClassName: "max-h-[min(20rem,50dvh)]",
560
+ optionClassName: "[@media(pointer:coarse)]:min-h-[var(--layout-touch-target)]"
555
561
  }
556
562
  ),
557
563
  /* @__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
- ),
564
+ /* @__PURE__ */ jsxs2("span", { "data-loading": loading, className: "relative p-2 text-[var(--color-text-muted)] pointer-events-none", children: [
565
+ /* @__PURE__ */ jsx5(
566
+ Search,
567
+ {
568
+ "aria-hidden": "true",
569
+ className: `w-6 h-6 transition-opacity duration-200 motion-reduce:transition-none ${loading ? "opacity-0" : ""}`
570
+ }
571
+ ),
572
+ /* @__PURE__ */ jsx5(
573
+ LoaderCircle,
574
+ {
575
+ "aria-hidden": "true",
576
+ 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"}`
577
+ }
578
+ )
579
+ ] }),
581
580
  canClear && /* @__PURE__ */ jsx5(
582
581
  "button",
583
582
  {
@@ -591,39 +590,6 @@ function GenreSearch({
591
590
  children: /* @__PURE__ */ jsx5(X, { "aria-hidden": "true", className: "w-6 h-6" })
592
591
  }
593
592
  )
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
593
  ] })
628
594
  ] });
629
595
  }
@@ -639,16 +605,16 @@ import { Share2 as Share22 } from "lucide-react";
639
605
  import { usePopup as usePopup3 } from "@behindthemusictree/app-kit/popup";
640
606
 
641
607
  // src/genre-tree/SharePopup.tsx
642
- import { useCallback as useCallback5, useEffect as useEffect2, useMemo as useMemo5, useState as useState2 } from "react";
608
+ import { useCallback as useCallback5, useEffect as useEffect2, useMemo as useMemo5, useState as useState3 } from "react";
643
609
  import { Link2, Share2 } from "lucide-react";
644
610
  import { FaWhatsapp } from "react-icons/fa";
645
611
  import { SiGmail } from "react-icons/si";
646
612
 
647
613
  // src/genre-tree/ShareDestinationButton.tsx
648
- import { Fragment, jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
614
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
649
615
  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
616
  function Contents({ icon, label }) {
651
- return /* @__PURE__ */ jsxs3(Fragment, { children: [
617
+ return /* @__PURE__ */ jsxs3(Fragment2, { children: [
652
618
  /* @__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
619
  /* @__PURE__ */ jsx6("span", { className: "max-w-full truncate", children: label })
654
620
  ] });
@@ -668,11 +634,11 @@ function ShareDestinationButton({
668
634
  }
669
635
 
670
636
  // src/genre-tree/SharePreviewCard.tsx
671
- import { Music2, UserRound } from "lucide-react";
637
+ import { Music, Music2, UserRound } from "lucide-react";
672
638
  import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
673
639
  function SharePreviewCard({ target, url }) {
674
- const Icon = target.kind === "genre" ? Music2 : UserRound;
675
- const typeLabel = target.kind === "genre" ? "Genre" : "Artist";
640
+ const Icon = { genre: Music2, artist: UserRound, song: Music }[target.kind];
641
+ const typeLabel = { genre: "Genre", artist: "Artist", song: "Song" }[target.kind];
676
642
  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
643
  /* @__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
644
  /* @__PURE__ */ jsxs4("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-1 p-4", children: [
@@ -684,7 +650,7 @@ function SharePreviewCard({ target, url }) {
684
650
  }
685
651
 
686
652
  // src/genre-tree/SharePopup.tsx
687
- import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
653
+ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
688
654
  function resolveAbsoluteUrl(url) {
689
655
  try {
690
656
  const parsed = new URL(url, window.location.href);
@@ -717,11 +683,11 @@ async function copyText(text) {
717
683
  }
718
684
  }
719
685
  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);
686
+ const [absoluteUrl, setAbsoluteUrl] = useState3(url);
687
+ const [copyStatus, setCopyStatus] = useState3("idle");
688
+ const [canUseNativeShare, setCanUseNativeShare] = useState3(false);
689
+ const [shareError, setShareError] = useState3(null);
690
+ const [toastId, setToastId] = useState3(0);
725
691
  useEffect2(() => {
726
692
  setAbsoluteUrl(resolveAbsoluteUrl(url) ?? "");
727
693
  setCopyStatus("idle");
@@ -759,7 +725,7 @@ function SharePopup({ target, url, onClose }) {
759
725
  setShareError("Could not open the share options.");
760
726
  }
761
727
  };
762
- return /* @__PURE__ */ jsxs5(Fragment2, { children: [
728
+ return /* @__PURE__ */ jsxs5(Fragment3, { children: [
763
729
  /* @__PURE__ */ jsx8(
764
730
  BasePopup_default,
765
731
  {
@@ -954,18 +920,18 @@ import { Skeleton as Skeleton2 } from "@behindthemusictree/ui";
954
920
  import { useFetchGenreOverview } from "@behindthemusictree/app-kit/genre-tree";
955
921
 
956
922
  // 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";
923
+ import { useLayoutEffect, useRef as useRef2, useState as useState4 } from "react";
924
+ import { Fragment as Fragment4, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
959
925
  function ClampedText({ children, className: className2 = "" }) {
960
926
  const ref = useRef2(null);
961
- const [expanded, setExpanded] = useState3(false);
962
- const [overflows, setOverflows] = useState3(false);
927
+ const [expanded, setExpanded] = useState4(false);
928
+ const [overflows, setOverflows] = useState4(false);
963
929
  useLayoutEffect(() => {
964
930
  const element = ref.current;
965
931
  if (!element || expanded) return;
966
932
  setOverflows(element.scrollHeight > element.clientHeight);
967
933
  }, [children, expanded]);
968
- return /* @__PURE__ */ jsxs8(Fragment3, { children: [
934
+ return /* @__PURE__ */ jsxs8(Fragment4, { children: [
969
935
  /* @__PURE__ */ jsx11("p", { ref, className: `${className2} ${expanded ? "" : "line-clamp-3"}`, children }),
970
936
  (overflows || expanded) && /* @__PURE__ */ jsx11(
971
937
  "button",
@@ -1001,14 +967,14 @@ import { useTrackList as useTrackList2 } from "@behindthemusictree/app-kit/genre
1001
967
 
1002
968
  // src/genre-tree/MusicBrainzRecordingLink.tsx
1003
969
  import { musicBrainzRecordingUrl } from "@behindthemusictree/app-kit/genre-tree";
1004
- import { Fragment as Fragment4, jsx as jsx12 } from "react/jsx-runtime";
970
+ import { Fragment as Fragment5, jsx as jsx12 } from "react/jsx-runtime";
1005
971
  function MusicBrainzRecordingLink({
1006
972
  song,
1007
973
  children,
1008
974
  className: className2 = ""
1009
975
  }) {
1010
976
  const url = musicBrainzRecordingUrl(song);
1011
- if (!url) return /* @__PURE__ */ jsx12(Fragment4, { children });
977
+ if (!url) return /* @__PURE__ */ jsx12(Fragment5, { children });
1012
978
  return /* @__PURE__ */ jsx12(
1013
979
  "a",
1014
980
  {
@@ -1025,7 +991,7 @@ function MusicBrainzRecordingLink({
1025
991
 
1026
992
  // src/genre-tree/GenrePlaylistTracks.tsx
1027
993
  import { ArtistNames } from "@behindthemusictree/app-kit/genre-tree";
1028
- import { capitalize as capitalize4, useTrackNoun as useTrackNoun4 } from "@behindthemusictree/app-kit/track-noun";
994
+ import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";
1029
995
  import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
1030
996
  var CATALOGUE_PAGE_SIZE = 100;
1031
997
  var SongPageSchema = PaginatedResponseSchema(SongDetailedSchema).extend({
@@ -1049,6 +1015,7 @@ function GenrePlaylistTracks({
1049
1015
  scope,
1050
1016
  getBackendBaseUrl,
1051
1017
  renderArtistLink,
1018
+ renderSongLink,
1052
1019
  renderSongLeading,
1053
1020
  renderSongActions,
1054
1021
  renderTracksToolbar,
@@ -1058,7 +1025,7 @@ function GenrePlaylistTracks({
1058
1025
  const { session, sessionRestored } = useSession();
1059
1026
  const { playerTrackObject, playState, handlePlayPauseAction } = usePlayer4();
1060
1027
  const { showPopup } = usePopup4();
1061
- const trackNoun = useTrackNoun4();
1028
+ const trackNoun = useTrackNoun();
1062
1029
  const { uuid } = genrePlaylist;
1063
1030
  const { fetch } = useFetchWrapper(getBackendBaseUrl);
1064
1031
  const { data, isPending, isError, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({
@@ -1103,7 +1070,7 @@ function GenrePlaylistTracks({
1103
1070
  };
1104
1071
  return /* @__PURE__ */ jsxs9("div", { className: "gtv-info-panel-children", children: [
1105
1072
  /* @__PURE__ */ jsxs9("span", { className: "gtv-info-panel-children-title", children: [
1106
- capitalize4(trackNoun.plural),
1073
+ capitalize(trackNoun.plural),
1107
1074
  total !== void 0 && ` (${total})`
1108
1075
  ] }),
1109
1076
  renderTracksToolbar?.(),
@@ -1119,7 +1086,7 @@ function GenrePlaylistTracks({
1119
1086
  const isCurrent = playerTrackObject?.track.id === track.uuid;
1120
1087
  const isPlaying = isCurrent && playState === PlayStates.PLAYING;
1121
1088
  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 }),
1089
+ /* @__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
1090
  track.artists?.length ? /* @__PURE__ */ jsx13("span", { className: SEPARATOR, children: " \u2014 " }) : null,
1124
1091
  /* @__PURE__ */ jsx13("span", { className: COLUMN_CELL, children: track.artists?.length ? /* @__PURE__ */ jsx13(ArtistNames, { artists: track.artists, renderArtistLink }) : null }),
1125
1092
  track.album && /* @__PURE__ */ jsx13("span", { className: ALBUM_CELL, children: track.album.name })
@@ -1174,9 +1141,9 @@ function GenrePlaylistTracks({
1174
1141
  }
1175
1142
 
1176
1143
  // src/genre-tree/GenreDetailExtras.tsx
1177
- import { useTrackNoun as useTrackNoun5 } from "@behindthemusictree/app-kit/track-noun";
1144
+ import { useTrackNoun as useTrackNoun2 } from "@behindthemusictree/app-kit/track-noun";
1178
1145
  import { SiWikidata, SiWikipedia } from "react-icons/si";
1179
- import { Fragment as Fragment5, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
1146
+ import { Fragment as Fragment6, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
1180
1147
  function GenreDetailExtras({
1181
1148
  genreUuid,
1182
1149
  genrePlaylist,
@@ -1186,12 +1153,13 @@ function GenreDetailExtras({
1186
1153
  criteriaOverviewSchema,
1187
1154
  renderGenreDetailExtras,
1188
1155
  renderArtistLink,
1156
+ renderSongLink,
1189
1157
  renderSummary,
1190
1158
  renderEssentialTracks,
1191
1159
  family,
1192
1160
  ...trackSlots
1193
1161
  }) {
1194
- const trackNoun = useTrackNoun5();
1162
+ const trackNoun = useTrackNoun2();
1195
1163
  const { data: overview, isPending } = useFetchGenreOverview(
1196
1164
  genreUuid,
1197
1165
  scope,
@@ -1199,7 +1167,7 @@ function GenreDetailExtras({
1199
1167
  criteriaOverviewSchema
1200
1168
  );
1201
1169
  if (isPending) {
1202
- return /* @__PURE__ */ jsxs10(Fragment5, { children: [
1170
+ return /* @__PURE__ */ jsxs10(Fragment6, { children: [
1203
1171
  /* @__PURE__ */ jsxs10("div", { className: "gtv-info-panel-children", "aria-busy": "true", "data-testid": "genre-detail-extras-skeleton", children: [
1204
1172
  /* @__PURE__ */ jsx14("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
1205
1173
  /* @__PURE__ */ jsx14(Skeleton2, { className: "h-4 w-3/4" })
@@ -1207,10 +1175,10 @@ function GenreDetailExtras({
1207
1175
  family
1208
1176
  ] });
1209
1177
  }
1210
- if (!overview) return /* @__PURE__ */ jsx14(Fragment5, { children: family });
1178
+ if (!overview) return /* @__PURE__ */ jsx14(Fragment6, { children: family });
1211
1179
  const { summary, summarySourceUrl, essentialSongs } = overview;
1212
1180
  const wikidataUrl = wikidataId ? `https://www.wikidata.org/wiki/${encodeURIComponent(wikidataId)}` : null;
1213
- return /* @__PURE__ */ jsxs10(Fragment5, { children: [
1181
+ return /* @__PURE__ */ jsxs10(Fragment6, { children: [
1214
1182
  (summarySourceUrl || wikidataUrl) && /* @__PURE__ */ jsxs10("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
1215
1183
  /* @__PURE__ */ jsx14("span", { className: "gtv-info-panel-children-title", children: "Links" }),
1216
1184
  /* @__PURE__ */ jsxs10("div", { className: "mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs", children: [
@@ -1263,6 +1231,7 @@ function GenreDetailExtras({
1263
1231
  scope,
1264
1232
  getBackendBaseUrl,
1265
1233
  renderArtistLink,
1234
+ renderSongLink,
1266
1235
  ...trackSlots
1267
1236
  }
1268
1237
  )
@@ -1270,7 +1239,7 @@ function GenreDetailExtras({
1270
1239
  }
1271
1240
 
1272
1241
  // src/genre-tree/GenreInfoPanelBody.tsx
1273
- import { Fragment as Fragment6, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
1242
+ import { Fragment as Fragment7, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
1274
1243
  import { createElement } from "react";
1275
1244
  function GenreNodeActionBand({ genrePlaylist, playback, ...bandProps }) {
1276
1245
  const { isPlaying, setIsPlaying } = usePlayer5();
@@ -1305,7 +1274,7 @@ function GenreInfoPanelBody({
1305
1274
  band,
1306
1275
  extras
1307
1276
  }) {
1308
- return /* @__PURE__ */ jsxs11(Fragment6, { children: [
1277
+ return /* @__PURE__ */ jsxs11(Fragment7, { children: [
1309
1278
  toolbar,
1310
1279
  /* @__PURE__ */ jsx15(GenreNodeActionBand, { ...band, genrePlaylist, playback }),
1311
1280
  /* @__PURE__ */ createElement(
@@ -1324,69 +1293,13 @@ function GenreInfoPanelBody({
1324
1293
  ] });
1325
1294
  }
1326
1295
 
1327
- // src/genre-tree/PanelEdgeTab.tsx
1328
- import { ChevronLeft, ChevronRight } from "lucide-react";
1329
- import { twMerge } from "tailwind-merge";
1330
- import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
1331
- var buttonClassName = "flex h-12 w-full items-center justify-center p-0 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--color-primary)]";
1332
- function PanelEdgeTab({ side, edge, center, controls, className: className2 }) {
1333
- if (!edge && !center) return null;
1334
- const isLeft = side === "left";
1335
- const EdgeIcon = isLeft ? ChevronLeft : ChevronRight;
1336
- const CenterIcon = isLeft ? ChevronRight : ChevronLeft;
1337
- const renderButton = (action, icon) => /* @__PURE__ */ jsx16(
1338
- "button",
1339
- {
1340
- type: "button",
1341
- "aria-label": action.label,
1342
- title: action.label,
1343
- "aria-controls": controls,
1344
- "aria-expanded": action.expanded,
1345
- className: buttonClassName,
1346
- onClick: action.onClick,
1347
- children: icon
1348
- }
1349
- );
1350
- const edgeButton = edge && renderButton(edge, /* @__PURE__ */ jsx16(EdgeIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1351
- const centerButton = center && renderButton(center, /* @__PURE__ */ jsx16(CenterIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1352
- const buttons = isLeft ? [edgeButton, centerButton] : [centerButton, edgeButton];
1353
- return /* @__PURE__ */ jsxs12(
1354
- "div",
1355
- {
1356
- className: twMerge(
1357
- "panel-edge-tab flex w-[var(--layout-edge-tab-w)] flex-col overflow-hidden bg-[var(--color-surface)] shadow-[0_1px_2px_rgba(60,64,67,0.3),0_2px_6px_2px_rgba(60,64,67,0.15)] [clip-path:inset(-0.5rem_-0.5rem_-0.5rem_0)]",
1358
- isLeft ? "rounded-r-lg" : "rounded-l-lg [clip-path:inset(-0.5rem_0_-0.5rem_-0.5rem)]",
1359
- className2
1360
- ),
1361
- children: [
1362
- buttons[0],
1363
- buttons[0] && buttons[1] && /* @__PURE__ */ jsx16("div", { role: "presentation", className: "h-px shrink-0 bg-[var(--color-border)]" }),
1364
- buttons[1]
1365
- ]
1366
- }
1367
- );
1368
- }
1369
-
1370
- // src/genre-tree/useLeftDetailPanelLayout.ts
1371
- import { useEffect as useEffect4 } from "react";
1372
- function useLeftDetailPanelLayout(isPanelShown, enabled = true) {
1373
- useEffect4(() => {
1374
- if (!enabled || isPanelShown) return void 0;
1375
- const root = document.documentElement;
1376
- root.style.setProperty("--layout-left-w", "0px");
1377
- return () => {
1378
- root.style.removeProperty("--layout-left-w");
1379
- };
1380
- }, [enabled, isPanelShown]);
1381
- }
1382
-
1383
1296
  // src/genre-tree/ChangeParentPopup.tsx
1384
- import { useEffect as useEffect5, useMemo as useMemo6 } from "react";
1297
+ import { useEffect as useEffect4, useMemo as useMemo6 } from "react";
1385
1298
  import {
1386
1299
  getReparentCandidates,
1387
1300
  useUpdateGenre
1388
1301
  } from "@behindthemusictree/app-kit/genre-tree";
1389
- import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
1302
+ import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
1390
1303
  function ChangeParentPopup({
1391
1304
  genrePlaylist,
1392
1305
  genrePlaylists,
@@ -1414,19 +1327,19 @@ function ChangeParentPopup({
1414
1327
  }
1415
1328
  );
1416
1329
  };
1417
- useEffect5(() => {
1330
+ useEffect4(() => {
1418
1331
  const handleKeyDown = (event) => {
1419
1332
  if (event.key === "Escape") onClose();
1420
1333
  };
1421
1334
  window.addEventListener("keydown", handleKeyDown);
1422
1335
  return () => window.removeEventListener("keydown", handleKeyDown);
1423
1336
  }, [onClose]);
1424
- return /* @__PURE__ */ jsxs13(BasePopup_default, { title: `Change parent of ${genrePlaylist.name}`, onClose, showCancelButton: true, fitContent: true, children: [
1425
- /* @__PURE__ */ jsxs13("p", { className: "mb-3 text-sm text-[var(--color-text-muted)]", children: [
1337
+ return /* @__PURE__ */ jsxs12(BasePopup_default, { title: `Change parent of ${genrePlaylist.name}`, onClose, showCancelButton: true, fitContent: true, children: [
1338
+ /* @__PURE__ */ jsxs12("p", { className: "mb-3 text-sm text-[var(--color-text-muted)]", children: [
1426
1339
  "Current parent: ",
1427
1340
  genrePlaylist.parent?.name ?? "none"
1428
1341
  ] }),
1429
- /* @__PURE__ */ jsx17("div", { "aria-busy": isPending, className: isPending ? "pointer-events-none opacity-60" : void 0, children: /* @__PURE__ */ jsx17(
1342
+ /* @__PURE__ */ jsx16("div", { "aria-busy": isPending, className: isPending ? "pointer-events-none opacity-60" : void 0, children: /* @__PURE__ */ jsx16(
1430
1343
  GenreSearch,
1431
1344
  {
1432
1345
  genrePlaylists: candidates,
@@ -1434,7 +1347,7 @@ function ChangeParentPopup({
1434
1347
  placeholder: "Search a new parent\u2026"
1435
1348
  }
1436
1349
  ) }),
1437
- formErrors.length > 0 && /* @__PURE__ */ jsx17("p", { role: "alert", className: "mt-3 text-sm text-[var(--color-red-500)]", children: formErrors.map(({ message }) => message).join(" ") })
1350
+ formErrors.length > 0 && /* @__PURE__ */ jsx16("p", { role: "alert", className: "mt-3 text-sm text-[var(--color-red-500)]", children: formErrors.map(({ message }) => message).join(" ") })
1438
1351
  ] });
1439
1352
  }
1440
1353
 
@@ -1458,7 +1371,7 @@ import {
1458
1371
  SiYoutube
1459
1372
  } from "react-icons/si";
1460
1373
  import { Chip } from "@behindthemusictree/ui";
1461
- import { jsx as jsx18 } from "react/jsx-runtime";
1374
+ import { jsx as jsx17 } from "react/jsx-runtime";
1462
1375
  var LINK_KINDS = {
1463
1376
  official: { label: "Official website", Icon: Globe },
1464
1377
  wikipedia: { label: "Wikipedia", Icon: SiWikipedia2 },
@@ -1495,7 +1408,7 @@ function artistLinks(artist) {
1495
1408
  return links;
1496
1409
  }
1497
1410
  function ArtistLinks({ links }) {
1498
- return /* @__PURE__ */ jsx18("ul", { className: "flex flex-wrap gap-1.5", children: links.map(({ key, url, Icon, label }) => /* @__PURE__ */ jsx18("li", { children: /* @__PURE__ */ jsx18(Chip, { href: url, icon: /* @__PURE__ */ jsx18(Icon, { className: "h-3.5 w-3.5 shrink-0", "aria-hidden": "true" }), children: label }) }, key)) });
1411
+ return /* @__PURE__ */ jsx17("ul", { className: "flex flex-wrap gap-1.5", children: links.map(({ key, url, Icon, label }) => /* @__PURE__ */ jsx17("li", { children: /* @__PURE__ */ jsx17(Chip, { href: url, icon: /* @__PURE__ */ jsx17(Icon, { className: "h-3.5 w-3.5 shrink-0", "aria-hidden": "true" }), children: label }) }, key)) });
1499
1412
  }
1500
1413
 
1501
1414
  // src/genre-tree/artist/formatYears.ts
@@ -1506,7 +1419,7 @@ function formatYears(beginYear, endYear, ended) {
1506
1419
  }
1507
1420
 
1508
1421
  // src/genre-tree/artist/ArtistHeader.tsx
1509
- import { jsx as jsx19 } from "react/jsx-runtime";
1422
+ import { jsx as jsx18 } from "react/jsx-runtime";
1510
1423
  var countryNames = new Intl.DisplayNames(["en"], { type: "region" });
1511
1424
  function ArtistHeader({ artist }) {
1512
1425
  const fields = [];
@@ -1516,35 +1429,35 @@ function ArtistHeader({ artist }) {
1516
1429
  const years = formatYears(artist.beginYear, artist.endYear, artist.ended);
1517
1430
  if (years) fields.push({ label: "Active", value: years });
1518
1431
  const links = artistLinks(artist);
1519
- if (links.length > 0) fields.push({ label: "Links", value: /* @__PURE__ */ jsx19(ArtistLinks, { links }) });
1432
+ if (links.length > 0) fields.push({ label: "Links", value: /* @__PURE__ */ jsx18(ArtistLinks, { links }) });
1520
1433
  if (fields.length === 0) return null;
1521
- return /* @__PURE__ */ jsx19(FieldList, { items: fields });
1434
+ return /* @__PURE__ */ jsx18(FieldList, { items: fields });
1522
1435
  }
1523
1436
 
1524
1437
  // src/genre-tree/artist/ArtistPhoto.tsx
1525
- import { jsx as jsx20, jsxs as jsxs14 } from "react/jsx-runtime";
1438
+ import { jsx as jsx19, jsxs as jsxs13 } from "react/jsx-runtime";
1526
1439
  function ExternalLink({ href, children }) {
1527
- return /* @__PURE__ */ jsx20("a", { href, target: "_blank", rel: "noopener noreferrer", className: "underline-offset-2 hover:underline", children });
1440
+ return /* @__PURE__ */ jsx19("a", { href, target: "_blank", rel: "noopener noreferrer", className: "underline-offset-2 hover:underline", children });
1528
1441
  }
1529
1442
  function ArtistPhoto({ image, name }) {
1530
- return /* @__PURE__ */ jsxs14("figure", { className: "flex flex-col gap-1.5 px-6 py-2.5", children: [
1531
- /* @__PURE__ */ jsx20("img", { src: image.url, alt: name, className: "h-64 w-full object-contain bg-[var(--color-surface-muted)] rounded" }),
1532
- /* @__PURE__ */ jsxs14("figcaption", { className: "text-xs text-[var(--color-text-muted)]", children: [
1443
+ return /* @__PURE__ */ jsxs13("figure", { className: "flex flex-col gap-1.5 px-6 py-2.5", children: [
1444
+ /* @__PURE__ */ jsx19("img", { src: image.url, alt: name, className: "h-64 w-full object-contain bg-[var(--color-surface-muted)] rounded" }),
1445
+ /* @__PURE__ */ jsxs13("figcaption", { className: "text-xs text-[var(--color-text-muted)]", children: [
1533
1446
  "Photo: ",
1534
- /* @__PURE__ */ jsx20(ExternalLink, { href: image.pageUrl, children: image.author ?? "Wikimedia Commons" }),
1447
+ /* @__PURE__ */ jsx19(ExternalLink, { href: image.pageUrl, children: image.author ?? "Wikimedia Commons" }),
1535
1448
  ",",
1536
1449
  " ",
1537
- image.licenseUrl ? /* @__PURE__ */ jsx20(ExternalLink, { href: image.licenseUrl, children: image.license }) : image.license
1450
+ image.licenseUrl ? /* @__PURE__ */ jsx19(ExternalLink, { href: image.licenseUrl, children: image.license }) : image.license
1538
1451
  ] })
1539
1452
  ] });
1540
1453
  }
1541
1454
 
1542
1455
  // src/genre-tree/artist/ArtistMembers.tsx
1543
1456
  import { PanelGroup, PanelSection } from "@behindthemusictree/ui";
1544
- import { jsx as jsx21, jsxs as jsxs15 } from "react/jsx-runtime";
1457
+ import { jsx as jsx20, jsxs as jsxs14 } from "react/jsx-runtime";
1545
1458
  function MembershipList({ title, memberships, renderArtistLink }) {
1546
1459
  if (memberships.length === 0) return null;
1547
- return /* @__PURE__ */ jsx21(PanelGroup, { title, children: /* @__PURE__ */ jsx21("ul", { children: memberships.map((membership, index) => {
1460
+ return /* @__PURE__ */ jsx20(PanelGroup, { title, children: /* @__PURE__ */ jsx20("ul", { children: memberships.map((membership, index) => {
1548
1461
  const details = [
1549
1462
  formatYears(membership.beginYear, membership.endYear, membership.ended),
1550
1463
  ...membership.roles
@@ -1552,9 +1465,9 @@ function MembershipList({ title, memberships, renderArtistLink }) {
1552
1465
  const detailsText = details.filter(Boolean).join(" \xB7 ");
1553
1466
  return (
1554
1467
  // A member can have several periods in the same group.
1555
- /* @__PURE__ */ jsxs15("li", { className: "truncate py-0.5", title: detailsText || void 0, children: [
1556
- /* @__PURE__ */ jsx21("span", { className: "font-medium text-[var(--color-text)]", children: renderArtistLink(membership.artist) }),
1557
- detailsText && /* @__PURE__ */ jsxs15("span", { className: "text-[var(--color-text-muted)]", children: [
1468
+ /* @__PURE__ */ jsxs14("li", { className: "truncate py-0.5", title: detailsText || void 0, children: [
1469
+ /* @__PURE__ */ jsx20("span", { className: "font-medium text-[var(--color-text)]", children: renderArtistLink(membership.artist) }),
1470
+ detailsText && /* @__PURE__ */ jsxs14("span", { className: "text-[var(--color-text-muted)]", children: [
1558
1471
  " \u2014 ",
1559
1472
  detailsText
1560
1473
  ] })
@@ -1565,9 +1478,9 @@ function MembershipList({ title, memberships, renderArtistLink }) {
1565
1478
  function ArtistMembers({ title, memberships, renderArtistLink }) {
1566
1479
  if (memberships.length === 0) return null;
1567
1480
  const isCurrent = (membership) => !membership.ended && membership.endYear === null;
1568
- return /* @__PURE__ */ jsxs15(PanelSection, { title, children: [
1569
- /* @__PURE__ */ jsx21(MembershipList, { title: "Current", memberships: memberships.filter(isCurrent), renderArtistLink }),
1570
- /* @__PURE__ */ jsx21(
1481
+ return /* @__PURE__ */ jsxs14(PanelSection, { title, children: [
1482
+ /* @__PURE__ */ jsx20(MembershipList, { title: "Current", memberships: memberships.filter(isCurrent), renderArtistLink }),
1483
+ /* @__PURE__ */ jsx20(
1571
1484
  MembershipList,
1572
1485
  {
1573
1486
  title: "Past",
@@ -1580,7 +1493,7 @@ function ArtistMembers({ title, memberships, renderArtistLink }) {
1580
1493
 
1581
1494
  // src/genre-tree/artist/ArtistDiscography.tsx
1582
1495
  import { PanelGroup as PanelGroup2, PanelSection as PanelSection2 } from "@behindthemusictree/ui";
1583
- import { jsx as jsx22, jsxs as jsxs16 } from "react/jsx-runtime";
1496
+ import { jsx as jsx21, jsxs as jsxs15 } from "react/jsx-runtime";
1584
1497
  var GROUPS = ["Albums", "EPs", "Singles", "Live", "Compilations", "Other"];
1585
1498
  var PRIMARY_GROUPS = { album: "Albums", ep: "EPs", single: "Singles" };
1586
1499
  function discographyGroup({ primaryType, secondaryTypes }) {
@@ -1600,18 +1513,18 @@ function byYear(a, b) {
1600
1513
  function ArtistDiscography({ albums }) {
1601
1514
  if (albums.length === 0) return null;
1602
1515
  const sorted = [...albums].sort(byYear);
1603
- return /* @__PURE__ */ jsx22(PanelSection2, { title: "Discography", children: GROUPS.map((group) => {
1516
+ return /* @__PURE__ */ jsx21(PanelSection2, { title: "Discography", children: GROUPS.map((group) => {
1604
1517
  const groupAlbums = sorted.filter((album) => discographyGroup(album) === group);
1605
1518
  if (groupAlbums.length === 0) return null;
1606
- return /* @__PURE__ */ jsx22(PanelGroup2, { title: group, children: /* @__PURE__ */ jsx22("ul", { children: groupAlbums.map((album) => /* @__PURE__ */ jsxs16("li", { className: "grid grid-cols-[2.5rem_minmax(0,1fr)] gap-2 py-0.5", children: [
1607
- /* @__PURE__ */ jsx22("span", { className: "tabular-nums text-[var(--color-text-muted)]", children: album.year ?? "\u2014" }),
1608
- /* @__PURE__ */ jsx22("span", { className: "truncate text-[var(--color-text)]", title: album.name, children: album.name })
1519
+ return /* @__PURE__ */ jsx21(PanelGroup2, { title: group, children: /* @__PURE__ */ jsx21("ul", { children: groupAlbums.map((album) => /* @__PURE__ */ jsxs15("li", { className: "grid grid-cols-[2.5rem_minmax(0,1fr)] gap-2 py-0.5", children: [
1520
+ /* @__PURE__ */ jsx21("span", { className: "tabular-nums text-[var(--color-text-muted)]", children: album.year ?? "\u2014" }),
1521
+ /* @__PURE__ */ jsx21("span", { className: "truncate text-[var(--color-text)]", title: album.name, children: album.name })
1609
1522
  ] }, album.uuid)) }) }, group);
1610
1523
  }) });
1611
1524
  }
1612
1525
 
1613
1526
  // src/genre-tree/artist/ArtistSongs.tsx
1614
- import { useState as useState4 } from "react";
1527
+ import { useState as useState5 } from "react";
1615
1528
  import { keepPreviousData } from "@tanstack/react-query";
1616
1529
  import { Play as Play2 } from "lucide-react";
1617
1530
  import { Pagination, PanelSection as PanelSection3, Skeleton as Skeleton3 } from "@behindthemusictree/ui";
@@ -1632,7 +1545,7 @@ import { useTrackList as useTrackList3 } from "@behindthemusictree/app-kit/genre
1632
1545
  import { FaPlay as FaPlay2, FaPause as FaPause2 } from "react-icons/fa";
1633
1546
  import { usePlayer as usePlayer6 } from "@behindthemusictree/app-kit/player";
1634
1547
  import { PlayStates as PlayStates2 } from "@behindthemusictree/app-kit/player";
1635
- import { jsx as jsx23, jsxs as jsxs17 } from "react/jsx-runtime";
1548
+ import { jsx as jsx22, jsxs as jsxs16 } from "react/jsx-runtime";
1636
1549
  function TrackPositionPlayPause({
1637
1550
  position,
1638
1551
  uuid,
@@ -1646,14 +1559,14 @@ function TrackPositionPlayPause({
1646
1559
  const isPlaying = isThisTrack && isConfirmedPlayable && playState === PlayStates2.PLAYING;
1647
1560
  const isPaused = isThisTrack && isConfirmedPlayable && playState === PlayStates2.PAUSED;
1648
1561
  const isCurrentTrackActive = isPlaying || isPaused;
1649
- return /* @__PURE__ */ jsxs17(
1562
+ return /* @__PURE__ */ jsxs16(
1650
1563
  "div",
1651
1564
  {
1652
1565
  className: "track-position-play-pause relative flex items-center justify-center gap-2 text-lg w-16 cursor-pointer",
1653
1566
  style: { minWidth: "4rem", maxWidth: "4rem" },
1654
1567
  onClick: handlePlayPauseClick,
1655
1568
  children: [
1656
- /* @__PURE__ */ jsx23("div", { className: "track-position-play-pause__status group-hover:hidden flex items-center justify-center", children: isAwaitingPlayback ? /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__spinner", role: "status", "aria-label": "Loading track" }) : isCurrentTrackActive ? /* @__PURE__ */ jsxs17(
1569
+ /* @__PURE__ */ jsx22("div", { className: "track-position-play-pause__status group-hover:hidden flex items-center justify-center", children: isAwaitingPlayback ? /* @__PURE__ */ jsx22("span", { className: "track-position-play-pause__spinner", role: "status", "aria-label": "Loading track" }) : isCurrentTrackActive ? /* @__PURE__ */ jsxs16(
1657
1570
  "span",
1658
1571
  {
1659
1572
  className: "track-position-play-pause__bars",
@@ -1661,18 +1574,18 @@ function TrackPositionPlayPause({
1661
1574
  role: "img",
1662
1575
  "aria-label": isPlaying ? "Playing" : "Paused",
1663
1576
  children: [
1664
- /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__bar" }),
1665
- /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__bar" }),
1666
- /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__bar" })
1577
+ /* @__PURE__ */ jsx22("span", { className: "track-position-play-pause__bar" }),
1578
+ /* @__PURE__ */ jsx22("span", { className: "track-position-play-pause__bar" }),
1579
+ /* @__PURE__ */ jsx22("span", { className: "track-position-play-pause__bar" })
1667
1580
  ]
1668
1581
  }
1669
1582
  ) : position }),
1670
- /* @__PURE__ */ jsx23(
1583
+ /* @__PURE__ */ jsx22(
1671
1584
  "div",
1672
1585
  {
1673
1586
  className: `hidden group-hover:flex items-center justify-center ${isCurrentTrackActive ? "" : "absolute inset-0"}`,
1674
1587
  "aria-hidden": "true",
1675
- children: isAwaitingPlayback ? /* @__PURE__ */ jsx23("span", { className: "track-position-play-pause__spinner" }) : isPlaying ? /* @__PURE__ */ jsx23(FaPause2, {}) : /* @__PURE__ */ jsx23(FaPlay2, {})
1588
+ children: isAwaitingPlayback ? /* @__PURE__ */ jsx22("span", { className: "track-position-play-pause__spinner" }) : isPlaying ? /* @__PURE__ */ jsx22(FaPause2, {}) : /* @__PURE__ */ jsx22(FaPlay2, {})
1676
1589
  }
1677
1590
  )
1678
1591
  ]
@@ -1683,13 +1596,14 @@ function TrackPositionPlayPause({
1683
1596
  // src/genre-tree/track-list-sidebar/TrackItem.tsx
1684
1597
  import { isSong as isSong2, songUnplayableReason as songUnplayableReason2 } from "@behindthemusictree/app-kit/genre-tree";
1685
1598
  import { ArtistNames as ArtistNames2 } from "@behindthemusictree/app-kit/genre-tree";
1686
- import { jsx as jsx24, jsxs as jsxs18 } from "react/jsx-runtime";
1599
+ import { jsx as jsx23, jsxs as jsxs17 } from "react/jsx-runtime";
1687
1600
  function TrackItem({
1688
1601
  track,
1689
1602
  position,
1690
1603
  renderDuration,
1691
1604
  renderActions,
1692
- renderArtistLink
1605
+ renderArtistLink,
1606
+ renderSongLink
1693
1607
  }) {
1694
1608
  const { handlePlayPauseAction, playerTrackObject, loadTrackForPlayer } = usePlayer7();
1695
1609
  const { trackList, selectedTrack, toTrackAtPosition, runtimeUnplayableTrackIds } = useTrackList3();
@@ -1706,12 +1620,12 @@ function TrackItem({
1706
1620
  loadTrackForPlayer(track.uuid);
1707
1621
  }
1708
1622
  };
1709
- return /* @__PURE__ */ jsxs18(
1623
+ return /* @__PURE__ */ jsxs17(
1710
1624
  "div",
1711
1625
  {
1712
1626
  className: `track-item flex h-14 text-[var(--color-text)] ${isUnplayable ? "opacity-50" : "hover:bg-[var(--color-surface-muted)] group"}`,
1713
1627
  children: [
1714
- isUnplayable ? /* @__PURE__ */ jsx24(
1628
+ isUnplayable ? /* @__PURE__ */ jsx23(
1715
1629
  "div",
1716
1630
  {
1717
1631
  className: "flex items-center justify-center text-lg w-16",
@@ -1719,7 +1633,7 @@ function TrackItem({
1719
1633
  "aria-disabled": "true",
1720
1634
  children: position
1721
1635
  }
1722
- ) : /* @__PURE__ */ jsx24(
1636
+ ) : /* @__PURE__ */ jsx23(
1723
1637
  TrackPositionPlayPause,
1724
1638
  {
1725
1639
  position,
@@ -1728,12 +1642,12 @@ function TrackItem({
1728
1642
  isSelected: selectedTrack?.uuid === track.uuid
1729
1643
  }
1730
1644
  ),
1731
- /* @__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 }) }),
1733
- 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 }) }) : ""
1645
+ /* @__PURE__ */ jsxs17("div", { className: "title-artist-container flex flex-col items-start justify-center min-w-0 flex-1", children: [
1646
+ /* @__PURE__ */ jsx23("div", { className: "flex max-w-full items-center gap-2", children: /* @__PURE__ */ jsx23("div", { className: "title text-lg font-bold text-[var(--color-text)] max-w-full truncate", children: renderSongLink ? renderSongLink(track) : isSong2(track) ? /* @__PURE__ */ jsx23(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }) }),
1647
+ track.artists && track.artists.length > 0 ? /* @__PURE__ */ jsx23("div", { className: "artist text-base max-w-full truncate text-[var(--color-text-muted)]", children: /* @__PURE__ */ jsx23(ArtistNames2, { artists: track.artists, renderArtistLink }) }) : ""
1734
1648
  ] }),
1735
- /* @__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 }) : "" }),
1736
- renderDuration && /* @__PURE__ */ jsx24(
1649
+ /* @__PURE__ */ jsx23("div", { className: "genre-name-container flex items-center justify-start w-[21.6%] min-w-0 shrink-0", children: track.genre ? /* @__PURE__ */ jsx23("div", { className: "genre-name max-w-full whitespace-normal break-words p-1 text-xs font-bold", children: track.genre.name }) : "" }),
1650
+ renderDuration && /* @__PURE__ */ jsx23(
1737
1651
  "div",
1738
1652
  {
1739
1653
  className: "duration flex text-base w-16 items-center justify-center",
@@ -1741,22 +1655,22 @@ function TrackItem({
1741
1655
  children: renderDuration(track)
1742
1656
  }
1743
1657
  ),
1744
- renderActions && /* @__PURE__ */ jsx24("div", { className: "edit flex text-base w-6 items-center justify-center mr-2 cursor-pointer", children: renderActions(track) })
1658
+ renderActions && /* @__PURE__ */ jsx23("div", { className: "edit flex text-base w-6 items-center justify-center mr-2 cursor-pointer", children: renderActions(track) })
1745
1659
  ]
1746
1660
  }
1747
1661
  );
1748
1662
  }
1749
1663
 
1750
1664
  // src/genre-tree/artist/ArtistSongs.tsx
1751
- import { Fragment as Fragment7, jsx as jsx25, jsxs as jsxs19 } from "react/jsx-runtime";
1665
+ import { Fragment as Fragment8, jsx as jsx24, jsxs as jsxs18 } from "react/jsx-runtime";
1752
1666
  var SongPageSchema2 = PaginatedResponseSchema2(SongDetailedSchema2);
1753
1667
  var SORTS = [
1754
1668
  { ordering: "title", label: "Title" },
1755
1669
  { ordering: "-albumYear", label: "Year" }
1756
1670
  ];
1757
- function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1758
- const [page, setPage] = useState4(1);
1759
- const [ordering, setOrdering] = useState4("title");
1671
+ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink, renderSongLink }) {
1672
+ const [page, setPage] = useState5(1);
1673
+ const [ordering, setOrdering] = useState5("title");
1760
1674
  const { fetch } = useFetchWrapper2(getBackendBaseUrl);
1761
1675
  const { playNewTrackListFromArtist } = useTrackList4();
1762
1676
  const { playerTrackObject } = usePlayer8();
@@ -1781,21 +1695,21 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1781
1695
  event.stopPropagation();
1782
1696
  void play(song.uuid);
1783
1697
  };
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: [
1785
- /* @__PURE__ */ jsxs19("div", { className: "flex flex-wrap items-center justify-between gap-2 mb-2", children: [
1786
- /* @__PURE__ */ jsxs19(
1698
+ return /* @__PURE__ */ jsx24(PanelSection3, { title: "Songs", children: isPending ? /* @__PURE__ */ jsx24(Skeleton3, { className: "h-14", "aria-busy": "true" }) : isError ? /* @__PURE__ */ jsx24("p", { className: "text-red-600", children: "Could not load songs." }) : data.results.length === 0 ? /* @__PURE__ */ jsx24("p", { className: "text-[var(--color-text-muted)]", children: "No songs yet." }) : /* @__PURE__ */ jsxs18(Fragment8, { children: [
1699
+ /* @__PURE__ */ jsxs18("div", { className: "flex flex-wrap items-center justify-between gap-2 mb-2", children: [
1700
+ /* @__PURE__ */ jsxs18(
1787
1701
  "button",
1788
1702
  {
1789
1703
  type: "button",
1790
1704
  onClick: () => void play(),
1791
1705
  className: "flex w-fit items-center gap-1.5 px-3 py-1 text-xs font-medium bg-gray-900 text-white rounded-full hover:bg-gray-700",
1792
1706
  children: [
1793
- /* @__PURE__ */ jsx25(Play2, { className: "h-3.5 w-3.5", "aria-hidden": "true" }),
1707
+ /* @__PURE__ */ jsx24(Play2, { className: "h-3.5 w-3.5", "aria-hidden": "true" }),
1794
1708
  "Play all"
1795
1709
  ]
1796
1710
  }
1797
1711
  ),
1798
- /* @__PURE__ */ jsx25("div", { role: "group", "aria-label": "Sort songs by", className: "flex gap-1 text-xs", children: SORTS.map((sort) => /* @__PURE__ */ jsx25(
1712
+ /* @__PURE__ */ jsx24("div", { role: "group", "aria-label": "Sort songs by", className: "flex gap-1 text-xs", children: SORTS.map((sort) => /* @__PURE__ */ jsx24(
1799
1713
  "button",
1800
1714
  {
1801
1715
  type: "button",
@@ -1810,26 +1724,28 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1810
1724
  sort.ordering
1811
1725
  )) })
1812
1726
  ] }),
1813
- /* @__PURE__ */ jsx25("ul", { className: "overflow-hidden mb-2 bg-zinc-950 rounded-lg", children: data.results.map((song, index) => /* @__PURE__ */ jsx25("li", { onClickCapture: (event) => handleRowClickCapture(event, song), children: /* @__PURE__ */ jsx25(
1727
+ /* @__PURE__ */ jsx24("ul", { className: "overflow-hidden mb-2 bg-zinc-950 rounded-lg", children: data.results.map((song, index) => /* @__PURE__ */ jsx24("li", { onClickCapture: (event) => handleRowClickCapture(event, song), children: /* @__PURE__ */ jsx24(
1814
1728
  TrackItem,
1815
1729
  {
1816
1730
  track: song,
1817
1731
  position: (page - 1) * data.pageSize + index + 1,
1818
- renderArtistLink
1732
+ renderArtistLink,
1733
+ renderSongLink
1819
1734
  }
1820
1735
  ) }, song.uuid)) }),
1821
- /* @__PURE__ */ jsx25(Pagination, { currentPage: page, totalPages: data.totalPages, onPageChange: setPage })
1736
+ /* @__PURE__ */ jsx24(Pagination, { currentPage: page, totalPages: data.totalPages, onPageChange: setPage })
1822
1737
  ] }) });
1823
1738
  }
1824
1739
 
1825
1740
  // src/genre-tree/artist/useArtistPanel.tsx
1826
- import { Fragment as Fragment8, jsx as jsx26, jsxs as jsxs20 } from "react/jsx-runtime";
1741
+ import { Fragment as Fragment9, jsx as jsx25, jsxs as jsxs19 } from "react/jsx-runtime";
1827
1742
  function useArtistPanel({
1828
1743
  uuid,
1829
1744
  scope,
1830
1745
  getBackendBaseUrl,
1831
1746
  getShareUrl,
1832
1747
  renderArtistLink,
1748
+ renderSongLink,
1833
1749
  expanded,
1834
1750
  onExpandedChange,
1835
1751
  onClose
@@ -1843,26 +1759,26 @@ function useArtistPanel({
1843
1759
  return {
1844
1760
  ...panel,
1845
1761
  title: "Artist",
1846
- content: /* @__PURE__ */ jsxs20("div", { "aria-busy": "true", className: "flex flex-col gap-2.5 px-6 py-2.5", children: [
1847
- /* @__PURE__ */ jsx26(Skeleton4, { className: "h-3 w-16" }),
1848
- /* @__PURE__ */ jsx26(Skeleton4, { className: "h-4 w-32" }),
1849
- /* @__PURE__ */ jsx26(Skeleton4, { className: "h-3 w-16" }),
1850
- /* @__PURE__ */ jsx26(Skeleton4, { className: "h-4 w-24" }),
1851
- /* @__PURE__ */ jsx26(Skeleton4, { className: "h-16 mt-2.5" }),
1852
- /* @__PURE__ */ jsx26(Skeleton4, { className: "h-16" })
1762
+ content: /* @__PURE__ */ jsxs19("div", { "aria-busy": "true", className: "flex flex-col gap-2.5 px-6 py-2.5", children: [
1763
+ /* @__PURE__ */ jsx25(Skeleton4, { className: "h-3 w-16" }),
1764
+ /* @__PURE__ */ jsx25(Skeleton4, { className: "h-4 w-32" }),
1765
+ /* @__PURE__ */ jsx25(Skeleton4, { className: "h-3 w-16" }),
1766
+ /* @__PURE__ */ jsx25(Skeleton4, { className: "h-4 w-24" }),
1767
+ /* @__PURE__ */ jsx25(Skeleton4, { className: "h-16 mt-2.5" }),
1768
+ /* @__PURE__ */ jsx25(Skeleton4, { className: "h-16" })
1853
1769
  ] })
1854
1770
  };
1855
1771
  case "not-found":
1856
1772
  return {
1857
1773
  ...panel,
1858
1774
  title: "Artist not found",
1859
- content: /* @__PURE__ */ jsx26("p", { className: "px-6 py-2.5 text-[var(--color-text-muted)]", children: "This artist does not exist." })
1775
+ content: /* @__PURE__ */ jsx25("p", { className: "px-6 py-2.5 text-[var(--color-text-muted)]", children: "This artist does not exist." })
1860
1776
  };
1861
1777
  case "error":
1862
1778
  return {
1863
1779
  ...panel,
1864
1780
  title: "Artist",
1865
- content: /* @__PURE__ */ jsx26("p", { className: "px-6 py-2.5 text-red-600", children: "Could not load this artist." })
1781
+ content: /* @__PURE__ */ jsx25("p", { className: "px-6 py-2.5 text-red-600", children: "Could not load this artist." })
1866
1782
  };
1867
1783
  case "success": {
1868
1784
  const { artist } = result;
@@ -1871,22 +1787,225 @@ function useArtistPanel({
1871
1787
  return {
1872
1788
  ...panel,
1873
1789
  title: artist.name,
1874
- content: /* @__PURE__ */ jsxs20(Fragment8, { children: [
1875
- 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
- artist.image && /* @__PURE__ */ jsx26(ArtistPhoto, { image: artist.image, name: artist.name }),
1877
- /* @__PURE__ */ jsx26(ArtistHeader, { artist }),
1878
- /* @__PURE__ */ jsx26(ArtistMembers, { title: "Members", memberships: artist.members, renderArtistLink }),
1879
- /* @__PURE__ */ jsx26(ArtistMembers, { title: "Member of", memberships: artist.memberOf, renderArtistLink }),
1880
- artist.tracksCount > 0 && /* @__PURE__ */ jsx26(
1790
+ content: /* @__PURE__ */ jsxs19(Fragment9, { children: [
1791
+ shareUrl && /* @__PURE__ */ jsx25("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: /* @__PURE__ */ jsx25("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: shareTarget && /* @__PURE__ */ jsx25(ShareActionButton, { target: shareTarget, url: shareUrl }) }) }),
1792
+ artist.image && /* @__PURE__ */ jsx25(ArtistPhoto, { image: artist.image, name: artist.name }),
1793
+ /* @__PURE__ */ jsx25(ArtistHeader, { artist }),
1794
+ /* @__PURE__ */ jsx25(ArtistMembers, { title: "Members", memberships: artist.members, renderArtistLink }),
1795
+ /* @__PURE__ */ jsx25(ArtistMembers, { title: "Member of", memberships: artist.memberOf, renderArtistLink }),
1796
+ artist.tracksCount > 0 && /* @__PURE__ */ jsx25(
1881
1797
  ArtistSongs,
1882
1798
  {
1883
1799
  artist: { uuid: artist.uuid, name: artist.name },
1884
1800
  getBackendBaseUrl,
1885
- renderArtistLink
1801
+ renderArtistLink,
1802
+ renderSongLink
1886
1803
  }
1887
1804
  )
1888
1805
  ] }),
1889
- extraContent: artist.albums.length > 0 ? /* @__PURE__ */ jsx26(ArtistDiscography, { albums: artist.albums }) : void 0
1806
+ extraContent: artist.albums.length > 0 ? /* @__PURE__ */ jsx25(ArtistDiscography, { albums: artist.albums }) : void 0
1807
+ };
1808
+ }
1809
+ }
1810
+ }
1811
+
1812
+ // src/genre-tree/song/useSongPanel.tsx
1813
+ import { Skeleton as Skeleton6 } from "@behindthemusictree/ui";
1814
+ import {
1815
+ useSong
1816
+ } from "@behindthemusictree/app-kit/genre-tree";
1817
+
1818
+ // src/genre-tree/song/SongPanelContent.tsx
1819
+ import { useState as useState6 } from "react";
1820
+ import { Pause as Pause2, Play as Play3 } from "lucide-react";
1821
+ import { FieldList as FieldList2, PanelSection as PanelSection4, Skeleton as Skeleton5 } from "@behindthemusictree/ui";
1822
+ import { PlayStates as PlayStates3, usePlayer as usePlayer9 } from "@behindthemusictree/app-kit/player";
1823
+ import {
1824
+ ArtistNames as ArtistNames3,
1825
+ musicBrainzRecordingUrl as musicBrainzRecordingUrl2,
1826
+ songUnplayableReason as songUnplayableReason4,
1827
+ useSongHistory,
1828
+ useTrackList as useTrackList5
1829
+ } from "@behindthemusictree/app-kit/genre-tree";
1830
+ import { Fragment as Fragment10, jsx as jsx26, jsxs as jsxs20 } from "react/jsx-runtime";
1831
+ var numberFormat = new Intl.NumberFormat("en");
1832
+ var dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium" });
1833
+ var externalLink = "text-[var(--color-primary)] hover:underline focus-visible:underline";
1834
+ function SongHistory({ uuid, getBackendBaseUrl }) {
1835
+ const [open, setOpen] = useState6(false);
1836
+ const history = useSongHistory(uuid, getBackendBaseUrl, open);
1837
+ let body = null;
1838
+ if (history.status === "loading") body = /* @__PURE__ */ jsx26(Skeleton5, { className: "h-10", "aria-busy": "true" });
1839
+ else if (history.status === "error") body = /* @__PURE__ */ jsx26("p", { className: "text-red-600", children: "Could not load the history." });
1840
+ else if (history.status === "success")
1841
+ body = history.entries.length === 0 ? /* @__PURE__ */ jsx26("p", { className: "text-[var(--color-text-muted)]", children: "No changes yet." }) : /* @__PURE__ */ jsx26("ul", { className: "divide-y divide-[var(--color-border)]", children: history.entries.map((entry) => /* @__PURE__ */ jsxs20("li", { className: "py-1.5 text-sm", children: [
1842
+ /* @__PURE__ */ jsx26("span", { className: "font-medium", children: entry.action }),
1843
+ entry.oldValue !== null || entry.newValue !== null ? /* @__PURE__ */ jsxs20("span", { className: "text-[var(--color-text-muted)]", children: [
1844
+ " ",
1845
+ "\u2014 ",
1846
+ entry.oldValue ?? "none",
1847
+ " \u2192 ",
1848
+ entry.newValue ?? "none"
1849
+ ] }) : null,
1850
+ /* @__PURE__ */ jsxs20("span", { className: "block text-xs text-[var(--color-text-muted)]", children: [
1851
+ entry.actorPseudo ?? "Anonymous",
1852
+ " \xB7 ",
1853
+ dateFormat.format(new Date(entry.createdOn))
1854
+ ] })
1855
+ ] }, entry.uuid)) });
1856
+ return /* @__PURE__ */ jsxs20(
1857
+ "details",
1858
+ {
1859
+ onToggle: (event) => setOpen(event.currentTarget.open),
1860
+ className: "px-6 pb-2.5 mt-2.5 border-t border-[var(--color-border)]",
1861
+ children: [
1862
+ /* @__PURE__ */ jsx26("summary", { className: "mt-2.5 cursor-pointer text-[0.6875rem] font-semibold uppercase text-[var(--color-text-muted)]", children: "History" }),
1863
+ /* @__PURE__ */ jsx26("div", { className: "mt-1.5", children: body })
1864
+ ]
1865
+ }
1866
+ );
1867
+ }
1868
+ function SongPanelContent({
1869
+ song,
1870
+ scope,
1871
+ getBackendBaseUrl,
1872
+ shareUrl,
1873
+ renderArtistLink,
1874
+ genrePath,
1875
+ onSelectGenre
1876
+ }) {
1877
+ const { playNewTrackListFromTrackUuid } = useTrackList5();
1878
+ const { playerTrackObject, playState, handlePlayPauseAction } = usePlayer9();
1879
+ const unplayableReason = songUnplayableReason4(song);
1880
+ const isCurrent = playerTrackObject?.track.id === song.uuid;
1881
+ const isPlaying = isCurrent && playState === PlayStates3.PLAYING;
1882
+ const shareTarget = { kind: "song", uuid: song.uuid, name: song.title, scope };
1883
+ const musicBrainzUrl = musicBrainzRecordingUrl2(song);
1884
+ const fields = [];
1885
+ if (song.genre) {
1886
+ const path = genrePath.length > 0 ? genrePath : null;
1887
+ fields.push({
1888
+ label: "Genre",
1889
+ value: path ? /* @__PURE__ */ jsx26("span", { className: "flex flex-wrap items-center gap-x-1", children: path.map((genrePlaylist, index) => /* @__PURE__ */ jsxs20("span", { children: [
1890
+ index > 0 && /* @__PURE__ */ jsx26("span", { "aria-hidden": "true", children: "\u203A " }),
1891
+ /* @__PURE__ */ jsx26("button", { type: "button", onClick: () => onSelectGenre(genrePlaylist), className: "hover:underline", children: genrePlaylist.name })
1892
+ ] }, genrePlaylist.uuid)) }) : song.genre.name
1893
+ });
1894
+ } else {
1895
+ fields.push({ label: "Genre", value: /* @__PURE__ */ jsx26("span", { className: "text-[var(--color-text-muted)]", children: "No genre" }) });
1896
+ }
1897
+ if (song.firstReleaseYear) fields.push({ label: "First released", value: song.firstReleaseYear });
1898
+ const viewCount = song.youtubeVideo?.viewCount;
1899
+ if (viewCount !== null && viewCount !== void 0)
1900
+ fields.push({ label: "YouTube views", value: numberFormat.format(viewCount) });
1901
+ fields.push({ label: "Plays", value: numberFormat.format(song.playCount) });
1902
+ const links = [];
1903
+ if (musicBrainzUrl)
1904
+ links.push(
1905
+ /* @__PURE__ */ jsx26("a", { href: musicBrainzUrl, target: "_blank", rel: "noopener noreferrer", className: externalLink, children: "MusicBrainz" }, "mb")
1906
+ );
1907
+ if (song.youtubeVideo)
1908
+ links.push(
1909
+ /* @__PURE__ */ jsx26(
1910
+ "a",
1911
+ {
1912
+ href: `https://www.youtube.com/watch?v=${encodeURIComponent(song.youtubeVideo.id)}`,
1913
+ target: "_blank",
1914
+ rel: "noopener noreferrer",
1915
+ className: externalLink,
1916
+ children: "YouTube"
1917
+ },
1918
+ "yt"
1919
+ )
1920
+ );
1921
+ if (links.length > 0)
1922
+ fields.push({
1923
+ label: "Links",
1924
+ value: /* @__PURE__ */ jsx26("span", { className: "flex gap-3", children: links })
1925
+ });
1926
+ return /* @__PURE__ */ jsxs20(Fragment10, { children: [
1927
+ /* @__PURE__ */ jsx26("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: /* @__PURE__ */ jsxs20("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: [
1928
+ unplayableReason ? /* @__PURE__ */ jsx26("p", { className: "flex items-center px-2.5 text-sm text-[var(--color-text-muted)]", children: unplayableReason }) : /* @__PURE__ */ jsxs20(
1929
+ "button",
1930
+ {
1931
+ type: "button",
1932
+ onClick: () => isCurrent ? handlePlayPauseAction() : playNewTrackListFromTrackUuid(song, scope),
1933
+ className: "group flex flex-col items-center gap-1 w-16 text-xs text-center text-[var(--color-text)] focus-visible:outline-none",
1934
+ children: [
1935
+ /* @__PURE__ */ jsx26("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__ */ jsx26(Pause2, { "aria-hidden": "true", className: "w-5 h-5" }) : /* @__PURE__ */ jsx26(Play3, { "aria-hidden": "true", className: "w-5 h-5" }) }),
1936
+ isPlaying ? "Pause" : "Play"
1937
+ ]
1938
+ }
1939
+ ),
1940
+ shareUrl && /* @__PURE__ */ jsx26(ShareActionButton, { target: shareTarget, url: shareUrl })
1941
+ ] }) }),
1942
+ song.artists && song.artists.length > 0 && /* @__PURE__ */ jsx26("p", { className: "px-6 pt-2.5 text-[var(--color-text-muted)]", children: /* @__PURE__ */ jsx26(ArtistNames3, { artists: song.artists, renderArtistLink }) }),
1943
+ /* @__PURE__ */ jsx26(FieldList2, { items: fields }),
1944
+ song.essentialGenres && song.essentialGenres.length > 0 && /* @__PURE__ */ jsx26(PanelSection4, { title: "Essential in", children: /* @__PURE__ */ jsx26("ul", { children: song.essentialGenres.map((genre) => /* @__PURE__ */ jsx26("li", { className: "py-0.5", children: genre.name }, genre.uuid)) }) }),
1945
+ /* @__PURE__ */ jsx26(SongHistory, { uuid: song.uuid, getBackendBaseUrl })
1946
+ ] });
1947
+ }
1948
+
1949
+ // src/genre-tree/song/useSongPanel.tsx
1950
+ import { jsx as jsx27, jsxs as jsxs21 } from "react/jsx-runtime";
1951
+ function useSongPanel({
1952
+ uuid,
1953
+ scope,
1954
+ getBackendBaseUrl,
1955
+ getShareUrl,
1956
+ renderArtistLink,
1957
+ getGenrePath,
1958
+ onSelectGenre,
1959
+ expanded,
1960
+ onExpandedChange,
1961
+ onClose
1962
+ }) {
1963
+ const result = useSong(uuid, getBackendBaseUrl);
1964
+ const panel = { expanded, onExpandedChange, onClose };
1965
+ switch (result.status) {
1966
+ case "idle":
1967
+ return void 0;
1968
+ case "loading":
1969
+ return {
1970
+ ...panel,
1971
+ title: "Song",
1972
+ content: /* @__PURE__ */ jsxs21("div", { "aria-busy": "true", className: "flex flex-col gap-2.5 px-6 py-2.5", children: [
1973
+ /* @__PURE__ */ jsx27(Skeleton6, { className: "h-3 w-16" }),
1974
+ /* @__PURE__ */ jsx27(Skeleton6, { className: "h-4 w-32" }),
1975
+ /* @__PURE__ */ jsx27(Skeleton6, { className: "h-3 w-16" }),
1976
+ /* @__PURE__ */ jsx27(Skeleton6, { className: "h-4 w-24" })
1977
+ ] })
1978
+ };
1979
+ case "not-found":
1980
+ return {
1981
+ ...panel,
1982
+ title: "Song not found",
1983
+ content: /* @__PURE__ */ jsx27("p", { className: "px-6 py-2.5 text-[var(--color-text-muted)]", children: "This song does not exist." })
1984
+ };
1985
+ case "error":
1986
+ return {
1987
+ ...panel,
1988
+ title: "Song",
1989
+ content: /* @__PURE__ */ jsx27("p", { className: "px-6 py-2.5 text-red-600", children: "Could not load this song." })
1990
+ };
1991
+ case "success": {
1992
+ const { song } = result;
1993
+ const shareUrl = getShareUrl?.({ kind: "song", uuid: song.uuid, name: song.title, scope });
1994
+ return {
1995
+ ...panel,
1996
+ title: song.title,
1997
+ content: /* @__PURE__ */ jsx27(
1998
+ SongPanelContent,
1999
+ {
2000
+ song,
2001
+ scope,
2002
+ getBackendBaseUrl,
2003
+ shareUrl: shareUrl ?? void 0,
2004
+ renderArtistLink,
2005
+ genrePath: song.genre ? getGenrePath(song.genre) : [],
2006
+ onSelectGenre
2007
+ }
2008
+ )
1890
2009
  };
1891
2010
  }
1892
2011
  }
@@ -1895,12 +2014,12 @@ function useArtistPanel({
1895
2014
  // src/genre-tree/SizeMetricToggle.tsx
1896
2015
  import { MdMusicNote, MdVisibility } from "react-icons/md";
1897
2016
  import { SegmentedControl } from "@behindthemusictree/ui";
1898
- import { jsx as jsx27 } from "react/jsx-runtime";
2017
+ import { jsx as jsx28 } from "react/jsx-runtime";
1899
2018
  function SizeMetricToggle({
1900
2019
  value,
1901
2020
  onChange
1902
2021
  }) {
1903
- return /* @__PURE__ */ jsx27(
2022
+ return /* @__PURE__ */ jsx28(
1904
2023
  SegmentedControl,
1905
2024
  {
1906
2025
  variant: "floating",
@@ -1908,26 +2027,19 @@ function SizeMetricToggle({
1908
2027
  value,
1909
2028
  onChange,
1910
2029
  options: [
1911
- { value: "songs", label: /* @__PURE__ */ jsx27(MdMusicNote, { size: 18, "aria-hidden": true }), "aria-label": "Size by songs", title: "Node size by song count" },
1912
- { value: "views", label: /* @__PURE__ */ jsx27(MdVisibility, { size: 18, "aria-hidden": true }), "aria-label": "Size by views", title: "Node size by song views" }
2030
+ { value: "songs", label: /* @__PURE__ */ jsx28(MdMusicNote, { size: 18, "aria-hidden": true }), "aria-label": "Size by songs", title: "Node size by song count" },
2031
+ { value: "views", label: /* @__PURE__ */ jsx28(MdVisibility, { size: 18, "aria-hidden": true }), "aria-label": "Size by views", title: "Node size by song views" }
1913
2032
  ]
1914
2033
  }
1915
2034
  );
1916
2035
  }
1917
2036
 
1918
2037
  // src/genre-tree/GenreTreeView.tsx
1919
- import { Fragment as Fragment9, jsx as jsx28, jsxs as jsxs21 } from "react/jsx-runtime";
1920
- var PREVIOUS_BUTTON_CSS = `
1921
- .genre-tree-view .search-overlay .gtv-info-panel-previous { display: none; }
1922
- .genre-tree-view:has(.search-overlay .gtv-info-panel-previous) .genre-search input { padding-left: 2.75rem; }
1923
- .genre-tree-view:has(.search-overlay .gtv-info-panel-previous) .genre-search-previous { display: flex; }
1924
- @keyframes genre-tree-view-previous-in { from { opacity: 0; transform: translate(-0.5rem, -50%); } to { opacity: 1; transform: translate(0, -50%); } }
1925
- @media (prefers-reduced-motion: no-preference) {
1926
- .genre-search-previous { animation: genre-tree-view-previous-in 200ms ease-out; }
1927
- }
1928
- `;
2038
+ import { Fragment as Fragment11, jsx as jsx29, jsxs as jsxs22 } from "react/jsx-runtime";
1929
2039
  var NO_POP_ROOT_TITLE = "This genre tree has no 'Mainstream Pop' root yet";
1930
- var REGIONS_CLASS = "[--gtv-center-left:var(--layout-center-left)] [--gtv-center-right:var(--layout-center-right)] [--gtv-center-top:var(--layout-band-top)] [&_.gtv-info-panel--overlay]:[--gtv-info-panel-header-inset:0px]";
2040
+ var REGIONS_CLASS = "[--gtv-center-left:var(--layout-center-left)] [--gtv-center-right:var(--layout-center-right)] [--gtv-center-top:var(--layout-band-top)]";
2041
+ var noop = () => {
2042
+ };
1931
2043
  function GenreTreeView({
1932
2044
  scope,
1933
2045
  handleGenreCreationAction,
@@ -1960,6 +2072,13 @@ function GenreTreeView({
1960
2072
  artistSearch = true,
1961
2073
  onArtistSelect,
1962
2074
  artistSearchBaseUrl,
2075
+ songUuid: controlledSongUuid,
2076
+ onSongUuidChange,
2077
+ songExpanded: controlledSongExpanded,
2078
+ onSongExpandedChange,
2079
+ songSearch = true,
2080
+ onSongSelect,
2081
+ renderSongLink,
1963
2082
  renderSummary,
1964
2083
  renderEssentialTracks,
1965
2084
  renderSongLeading,
@@ -1976,10 +2095,17 @@ function GenreTreeView({
1976
2095
  }) {
1977
2096
  const getShareUrlRef = useRef4(getShareUrl);
1978
2097
  getShareUrlRef.current = getShareUrl;
1979
- const [internalArtistUuid, setInternalArtistUuid] = useState5(null);
2098
+ const [internalArtistUuid, setInternalArtistUuid] = useState7(null);
1980
2099
  const isArtistControlled = controlledArtistUuid !== void 0;
1981
2100
  const artistUuid = isArtistControlled ? controlledArtistUuid : internalArtistUuid;
1982
- const [internalSizeMetric, setInternalSizeMetric] = useState5("songs");
2101
+ const [internalSongUuid, setInternalSongUuid] = useState7(null);
2102
+ const isSongControlled = controlledSongUuid !== void 0;
2103
+ const songUuid = isSongControlled ? controlledSongUuid : internalSongUuid;
2104
+ const changeSong = (uuid) => {
2105
+ if (!isSongControlled) setInternalSongUuid(uuid);
2106
+ onSongUuidChange?.(uuid);
2107
+ };
2108
+ const [internalSizeMetric, setInternalSizeMetric] = useState7("songs");
1983
2109
  const sizeMetric = controlledSizeMetric ?? internalSizeMetric;
1984
2110
  const deferredSizeMetric = useDeferredValue(sizeMetric);
1985
2111
  const changeSizeMetric = (metric) => {
@@ -2018,7 +2144,7 @@ function GenreTreeView({
2018
2144
  artistUuid
2019
2145
  });
2020
2146
  const restoreRevision = viewportRestore?.revision;
2021
- const [stackedRestoreOwner, setStackedRestoreOwner] = useState5({ revision: restoreRevision, rootUuid: selectedGenrePlaylist?.root.uuid });
2147
+ const [stackedRestoreOwner, setStackedRestoreOwner] = useState7({ revision: restoreRevision, rootUuid: selectedGenrePlaylist?.root.uuid });
2022
2148
  let restoreRootUuid = stackedRestoreOwner.rootUuid;
2023
2149
  if (stackedRestoreOwner.revision !== restoreRevision) {
2024
2150
  restoreRootUuid = selectedGenrePlaylist?.root.uuid;
@@ -2042,27 +2168,71 @@ function GenreTreeView({
2042
2168
  const playback = useGenrePlaylistPlayback({ scope, genrePlaylists: visibleGenrePlaylists, artist: filterArtist });
2043
2169
  const openArtist = (uuid) => {
2044
2170
  if (selectedNodeId !== null) clearSelection();
2171
+ if (songUuid !== null) changeSong(null);
2045
2172
  changeArtist(uuid);
2046
2173
  };
2047
- const [expandedArtistUuid, setExpandedArtistUuid] = useState5(null);
2174
+ const openSong = (uuid) => {
2175
+ if (selectedNodeId !== null) clearSelection();
2176
+ changeSong(uuid);
2177
+ };
2178
+ const openSongRef = useRef4(openSong);
2179
+ openSongRef.current = openSong;
2180
+ const resolvedRenderSongLink = useCallback6(
2181
+ (song) => renderSongLink ? renderSongLink(song) : /* @__PURE__ */ jsx29("button", { type: "button", onClick: () => openSongRef.current(song.uuid), className: "hover:underline", children: song.title }),
2182
+ [renderSongLink]
2183
+ );
2184
+ const [expandedSongUuid, setExpandedSongUuid] = useState7(null);
2185
+ const [expandedArtistUuid, setExpandedArtistUuid] = useState7(null);
2048
2186
  const artistBaseUrl = artistSearchBaseUrl ?? getBackendBaseUrl;
2187
+ const songBaseUrl = getBackendBaseUrl;
2049
2188
  const artistPanel = useArtistPanel({
2050
2189
  uuid: artistUuid,
2051
2190
  scope,
2052
2191
  getBackendBaseUrl: artistBaseUrl,
2053
2192
  getShareUrl: getShareUrlRef.current,
2054
- renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ jsx28("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
2193
+ renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ jsx29("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
2055
2194
  expanded: controlledArtistExpanded ?? (artistUuid !== null && expandedArtistUuid === artistUuid),
2056
2195
  onExpandedChange: (on) => {
2057
2196
  if (controlledArtistExpanded === void 0) setExpandedArtistUuid(on ? artistUuid : null);
2058
2197
  onArtistExpandedChange?.(on);
2059
2198
  },
2199
+ renderSongLink: resolvedRenderSongLink,
2060
2200
  onClose: () => changeArtist(null)
2061
2201
  });
2062
- const builtInArtistPanel = overlayPanel || hasSelectedGenreNode ? void 0 : artistPanel;
2063
- const activeOverlayPanel = overlayPanel ?? (builtInArtistPanel && { ...builtInArtistPanel, hideTitle: true, hideClose: true });
2064
- const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState5(false);
2065
- const [uncontrolledExpanded, setUncontrolledExpanded] = useState5(false);
2202
+ const resolvedRenderArtistLink = renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ jsx29("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name }));
2203
+ const getGenrePath = (genre) => {
2204
+ const path = [];
2205
+ let current = visibleGenrePlaylists.find((playlist) => playlist.criteria?.uuid === genre.uuid);
2206
+ while (current) {
2207
+ path.unshift(current);
2208
+ const parentUuid = current.parent?.uuid;
2209
+ current = parentUuid ? visibleGenrePlaylists.find((playlist) => playlist.uuid === parentUuid) : void 0;
2210
+ }
2211
+ return path;
2212
+ };
2213
+ const songPanel = useSongPanel({
2214
+ uuid: songUuid,
2215
+ scope,
2216
+ getBackendBaseUrl: songBaseUrl,
2217
+ getShareUrl: getShareUrlRef.current,
2218
+ renderArtistLink: resolvedRenderArtistLink,
2219
+ getGenrePath,
2220
+ onSelectGenre: (genrePlaylist) => {
2221
+ changeSong(null);
2222
+ selectGenrePlaylist(genrePlaylist);
2223
+ },
2224
+ expanded: controlledSongExpanded ?? (songUuid !== null && expandedSongUuid === songUuid),
2225
+ onExpandedChange: (on) => {
2226
+ if (controlledSongExpanded === void 0) setExpandedSongUuid(on ? songUuid : null);
2227
+ onSongExpandedChange?.(on);
2228
+ },
2229
+ onClose: () => changeSong(null)
2230
+ });
2231
+ const builtInSongPanel = overlayPanel || hasSelectedGenreNode ? void 0 : songPanel;
2232
+ const builtInArtistPanel = overlayPanel || hasSelectedGenreNode || builtInSongPanel ? void 0 : artistPanel;
2233
+ const builtInPanel = builtInSongPanel ?? builtInArtistPanel;
2234
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState7(false);
2235
+ const [uncontrolledExpanded, setUncontrolledExpanded] = useState7(false);
2066
2236
  const isCollapsed = collapsed ?? uncontrolledCollapsed;
2067
2237
  const isExpanded = expanded ?? uncontrolledExpanded;
2068
2238
  const changeCollapsed = (next) => {
@@ -2073,54 +2243,56 @@ function GenreTreeView({
2073
2243
  setUncontrolledExpanded(next);
2074
2244
  onExpandedChange?.(next);
2075
2245
  };
2076
- useEffect6(() => {
2246
+ useEffect5(() => {
2077
2247
  setUncontrolledCollapsed(false);
2078
- }, [selectedNodeId, artistUuid]);
2079
- const isDetailHidden = isCollapsed || !hasSelectedGenreNode && !activeOverlayPanel;
2080
- useLeftDetailPanelLayout(!isDetailHidden, placement === "regions");
2081
- const detailOverlay = placement === "regions" && activeOverlayPanel?.onExpandedChange ? activeOverlayPanel : void 0;
2082
- const detailExpanded = detailOverlay ? !!detailOverlay.expanded : isExpanded;
2083
- const onDetailExpandedChange = detailOverlay ? detailOverlay.onExpandedChange : changeExpanded;
2084
- const infoPanelCuration = {
2085
- renderDangerZone,
2086
- expanded: placement === "regions" ? isExpanded : expanded,
2087
- onExpandedChange: placement === "regions" ? changeExpanded : onExpandedChange,
2088
- collapsed: placement === "regions" ? isCollapsed : void 0,
2089
- hideExpandButton: placement === "regions" ? true : void 0,
2090
- overlayPanel: detailOverlay ? { ...detailOverlay, collapsed: isCollapsed, hideExpandButton: true } : activeOverlayPanel
2091
- };
2092
- const [isWheelRevealed, setIsWheelRevealed] = useState5(false);
2248
+ }, [selectedNodeId, artistUuid, songUuid]);
2249
+ const [isWheelRevealed, setIsWheelRevealed] = useState7(false);
2093
2250
  const usesHandoff = viewMode === "wheel" || viewMode === "pop-core";
2094
2251
  const isTreeReady = !isLoading && (!usesHandoff || isWheelRevealed);
2095
- const rootRef = useRef4(null);
2096
- const handlePrevious = () => {
2097
- rootRef.current?.querySelector(".search-overlay .gtv-info-panel-previous")?.click();
2098
- };
2099
- useEffect6(() => {
2100
- if (!isTreeReady || viewMode !== "pop-core" || !hasSelectedGenreNode || !selectedGenrePlaylist) return;
2101
- const root = rootRef.current;
2102
- const centerChip = root?.querySelector(".gtv-wheel-center-node .gtv-wheel-chip");
2103
- if (!centerChip || centerChip.getAttribute("aria-label") !== selectedGenrePlaylist.name) return;
2104
- const panelTitle = root?.querySelector(".gtv-info-panel-title");
2105
- if (panelTitle?.textContent?.trim().startsWith(selectedGenrePlaylist.name)) return;
2106
- centerChip.click();
2107
- }, [hasSelectedGenreNode, isTreeReady, selectedGenrePlaylist, viewMode]);
2252
+ const [resolveStyle, setResolveStyle] = useState7(null);
2253
+ const handleNodeStyleResolverChange = useCallback6(
2254
+ (resolver) => setResolveStyle(() => resolver),
2255
+ []
2256
+ );
2257
+ const panelRef = useRef4(null);
2258
+ const obscuredInset = useObscuredInset(panelRef);
2259
+ const hostPanel = { obscuredInset, onNodeStyleResolverChange: handleNodeStyleResolverChange };
2260
+ const nodeMetric = isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric;
2261
+ const treeNodes = useMemo7(() => toTreeNodes(visibleGenrePlaylists, nodeMetric), [visibleGenrePlaylists, nodeMetric]);
2262
+ const relations = useGenreNodeRelations(
2263
+ treeNodes,
2264
+ !overlayPanel && hasSelectedGenreNode ? selectedNodeId : null,
2265
+ resolveStyle
2266
+ );
2267
+ const [selectionHistory, setSelectionHistory] = useState7({
2268
+ current: selectedNodeId,
2269
+ previous: null
2270
+ });
2271
+ if (selectionHistory.current !== selectedNodeId) {
2272
+ setSelectionHistory({
2273
+ current: selectedNodeId,
2274
+ previous: selectionHistory.current ?? selectionHistory.previous
2275
+ });
2276
+ }
2277
+ const previousPlaylist = selectionHistory.previous !== null && selectionHistory.previous !== selectedNodeId ? genrePlaylistByNodeId.get(selectionHistory.previous) : void 0;
2278
+ const handlePrevious = previousPlaylist && visibleGenrePlaylists.includes(previousPlaylist) ? () => handleGenreSearchSelect(previousPlaylist) : void 0;
2108
2279
  const { showPopup, hidePopup } = usePopup5();
2109
- const [searchText, setSearchText] = useState5("");
2280
+ const [searchText, setSearchText] = useState7("");
2110
2281
  const artistSearchResult = useArtistSearch(artistBaseUrl, artistSearch ? searchText : "");
2111
- const artistExtraResults = artistSearch ? {
2282
+ const artistGroup = artistSearch ? {
2283
+ key: "artists",
2112
2284
  label: "Artists",
2113
2285
  items: artistSearchResult.artists.map((artist) => ({
2114
2286
  key: artist.uuid,
2115
- content: /* @__PURE__ */ jsxs21(Fragment9, { children: [
2116
- /* @__PURE__ */ jsxs21("span", { className: "block truncate font-medium", children: [
2287
+ content: /* @__PURE__ */ jsxs22(Fragment11, { children: [
2288
+ /* @__PURE__ */ jsxs22("span", { className: "block truncate font-medium", children: [
2117
2289
  artist.name,
2118
- artist.disambiguation && /* @__PURE__ */ jsxs21("span", { className: "font-normal text-[var(--color-neutral-500)]", children: [
2290
+ artist.disambiguation && /* @__PURE__ */ jsxs22("span", { className: "font-normal text-[var(--color-neutral-500)]", children: [
2119
2291
  " \xB7 ",
2120
2292
  artist.disambiguation
2121
2293
  ] })
2122
2294
  ] }),
2123
- artist.matchedAlias && /* @__PURE__ */ jsxs21("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [
2295
+ artist.matchedAlias && /* @__PURE__ */ jsxs22("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [
2124
2296
  "also known as ",
2125
2297
  artist.matchedAlias
2126
2298
  ] })
@@ -2133,11 +2305,32 @@ function GenreTreeView({
2133
2305
  status: artistSearchResult.isError ? "Search failed" : artistSearchResult.settled && artistSearchResult.artists.length === 0 ? "No artists found" : "",
2134
2306
  loading: artistSearchResult.pending
2135
2307
  } : void 0;
2308
+ const songSearchResult = useSongSearch(songBaseUrl, songSearch ? searchText : "");
2309
+ const songGroup = songSearch ? {
2310
+ key: "songs",
2311
+ label: "Songs",
2312
+ items: songSearchResult.songs.map((song) => ({
2313
+ key: song.uuid,
2314
+ content: /* @__PURE__ */ jsxs22("span", { className: isSongSearchResultPlayable(song) ? void 0 : "opacity-60", children: [
2315
+ /* @__PURE__ */ jsx29("span", { className: "block truncate font-medium", children: song.title }),
2316
+ /* @__PURE__ */ jsx29("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 ") })
2317
+ ] }),
2318
+ onSelect: () => {
2319
+ openSong(song.uuid);
2320
+ onSongSelect?.(song);
2321
+ }
2322
+ })),
2323
+ status: songSearchResult.isError ? "Search failed" : songSearchResult.settled && songSearchResult.songs.length === 0 ? "No songs found" : "",
2324
+ loading: songSearchResult.pending
2325
+ } : void 0;
2326
+ const extraGroups = [artistGroup, songGroup].filter((group) => group !== void 0);
2136
2327
  const handleSelectedNodeChange = (node) => {
2328
+ if (node !== null && songUuid !== null) changeSong(null);
2137
2329
  selectNode(node);
2138
2330
  };
2139
2331
  const handleGenreSearchClear = clearSelection;
2140
2332
  const handleGenreSearchSelect = (genrePlaylist) => {
2333
+ if (songUuid !== null) changeSong(null);
2141
2334
  selectGenrePlaylist(genrePlaylist);
2142
2335
  };
2143
2336
  const selectedUuid = selectedGenrePlaylist?.uuid;
@@ -2148,34 +2341,47 @@ function GenreTreeView({
2148
2341
  };
2149
2342
  const zoomInRef = useRef4(void 0);
2150
2343
  zoomInRef.current = canZoomIn ? handleGenreSearchZoomIn : void 0;
2151
- const renderInfoPanelBody = useCallback6(
2152
- (node, { toolbar, ancestors, family }) => {
2153
- const genrePlaylist = genrePlaylistByNodeId.get(node.id);
2154
- const criteria = genrePlaylist?.criteria;
2155
- if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ jsxs21(Fragment9, { children: [
2156
- toolbar,
2157
- family
2158
- ] }), aside: ancestors };
2159
- const genreUuid = criteria.uuid;
2160
- const parentPlaylist = visibleGenrePlaylists.find(({ uuid }) => uuid === genrePlaylist.parent?.uuid);
2161
- const canToggleSide = !readOnly && parentPlaylist !== void 0 && parentPlaylist.parent === null;
2162
- const side = criteria.side ?? "core";
2163
- const shareTarget = {
2164
- kind: "genre",
2165
- genrePlaylistUuid: genrePlaylist.uuid,
2166
- name: criteria.name,
2167
- scope,
2168
- artistUuid
2169
- };
2170
- const shareUrl = getShareUrlRef.current?.(shareTarget) ?? void 0;
2171
- const popSibling = canToggleSide && side === "core" ? visibleGenrePlaylists.find(
2172
- (sibling) => sibling.parent?.uuid === parentPlaylist.uuid && sibling.uuid !== genrePlaylist.uuid && sibling.criteria?.side === "pop"
2173
- ) : void 0;
2174
- return {
2175
- primary: /* @__PURE__ */ jsx28(
2344
+ const selectGenreFromPanel = (nodeId) => {
2345
+ const genrePlaylist = genrePlaylistByNodeId.get(nodeId);
2346
+ if (genrePlaylist) handleGenreSearchSelect(genrePlaylist);
2347
+ };
2348
+ const renderGenreBody = (relation) => {
2349
+ const { node } = relation;
2350
+ const family = /* @__PURE__ */ jsx29(
2351
+ GenreNodeFamily,
2352
+ {
2353
+ node,
2354
+ fill: relation.fill,
2355
+ textColor: relation.textColor,
2356
+ parentNode: relation.parentNode,
2357
+ childNodes: relation.childNodes,
2358
+ onSelectNode: selectGenreFromPanel
2359
+ }
2360
+ );
2361
+ const ancestors = /* @__PURE__ */ jsx29(GenreNodeAncestors, { ancestorNodes: relation.ancestorNodes, onSelectNode: selectGenreFromPanel });
2362
+ const genrePlaylist = genrePlaylistByNodeId.get(node.id);
2363
+ const criteria = genrePlaylist?.criteria;
2364
+ if (!genrePlaylist || !criteria) return { primary: family, aside: ancestors };
2365
+ const genreUuid = criteria.uuid;
2366
+ const parentPlaylist = visibleGenrePlaylists.find(({ uuid }) => uuid === genrePlaylist.parent?.uuid);
2367
+ const canToggleSide = !readOnly && parentPlaylist !== void 0 && parentPlaylist.parent === null;
2368
+ const side = criteria.side ?? "core";
2369
+ const shareTarget = {
2370
+ kind: "genre",
2371
+ genrePlaylistUuid: genrePlaylist.uuid,
2372
+ name: criteria.name,
2373
+ scope,
2374
+ artistUuid
2375
+ };
2376
+ const shareUrl = getShareUrlRef.current?.(shareTarget) ?? void 0;
2377
+ const popSibling = canToggleSide && side === "core" ? visibleGenrePlaylists.find(
2378
+ (sibling) => sibling.parent?.uuid === parentPlaylist.uuid && sibling.uuid !== genrePlaylist.uuid && sibling.criteria?.side === "pop"
2379
+ ) : void 0;
2380
+ return {
2381
+ primary: /* @__PURE__ */ jsxs22(Fragment11, { children: [
2382
+ /* @__PURE__ */ jsx29(
2176
2383
  GenreInfoPanelBody,
2177
2384
  {
2178
- toolbar,
2179
2385
  family,
2180
2386
  genreUuid,
2181
2387
  genrePlaylist,
@@ -2191,7 +2397,7 @@ function GenreTreeView({
2191
2397
  onAddChild: readOnly ? void 0 : () => handleGenreCreationAction(criteria),
2192
2398
  onRename: readOnly ? void 0 : () => handleGenreRenameAction(criteria),
2193
2399
  onChangeParent: readOnly ? void 0 : () => showPopup(
2194
- /* @__PURE__ */ jsx28(
2400
+ /* @__PURE__ */ jsx29(
2195
2401
  ChangeParentPopup,
2196
2402
  {
2197
2403
  genrePlaylist: { ...genrePlaylist, criteria },
@@ -2212,6 +2418,7 @@ function GenreTreeView({
2212
2418
  criteriaOverviewSchema,
2213
2419
  renderGenreDetailExtras,
2214
2420
  renderArtistLink,
2421
+ renderSongLink: resolvedRenderSongLink,
2215
2422
  artist: filterArtist,
2216
2423
  renderSummary,
2217
2424
  renderEssentialTracks,
@@ -2221,48 +2428,65 @@ function GenreTreeView({
2221
2428
  }
2222
2429
  }
2223
2430
  ),
2224
- aside: ancestors
2225
- };
2226
- },
2227
- [
2228
- genrePlaylistByNodeId,
2229
- additionalActions,
2230
- readOnly,
2231
- handleGenreCreationAction,
2232
- handleGenreRenameAction,
2233
- showPopup,
2234
- hidePopup,
2235
- onParentChanged,
2236
- visibleGenrePlaylists,
2237
- updateGenre,
2238
- playback,
2239
- filterArtist,
2240
- scope,
2241
- getBackendBaseUrl,
2242
- criteriaOverviewSchema,
2243
- renderGenreDetailExtras,
2244
- renderArtistLink,
2245
- renderSummary,
2246
- renderEssentialTracks,
2247
- renderSongLeading,
2248
- renderSongActions,
2249
- renderTracksToolbar
2250
- ]
2251
- );
2431
+ renderDangerZone?.(node)
2432
+ ] }),
2433
+ aside: ancestors
2434
+ };
2435
+ };
2252
2436
  const isRotationControlled = controlledAllowWheelRotation !== void 0;
2253
2437
  const allowWheelRotation = controlledAllowWheelRotation ?? internalAllowWheelRotation;
2254
- const detailTab = placement === "regions" && (hasSelectedGenreNode || detailOverlay) ? /* @__PURE__ */ jsx28(
2255
- PanelEdgeTab,
2438
+ let activePanel = null;
2439
+ if (overlayPanel) {
2440
+ activePanel = {
2441
+ title: overlayPanel.title,
2442
+ content: overlayPanel.content,
2443
+ aside: overlayPanel.extraContent,
2444
+ expanded: overlayPanel.expanded ?? false,
2445
+ onExpandedChange: overlayPanel.onExpandedChange,
2446
+ onClose: overlayPanel.onClose,
2447
+ compact: false
2448
+ };
2449
+ } else if (relations) {
2450
+ const { primary, aside } = renderGenreBody(relations);
2451
+ activePanel = {
2452
+ title: relations.node.name,
2453
+ content: primary,
2454
+ aside,
2455
+ expanded: isExpanded,
2456
+ onExpandedChange: changeExpanded,
2457
+ onClose: clearSelection,
2458
+ compact: true,
2459
+ headerAccent: { background: relations.fill, color: relations.textColor }
2460
+ };
2461
+ } else if (builtInPanel) {
2462
+ activePanel = {
2463
+ title: builtInPanel.title,
2464
+ content: builtInPanel.content,
2465
+ aside: builtInPanel.extraContent,
2466
+ expanded: builtInPanel.expanded,
2467
+ onExpandedChange: builtInPanel.onExpandedChange,
2468
+ onClose: builtInPanel.onClose,
2469
+ compact: true
2470
+ };
2471
+ }
2472
+ const isPanelShown = isTreeReady && activePanel !== null;
2473
+ const detailTab = placement === "regions" && activePanel?.onExpandedChange ? {
2474
+ edge: activePanel.expanded ? { label: "Shrink panel", onClick: () => activePanel.onExpandedChange?.(false) } : isCollapsed ? null : { label: "Hide panel", onClick: () => changeCollapsed(true) },
2475
+ center: activePanel.expanded ? null : isCollapsed ? { label: "Show panel", onClick: () => changeCollapsed(false) } : activePanel.onExpandedChange ? { label: "Extend panel", onClick: () => activePanel.onExpandedChange?.(true) } : null
2476
+ } : void 0;
2477
+ const expandButton = placement === "default" && activePanel?.onExpandedChange ? /* @__PURE__ */ jsx29(
2478
+ IconButton,
2256
2479
  {
2257
- side: "left",
2258
- edge: detailExpanded ? { label: "Shrink panel", onClick: () => onDetailExpandedChange?.(false) } : isCollapsed ? null : { label: "Hide panel", onClick: () => changeCollapsed(true) },
2259
- center: detailExpanded ? null : isCollapsed ? { label: "Show panel", onClick: () => changeCollapsed(false) } : onDetailExpandedChange ? { label: "Extend panel", onClick: () => onDetailExpandedChange(true) } : null
2480
+ "aria-label": activePanel.expanded ? "Shrink panel" : "Extend panel",
2481
+ style: activePanel.headerAccent && { color: "inherit" },
2482
+ onClick: () => activePanel.onExpandedChange?.(!activePanel.expanded),
2483
+ children: activePanel.expanded ? /* @__PURE__ */ jsx29(Minimize2, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsx29(Maximize2, { "aria-hidden": "true", className: "h-4 w-4" })
2260
2484
  }
2261
2485
  ) : null;
2262
2486
  const renderArtistFilter = () => {
2263
2487
  if (isLoading || !filterArtist) return null;
2264
- return /* @__PURE__ */ jsxs21("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
2265
- /* @__PURE__ */ jsx28(
2488
+ return /* @__PURE__ */ jsxs22("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
2489
+ /* @__PURE__ */ jsx29(
2266
2490
  "button",
2267
2491
  {
2268
2492
  type: "button",
@@ -2274,7 +2498,7 @@ function GenreTreeView({
2274
2498
  children: filterArtist.name
2275
2499
  }
2276
2500
  ),
2277
- /* @__PURE__ */ jsx28(
2501
+ /* @__PURE__ */ jsx29(
2278
2502
  "button",
2279
2503
  {
2280
2504
  type: "button",
@@ -2286,22 +2510,22 @@ function GenreTreeView({
2286
2510
  )
2287
2511
  ] });
2288
2512
  };
2289
- const actions = /* @__PURE__ */ jsxs21(Fragment9, { children: [
2290
- !isLoading && /* @__PURE__ */ jsx28(
2513
+ const actions = /* @__PURE__ */ jsxs22(Fragment11, { children: [
2514
+ !isLoading && /* @__PURE__ */ jsx29(
2291
2515
  GenreSearch,
2292
2516
  {
2293
2517
  genrePlaylists: visibleGenrePlaylists,
2294
2518
  onSelect: handleGenreSearchSelect,
2295
- selectedName: selectedGenrePlaylist?.name ?? builtInArtistPanel?.title ?? null,
2296
- onClear: builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
2519
+ selectedName: selectedGenrePlaylist?.name ?? (typeof builtInPanel?.title === "string" ? builtInPanel.title : null),
2520
+ onClear: builtInSongPanel ? () => changeSong(null) : builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
2297
2521
  onPrevious: handlePrevious,
2298
- onQueryChange: artistSearch ? setSearchText : void 0,
2299
- extraResults: artistExtraResults
2522
+ onQueryChange: artistSearch || songSearch ? setSearchText : void 0,
2523
+ extraGroups
2300
2524
  }
2301
2525
  ),
2302
- !isLoading && filterArtist && /* @__PURE__ */ jsx28("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
2303
- /* @__PURE__ */ jsxs21("div", { className: "contents max-md:flex max-md:w-full max-md:items-center max-md:gap-2 max-md:overflow-x-auto max-md:[scrollbar-width:none] max-md:[&>*]:shrink-0", children: [
2304
- !isLoading && !isControlled && /* @__PURE__ */ jsx28(
2526
+ !isLoading && filterArtist && /* @__PURE__ */ jsx29("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
2527
+ /* @__PURE__ */ jsxs22("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: [
2528
+ !isLoading && !isControlled && /* @__PURE__ */ jsx29(
2305
2529
  SegmentedControl2,
2306
2530
  {
2307
2531
  "aria-label": "Tree view mode",
@@ -2315,13 +2539,13 @@ function GenreTreeView({
2315
2539
  ]
2316
2540
  }
2317
2541
  ),
2318
- !isLoading && showDisplayOptions && /* @__PURE__ */ jsx28(
2542
+ !isLoading && showDisplayOptions && /* @__PURE__ */ jsx29(
2319
2543
  "div",
2320
2544
  {
2321
2545
  className: "flex items-center gap-1",
2322
2546
  role: "group",
2323
2547
  "aria-label": "Tree display options",
2324
- children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ jsx28(
2548
+ children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ jsx29(
2325
2549
  Button,
2326
2550
  {
2327
2551
  variant: allowWheelRotation ? "default" : "outline",
@@ -2332,7 +2556,7 @@ function GenreTreeView({
2332
2556
  )
2333
2557
  }
2334
2558
  ),
2335
- !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ jsx28(
2559
+ !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ jsx29(
2336
2560
  IconTextButton,
2337
2561
  {
2338
2562
  icon: Plus2,
@@ -2342,42 +2566,34 @@ function GenreTreeView({
2342
2566
  )
2343
2567
  ] })
2344
2568
  ] });
2345
- return /* @__PURE__ */ jsxs21(
2569
+ return /* @__PURE__ */ jsxs22(
2346
2570
  "div",
2347
2571
  {
2348
- ref: rootRef,
2572
+ "data-left-panel": isPanelShown && !isCollapsed ? void 0 : "hidden",
2349
2573
  className: ["genre-tree-view relative flex flex-col h-full", placement === "regions" && REGIONS_CLASS, className2].filter(Boolean).join(" "),
2350
2574
  children: [
2351
- /* @__PURE__ */ jsx28("style", { children: PREVIOUS_BUTTON_CSS }),
2352
- /* @__PURE__ */ jsx28(
2575
+ /* @__PURE__ */ jsx29(
2353
2576
  "div",
2354
2577
  {
2355
2578
  className: `actions-container absolute z-30 flex items-center gap-2 ${placement === "regions" ? "left-[calc(var(--layout-gutter)+var(--layout-panel-inset))] top-[calc(var(--layout-gutter)+var(--layout-panel-inset))] max-md:right-[calc(var(--layout-gutter)+var(--layout-panel-inset))] max-md:top-[var(--layout-band-top)] max-md:flex-col max-md:items-stretch" : "left-5 top-5"}`,
2356
2579
  children: actions
2357
2580
  }
2358
2581
  ),
2359
- !isLoading && filterArtist && /* @__PURE__ */ jsx28(
2582
+ !isLoading && filterArtist && /* @__PURE__ */ jsx29(
2360
2583
  "div",
2361
2584
  {
2362
2585
  className: `absolute left-1/2 z-30 hidden -translate-x-1/2 md:block ${placement === "regions" ? "top-[calc(var(--layout-gutter)+var(--layout-panel-inset))]" : "top-5"}`,
2363
2586
  children: renderArtistFilter()
2364
2587
  }
2365
2588
  ),
2366
- detailTab && /* @__PURE__ */ jsx28(
2367
- "div",
2368
- {
2369
- className: `absolute top-1/2 z-30 -translate-y-1/2 max-md:hidden transition-[opacity,visibility] duration-150 ease-out ${isTreeReady ? "opacity-100" : "pointer-events-none invisible opacity-0"} ${detailExpanded ? "right-[calc(var(--layout-center-right)-var(--layout-edge-tab-w))]" : isCollapsed ? "left-0" : "left-[calc(var(--layout-gutter)+var(--layout-left-w))]"}`,
2370
- children: detailTab
2371
- }
2372
- ),
2373
- /* @__PURE__ */ jsx28("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ jsxs21("div", { className: "tree-view-container relative flex-1 min-w-0 flex flex-col h-full", children: [
2374
- !isLoading && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ jsx28("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
2375
- isLoading ? /* @__PURE__ */ jsx28(GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
2589
+ /* @__PURE__ */ jsx29("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ jsxs22("div", { className: "tree-view-container relative flex-1 min-w-0 flex flex-col h-full", children: [
2590
+ !isLoading && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ jsx29("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
2591
+ isLoading ? /* @__PURE__ */ jsx29(GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
2376
2592
  // The info panel's top edge aligns with the tree's; the search bar floats `--layout-panel-inset` below it,
2377
2593
  // and the header inset (the bar's height plus that inset) extends the header's fill behind the bar,
2378
2594
  // Google Maps style; the 8px bottom padding mirrors the inset above the bar, and the hidden title's height is zeroed.
2379
2595
  // The panel's own Zoom in button is hidden: the search bar carries it.
2380
- /* @__PURE__ */ jsx28("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx28(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx28(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx28(
2596
+ /* @__PURE__ */ jsx29("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 ", children: /* @__PURE__ */ jsx29(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx29(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx29(
2381
2597
  GenrePlaylistTreeWheel,
2382
2598
  {
2383
2599
  scope,
@@ -2386,29 +2602,26 @@ function GenreTreeView({
2386
2602
  handleGenreRenameAction,
2387
2603
  additionalActions,
2388
2604
  onSelectedNodeChange: handleSelectedNodeChange,
2389
- hideInfoPanelClose: true,
2390
- hideInfoPanelTitle: true,
2391
2605
  onNodeHover: handleNodeHover,
2392
- renderInfoPanelBody,
2606
+ ...hostPanel,
2393
2607
  playback,
2394
2608
  selectedNodeId,
2395
2609
  readOnly,
2396
2610
  allowWheelRotation,
2397
2611
  sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2398
2612
  busy,
2399
- floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs21("div", { className: "flex items-center gap-2", children: [
2613
+ floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs22("div", { className: "flex items-center gap-2", children: [
2400
2614
  floatingControlsLeading,
2401
- canSizeByViews && /* @__PURE__ */ jsx28(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2615
+ canSizeByViews && /* @__PURE__ */ jsx29(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2402
2616
  ] }) : void 0,
2403
2617
  defaultViewport,
2404
2618
  viewportRestore,
2405
- onViewportChange,
2406
- ...infoPanelCuration
2619
+ onViewportChange
2407
2620
  }
2408
2621
  ) }) })
2409
2622
  ) : viewMode === "pop-core" ? (
2410
2623
  // Same as the wheel: the header inset extends its fill behind the floating search bar, Google Maps style.
2411
- /* @__PURE__ */ jsx28("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem))] [&_.gtv-info-panel-header]:pb-2 [&_.gtv-info-panel-title--hidden]:h-0 [&_.gtv-toolbar]:hidden [&_.gtv-info-panel-zoom-in]:hidden", children: /* @__PURE__ */ jsx28(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx28(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx28(
2624
+ /* @__PURE__ */ jsx29("div", { className: "tree-container search-overlay relative flex-1 w-full min-h-0 ", children: /* @__PURE__ */ jsx29(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ jsx29(GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ jsx29(
2412
2625
  GenrePlaylistTreeWheelRadialPopCore,
2413
2626
  {
2414
2627
  scope,
@@ -2417,28 +2630,25 @@ function GenreTreeView({
2417
2630
  handleGenreRenameAction,
2418
2631
  additionalActions,
2419
2632
  onSelectedNodeChange: handleSelectedNodeChange,
2420
- hideInfoPanelClose: true,
2421
- hideInfoPanelTitle: true,
2422
2633
  onNodeHover: handleNodeHover,
2423
- renderInfoPanelBody,
2634
+ ...hostPanel,
2424
2635
  playback,
2425
2636
  selectedNodeId,
2426
2637
  readOnly,
2427
2638
  allowWheelRotation,
2428
2639
  sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2429
2640
  busy,
2430
- floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs21("div", { className: "flex items-center gap-2", children: [
2641
+ floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ jsxs22("div", { className: "flex items-center gap-2", children: [
2431
2642
  floatingControlsLeading,
2432
- canSizeByViews && /* @__PURE__ */ jsx28(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2643
+ canSizeByViews && /* @__PURE__ */ jsx29(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2433
2644
  ] }) : void 0,
2434
2645
  defaultViewport,
2435
2646
  viewportRestore,
2436
2647
  onViewportChange,
2437
- ...infoPanelCuration,
2438
2648
  ...focus
2439
2649
  }
2440
2650
  ) }) })
2441
- ) : viewMode === "outline" ? /* @__PURE__ */ jsx28("div", { className: "tree-container flex-1 min-h-0 w-full overflow-y-auto relative [--gtv-info-panel-header-inset:calc(var(--layout-search-h,2.5rem)+var(--layout-panel-inset,0.5rem)+0.75rem)] [&_.gtv-toolbar]:hidden", children: /* @__PURE__ */ jsx28(
2651
+ ) : viewMode === "outline" ? /* @__PURE__ */ jsx29("div", { className: "tree-container flex-1 min-h-0 w-full overflow-y-auto relative", children: /* @__PURE__ */ jsx29(
2442
2652
  GenrePlaylistTreeWheelRadialPopCore,
2443
2653
  {
2444
2654
  outline: true,
@@ -2448,22 +2658,19 @@ function GenreTreeView({
2448
2658
  handleGenreRenameAction,
2449
2659
  additionalActions,
2450
2660
  onSelectedNodeChange: handleSelectedNodeChange,
2451
- hideInfoPanelClose: true,
2452
- hideInfoPanelTitle: true,
2453
2661
  onNodeHover: handleNodeHover,
2454
- renderInfoPanelBody,
2662
+ ...hostPanel,
2455
2663
  playback,
2456
2664
  selectedNodeId,
2457
- readOnly,
2458
- ...infoPanelCuration
2665
+ readOnly
2459
2666
  }
2460
- ) }) : /* @__PURE__ */ jsx28("div", { className: "tree-container relative flex flex-col gap-4 w-full pt-14 overflow-x-auto overflow-y-auto text-[var(--color-text)] [&_.gtv-toolbar]:hidden", children: Object.entries(groupedGenrePlaylistsByRoot).map(
2461
- ([uuid, genrePlaylistTreePerRoot], rootIndex) => {
2462
- return /* @__PURE__ */ jsx28(
2667
+ ) }) : /* @__PURE__ */ jsx29("div", { className: "tree-container relative flex flex-col gap-4 w-full pt-14 overflow-x-auto overflow-y-auto text-[var(--color-text)]", children: Object.entries(groupedGenrePlaylistsByRoot).map(
2668
+ ([uuid, genrePlaylistTreePerRoot]) => {
2669
+ return /* @__PURE__ */ jsx29(
2463
2670
  "div",
2464
2671
  {
2465
2672
  className: "tree-per-root-container relative shrink-0 h-[28rem] mt-2 mr-16 p-2 bg-[var(--color-neutral-100)] rounded-lg",
2466
- children: /* @__PURE__ */ jsx28("div", { className: "graph-container relative z-10 h-full", children: /* @__PURE__ */ jsx28(
2673
+ children: /* @__PURE__ */ jsx29("div", { className: "graph-container relative z-10 h-full", children: /* @__PURE__ */ jsx29(
2467
2674
  GenrePlaylistTreePerRoot,
2468
2675
  {
2469
2676
  scope,
@@ -2473,26 +2680,45 @@ function GenreTreeView({
2473
2680
  handleGenreRenameAction,
2474
2681
  additionalActions,
2475
2682
  onSelectedNodeChange: handleSelectedNodeChange,
2476
- hideInfoPanelClose: true,
2477
- hideInfoPanelTitle: true,
2478
2683
  onNodeHover: handleNodeHover,
2479
- renderInfoPanelBody,
2684
+ ...hostPanel,
2480
2685
  playback,
2481
2686
  selectedNodeId: uuid === selectedGenrePlaylist?.root.uuid ? selectedNodeId : null,
2482
2687
  readOnly,
2483
2688
  wheelZoom: "modifier",
2484
2689
  defaultViewport: uuid === selectedGenrePlaylist?.root.uuid ? defaultViewport : void 0,
2485
2690
  viewportRestore: uuid === selectedGenrePlaylist?.root.uuid && uuid === restoreRootUuid ? viewportRestore : void 0,
2486
- onViewportChange: uuid === selectedGenrePlaylist?.root.uuid ? onViewportChange : void 0,
2487
- ...infoPanelCuration,
2488
- overlayPanel: rootIndex === 0 ? activeOverlayPanel : void 0
2691
+ onViewportChange: uuid === selectedGenrePlaylist?.root.uuid ? onViewportChange : void 0
2489
2692
  }
2490
2693
  ) })
2491
2694
  },
2492
2695
  uuid
2493
2696
  );
2494
2697
  }
2495
- ) })
2698
+ ) }),
2699
+ /* @__PURE__ */ jsx29(
2700
+ DetailPanel,
2701
+ {
2702
+ ref: panelRef,
2703
+ side: "left",
2704
+ title: activePanel?.title ?? "",
2705
+ hideTitle: activePanel?.compact,
2706
+ hideClose: activePanel?.compact,
2707
+ headerAccent: activePanel?.headerAccent,
2708
+ headerActions: expandButton,
2709
+ onClose: activePanel?.onClose ?? noop,
2710
+ collapsed: isCollapsed || !isTreeReady || !activePanel,
2711
+ expanded: activePanel?.expanded,
2712
+ onExpandedChange: activePanel?.onExpandedChange,
2713
+ aside: activePanel?.aside,
2714
+ edgeTab: detailTab,
2715
+ headerClassName: activePanel?.compact ? "pt-[calc(var(--layout-search-h)+var(--layout-panel-inset)+0.5rem)]" : void 0,
2716
+ children: activePanel?.aside && !activePanel.expanded ? /* @__PURE__ */ jsxs22(Fragment11, { children: [
2717
+ activePanel.content,
2718
+ activePanel.aside
2719
+ ] }) : activePanel?.content
2720
+ }
2721
+ )
2496
2722
  ] }) })
2497
2723
  ]
2498
2724
  }
@@ -2503,17 +2729,18 @@ function GenreTreeView({
2503
2729
  import { useMemo as useMemo8 } from "react";
2504
2730
  import {
2505
2731
  ACCENT_TEXT_COLOR,
2732
+ GenreNodeAncestors as GenreNodeAncestors2,
2733
+ GenreNodeFamily as GenreNodeFamily2,
2506
2734
  getGenreTreeColor as getGenreTreeColor2,
2507
- InfoPanel,
2508
2735
  TEXT_COLOR,
2509
2736
  tintSurface
2510
2737
  } from "@behindthemusictree/genre-tree-view";
2511
- import { Skeleton as Skeleton5 } from "@behindthemusictree/ui";
2738
+ import { DetailPanel as DetailPanel2, IconButton as IconButton2, Skeleton as Skeleton7 } from "@behindthemusictree/ui";
2739
+ import { Maximize2 as Maximize22, Minimize2 as Minimize22 } from "lucide-react";
2512
2740
  import {
2513
2741
  useFetchGenreDetail
2514
2742
  } from "@behindthemusictree/app-kit/genre-tree";
2515
- import { jsx as jsx29, jsxs as jsxs22 } from "react/jsx-runtime";
2516
- var ITEM_COUNT_LABEL = "Songs";
2743
+ import { jsx as jsx30, jsxs as jsxs23 } from "react/jsx-runtime";
2517
2744
  var RELATIVE_ITEM_COUNT = 1;
2518
2745
  function toNode(genre, parentId) {
2519
2746
  return {
@@ -2537,6 +2764,7 @@ function GenreDetailPanel({
2537
2764
  collapsed,
2538
2765
  expanded,
2539
2766
  onExpandedChange,
2767
+ edgeTab,
2540
2768
  extras
2541
2769
  }) {
2542
2770
  const { data: detail, isPending, isError } = useFetchGenreDetail(genreUuid, scope, getBackendBaseUrl);
@@ -2549,7 +2777,7 @@ function GenreDetailPanel({
2549
2777
  fill: tintSurface(rootColor),
2550
2778
  textColor: TEXT_COLOR
2551
2779
  });
2552
- const ancestors = [...detail.ascendants].sort((a, b) => b.degree - a.degree);
2780
+ const ancestors2 = [...detail.ascendants].sort((a, b) => b.degree - a.degree);
2553
2781
  return {
2554
2782
  node: {
2555
2783
  id: detail.uuid,
@@ -2561,57 +2789,72 @@ function GenreDetailPanel({
2561
2789
  fill: rootColor,
2562
2790
  parentNode: detail.parent ? relative(detail.parent, null) : null,
2563
2791
  childNodes: detail.children.map((child) => relative(child, detail.uuid)),
2564
- ancestorNodes: ancestors.map(
2565
- ({ ascendant }, index) => relative(ascendant, index === 0 ? null : ancestors[index - 1].ascendant.uuid)
2792
+ ancestorNodes: ancestors2.map(
2793
+ ({ ascendant }, index) => relative(ascendant, index === 0 ? null : ancestors2[index - 1].ascendant.uuid)
2566
2794
  )
2567
2795
  };
2568
2796
  }, [detail]);
2569
2797
  if (isError) {
2570
- return /* @__PURE__ */ jsx29("div", { role: "alert", className: "p-4 text-sm text-[var(--color-text)]", children: "This genre could not be loaded." });
2798
+ return /* @__PURE__ */ jsx30("div", { role: "alert", className: "p-4 text-sm text-[var(--color-text)]", children: "This genre could not be loaded." });
2571
2799
  }
2572
2800
  if (isPending || !detail || !view) {
2573
- return /* @__PURE__ */ jsxs22("div", { className: "flex flex-col gap-3 p-4", "aria-busy": "true", "data-testid": "genre-detail-panel-skeleton", children: [
2574
- /* @__PURE__ */ jsx29(Skeleton5, { className: "h-6 w-2/3" }),
2575
- /* @__PURE__ */ jsx29(Skeleton5, { className: "h-4 w-full" }),
2576
- /* @__PURE__ */ jsx29(Skeleton5, { className: "h-4 w-3/4" })
2801
+ return /* @__PURE__ */ jsxs23("div", { className: "flex flex-col gap-3 p-4", "aria-busy": "true", "data-testid": "genre-detail-panel-skeleton", children: [
2802
+ /* @__PURE__ */ jsx30(Skeleton7, { className: "h-6 w-2/3" }),
2803
+ /* @__PURE__ */ jsx30(Skeleton7, { className: "h-4 w-full" }),
2804
+ /* @__PURE__ */ jsx30(Skeleton7, { className: "h-4 w-3/4" })
2577
2805
  ] });
2578
2806
  }
2579
- const renderBody = (node, sections) => ({
2580
- primary: /* @__PURE__ */ jsx29(
2581
- GenreInfoPanelBody,
2582
- {
2583
- toolbar: sections.toolbar,
2584
- family: sections.family,
2585
- genreUuid: detail.uuid,
2586
- genrePlaylist: { ...detail.criteriaPlaylist, tracksCount: detail.tracksCount },
2587
- wikidataId: detail.wikidataId,
2588
- scope,
2589
- getBackendBaseUrl,
2590
- playback,
2591
- band: { node, onZoomIn, shareUrl, shareTarget: shareUrl ? shareTarget : void 0 },
2592
- extras
2593
- }
2594
- ),
2595
- aside: null
2596
- });
2597
- return /* @__PURE__ */ jsx29(
2598
- InfoPanel,
2807
+ const selectGenre = (nodeId) => onSelectGenre(nodeId);
2808
+ const family = /* @__PURE__ */ jsx30(
2809
+ GenreNodeFamily2,
2599
2810
  {
2600
2811
  node: view.node,
2601
2812
  fill: view.fill,
2602
2813
  textColor: ACCENT_TEXT_COLOR,
2603
2814
  parentNode: view.parentNode,
2604
2815
  childNodes: view.childNodes,
2605
- ancestorNodes: view.ancestorNodes,
2816
+ onSelectNode: selectGenre
2817
+ }
2818
+ );
2819
+ const ancestors = /* @__PURE__ */ jsx30(GenreNodeAncestors2, { ancestorNodes: view.ancestorNodes, onSelectNode: selectGenre });
2820
+ return /* @__PURE__ */ jsxs23(
2821
+ DetailPanel2,
2822
+ {
2606
2823
  side: "left",
2824
+ title: view.node.name,
2825
+ headerAccent: { background: view.fill, color: ACCENT_TEXT_COLOR },
2826
+ headerActions: onExpandedChange ? /* @__PURE__ */ jsx30(
2827
+ IconButton2,
2828
+ {
2829
+ "aria-label": expanded ? "Shrink panel" : "Extend panel",
2830
+ style: { color: "inherit" },
2831
+ onClick: () => onExpandedChange(!expanded),
2832
+ children: expanded ? /* @__PURE__ */ jsx30(Minimize22, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsx30(Maximize22, { "aria-hidden": "true", className: "h-4 w-4" })
2833
+ }
2834
+ ) : void 0,
2607
2835
  onClose,
2608
- onSelectNode: (nodeId) => onSelectGenre(nodeId),
2609
- itemCountLabel: ITEM_COUNT_LABEL,
2610
- renderInfoPanelBody: renderBody,
2836
+ collapsed,
2611
2837
  expanded,
2612
2838
  onExpandedChange,
2613
- collapsed,
2614
- hideExpandButton: onExpandedChange === void 0
2839
+ edgeTab,
2840
+ aside: ancestors,
2841
+ children: [
2842
+ /* @__PURE__ */ jsx30(
2843
+ GenreInfoPanelBody,
2844
+ {
2845
+ family,
2846
+ genreUuid: detail.uuid,
2847
+ genrePlaylist: { ...detail.criteriaPlaylist, tracksCount: detail.tracksCount },
2848
+ wikidataId: detail.wikidataId,
2849
+ scope,
2850
+ getBackendBaseUrl,
2851
+ playback,
2852
+ band: { node: view.node, onZoomIn, shareUrl, shareTarget: shareUrl ? shareTarget : void 0 },
2853
+ extras
2854
+ }
2855
+ ),
2856
+ !expanded && ancestors
2857
+ ]
2615
2858
  }
2616
2859
  );
2617
2860
  }
@@ -2625,13 +2868,13 @@ import {
2625
2868
  } from "@behindthemusictree/genre-tree-view";
2626
2869
 
2627
2870
  // src/genre-tree/track-list-sidebar/TrackListSidebar.tsx
2628
- import { useEffect as useEffect7, useLayoutEffect as useLayoutEffect2, useRef as useRef5 } from "react";
2871
+ import { useEffect as useEffect6, useLayoutEffect as useLayoutEffect2, useRef as useRef5 } from "react";
2629
2872
  import { ListMusic } from "lucide-react";
2630
- import { useTrackList as useTrackList5 } from "@behindthemusictree/app-kit/genre-tree";
2873
+ import { useTrackList as useTrackList6 } from "@behindthemusictree/app-kit/genre-tree";
2631
2874
  import { TrackListOriginType as TrackListOriginType2 } from "@behindthemusictree/app-kit/genre-tree";
2632
- import { useTrackNoun as useTrackNoun6 } from "@behindthemusictree/app-kit/track-noun";
2633
- import { jsx as jsx30, jsxs as jsxs23 } from "react/jsx-runtime";
2634
- var useBrowserLayoutEffect = typeof window === "undefined" ? useEffect7 : useLayoutEffect2;
2875
+ import { useTrackNoun as useTrackNoun3 } from "@behindthemusictree/app-kit/track-noun";
2876
+ import { jsx as jsx31, jsxs as jsxs24 } from "react/jsx-runtime";
2877
+ var useBrowserLayoutEffect = typeof window === "undefined" ? useEffect6 : useLayoutEffect2;
2635
2878
  function TrackListSidebar({
2636
2879
  className: className2 = "",
2637
2880
  renderDuration,
@@ -2640,10 +2883,11 @@ function TrackListSidebar({
2640
2883
  isCollapsed = false,
2641
2884
  onExpand,
2642
2885
  onMinimize,
2643
- renderArtistLink
2886
+ renderArtistLink,
2887
+ renderSongLink
2644
2888
  }) {
2645
- const { trackList, loadMore, autoAdvanceScrollRequest } = useTrackList5();
2646
- const trackNoun = useTrackNoun6();
2889
+ const { trackList, loadMore, autoAdvanceScrollRequest } = useTrackList6();
2890
+ const trackNoun = useTrackNoun3();
2647
2891
  const trackListRef = useRef5(null);
2648
2892
  const previousOriginRef = useRef5(trackList?.origin ?? null);
2649
2893
  const handledAutoAdvanceRequestIdRef = useRef5(null);
@@ -2653,7 +2897,7 @@ function TrackListSidebar({
2653
2897
  const userScrollIntentTimerRef = useRef5(null);
2654
2898
  const loadMoreSentinelRef = useRef5(null);
2655
2899
  const hasMore = !!trackList && trackList.nextPage !== null;
2656
- useEffect7(() => {
2900
+ useEffect6(() => {
2657
2901
  const sentinel = loadMoreSentinelRef.current;
2658
2902
  if (!hasMore || !sentinel) return;
2659
2903
  const observer = new IntersectionObserver((entries) => {
@@ -2673,11 +2917,11 @@ function TrackListSidebar({
2673
2917
  programmaticScrollTopRef.current = null;
2674
2918
  if (trackListRef.current) trackListRef.current.scrollTop = 0;
2675
2919
  }, [trackList?.origin]);
2676
- useEffect7(() => {
2920
+ useEffect6(() => {
2677
2921
  const list = trackListRef.current;
2678
2922
  if (!isCollapsed && list?.scrollTop === 0) shouldFollowPlaybackRef.current = true;
2679
2923
  }, [isCollapsed]);
2680
- useEffect7(
2924
+ useEffect6(
2681
2925
  () => () => {
2682
2926
  if (userScrollIntentTimerRef.current !== null) clearTimeout(userScrollIntentTimerRef.current);
2683
2927
  },
@@ -2731,25 +2975,25 @@ function TrackListSidebar({
2731
2975
  }
2732
2976
  };
2733
2977
  const positionClasses = layout === "inline" ? "relative w-full flex h-full flex-col" : "fixed right-0 w-[50rem] flex flex-col";
2734
- return trackList ? /* @__PURE__ */ jsxs23(
2978
+ return trackList ? /* @__PURE__ */ jsxs24(
2735
2979
  "div",
2736
2980
  {
2737
2981
  "data-theme": "dark",
2738
2982
  className: `track-list-sidebar ${positionClasses} rounded-2xl bg-[var(--color-surface)] pb-1 ${className2}`,
2739
2983
  style: layout === "fixed" ? { top: "63.5px", maxHeight: "calc(100vh - 63.5px - 79px)" } : void 0,
2740
2984
  children: [
2741
- /* @__PURE__ */ jsxs23("div", { className: "header flex h-16 items-center px-4 py-2 text-[var(--color-text)]", children: [
2742
- /* @__PURE__ */ jsxs23("div", { className: "min-w-0 flex flex-1 flex-col justify-center", children: [
2743
- /* @__PURE__ */ jsxs23("div", { className: "origin flex text-xl", children: [
2744
- /* @__PURE__ */ jsx30("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
2745
- /* @__PURE__ */ jsx30("div", { className: "name-container flex flex-col justify-center items-center text-[var(--color-text)] font-bold pr-2 max-w-[27.5rem]", children: /* @__PURE__ */ jsx30("div", { className: "name max-w-full truncate", children: trackList.origin.label }) })
2985
+ /* @__PURE__ */ jsxs24("div", { className: "header flex h-16 items-center px-4 py-2 text-[var(--color-text)]", children: [
2986
+ /* @__PURE__ */ jsxs24("div", { className: "min-w-0 flex flex-1 flex-col justify-center", children: [
2987
+ /* @__PURE__ */ jsxs24("div", { className: "origin flex text-xl", children: [
2988
+ /* @__PURE__ */ jsx31("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
2989
+ /* @__PURE__ */ jsx31("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__ */ jsx31("div", { className: "name max-w-full truncate", children: trackList.origin.label }) })
2746
2990
  ] }),
2747
- /* @__PURE__ */ jsxs23("div", { className: "info flex items-center text-sm text-[var(--color-text-muted)]", children: [
2991
+ /* @__PURE__ */ jsxs24("div", { className: "info flex items-center text-sm text-[var(--color-text-muted)]", children: [
2748
2992
  trackList.origin.type === TrackListOriginType2.GENRE_PLAYLIST ? "Genre \u2022 " : trackList.origin.type === TrackListOriginType2.ARTIST ? "Artist \u2022 " : `${trackNoun.singular} playlist \u2022 `,
2749
2993
  `${trackList.total} ${trackList.total > 1 ? trackNoun.plural : trackNoun.singular}`
2750
2994
  ] })
2751
2995
  ] }),
2752
- (isCollapsed && onExpand || !isCollapsed && onMinimize) && /* @__PURE__ */ jsx30(
2996
+ (isCollapsed && onExpand || !isCollapsed && onMinimize) && /* @__PURE__ */ jsx31(
2753
2997
  "button",
2754
2998
  {
2755
2999
  type: "button",
@@ -2758,11 +3002,11 @@ function TrackListSidebar({
2758
3002
  "aria-expanded": !isCollapsed,
2759
3003
  className: `ml-2 flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-[var(--color-surface-muted)] ${isCollapsed ? "text-[var(--color-text-muted)]" : "text-white"}`,
2760
3004
  onClick: isCollapsed ? onExpand : onMinimize,
2761
- children: /* @__PURE__ */ jsx30(ListMusic, { "aria-hidden": "true", className: "h-5 w-5" })
3005
+ children: /* @__PURE__ */ jsx31(ListMusic, { "aria-hidden": "true", className: "h-5 w-5" })
2762
3006
  }
2763
3007
  )
2764
3008
  ] }),
2765
- !isCollapsed && /* @__PURE__ */ jsxs23(
3009
+ !isCollapsed && /* @__PURE__ */ jsxs24(
2766
3010
  "ul",
2767
3011
  {
2768
3012
  ref: trackListRef,
@@ -2772,17 +3016,18 @@ function TrackListSidebar({
2772
3016
  onPointerDown: markUserScrollIntent,
2773
3017
  className: layout === "inline" ? "track-list flex-1 min-h-0 overflow-auto list-none p-0 m-0" : "track-list flex-1 min-h-0 overflow-auto list-none p-0 m-0",
2774
3018
  children: [
2775
- trackList.tracks.map((track, index) => /* @__PURE__ */ jsx30("li", { "data-track-uuid": track.uuid, children: /* @__PURE__ */ jsx30(
3019
+ trackList.tracks.map((track, index) => /* @__PURE__ */ jsx31("li", { "data-track-uuid": track.uuid, children: /* @__PURE__ */ jsx31(
2776
3020
  TrackItem,
2777
3021
  {
2778
3022
  track,
2779
3023
  position: index + 1,
2780
3024
  renderDuration,
2781
3025
  renderActions,
2782
- renderArtistLink
3026
+ renderArtistLink,
3027
+ renderSongLink
2783
3028
  }
2784
3029
  ) }, track.uuid)),
2785
- hasMore && /* @__PURE__ */ jsx30("li", { ref: loadMoreSentinelRef, "aria-hidden": "true", className: "h-px" })
3030
+ hasMore && /* @__PURE__ */ jsx31("li", { ref: loadMoreSentinelRef, "aria-hidden": "true", className: "h-px" })
2786
3031
  ]
2787
3032
  }
2788
3033
  )
@@ -2792,21 +3037,22 @@ function TrackListSidebar({
2792
3037
  }
2793
3038
 
2794
3039
  // src/genre-tree/PlayerPanel.tsx
2795
- import { useEffect as useEffect8, useState as useState6 } from "react";
2796
- import { twMerge as twMerge2 } from "tailwind-merge";
3040
+ import { useEffect as useEffect7, useState as useState8 } from "react";
3041
+ import { twMerge } from "tailwind-merge";
2797
3042
  import {
2798
- usePlayer as usePlayer10,
3043
+ usePlayer as usePlayer11,
2799
3044
  PlayerVideoSurface
2800
3045
  } from "@behindthemusictree/app-kit/player";
2801
3046
  import {
2802
3047
  useTrackListSidebarVisibility
2803
3048
  } from "@behindthemusictree/app-kit/genre-tree";
3049
+ import { PanelEdgeTab } from "@behindthemusictree/ui";
2804
3050
 
2805
3051
  // src/genre-tree/PlayerControls.tsx
2806
- import { Pause as Pause2, Play as Play3, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
2807
- import { formatTime, useTrackList as useTrackList6 } from "@behindthemusictree/app-kit/genre-tree";
2808
- import { PlayStates as PlayStates3, useCurrentTime, usePlayer as usePlayer9 } from "@behindthemusictree/app-kit/player";
2809
- import { jsx as jsx31, jsxs as jsxs24 } from "react/jsx-runtime";
3052
+ import { Pause as Pause3, Play as Play4, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
3053
+ import { formatTime, useTrackList as useTrackList7 } from "@behindthemusictree/app-kit/genre-tree";
3054
+ import { PlayStates as PlayStates4, useCurrentTime, usePlayer as usePlayer10 } from "@behindthemusictree/app-kit/player";
3055
+ import { jsx as jsx32, jsxs as jsxs25 } from "react/jsx-runtime";
2810
3056
  function PlayerControls() {
2811
3057
  const {
2812
3058
  duration,
@@ -2817,23 +3063,23 @@ function PlayerControls() {
2817
3063
  seekTo,
2818
3064
  setVolume,
2819
3065
  volume
2820
- } = usePlayer9();
2821
- const { hasNextTrack, hasPreviousTrack, playNextTrack, playPreviousTrack } = useTrackList6();
3066
+ } = usePlayer10();
3067
+ const { hasNextTrack, hasPreviousTrack, playNextTrack, playPreviousTrack } = useTrackList7();
2822
3068
  const currentTime = useCurrentTime();
2823
- const isBusy = isLoading || playState === PlayStates3.LOADING;
3069
+ const isBusy = isLoading || playState === PlayStates4.LOADING;
2824
3070
  const isReady = !!playerTrackObject?.isReady && !isBusy && !playerTrackObject.loadError;
2825
3071
  const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
2826
3072
  const safeCurrentTime = Number.isFinite(currentTime) ? Math.max(0, Math.min(currentTime, safeDuration)) : 0;
2827
- const isPlaying = playState === PlayStates3.PLAYING;
2828
- return /* @__PURE__ */ jsxs24(
3073
+ const isPlaying = playState === PlayStates4.PLAYING;
3074
+ return /* @__PURE__ */ jsxs25(
2829
3075
  "section",
2830
3076
  {
2831
3077
  "aria-label": "Player controls",
2832
3078
  className: "player-controls flex shrink-0 flex-col gap-2 border-b border-zinc-800 px-3 py-2 text-zinc-200",
2833
3079
  children: [
2834
- /* @__PURE__ */ jsxs24("div", { className: "flex min-w-0 items-center gap-2 text-xs tabular-nums text-zinc-400", children: [
2835
- /* @__PURE__ */ jsx31("span", { "aria-label": "Elapsed time", children: formatTime(safeCurrentTime) }),
2836
- /* @__PURE__ */ jsx31(
3080
+ /* @__PURE__ */ jsxs25("div", { className: "flex min-w-0 items-center gap-2 text-xs tabular-nums text-zinc-400", children: [
3081
+ /* @__PURE__ */ jsx32("span", { "aria-label": "Elapsed time", children: formatTime(safeCurrentTime) }),
3082
+ /* @__PURE__ */ jsx32(
2837
3083
  "input",
2838
3084
  {
2839
3085
  "aria-label": "Seek",
@@ -2847,11 +3093,11 @@ function PlayerControls() {
2847
3093
  onChange: (event) => seekTo(Number(event.currentTarget.value))
2848
3094
  }
2849
3095
  ),
2850
- /* @__PURE__ */ jsx31("span", { "aria-label": "Duration", children: formatTime(safeDuration) })
3096
+ /* @__PURE__ */ jsx32("span", { "aria-label": "Duration", children: formatTime(safeDuration) })
2851
3097
  ] }),
2852
- /* @__PURE__ */ jsxs24("div", { className: "grid min-h-9 grid-cols-[1fr_auto_1fr] items-center gap-3", children: [
2853
- /* @__PURE__ */ jsxs24("div", { className: "col-start-2 flex items-center justify-center gap-2", children: [
2854
- /* @__PURE__ */ jsx31(
3098
+ /* @__PURE__ */ jsxs25("div", { className: "grid min-h-9 grid-cols-[1fr_auto_1fr] items-center gap-3", children: [
3099
+ /* @__PURE__ */ jsxs25("div", { className: "col-start-2 flex items-center justify-center gap-2", children: [
3100
+ /* @__PURE__ */ jsx32(
2855
3101
  "button",
2856
3102
  {
2857
3103
  type: "button",
@@ -2860,10 +3106,10 @@ function PlayerControls() {
2860
3106
  disabled: !isReady || !hasPreviousTrack,
2861
3107
  onClick: playPreviousTrack,
2862
3108
  className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-zinc-300 hover:bg-zinc-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-40",
2863
- children: /* @__PURE__ */ jsx31(SkipBack, { "aria-hidden": "true", className: "h-4 w-4" })
3109
+ children: /* @__PURE__ */ jsx32(SkipBack, { "aria-hidden": "true", className: "h-4 w-4" })
2864
3110
  }
2865
3111
  ),
2866
- /* @__PURE__ */ jsx31(
3112
+ /* @__PURE__ */ jsx32(
2867
3113
  "button",
2868
3114
  {
2869
3115
  type: "button",
@@ -2872,10 +3118,10 @@ function PlayerControls() {
2872
3118
  disabled: !isReady,
2873
3119
  onClick: handlePlayPauseAction,
2874
3120
  className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white text-zinc-950 hover:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-40",
2875
- children: isPlaying ? /* @__PURE__ */ jsx31(Pause2, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" }) : /* @__PURE__ */ jsx31(Play3, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" })
3121
+ children: isPlaying ? /* @__PURE__ */ jsx32(Pause3, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" }) : /* @__PURE__ */ jsx32(Play4, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" })
2876
3122
  }
2877
3123
  ),
2878
- /* @__PURE__ */ jsx31(
3124
+ /* @__PURE__ */ jsx32(
2879
3125
  "button",
2880
3126
  {
2881
3127
  type: "button",
@@ -2884,14 +3130,14 @@ function PlayerControls() {
2884
3130
  disabled: !isReady || !hasNextTrack,
2885
3131
  onClick: playNextTrack,
2886
3132
  className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-zinc-300 hover:bg-zinc-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-40",
2887
- children: /* @__PURE__ */ jsx31(SkipForward, { "aria-hidden": "true", className: "h-4 w-4" })
3133
+ children: /* @__PURE__ */ jsx32(SkipForward, { "aria-hidden": "true", className: "h-4 w-4" })
2888
3134
  }
2889
3135
  )
2890
3136
  ] }),
2891
- /* @__PURE__ */ jsxs24("label", { className: "col-start-3 flex w-[4.2rem] shrink-0 items-center gap-[0.35rem] justify-self-end text-zinc-400 sm:w-[4.9rem]", children: [
2892
- volume === 0 ? /* @__PURE__ */ jsx31(VolumeX, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }) : /* @__PURE__ */ jsx31(Volume2, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }),
2893
- /* @__PURE__ */ jsx31("span", { className: "sr-only", children: "Volume" }),
2894
- /* @__PURE__ */ jsx31(
3137
+ /* @__PURE__ */ jsxs25("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: [
3138
+ volume === 0 ? /* @__PURE__ */ jsx32(VolumeX, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }) : /* @__PURE__ */ jsx32(Volume2, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }),
3139
+ /* @__PURE__ */ jsx32("span", { className: "sr-only", children: "Volume" }),
3140
+ /* @__PURE__ */ jsx32(
2895
3141
  "input",
2896
3142
  {
2897
3143
  "aria-label": "Volume",
@@ -2913,7 +3159,7 @@ function PlayerControls() {
2913
3159
  }
2914
3160
 
2915
3161
  // src/genre-tree/PlayerPanel.tsx
2916
- import { jsx as jsx32, jsxs as jsxs25 } from "react/jsx-runtime";
3162
+ import { jsx as jsx33, jsxs as jsxs26 } from "react/jsx-runtime";
2917
3163
  var PANEL_ID = "player-panel";
2918
3164
  var DEFAULT_EXTENDED_WIDTH = "calc(100vw - var(--layout-center-left) - var(--layout-gutter))";
2919
3165
  function PlayerPanel({
@@ -2922,16 +3168,17 @@ function PlayerPanel({
2922
3168
  expanded,
2923
3169
  onExpandedChange,
2924
3170
  extendedWidth = DEFAULT_EXTENDED_WIDTH,
2925
- renderArtistLink
3171
+ renderArtistLink,
3172
+ renderSongLink
2926
3173
  }) {
2927
- const { playerTrackObject } = usePlayer10();
3174
+ const { playerTrackObject } = usePlayer11();
2928
3175
  const { toggleTrackListSidebar, isTrackListSidebarVisible } = useTrackListSidebarVisibility();
2929
- const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState6("");
3176
+ const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState8("");
2930
3177
  const loadedTrackTitle = playerTrackObject?.track.title;
2931
- useEffect8(() => {
3178
+ useEffect7(() => {
2932
3179
  if (loadedTrackTitle) setLastLoadedTrackTitle(loadedTrackTitle);
2933
3180
  }, [loadedTrackTitle]);
2934
- const [uncontrolledExtended, setUncontrolledExtended] = useState6(false);
3181
+ const [uncontrolledExtended, setUncontrolledExtended] = useState8(false);
2935
3182
  const isExtendRequested = expanded ?? uncontrolledExtended;
2936
3183
  const setIsExtendRequested = (next) => {
2937
3184
  setUncontrolledExtended(next);
@@ -2952,11 +3199,11 @@ function PlayerPanel({
2952
3199
  onClick: () => setIsExtendRequested(true),
2953
3200
  expanded: false
2954
3201
  } : null;
2955
- return /* @__PURE__ */ jsxs25(
3202
+ return /* @__PURE__ */ jsxs26(
2956
3203
  "div",
2957
3204
  {
2958
3205
  id: PANEL_ID,
2959
- className: twMerge2(
3206
+ className: twMerge(
2960
3207
  "fixed right-[var(--layout-gutter)] top-[var(--layout-band-top)] z-40 max-h-[calc(100vh-var(--layout-band-top)-3.5rem)]",
2961
3208
  !isExtended && "w-[var(--layout-right-w)]",
2962
3209
  isTrackListSidebarVisible && "h-[calc(100vh-var(--layout-band-top)-3.5rem)]",
@@ -2965,7 +3212,7 @@ function PlayerPanel({
2965
3212
  ),
2966
3213
  style: isExtended ? { width: extendedWidth } : void 0,
2967
3214
  children: [
2968
- /* @__PURE__ */ jsx32(
3215
+ /* @__PURE__ */ jsx33(
2969
3216
  PanelEdgeTab,
2970
3217
  {
2971
3218
  side: "right",
@@ -2975,34 +3222,35 @@ function PlayerPanel({
2975
3222
  className: "absolute right-full top-1/2 -translate-y-1/2"
2976
3223
  }
2977
3224
  ),
2978
- /* @__PURE__ */ jsxs25(
3225
+ /* @__PURE__ */ jsxs26(
2979
3226
  "div",
2980
3227
  {
2981
- className: twMerge2(
3228
+ className: twMerge(
2982
3229
  "flex h-full max-h-[inherit] overflow-hidden rounded-[var(--radius-panel,1rem)] bg-zinc-950 text-zinc-100 shadow-[0_1px_2px_rgba(0,0,0,0.5),0_2px_6px_2px_rgba(0,0,0,0.3)]",
2983
3230
  isExtended ? "flex-row" : "flex-col"
2984
3231
  ),
2985
3232
  children: [
2986
- /* @__PURE__ */ jsxs25("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
2987
- /* @__PURE__ */ jsx32("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-zinc-800 p-2", children: /* @__PURE__ */ jsx32("span", { className: "min-w-0 truncate text-sm font-semibold", children: loadedTrackTitle || lastLoadedTrackTitle }) }),
2988
- /* @__PURE__ */ jsxs25("div", { className: "relative w-full shrink-0", children: [
2989
- /* @__PURE__ */ jsx32(PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
2990
- playerTrackObject?.loadError && /* @__PURE__ */ jsx32("span", { className: "absolute bottom-2 left-2 right-2 text-xs text-red-400", children: playerTrackObject.loadError })
3233
+ /* @__PURE__ */ jsxs26("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
3234
+ /* @__PURE__ */ jsx33("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-zinc-800 p-2", children: /* @__PURE__ */ jsx33("span", { className: "min-w-0 truncate text-sm font-semibold", children: loadedTrackTitle || lastLoadedTrackTitle }) }),
3235
+ /* @__PURE__ */ jsxs26("div", { className: "relative w-full shrink-0", children: [
3236
+ /* @__PURE__ */ jsx33(PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
3237
+ playerTrackObject?.loadError && /* @__PURE__ */ jsx33("span", { className: "absolute bottom-2 left-2 right-2 text-xs text-red-400", children: playerTrackObject.loadError })
2991
3238
  ] }),
2992
- /* @__PURE__ */ jsx32(PlayerControls, {})
3239
+ /* @__PURE__ */ jsx33(PlayerControls, {})
2993
3240
  ] }),
2994
- /* @__PURE__ */ jsx32(
3241
+ /* @__PURE__ */ jsx33(
2995
3242
  TrackListSidebar,
2996
3243
  {
2997
3244
  layout: "inline",
2998
3245
  isCollapsed: !isTrackListSidebarVisible,
2999
3246
  onExpand: toggleTrackListSidebar,
3000
3247
  onMinimize: hideSongList,
3001
- className: twMerge2(
3248
+ className: twMerge(
3002
3249
  "min-h-0 min-w-0 flex-1",
3003
3250
  isExtended && "border-l border-zinc-800"
3004
3251
  ),
3005
- renderArtistLink
3252
+ renderArtistLink,
3253
+ renderSongLink
3006
3254
  }
3007
3255
  )
3008
3256
  ]
@@ -3017,11 +3265,11 @@ function PlayerPanel({
3017
3265
  import { v4 as uuidv4 } from "uuid";
3018
3266
  import { AiFillStar, AiOutlineStar } from "react-icons/ai";
3019
3267
  import { FORM_RATING_NULL_VALUE } from "@behindthemusictree/app-kit/genre-tree";
3020
- import { jsx as jsx33, jsxs as jsxs26 } from "react/jsx-runtime";
3268
+ import { jsx as jsx34, jsxs as jsxs27 } from "react/jsx-runtime";
3021
3269
  function Rating({ rating, handleChange }) {
3022
3270
  const instanceId = uuidv4();
3023
- return /* @__PURE__ */ jsx33("div", { className: "flex items-center", children: [...Array(6)].map((_, i) => /* @__PURE__ */ jsxs26("div", { className: `relative ${i === 0 ? "mr-4" : "mr-1"}`, children: [
3024
- /* @__PURE__ */ jsx33(
3271
+ return /* @__PURE__ */ jsx34("div", { className: "flex items-center", children: [...Array(6)].map((_, i) => /* @__PURE__ */ jsxs27("div", { className: `relative ${i === 0 ? "mr-4" : "mr-1"}`, children: [
3272
+ /* @__PURE__ */ jsx34(
3025
3273
  "input",
3026
3274
  {
3027
3275
  id: `star-${instanceId}-${i}`,
@@ -3033,16 +3281,16 @@ function Rating({ rating, handleChange }) {
3033
3281
  onChange: handleChange
3034
3282
  }
3035
3283
  ),
3036
- /* @__PURE__ */ jsx33("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ jsxs26("div", { className: "flex", children: [
3037
- /* @__PURE__ */ jsx33(
3284
+ /* @__PURE__ */ jsx34("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ jsxs27("div", { className: "flex", children: [
3285
+ /* @__PURE__ */ jsx34(
3038
3286
  AiOutlineStar,
3039
3287
  {
3040
3288
  className: `w-7 h-7 absolute top-0 left-0 ${rating === FORM_RATING_NULL_VALUE ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
3041
3289
  }
3042
3290
  ),
3043
- /* @__PURE__ */ jsx33("span", { className: "flex-grow w-8" }),
3291
+ /* @__PURE__ */ jsx34("span", { className: "flex-grow w-8" }),
3044
3292
  " "
3045
- ] }) : /* @__PURE__ */ jsx33(
3293
+ ] }) : /* @__PURE__ */ jsx34(
3046
3294
  AiFillStar,
3047
3295
  {
3048
3296
  className: `w-7 h-7 absolute top-0 left-0 ${(rating ?? -1) >= i ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
@@ -3057,8 +3305,6 @@ export {
3057
3305
  GenreSearch,
3058
3306
  GenreNodeActionBand,
3059
3307
  GenreInfoPanelBody,
3060
- PanelEdgeTab,
3061
- useLeftDetailPanelLayout,
3062
3308
  TrackPositionPlayPause,
3063
3309
  TrackItem,
3064
3310
  GenreTreeView,
@@ -3071,4 +3317,4 @@ export {
3071
3317
  GenreTreeViewSkeleton2 as GenreTreeViewSkeleton,
3072
3318
  GenreTreeOutlineSkeleton
3073
3319
  };
3074
- //# sourceMappingURL=chunk-36YVZ6JR.js.map
3320
+ //# sourceMappingURL=chunk-AQASG5KT.js.map