@behindthemusictree/app-kit-ui 24.0.1 → 28.0.2

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.
@@ -37,23 +37,24 @@ __export(genre_tree_exports, {
37
37
  module.exports = __toCommonJS(genre_tree_exports);
38
38
 
39
39
  // src/genre-tree/GenreTreeView.tsx
40
- var import_react10 = require("react");
41
- var import_lucide_react8 = require("lucide-react");
40
+ var import_react13 = require("react");
41
+ var import_react_query3 = require("@tanstack/react-query");
42
+ var import_lucide_react11 = require("lucide-react");
42
43
  var import_ui13 = require("@behindthemusictree/ui");
43
44
  var import_genre_tree_view4 = require("@behindthemusictree/genre-tree-view");
45
+ var import_genre_tree16 = require("@behindthemusictree/app-kit/genre-tree");
44
46
  var import_genre_tree17 = require("@behindthemusictree/app-kit/genre-tree");
45
- var import_genre_tree18 = require("@behindthemusictree/app-kit/genre-tree");
46
47
  var import_player10 = require("@behindthemusictree/app-kit/player");
47
- var import_popup4 = require("@behindthemusictree/app-kit/popup");
48
+ var import_popup5 = require("@behindthemusictree/app-kit/popup");
48
49
 
49
50
  // src/genre-tree/GenreDetailExtras.tsx
50
51
  var import_ui4 = require("@behindthemusictree/ui");
51
- var import_genre_tree8 = require("@behindthemusictree/app-kit/genre-tree");
52
+ var import_genre_tree7 = require("@behindthemusictree/app-kit/genre-tree");
52
53
 
53
54
  // src/genre-tree/ClampedText.tsx
54
55
  var import_react = require("react");
55
56
  var import_jsx_runtime = require("react/jsx-runtime");
56
- function ClampedText({ children, className = "" }) {
57
+ function ClampedText({ children, className: className2 = "" }) {
57
58
  const ref = (0, import_react.useRef)(null);
58
59
  const [expanded, setExpanded] = (0, import_react.useState)(false);
59
60
  const [overflows, setOverflows] = (0, import_react.useState)(false);
@@ -63,7 +64,7 @@ function ClampedText({ children, className = "" }) {
63
64
  setOverflows(element.scrollHeight > element.clientHeight);
64
65
  }, [children, expanded]);
65
66
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
66
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { ref, className: `${className} ${expanded ? "" : "line-clamp-3"}`, children }),
67
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { ref, className: `${className2} ${expanded ? "" : "line-clamp-3"}`, children }),
67
68
  (overflows || expanded) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
68
69
  "button",
69
70
  {
@@ -78,10 +79,10 @@ function ClampedText({ children, className = "" }) {
78
79
  }
79
80
 
80
81
  // src/genre-tree/GenrePlaylistTracks.tsx
81
- var import_react2 = require("react");
82
+ var import_react3 = require("react");
82
83
  var import_react_query = require("@tanstack/react-query");
83
84
  var import_zod = require("zod");
84
- var import_fa3 = require("react-icons/fa");
85
+ var import_fa = require("react-icons/fa");
85
86
  var import_ui3 = require("@behindthemusictree/ui");
86
87
  var import_auth = require("@behindthemusictree/app-kit/auth");
87
88
  var import_transport = require("@behindthemusictree/app-kit/transport");
@@ -96,30 +97,47 @@ var import_lucide_react2 = require("lucide-react");
96
97
 
97
98
  // src/popup/BasePopup.tsx
98
99
  var import_react_dom = require("react-dom");
100
+ var import_react2 = require("react");
99
101
 
100
102
  // src/popup/PopupTitle.tsx
101
103
  var import_lucide_react = require("lucide-react");
102
104
  var import_ui = require("@behindthemusictree/ui");
103
105
  var import_jsx_runtime2 = require("react/jsx-runtime");
104
- function PopupTitle({ title, onClose, isDismissable, icon: Icon }) {
105
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "popup-title flex items-center justify-between bg-black p-4 rounded-t-lg", children: [
106
+ function PopupTitle({
107
+ title,
108
+ onClose,
109
+ isDismissable,
110
+ icon: Icon,
111
+ className: className2 = "",
112
+ titleClassName = "",
113
+ closeButtonClassName = ""
114
+ }) {
115
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: `popup-title flex items-center justify-between bg-black p-4 rounded-t-lg ${className2}`, children: [
106
116
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-center gap-2", children: [
107
117
  Icon && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Icon, { className: "h-5 w-5 text-white" }),
108
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h3", { id: "popup-title", className: "text-white font-semibold", children: title })
118
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h3", { id: "popup-title", className: `text-white font-semibold ${titleClassName}`, children: title })
109
119
  ] }),
110
- isDismissable && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_ui.Button, { onClick: onClose, className: "h-8 w-8 text-white hover:text-white/80", "aria-label": "Close popup", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react.X, { className: "h-4 w-4" }) })
120
+ isDismissable && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
121
+ import_ui.Button,
122
+ {
123
+ onClick: onClose,
124
+ className: `h-8 w-8 text-white hover:text-white/80 ${closeButtonClassName}`,
125
+ "aria-label": "Close popup",
126
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react.X, { className: "h-4 w-4" })
127
+ }
128
+ )
111
129
  ] });
112
130
  }
113
131
 
114
132
  // src/popup/PopupButtons.tsx
115
133
  var import_jsx_runtime3 = require("react/jsx-runtime");
116
- function PopupButtons({ children, alignment = "center", className = "" }) {
134
+ function PopupButtons({ children, alignment = "center", className: className2 = "" }) {
117
135
  const alignmentClasses = {
118
136
  left: "justify-start",
119
137
  center: "justify-center",
120
138
  right: "justify-end"
121
139
  };
122
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `flex gap-3 ${alignmentClasses[alignment]} ${className}`, children });
140
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `flex gap-3 ${alignmentClasses[alignment]} ${className2}`, children });
123
141
  }
124
142
 
125
143
  // src/popup/BasePopup.tsx
@@ -146,8 +164,13 @@ function BasePopup(props) {
146
164
  type = "default",
147
165
  icon,
148
166
  isDismissable = true,
149
- className,
167
+ className: className2,
168
+ overlayClassName = "",
169
+ titleClassName = "",
170
+ titleTextClassName = "",
171
+ closeButtonClassName = "",
150
172
  fitContent = false,
173
+ contentClassName = "",
151
174
  showOkButton = false,
152
175
  showCancelButton = false,
153
176
  okButtonText = "OK",
@@ -156,8 +179,49 @@ function BasePopup(props) {
156
179
  okButtonDisabled = false,
157
180
  onCancel,
158
181
  buttonAlignment = "center",
159
- topOffset = 0
182
+ topOffset = 0,
183
+ trapFocus = false,
184
+ closeOnEscape = false,
185
+ closeOnBackdrop = false
160
186
  } = props;
187
+ const dialogRef = (0, import_react2.useRef)(null);
188
+ (0, import_react2.useEffect)(() => {
189
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
190
+ const dialog = dialogRef.current;
191
+ const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
192
+ const getFocusable = () => Array.from(dialog?.querySelectorAll(focusableSelector) ?? []).filter(
193
+ (element) => !element.hasAttribute("hidden") && element.getAttribute("aria-hidden") !== "true"
194
+ );
195
+ if (trapFocus) (getFocusable()[0] ?? dialog)?.focus();
196
+ const handleKeyDown = (event) => {
197
+ if (event.key === "Escape" && closeOnEscape && isDismissable) {
198
+ event.preventDefault();
199
+ onClose?.();
200
+ return;
201
+ }
202
+ if (!trapFocus || event.key !== "Tab") return;
203
+ const focusable = getFocusable();
204
+ if (focusable.length === 0) {
205
+ event.preventDefault();
206
+ dialog?.focus();
207
+ return;
208
+ }
209
+ const first = focusable[0];
210
+ const last = focusable[focusable.length - 1];
211
+ if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) {
212
+ event.preventDefault();
213
+ last.focus();
214
+ } else if (!event.shiftKey && (document.activeElement === last || !dialog?.contains(document.activeElement))) {
215
+ event.preventDefault();
216
+ first.focus();
217
+ }
218
+ };
219
+ if (trapFocus || closeOnEscape) document.addEventListener("keydown", handleKeyDown);
220
+ return () => {
221
+ if (trapFocus || closeOnEscape) document.removeEventListener("keydown", handleKeyDown);
222
+ if (trapFocus && previousFocus?.isConnected) previousFocus.focus();
223
+ };
224
+ }, [closeOnEscape, isDismissable, onClose, trapFocus]);
161
225
  const baseClasses = "bg-white rounded-lg";
162
226
  const renderButtons = () => {
163
227
  if (!showOkButton && !showCancelButton) return null;
@@ -171,7 +235,7 @@ function BasePopup(props) {
171
235
  const popupComponent = /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
172
236
  "div",
173
237
  {
174
- className: "fixed z-50 flex items-center justify-center bg-transparent",
238
+ className: `fixed z-50 flex items-center justify-center bg-transparent ${overlayClassName}`,
175
239
  style: {
176
240
  top: topOffset,
177
241
  left: 0,
@@ -181,15 +245,29 @@ function BasePopup(props) {
181
245
  role: "dialog",
182
246
  "aria-modal": "true",
183
247
  "aria-labelledby": "popup-title",
248
+ onClick: closeOnBackdrop && isDismissable ? onClose : void 0,
184
249
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
185
250
  "div",
186
251
  {
187
- className: `${baseClasses} ${fitContent ? typeClasses[type].replace("w-full", "w-fit") : typeClasses[type]} ${className || ""}`,
252
+ ref: dialogRef,
253
+ tabIndex: trapFocus ? -1 : void 0,
254
+ className: `${baseClasses} ${fitContent ? typeClasses[type].replace("w-full", "w-fit") : typeClasses[type]} ${className2 || ""}`,
188
255
  onClick: (e) => e.stopPropagation(),
189
256
  children: [
190
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PopupTitle, { title, onClose: onClose ?? (() => {
191
- }), isDismissable, icon }),
192
- /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: `popup-content rounded-b-lg p-4 ${contentBgClasses[type] ?? ""}`, children: [
257
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
258
+ PopupTitle,
259
+ {
260
+ title,
261
+ onClose: onClose ?? (() => {
262
+ }),
263
+ isDismissable,
264
+ icon,
265
+ className: titleClassName,
266
+ titleClassName: titleTextClassName,
267
+ closeButtonClassName
268
+ }
269
+ ),
270
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: `popup-content rounded-b-lg p-4 ${contentBgClasses[type] ?? ""} ${contentClassName}`, children: [
193
271
  children,
194
272
  renderButtons()
195
273
  ] })
@@ -228,53 +306,43 @@ function InternalErrorPopup({ errorCode, contactEmail, ...rest }) {
228
306
  );
229
307
  }
230
308
 
231
- // src/genre-tree/UnplayableReason.tsx
232
- var import_fa = require("react-icons/fa");
233
- var import_genre_tree = require("@behindthemusictree/app-kit/genre-tree");
234
- var import_jsx_runtime6 = require("react/jsx-runtime");
235
- function UnplayableReason({ reason }) {
236
- if (reason !== import_genre_tree.NO_VIDEO_LABEL) return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_jsx_runtime6.Fragment, { children: reason });
237
- return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { role: "img", "aria-label": reason, className: "relative inline-flex shrink-0 items-center", children: [
238
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_fa.FaYoutube, { size: 16, "aria-hidden": "true" }),
239
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { "aria-hidden": "true", className: "absolute left-1/2 top-1/2 h-0.5 w-[130%] -translate-x-1/2 -translate-y-1/2 -rotate-45 rounded bg-current" })
240
- ] });
241
- }
242
-
243
309
  // src/genre-tree/GenrePlaylistTracks.tsx
244
310
  var import_transport4 = require("@behindthemusictree/app-kit/transport");
311
+ var import_genre_tree2 = require("@behindthemusictree/app-kit/genre-tree");
245
312
  var import_genre_tree3 = require("@behindthemusictree/app-kit/genre-tree");
246
313
  var import_genre_tree4 = require("@behindthemusictree/app-kit/genre-tree");
247
314
  var import_genre_tree5 = require("@behindthemusictree/app-kit/genre-tree");
248
- var import_genre_tree6 = require("@behindthemusictree/app-kit/genre-tree");
249
315
 
250
316
  // src/genre-tree/MusicBrainzRecordingLink.tsx
251
- var import_fa2 = require("react-icons/fa");
252
- var import_genre_tree2 = require("@behindthemusictree/app-kit/genre-tree");
253
- var import_jsx_runtime7 = require("react/jsx-runtime");
254
- function MusicBrainzRecordingLink({ song }) {
255
- const url = (0, import_genre_tree2.musicBrainzRecordingUrl)(song);
256
- if (!url) return null;
257
- return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
317
+ var import_genre_tree = require("@behindthemusictree/app-kit/genre-tree");
318
+ var import_jsx_runtime6 = require("react/jsx-runtime");
319
+ function MusicBrainzRecordingLink({
320
+ song,
321
+ children,
322
+ className: className2 = ""
323
+ }) {
324
+ const url = (0, import_genre_tree.musicBrainzRecordingUrl)(song);
325
+ if (!url) return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_jsx_runtime6.Fragment, { children });
326
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
258
327
  "a",
259
328
  {
260
329
  href: url,
261
330
  target: "_blank",
262
331
  rel: "noopener noreferrer",
263
332
  "aria-label": `View ${song.title} on MusicBrainz`,
264
- title: "View on MusicBrainz",
265
- className: "inline-flex shrink-0 items-center opacity-50 hover:opacity-100 focus-visible:opacity-100",
333
+ className: className2,
266
334
  onClick: (event) => event.stopPropagation(),
267
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_fa2.FaExternalLinkAlt, { size: 10 })
335
+ children
268
336
  }
269
337
  );
270
338
  }
271
339
 
272
340
  // src/genre-tree/GenrePlaylistTracks.tsx
273
- var import_genre_tree7 = require("@behindthemusictree/app-kit/genre-tree");
341
+ var import_genre_tree6 = require("@behindthemusictree/app-kit/genre-tree");
274
342
  var import_track_noun = require("@behindthemusictree/app-kit/track-noun");
275
- var import_jsx_runtime8 = require("react/jsx-runtime");
343
+ var import_jsx_runtime7 = require("react/jsx-runtime");
276
344
  var CATALOGUE_PAGE_SIZE = 100;
277
- var SongPageSchema = (0, import_transport2.PaginatedResponseSchema)(import_genre_tree5.SongDetailedSchema).extend({
345
+ var SongPageSchema = (0, import_transport2.PaginatedResponseSchema)(import_genre_tree4.SongDetailedSchema).extend({
278
346
  overallTotal: import_zod.z.number().nullable(),
279
347
  page: import_zod.z.number().nullable(),
280
348
  totalPages: import_zod.z.number().nullable()
@@ -300,7 +368,7 @@ function GenrePlaylistTracks({
300
368
  renderTracksToolbar,
301
369
  artist = null
302
370
  }) {
303
- const { fetchGenrePlaylistTracksPage, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist } = (0, import_genre_tree6.useTrackList)();
371
+ const { fetchGenrePlaylistTracksPage, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist } = (0, import_genre_tree5.useTrackList)();
304
372
  const { session, sessionRestored } = (0, import_auth.useSession)();
305
373
  const { playerTrackObject, playState, handlePlayPauseAction } = (0, import_player.usePlayer)();
306
374
  const { showPopup } = (0, import_popup.usePopup)();
@@ -308,11 +376,11 @@ function GenrePlaylistTracks({
308
376
  const { uuid } = genrePlaylist;
309
377
  const { fetch } = (0, import_transport.useFetchWrapper)(getBackendBaseUrl);
310
378
  const { data, isPending, isError, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = (0, import_react_query.useInfiniteQuery)({
311
- queryKey: scope === "me" ? import_genre_tree3.genrePlaylistQueryKeys.me.tracks(uuid) : artist ? [...import_genre_tree4.libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid), "artist", artist.uuid] : import_genre_tree4.libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid),
379
+ queryKey: scope === "me" ? import_genre_tree2.genrePlaylistQueryKeys.me.tracks(uuid) : artist ? [...import_genre_tree3.libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid), "artist", artist.uuid] : import_genre_tree3.libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid),
312
380
  queryFn: async ({ pageParam }) => {
313
381
  if (scope === "me") return fetchGenrePlaylistTracksPage(uuid, scope, Number(pageParam));
314
382
  const response = await fetch(
315
- import_genre_tree4.libraryEndpoints.reference.songs.list(),
383
+ import_genre_tree3.libraryEndpoints.reference.songs.list(),
316
384
  true,
317
385
  false,
318
386
  {},
@@ -325,8 +393,8 @@ function GenrePlaylistTracks({
325
393
  getNextPageParam: (lastPage) => lastPage.nextPage ?? void 0,
326
394
  enabled: scope !== "me" || sessionRestored && !!session?.accessToken
327
395
  });
328
- const sentinelRef = (0, import_react2.useRef)(null);
329
- (0, import_react2.useEffect)(() => {
396
+ const sentinelRef = (0, import_react3.useRef)(null);
397
+ (0, import_react3.useEffect)(() => {
330
398
  const sentinel = sentinelRef.current;
331
399
  if (!hasNextPage || isFetchingNextPage || !sentinel) return;
332
400
  const observer = new IntersectionObserver((entries) => {
@@ -344,90 +412,89 @@ function GenrePlaylistTracks({
344
412
  const started = artist ? playNewTrackListFromArtist(artist, scope, { startSongUuid, ordering: "title", genreUuid }) : playNewTrackListFromGenrePlaylist(genrePlaylist, scope, { startSongUuid });
345
413
  started.catch((error) => {
346
414
  console.error("Failed to play genre playlist:", error);
347
- showPopup(/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(InternalErrorPopup, { errorCode: import_transport4.ErrorCode.CLIENT_UNKNOWN }));
415
+ showPopup(/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(InternalErrorPopup, { errorCode: import_transport4.ErrorCode.CLIENT_UNKNOWN }));
348
416
  });
349
417
  };
350
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-children", children: [
351
- /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { className: "gtv-info-panel-children-title", children: [
418
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "gtv-info-panel-children", children: [
419
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("span", { className: "gtv-info-panel-children-title", children: [
352
420
  (0, import_track_noun.capitalize)(trackNoun.plural),
353
421
  total !== void 0 && ` (${total})`
354
422
  ] }),
355
423
  renderTracksToolbar?.(),
356
- isPending ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_ui3.Skeleton, { className: "h-4 w-3/4", "aria-busy": "true" }) : isError && tracks.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("p", { role: "alert", children: [
424
+ isPending ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_ui3.Skeleton, { className: "h-4 w-3/4", "aria-busy": "true" }) : isError && tracks.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("p", { role: "alert", children: [
357
425
  "Couldn't load ",
358
426
  trackNoun.plural,
359
427
  ".",
360
428
  " ",
361
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", className: "underline", onClick: () => void refetch(), children: "Retry" })
362
- ] }) : tracks.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { children: "\u2014" }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("ul", { className: "mt-1 max-h-64 overflow-y-auto text-[0.8125rem] leading-[1.45] [container-type:inline-size]", "aria-label": `${genrePlaylist.name} ${trackNoun.plural}`, children: [
429
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", className: "underline", onClick: () => void refetch(), children: "Retry" })
430
+ ] }) : tracks.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { children: "\u2014" }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("ul", { className: "mt-1 max-h-64 overflow-y-auto text-[0.8125rem] leading-[1.45] [container-type:inline-size]", "aria-label": `${genrePlaylist.name} ${trackNoun.plural}`, children: [
363
431
  tracks.map((track, index) => {
364
432
  const label = track.artists?.length ? `${track.title} \u2014 ${track.artists.map((artist2) => artist2.name).join(", ")}` : track.title;
365
433
  const isCurrent = playerTrackObject?.track.id === track.uuid;
366
434
  const isPlaying = isCurrent && playState === import_player2.PlayStates.PLAYING;
367
- const text = /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { className: TEXT_CELL, children: [
368
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: TITLE_CELL, children: track.title }),
369
- track.artists?.length ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: SEPARATOR, children: " \u2014 " }) : null,
370
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: COLUMN_CELL, children: track.artists?.length ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_genre_tree7.ArtistNames, { artists: track.artists, renderArtistLink }) : null }),
371
- track.album && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: ALBUM_CELL, children: track.album.name })
435
+ const text = /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("span", { className: TEXT_CELL, children: [
436
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: TITLE_CELL, children: (0, import_genre_tree4.isSong)(track) ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }),
437
+ track.artists?.length ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: SEPARATOR, children: " \u2014 " }) : null,
438
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: COLUMN_CELL, children: track.artists?.length ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_genre_tree6.ArtistNames, { artists: track.artists, renderArtistLink }) : null }),
439
+ track.album && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: ALBUM_CELL, children: track.album.name })
372
440
  ] });
373
441
  const leading = renderSongLeading?.(track, index);
374
442
  const actions = renderSongActions?.(track);
375
- const unplayableReason = (0, import_genre_tree5.isSong)(track) ? (0, import_genre_tree5.songUnplayableReason)(track) : null;
376
- if (unplayableReason) {
377
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("li", { className: "flex min-h-8 items-center gap-1 px-1 opacity-50", title: unplayableReason, children: [
443
+ const isUnplayable = (0, import_genre_tree4.isSong)(track) && (0, import_genre_tree4.songUnplayableReason)(track) !== null;
444
+ if (isUnplayable) {
445
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("li", { className: "flex min-h-8 items-center gap-1 px-1 opacity-50", children: [
378
446
  leading,
379
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
447
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
380
448
  "button",
381
449
  {
382
450
  type: "button",
383
451
  disabled: true,
384
452
  "aria-disabled": "true",
385
- "aria-label": `${label} \u2014 ${unplayableReason}`,
453
+ "aria-label": label,
386
454
  className: "flex h-6 w-6 shrink-0 items-center justify-center rounded-full opacity-0 cursor-not-allowed",
387
- children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_fa3.FaPlay, { size: 10 })
455
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_fa.FaPlay, { size: 10 })
388
456
  }
389
457
  ),
390
458
  text,
391
- (0, import_genre_tree5.isSong)(track) && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(MusicBrainzRecordingLink, { song: track }),
392
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "shrink-0 text-xs", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(UnplayableReason, { reason: unplayableReason }) }),
393
459
  actions
394
460
  ] }, track.uuid);
395
461
  }
396
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
462
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
397
463
  "li",
398
464
  {
399
465
  className: `group flex min-h-8 items-center gap-1 rounded px-1 hover:bg-[var(--color-neutral-100)] ${isCurrent ? "font-medium text-[var(--color-primary)]" : ""}`,
400
466
  children: [
401
467
  leading,
402
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
468
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
403
469
  "button",
404
470
  {
405
471
  type: "button",
406
472
  "aria-label": `${isPlaying ? "Pause" : "Play"} ${label}`,
407
473
  className: `flex h-6 w-6 shrink-0 items-center justify-center rounded-full cursor-pointer group-hover:opacity-100 focus-visible:opacity-100 ${isCurrent && playState !== import_player2.PlayStates.STOPPED ? "opacity-100" : "opacity-0 [@media(pointer:coarse)]:opacity-100"}`,
408
474
  onClick: () => isCurrent ? handlePlayPauseAction() : play(track.uuid),
409
- children: isCurrent && playState === import_player2.PlayStates.LOADING ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_ui3.RingLoader, { size: 10 }) : isPlaying ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_fa3.FaPause, { size: 10 }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_fa3.FaPlay, { size: 10 })
475
+ children: isCurrent && playState === import_player2.PlayStates.LOADING ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_ui3.RingLoader, { size: 10 }) : isPlaying ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_fa.FaPause, { size: 10 }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_fa.FaPlay, { size: 10 })
410
476
  }
411
477
  ),
412
478
  text,
413
- (0, import_genre_tree5.isSong)(track) && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(MusicBrainzRecordingLink, { song: track }),
414
479
  actions
415
480
  ]
416
481
  },
417
482
  track.uuid
418
483
  );
419
484
  }),
420
- hasNextPage && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("li", { ref: sentinelRef, "aria-hidden": "true", className: "h-4" })
485
+ hasNextPage && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("li", { ref: sentinelRef, "aria-hidden": "true", className: "h-4" })
421
486
  ] })
422
487
  ] });
423
488
  }
424
489
 
425
490
  // src/genre-tree/GenreDetailExtras.tsx
426
491
  var import_track_noun2 = require("@behindthemusictree/app-kit/track-noun");
427
- var import_jsx_runtime9 = require("react/jsx-runtime");
492
+ var import_si = require("react-icons/si");
493
+ var import_jsx_runtime8 = require("react/jsx-runtime");
428
494
  function GenreDetailExtras({
429
495
  genreUuid,
430
496
  genrePlaylist,
497
+ wikidataId,
431
498
  scope,
432
499
  getBackendBaseUrl,
433
500
  criteriaOverviewSchema,
@@ -439,59 +506,70 @@ function GenreDetailExtras({
439
506
  ...trackSlots
440
507
  }) {
441
508
  const trackNoun = (0, import_track_noun2.useTrackNoun)();
442
- const { data: overview, isPending } = (0, import_genre_tree8.useFetchGenreOverview)(
509
+ const { data: overview, isPending } = (0, import_genre_tree7.useFetchGenreOverview)(
443
510
  genreUuid,
444
511
  scope,
445
512
  getBackendBaseUrl,
446
513
  criteriaOverviewSchema
447
514
  );
448
515
  if (isPending) {
449
- return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
450
- /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "gtv-info-panel-children", "aria-busy": "true", "data-testid": "genre-detail-extras-skeleton", children: [
451
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
452
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_ui4.Skeleton, { className: "h-4 w-3/4" })
516
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_jsx_runtime8.Fragment, { children: [
517
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-children", "aria-busy": "true", "data-testid": "genre-detail-extras-skeleton", children: [
518
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
519
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_ui4.Skeleton, { className: "h-4 w-3/4" })
453
520
  ] }),
454
521
  family
455
522
  ] });
456
523
  }
457
- if (!overview) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_jsx_runtime9.Fragment, { children: family });
458
- const { summary, description, summarySourceUrl, essentialSongs } = overview;
459
- return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
460
- description && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: "px-3.5 py-2.5", children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(ClampedText, { className: "text-[0.8125rem] leading-[1.45] text-[var(--color-text-muted)]", children: description }, genreUuid) }),
461
- renderSummary ? renderSummary(overview) : /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
462
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
463
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(ClampedText, { className: "mt-1", children: summary ?? "\u2014" }, genreUuid),
464
- summarySourceUrl && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
465
- "a",
466
- {
467
- href: summarySourceUrl,
468
- target: "_blank",
469
- rel: "noopener noreferrer",
470
- className: "mt-1 inline-block text-xs text-[var(--color-text-muted)] underline hover:text-[var(--color-text)]",
471
- children: "Source: Wikipedia"
472
- }
473
- )
524
+ if (!overview) return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_jsx_runtime8.Fragment, { children: family });
525
+ const { summary, summarySourceUrl, essentialSongs } = overview;
526
+ const wikidataUrl = wikidataId ? `https://www.wikidata.org/wiki/${encodeURIComponent(wikidataId)}` : null;
527
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_jsx_runtime8.Fragment, { children: [
528
+ (summarySourceUrl || wikidataUrl) && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
529
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "gtv-info-panel-children-title", children: "Links" }),
530
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs", children: [
531
+ summarySourceUrl && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
532
+ "a",
533
+ {
534
+ href: summarySourceUrl,
535
+ target: "_blank",
536
+ rel: "noopener noreferrer",
537
+ className: "inline-flex items-center gap-1.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]",
538
+ children: [
539
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_si.SiWikipedia, { "aria-hidden": "true", className: "h-3.5 w-3.5" }),
540
+ "Wikipedia"
541
+ ]
542
+ }
543
+ ),
544
+ wikidataUrl && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
545
+ "a",
546
+ {
547
+ href: wikidataUrl,
548
+ target: "_blank",
549
+ rel: "noopener noreferrer",
550
+ className: "inline-flex items-center gap-1.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]",
551
+ children: [
552
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_si.SiWikidata, { "aria-hidden": "true", className: "h-3.5 w-3.5" }),
553
+ "Wikidata"
554
+ ]
555
+ }
556
+ )
557
+ ] })
558
+ ] }),
559
+ renderSummary ? renderSummary(overview) : /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
560
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "gtv-info-panel-children-title", children: "Summary" }),
561
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ClampedText, { className: "mt-1", children: summary ?? "\u2014" }, genreUuid)
474
562
  ] }),
475
- renderSummary && summarySourceUrl && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
476
- "a",
477
- {
478
- href: summarySourceUrl,
479
- target: "_blank",
480
- rel: "noopener noreferrer",
481
- className: "mx-3.5 mb-2.5 inline-block text-xs text-[var(--color-text-muted)] underline hover:text-[var(--color-text)]",
482
- children: "Source: Wikipedia"
483
- }
484
- ),
485
563
  family,
486
564
  renderGenreDetailExtras?.(overview),
487
- renderEssentialTracks ? renderEssentialTracks(overview) : essentialSongs && /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
488
- /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("span", { className: "gtv-info-panel-children-title", children: [
565
+ renderEssentialTracks ? renderEssentialTracks(overview) : essentialSongs && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-children text-[0.8125rem] leading-[1.45] text-[var(--color-text)]", children: [
566
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { className: "gtv-info-panel-children-title", children: [
489
567
  "Essential ",
490
568
  trackNoun.plural
491
569
  ] }),
492
- essentialSongs.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { className: "mt-1 divide-y divide-[var(--color-border)]", children: essentialSongs.map((track) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("li", { className: "py-1", children: track.title }, track.uuid)) }) : /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "mt-1", children: "\u2014" })
570
+ essentialSongs.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("ul", { className: "mt-1 divide-y divide-[var(--color-border)]", children: essentialSongs.map((track) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("li", { className: "py-1", children: track.title }, track.uuid)) }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { className: "mt-1", children: "\u2014" })
493
571
  ] }),
494
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
572
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
495
573
  GenrePlaylistTracks,
496
574
  {
497
575
  genreUuid,
@@ -506,30 +584,30 @@ function GenreDetailExtras({
506
584
  }
507
585
 
508
586
  // src/genre-tree/playlist-tree/TreePerRoot.tsx
509
- var import_react4 = require("react");
587
+ var import_react5 = require("react");
510
588
  var import_genre_tree_view = require("@behindthemusictree/genre-tree-view");
511
589
  var import_player3 = require("@behindthemusictree/app-kit/player");
512
590
  var import_popup2 = require("@behindthemusictree/app-kit/popup");
513
591
  var import_transport5 = require("@behindthemusictree/app-kit/transport");
514
592
 
515
593
  // src/genre-tree/playlist-tree/useGenrePlaylistPlayback.ts
516
- var import_react3 = require("react");
517
- var import_genre_tree9 = require("@behindthemusictree/app-kit/genre-tree");
594
+ var import_react4 = require("react");
595
+ var import_genre_tree8 = require("@behindthemusictree/app-kit/genre-tree");
518
596
  function useGenrePlaylistPlayback({
519
597
  scope,
520
598
  genrePlaylists,
521
599
  artist = null
522
600
  }) {
523
- const { trackList, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist } = (0, import_genre_tree9.useTrackList)();
601
+ const { trackList, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist } = (0, import_genre_tree8.useTrackList)();
524
602
  const origin = trackList?.origin;
525
- const playingNodeId = (0, import_react3.useMemo)(() => {
603
+ const playingNodeId = (0, import_react4.useMemo)(() => {
526
604
  if (!origin) return null;
527
- if (!artist) return origin.type === import_genre_tree9.TrackListOriginType.GENRE_PLAYLIST ? origin.uuid : null;
528
- if (origin.type !== import_genre_tree9.TrackListOriginType.ARTIST || origin.uuid !== artist.uuid) return null;
605
+ if (!artist) return origin.type === import_genre_tree8.TrackListOriginType.GENRE_PLAYLIST ? origin.uuid : null;
606
+ if (origin.type !== import_genre_tree8.TrackListOriginType.ARTIST || origin.uuid !== artist.uuid) return null;
529
607
  const { genreUuid } = origin;
530
608
  return genreUuid ? genrePlaylists.find((gp) => gp.criteria?.uuid === genreUuid)?.uuid ?? null : null;
531
609
  }, [origin, artist, genrePlaylists]);
532
- const play = (0, import_react3.useCallback)(
610
+ const play = (0, import_react4.useCallback)(
533
611
  async (genrePlaylist) => {
534
612
  if (!artist) return playNewTrackListFromGenrePlaylist(genrePlaylist, scope);
535
613
  if (!genrePlaylist.criteria) return;
@@ -537,15 +615,15 @@ function useGenrePlaylistPlayback({
537
615
  },
538
616
  [artist, scope, playNewTrackListFromGenrePlaylist, playNewTrackListFromArtist]
539
617
  );
540
- return (0, import_react3.useMemo)(() => ({ playingNodeId, play }), [playingNodeId, play]);
618
+ return (0, import_react4.useMemo)(() => ({ playingNodeId, play }), [playingNodeId, play]);
541
619
  }
542
620
 
543
621
  // src/genre-tree/playlist-tree/TreePerRoot.tsx
544
622
  var import_track_noun3 = require("@behindthemusictree/app-kit/track-noun");
545
- var import_jsx_runtime10 = require("react/jsx-runtime");
623
+ var import_jsx_runtime9 = require("react/jsx-runtime");
546
624
  function GenrePlaylistTreePerRoot({
547
625
  scope,
548
- className,
626
+ className: className2,
549
627
  rootUuid,
550
628
  genrePlaylistTreePerRoot,
551
629
  handleGenreCreationAction,
@@ -572,7 +650,7 @@ function GenrePlaylistTreePerRoot({
572
650
  const { playingNodeId, play } = playback ?? defaultPlayback;
573
651
  const trackNoun = (0, import_track_noun3.useTrackNoun)();
574
652
  const { showPopup } = (0, import_popup2.usePopup)();
575
- const nodes = (0, import_react4.useMemo)(
653
+ const nodes = (0, import_react5.useMemo)(
576
654
  () => genrePlaylistTreePerRoot.map((genrePlaylist) => ({
577
655
  id: genrePlaylist.uuid,
578
656
  parentId: genrePlaylist.parent?.uuid ?? null,
@@ -585,7 +663,7 @@ function GenrePlaylistTreePerRoot({
585
663
  })),
586
664
  [genrePlaylistTreePerRoot]
587
665
  );
588
- const handlePlayPause = (0, import_react4.useCallback)(
666
+ const handlePlayPause = (0, import_react5.useCallback)(
589
667
  (nodeId) => {
590
668
  const genrePlaylist = genrePlaylistTreePerRoot.find((g) => g.uuid === nodeId);
591
669
  if (!genrePlaylist) return;
@@ -598,7 +676,7 @@ function GenrePlaylistTreePerRoot({
598
676
  }
599
677
  play(genrePlaylist).catch((error) => {
600
678
  console.error("Failed to load genre playlist tracks:", error);
601
- showPopup(/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(InternalErrorPopup, { errorCode: import_transport5.ErrorCode.CLIENT_UNKNOWN }));
679
+ showPopup(/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(InternalErrorPopup, { errorCode: import_transport5.ErrorCode.CLIENT_UNKNOWN }));
602
680
  });
603
681
  },
604
682
  [
@@ -610,7 +688,7 @@ function GenrePlaylistTreePerRoot({
610
688
  showPopup
611
689
  ]
612
690
  );
613
- const handleAddChild = (0, import_react4.useCallback)(
691
+ const handleAddChild = (0, import_react5.useCallback)(
614
692
  (parentId) => {
615
693
  const genrePlaylist = genrePlaylistTreePerRoot.find((g) => g.uuid === parentId);
616
694
  if (!genrePlaylist?.criteria) return;
@@ -618,7 +696,7 @@ function GenrePlaylistTreePerRoot({
618
696
  },
619
697
  [genrePlaylistTreePerRoot, handleGenreCreationAction]
620
698
  );
621
- const handleRenameRequest = (0, import_react4.useCallback)(
699
+ const handleRenameRequest = (0, import_react5.useCallback)(
622
700
  (node) => {
623
701
  const genrePlaylist = genrePlaylistTreePerRoot.find((g) => g.uuid === node.id);
624
702
  if (!genrePlaylist?.criteria) return;
@@ -626,14 +704,14 @@ function GenrePlaylistTreePerRoot({
626
704
  },
627
705
  [genrePlaylistTreePerRoot, handleGenreRenameAction]
628
706
  );
629
- const handleDeleteRequest = (0, import_react4.useCallback)((node) => {
707
+ const handleDeleteRequest = (0, import_react5.useCallback)((node) => {
630
708
  if (confirm(`Are you sure you want to delete "${node.name}"?`)) {
631
709
  }
632
710
  }, []);
633
- return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
711
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
634
712
  import_genre_tree_view.GenreTree,
635
713
  {
636
- className,
714
+ className: className2,
637
715
  nodes,
638
716
  itemCountLabel: `${(0, import_track_noun3.capitalize)(trackNoun.singular)} count`,
639
717
  rootColor: (0, import_genre_tree_view.getGenreTreeColor)(rootUuid),
@@ -663,16 +741,28 @@ function GenrePlaylistTreePerRoot({
663
741
  }
664
742
 
665
743
  // src/genre-tree/playlist-tree/TreeWheel.tsx
666
- var import_react5 = require("react");
744
+ var import_react6 = require("react");
667
745
  var import_genre_tree_view2 = require("@behindthemusictree/genre-tree-view");
668
746
  var import_player4 = require("@behindthemusictree/app-kit/player");
669
747
  var import_popup3 = require("@behindthemusictree/app-kit/popup");
670
748
  var import_transport6 = require("@behindthemusictree/app-kit/transport");
749
+
750
+ // src/genre-tree/playlist-tree/size-metric.ts
751
+ var hasViewsCount = (genrePlaylists) => genrePlaylists.every((genrePlaylist) => genrePlaylist.viewsCount !== void 0);
752
+ var genrePlaylistSize = (genrePlaylist, metric) => {
753
+ if (metric === "songs") return genrePlaylist.tracksCount;
754
+ if (genrePlaylist.viewsCount === void 0) {
755
+ throw new Error(`Genre playlist ${genrePlaylist.uuid} has no viewsCount to size by views`);
756
+ }
757
+ return genrePlaylist.viewsCount;
758
+ };
759
+
760
+ // src/genre-tree/playlist-tree/TreeWheel.tsx
671
761
  var import_track_noun4 = require("@behindthemusictree/app-kit/track-noun");
672
- var import_jsx_runtime11 = require("react/jsx-runtime");
762
+ var import_jsx_runtime10 = require("react/jsx-runtime");
673
763
  function GenrePlaylistTreeWheel({
674
764
  scope,
675
- className,
765
+ className: className2,
676
766
  genrePlaylists,
677
767
  handleGenreCreationAction,
678
768
  handleGenreRenameAction,
@@ -688,6 +778,9 @@ function GenrePlaylistTreeWheel({
688
778
  allowWheelRotation,
689
779
  showToolbar,
690
780
  controlsOrientation,
781
+ sizeMetric = "songs",
782
+ floatingControlsLeading,
783
+ busy,
691
784
  defaultViewport,
692
785
  onViewportChange,
693
786
  playback,
@@ -698,19 +791,19 @@ function GenrePlaylistTreeWheel({
698
791
  const { playingNodeId, play } = playback ?? defaultPlayback;
699
792
  const trackNoun = (0, import_track_noun4.useTrackNoun)();
700
793
  const { showPopup } = (0, import_popup3.usePopup)();
701
- const nodes = (0, import_react5.useMemo)(
794
+ const nodes = (0, import_react6.useMemo)(
702
795
  () => genrePlaylists.map((genrePlaylist) => ({
703
796
  id: genrePlaylist.uuid,
704
797
  parentId: genrePlaylist.parent?.uuid ?? null,
705
798
  name: genrePlaylist.name,
706
- itemCount: genrePlaylist.tracksCount,
799
+ itemCount: genrePlaylistSize(genrePlaylist, sizeMetric),
707
800
  actionable: Boolean(genrePlaylist.criteria),
708
801
  side: genrePlaylist.criteria?.side ?? void 0,
709
802
  wikidataId: genrePlaylist.criteria?.wikidataId ?? null
710
803
  })),
711
- [genrePlaylists]
804
+ [genrePlaylists, sizeMetric]
712
805
  );
713
- const handlePlayPause = (0, import_react5.useCallback)(
806
+ const handlePlayPause = (0, import_react6.useCallback)(
714
807
  (nodeId) => {
715
808
  const genrePlaylist = genrePlaylists.find((g) => g.uuid === nodeId);
716
809
  if (!genrePlaylist) return;
@@ -723,7 +816,7 @@ function GenrePlaylistTreeWheel({
723
816
  }
724
817
  play(genrePlaylist).catch((error) => {
725
818
  console.error("Failed to load genre playlist tracks:", error);
726
- showPopup(/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(InternalErrorPopup, { errorCode: import_transport6.ErrorCode.CLIENT_UNKNOWN }));
819
+ showPopup(/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(InternalErrorPopup, { errorCode: import_transport6.ErrorCode.CLIENT_UNKNOWN }));
727
820
  });
728
821
  },
729
822
  [
@@ -735,7 +828,7 @@ function GenrePlaylistTreeWheel({
735
828
  showPopup
736
829
  ]
737
830
  );
738
- const handleAddChild = (0, import_react5.useCallback)(
831
+ const handleAddChild = (0, import_react6.useCallback)(
739
832
  (parentId) => {
740
833
  const genrePlaylist = genrePlaylists.find((g) => g.uuid === parentId);
741
834
  if (!genrePlaylist?.criteria) return;
@@ -743,7 +836,7 @@ function GenrePlaylistTreeWheel({
743
836
  },
744
837
  [genrePlaylists, handleGenreCreationAction]
745
838
  );
746
- const handleRenameRequest = (0, import_react5.useCallback)(
839
+ const handleRenameRequest = (0, import_react6.useCallback)(
747
840
  (node) => {
748
841
  const genrePlaylist = genrePlaylists.find((g) => g.uuid === node.id);
749
842
  if (!genrePlaylist?.criteria) return;
@@ -751,16 +844,16 @@ function GenrePlaylistTreeWheel({
751
844
  },
752
845
  [genrePlaylists, handleGenreRenameAction]
753
846
  );
754
- const handleDeleteRequest = (0, import_react5.useCallback)((node) => {
847
+ const handleDeleteRequest = (0, import_react6.useCallback)((node) => {
755
848
  if (confirm(`Are you sure you want to delete "${node.name}"?`)) {
756
849
  }
757
850
  }, []);
758
- return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
851
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
759
852
  import_genre_tree_view2.GenreTreeWheel,
760
853
  {
761
- className,
854
+ className: className2,
762
855
  nodes,
763
- itemCountLabel: `${(0, import_track_noun4.capitalize)(trackNoun.singular)} count`,
856
+ itemCountLabel: sizeMetric === "views" ? "Views" : `${(0, import_track_noun4.capitalize)(trackNoun.singular)} count`,
764
857
  playingNodeId,
765
858
  playState: isPlaying ? "playing" : "paused",
766
859
  onPlayPause: handlePlayPause,
@@ -778,6 +871,8 @@ function GenrePlaylistTreeWheel({
778
871
  allowWheelRotation,
779
872
  showToolbar,
780
873
  controlsOrientation,
874
+ floatingControlsLeading,
875
+ busy,
781
876
  defaultViewport,
782
877
  onViewportChange,
783
878
  ...infoPanelCuration,
@@ -787,15 +882,15 @@ function GenrePlaylistTreeWheel({
787
882
  }
788
883
 
789
884
  // src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx
790
- var import_react6 = require("react");
885
+ var import_react7 = require("react");
791
886
  var import_genre_tree_view3 = require("@behindthemusictree/genre-tree-view");
792
887
  var import_player5 = require("@behindthemusictree/app-kit/player");
793
888
  var import_track_noun5 = require("@behindthemusictree/app-kit/track-noun");
794
- var import_jsx_runtime12 = require("react/jsx-runtime");
889
+ var import_jsx_runtime11 = require("react/jsx-runtime");
795
890
  function GenrePlaylistTreeWheelRadialPopCore(props) {
796
891
  const {
797
892
  scope,
798
- className,
893
+ className: className2,
799
894
  genrePlaylists,
800
895
  handleGenreCreationAction,
801
896
  handleGenreRenameAction,
@@ -811,6 +906,9 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
811
906
  allowWheelRotation,
812
907
  showToolbar,
813
908
  controlsOrientation,
909
+ sizeMetric = "songs",
910
+ floatingControlsLeading,
911
+ busy,
814
912
  outline = false,
815
913
  defaultViewport,
816
914
  onViewportChange,
@@ -818,10 +916,6 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
818
916
  } = props;
819
917
  const focus = props;
820
918
  const infoPanelCuration = {
821
- editMode: props.editMode,
822
- onEditModeChange: props.onEditModeChange,
823
- renderTitleEditor: props.renderTitleEditor,
824
- renderParentsEditor: props.renderParentsEditor,
825
919
  renderDangerZone: props.renderDangerZone,
826
920
  expanded: props.expanded,
827
921
  onExpandedChange: props.onExpandedChange,
@@ -833,21 +927,22 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
833
927
  const defaultPlayback = useGenrePlaylistPlayback({ scope, genrePlaylists });
834
928
  const { playingNodeId, play } = playback ?? defaultPlayback;
835
929
  const trackNoun = (0, import_track_noun5.useTrackNoun)();
836
- const nodes = (0, import_react6.useMemo)(
930
+ const nodes = (0, import_react7.useMemo)(
837
931
  () => genrePlaylists.map((genrePlaylist) => ({
838
932
  id: genrePlaylist.uuid,
839
933
  parentId: genrePlaylist.parent?.uuid ?? null,
840
934
  name: genrePlaylist.name,
841
- itemCount: genrePlaylist.tracksCount,
935
+ itemCount: genrePlaylistSize(genrePlaylist, sizeMetric),
842
936
  actionable: Boolean(genrePlaylist.criteria),
843
937
  detached: !genrePlaylist.criteria,
844
938
  side: genrePlaylist.criteria?.side ?? void 0,
845
939
  wikidataId: genrePlaylist.criteria?.wikidataId ?? null,
846
- umbrella: genrePlaylist.isUmbrella
940
+ umbrella: genrePlaylist.isUmbrella,
941
+ scene: genrePlaylist.scene ?? void 0
847
942
  })),
848
- [genrePlaylists]
943
+ [genrePlaylists, sizeMetric]
849
944
  );
850
- const handlePlayPause = (0, import_react6.useCallback)(
945
+ const handlePlayPause = (0, import_react7.useCallback)(
851
946
  (nodeId) => {
852
947
  const genrePlaylist = genrePlaylists.find((g) => g.uuid === nodeId);
853
948
  if (!genrePlaylist) return;
@@ -864,7 +959,7 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
864
959
  },
865
960
  [genrePlaylists, playingNodeId, isPlaying, setIsPlaying, play]
866
961
  );
867
- const handleAddChild = (0, import_react6.useCallback)(
962
+ const handleAddChild = (0, import_react7.useCallback)(
868
963
  (parentId) => {
869
964
  const genrePlaylist = genrePlaylists.find((g) => g.uuid === parentId);
870
965
  if (!genrePlaylist?.criteria) return;
@@ -872,7 +967,7 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
872
967
  },
873
968
  [genrePlaylists, handleGenreCreationAction]
874
969
  );
875
- const handleRenameRequest = (0, import_react6.useCallback)(
970
+ const handleRenameRequest = (0, import_react7.useCallback)(
876
971
  (node) => {
877
972
  const genrePlaylist = genrePlaylists.find((g) => g.uuid === node.id);
878
973
  if (!genrePlaylist?.criteria) return;
@@ -880,14 +975,14 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
880
975
  },
881
976
  [genrePlaylists, handleGenreRenameAction]
882
977
  );
883
- const handleDeleteRequest = (0, import_react6.useCallback)((node) => {
978
+ const handleDeleteRequest = (0, import_react7.useCallback)((node) => {
884
979
  if (confirm(`Are you sure you want to delete "${node.name}"?`)) {
885
980
  }
886
981
  }, []);
887
982
  const treeProps = {
888
- className,
983
+ className: className2,
889
984
  nodes,
890
- itemCountLabel: `${(0, import_track_noun5.capitalize)(trackNoun.singular)} count`,
985
+ itemCountLabel: sizeMetric === "views" ? "Views" : `${(0, import_track_noun5.capitalize)(trackNoun.singular)} count`,
891
986
  playingNodeId,
892
987
  playState: isPlaying ? "playing" : "paused",
893
988
  onPlayPause: handlePlayPause,
@@ -906,12 +1001,14 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
906
1001
  ...infoPanelCuration,
907
1002
  readOnly
908
1003
  };
909
- return outline ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_genre_tree_view3.GenreTreeOutline, { ...treeProps }) : /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
1004
+ return outline ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_genre_tree_view3.GenreTreeOutline, { ...treeProps }) : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
910
1005
  import_genre_tree_view3.GenreTreeWheelRadialPopCore,
911
1006
  {
912
1007
  ...treeProps,
913
1008
  allowWheelRotation,
914
1009
  controlsOrientation,
1010
+ floatingControlsLeading,
1011
+ busy,
915
1012
  defaultViewport,
916
1013
  onViewportChange,
917
1014
  ...focus.onFocusGenreChange ? { focusNodeId: focus.focusGenreUuid, onFocusChange: focus.onFocusGenreChange } : { focusNodeId: void 0, onFocusChange: void 0 }
@@ -920,13 +1017,13 @@ function GenrePlaylistTreeWheelRadialPopCore(props) {
920
1017
  }
921
1018
 
922
1019
  // src/genre-tree/GenreTreeWheelHandoff.tsx
923
- var import_react7 = require("react");
924
- var import_jsx_runtime13 = require("react/jsx-runtime");
1020
+ var import_react8 = require("react");
1021
+ var import_jsx_runtime12 = require("react/jsx-runtime");
925
1022
  function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
926
- const [revealed, setRevealed] = (0, import_react7.useState)(false);
927
- const onRevealedChangeRef = (0, import_react7.useRef)(onRevealedChange);
1023
+ const [revealed, setRevealed] = (0, import_react8.useState)(false);
1024
+ const onRevealedChangeRef = (0, import_react8.useRef)(onRevealedChange);
928
1025
  onRevealedChangeRef.current = onRevealedChange;
929
- (0, import_react7.useEffect)(() => {
1026
+ (0, import_react8.useEffect)(() => {
930
1027
  let innerFrame = 0;
931
1028
  const outerFrame = requestAnimationFrame(() => {
932
1029
  innerFrame = requestAnimationFrame(() => {
@@ -940,8 +1037,8 @@ function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
940
1037
  cancelAnimationFrame(innerFrame);
941
1038
  };
942
1039
  }, []);
943
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "relative w-full h-full", children: [
944
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1040
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "relative w-full h-full", children: [
1041
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
945
1042
  "div",
946
1043
  {
947
1044
  className: "absolute inset-0 transition-opacity duration-150 ease-out",
@@ -950,15 +1047,15 @@ function GenreTreeWheelHandoff({ skeleton, children, onRevealedChange }) {
950
1047
  children
951
1048
  }
952
1049
  ),
953
- !revealed && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "absolute inset-0", "aria-hidden": "true", children: skeleton })
1050
+ !revealed && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "absolute inset-0", "aria-hidden": "true", children: skeleton })
954
1051
  ] });
955
1052
  }
956
1053
 
957
1054
  // src/genre-tree/GenreSearch.tsx
958
1055
  var import_lucide_react3 = require("lucide-react");
959
1056
  var import_ui5 = require("@behindthemusictree/ui");
960
- var import_genre_tree10 = require("@behindthemusictree/app-kit/genre-tree");
961
- var import_jsx_runtime14 = require("react/jsx-runtime");
1057
+ var import_genre_tree9 = require("@behindthemusictree/app-kit/genre-tree");
1058
+ var import_jsx_runtime13 = require("react/jsx-runtime");
962
1059
  function GenreSearch({
963
1060
  genrePlaylists,
964
1061
  onSelect,
@@ -969,7 +1066,7 @@ function GenreSearch({
969
1066
  onQueryChange,
970
1067
  extraResults
971
1068
  }) {
972
- const { query, setQuery, results, canClear, clear, select } = (0, import_genre_tree10.useGenreSearch)({
1069
+ const { query, setQuery, results, canClear, clear, select } = (0, import_genre_tree9.useGenreSearch)({
973
1070
  genrePlaylists,
974
1071
  onSelect,
975
1072
  selectedName,
@@ -977,21 +1074,22 @@ function GenreSearch({
977
1074
  });
978
1075
  const extraItems = query !== "" && query !== selectedName ? extraResults?.items ?? [] : [];
979
1076
  const label = extraResults ? "Search" : "Search a genre";
1077
+ const loading = query !== "" && query !== selectedName && (extraResults?.loading ?? false);
980
1078
  const showList = results.length > 0 || extraItems.length > 0;
981
1079
  const heading = "px-3 pt-2 pb-1 text-xs font-medium uppercase text-[var(--color-text-muted)]";
982
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "genre-search relative w-[calc(var(--layout-detail-panel-w,25.5rem)-2*var(--layout-panel-inset,1rem))] shrink-0", children: [
983
- onPrevious && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
1080
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("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: [
1081
+ onPrevious && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
984
1082
  "button",
985
1083
  {
986
1084
  type: "button",
987
1085
  "aria-label": "Previous",
988
1086
  title: "Previous",
989
- className: "genre-search-previous absolute left-2 top-1/2 z-10 hidden p-1.5 text-[var(--color-text-muted)] rounded-full -translate-y-1/2 hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
1087
+ 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)]",
990
1088
  onClick: onPrevious,
991
- children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react3.ArrowLeft, { "aria-hidden": "true", className: "w-4 h-4" })
1089
+ children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_lucide_react3.ArrowLeft, { "aria-hidden": "true", className: "w-4 h-4" })
992
1090
  }
993
1091
  ),
994
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
1092
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
995
1093
  import_ui5.Input,
996
1094
  {
997
1095
  type: "text",
@@ -1002,49 +1100,75 @@ function GenreSearch({
1002
1100
  },
1003
1101
  placeholder: placeholder ?? `${label}\u2026`,
1004
1102
  "aria-label": label,
1103
+ "aria-busy": loading,
1104
+ inputMode: "search",
1105
+ enterKeyHint: "search",
1106
+ autoComplete: "off",
1005
1107
  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"
1006
1108
  }
1007
1109
  ),
1008
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "absolute right-3 top-1/2 flex items-center -translate-y-1/2", children: [
1009
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "p-2 text-[var(--color-text-muted)] pointer-events-none", children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react3.Search, { "aria-hidden": "true", className: "w-6 h-6" }) }),
1010
- canClear && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
1110
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "absolute right-3 top-1/2 flex items-center -translate-y-1/2", children: [
1111
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
1112
+ "span",
1113
+ {
1114
+ "data-loading": loading,
1115
+ className: "relative p-2 text-[var(--color-text-muted)] pointer-events-none",
1116
+ children: [
1117
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1118
+ import_lucide_react3.Search,
1119
+ {
1120
+ "aria-hidden": "true",
1121
+ className: `w-6 h-6 transition-opacity duration-200 motion-reduce:transition-none ${loading ? "opacity-0" : ""}`
1122
+ }
1123
+ ),
1124
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1125
+ import_lucide_react3.LoaderCircle,
1126
+ {
1127
+ "aria-hidden": "true",
1128
+ 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"}`
1129
+ }
1130
+ )
1131
+ ]
1132
+ }
1133
+ ),
1134
+ canClear && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1011
1135
  "button",
1012
1136
  {
1013
1137
  type: "button",
1014
1138
  "aria-label": "Clear search",
1015
- className: "p-2 text-[var(--color-text-muted)] rounded-full hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
1139
+ className: "flex items-center justify-center p-2 [@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 hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]",
1016
1140
  onClick: () => {
1017
1141
  clear();
1018
1142
  onQueryChange?.("");
1019
1143
  },
1020
- children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react3.X, { "aria-hidden": "true", className: "w-6 h-6" })
1144
+ children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_lucide_react3.X, { "aria-hidden": "true", className: "w-6 h-6" })
1021
1145
  }
1022
1146
  )
1023
1147
  ] }),
1024
- extraResults?.status && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { role: "status", "aria-live": "polite", className: "sr-only", children: extraResults.status }),
1025
- showList && /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("ul", { className: "genre-search-results absolute z-40 w-full max-h-64 mt-1 overflow-y-auto bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl shadow-md", children: [
1026
- extraResults && results.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("li", { className: heading, children: "Genres" }),
1027
- results.map((genrePlaylist) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
1148
+ extraResults?.status && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { role: "status", "aria-live": "polite", className: "sr-only", children: extraResults.status }),
1149
+ showList && /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("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: [
1150
+ extraResults && results.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { className: heading, children: "Genres" }),
1151
+ results.map((genrePlaylist) => /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
1028
1152
  "button",
1029
1153
  {
1030
1154
  type: "button",
1031
- className: "w-full px-3 py-1.5 text-left hover:bg-[var(--color-surface-muted)]",
1155
+ 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)]",
1032
1156
  onClick: () => {
1033
1157
  select(genrePlaylist);
1034
1158
  onQueryChange?.("");
1035
1159
  },
1036
1160
  children: [
1037
1161
  genrePlaylist.name,
1038
- genrePlaylist.isUmbrella && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "ml-2 text-xs italic text-[var(--color-text-muted)]", children: "umbrella" })
1162
+ genrePlaylist.isUmbrella && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "ml-2 text-xs italic text-[var(--color-text-muted)]", children: "umbrella" })
1039
1163
  ]
1040
1164
  }
1041
1165
  ) }, genrePlaylist.uuid)),
1042
- extraResults && extraItems.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("li", { className: heading, children: extraResults.label }),
1043
- extraItems.map((item) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
1166
+ extraResults && extraItems.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { className: heading, children: extraResults.label }),
1167
+ extraItems.map((item) => /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1044
1168
  "button",
1045
1169
  {
1046
1170
  type: "button",
1047
- className: "w-full px-3 py-1.5 text-left hover:bg-[var(--color-surface-muted)]",
1171
+ 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)]",
1048
1172
  onClick: () => {
1049
1173
  setQuery("");
1050
1174
  onQueryChange?.("");
@@ -1058,8 +1182,254 @@ function GenreSearch({
1058
1182
  }
1059
1183
 
1060
1184
  // src/genre-tree/GenreActionBand.tsx
1061
- var import_lucide_react4 = require("lucide-react");
1185
+ var import_lucide_react7 = require("lucide-react");
1186
+
1187
+ // src/genre-tree/ShareActionButton.tsx
1188
+ var import_lucide_react6 = require("lucide-react");
1189
+ var import_popup4 = require("@behindthemusictree/app-kit/popup");
1190
+
1191
+ // src/genre-tree/SharePopup.tsx
1192
+ var import_react10 = require("react");
1193
+ var import_lucide_react5 = require("lucide-react");
1194
+ var import_fa2 = require("react-icons/fa");
1195
+ var import_si2 = require("react-icons/si");
1196
+
1197
+ // src/popup/Toast.tsx
1198
+ var import_react9 = require("react");
1199
+ var import_react_dom2 = require("react-dom");
1200
+ var import_jsx_runtime14 = require("react/jsx-runtime");
1201
+ function Toast({ message, onClose, durationMs = 2200 }) {
1202
+ const [mounted, setMounted] = (0, import_react9.useState)(false);
1203
+ (0, import_react9.useEffect)(() => {
1204
+ setMounted(true);
1205
+ const timeout = window.setTimeout(onClose, durationMs);
1206
+ return () => window.clearTimeout(timeout);
1207
+ }, [durationMs, onClose]);
1208
+ if (!mounted) return null;
1209
+ return (0, import_react_dom2.createPortal)(
1210
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "pointer-events-none fixed inset-x-0 bottom-8 z-[60] flex justify-center px-4", children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
1211
+ "div",
1212
+ {
1213
+ role: "status",
1214
+ "aria-live": "polite",
1215
+ "aria-atomic": "true",
1216
+ className: "pointer-events-auto w-full max-w-md rounded-md bg-[var(--color-surface)] px-4 py-4 text-sm text-[var(--color-text)] shadow-lg ring-1 ring-black/10",
1217
+ children: message
1218
+ }
1219
+ ) }),
1220
+ document.body
1221
+ );
1222
+ }
1223
+
1224
+ // src/genre-tree/ShareDestinationButton.tsx
1062
1225
  var import_jsx_runtime15 = require("react/jsx-runtime");
1226
+ 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";
1227
+ function Contents({ icon, label }) {
1228
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(import_jsx_runtime15.Fragment, { children: [
1229
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("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 }),
1230
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "max-w-full truncate", children: label })
1231
+ ] });
1232
+ }
1233
+ function ShareDestinationButton({
1234
+ label,
1235
+ icon,
1236
+ href,
1237
+ onClick,
1238
+ disabled = false,
1239
+ ariaLabel
1240
+ }) {
1241
+ if (href) {
1242
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("a", { href, target: "_blank", rel: "noopener noreferrer", "aria-label": ariaLabel, className, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Contents, { icon, label }) });
1243
+ }
1244
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("button", { type: "button", onClick, disabled, "aria-label": ariaLabel, className, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Contents, { icon, label }) });
1245
+ }
1246
+
1247
+ // src/genre-tree/SharePreviewCard.tsx
1248
+ var import_lucide_react4 = require("lucide-react");
1249
+ var import_jsx_runtime16 = require("react/jsx-runtime");
1250
+ function SharePreviewCard({ target, url }) {
1251
+ const Icon = target.kind === "genre" ? import_lucide_react4.Music2 : import_lucide_react4.UserRound;
1252
+ const typeLabel = target.kind === "genre" ? "Genre" : "Artist";
1253
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "rounded-3xl bg-[var(--color-surface-muted)] p-4", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "flex min-h-32 overflow-hidden rounded-2xl bg-[var(--color-surface)] shadow-lg", children: [
1254
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "flex w-28 shrink-0 items-center justify-center bg-[var(--color-primary)]/15 text-[var(--color-primary)]", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Icon, { "aria-hidden": "true", className: "h-10 w-10" }) }),
1255
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-1 p-4", children: [
1256
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "text-xs font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: typeLabel }),
1257
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "truncate text-base font-semibold text-[var(--color-text)]", title: target.name, children: target.name }),
1258
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "truncate text-sm text-[var(--color-primary)]", title: url, children: url })
1259
+ ] })
1260
+ ] }) });
1261
+ }
1262
+
1263
+ // src/genre-tree/SharePopup.tsx
1264
+ var import_jsx_runtime17 = require("react/jsx-runtime");
1265
+ function resolveAbsoluteUrl(url) {
1266
+ try {
1267
+ const parsed = new URL(url, window.location.href);
1268
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : void 0;
1269
+ } catch {
1270
+ return void 0;
1271
+ }
1272
+ }
1273
+ async function copyText(text) {
1274
+ try {
1275
+ if (navigator.clipboard?.writeText) {
1276
+ await navigator.clipboard.writeText(text);
1277
+ return true;
1278
+ }
1279
+ } catch {
1280
+ }
1281
+ const input = document.createElement("textarea");
1282
+ input.value = text;
1283
+ input.setAttribute("readonly", "");
1284
+ input.style.position = "fixed";
1285
+ input.style.opacity = "0";
1286
+ document.body.append(input);
1287
+ input.select();
1288
+ try {
1289
+ return document.execCommand("copy");
1290
+ } catch {
1291
+ return false;
1292
+ } finally {
1293
+ input.remove();
1294
+ }
1295
+ }
1296
+ function SharePopup({ target, url, onClose }) {
1297
+ const [absoluteUrl, setAbsoluteUrl] = (0, import_react10.useState)(url);
1298
+ const [copyStatus, setCopyStatus] = (0, import_react10.useState)("idle");
1299
+ const [canUseNativeShare, setCanUseNativeShare] = (0, import_react10.useState)(false);
1300
+ const [shareError, setShareError] = (0, import_react10.useState)(null);
1301
+ const [toastId, setToastId] = (0, import_react10.useState)(0);
1302
+ (0, import_react10.useEffect)(() => {
1303
+ setAbsoluteUrl(resolveAbsoluteUrl(url) ?? "");
1304
+ setCopyStatus("idle");
1305
+ setShareError(null);
1306
+ }, [url]);
1307
+ (0, import_react10.useEffect)(() => {
1308
+ setCanUseNativeShare(typeof navigator !== "undefined" && typeof navigator.share === "function");
1309
+ }, []);
1310
+ const destinationUrls = (0, import_react10.useMemo)(() => {
1311
+ if (!absoluteUrl) return null;
1312
+ const message = `${target.name} \u2014 ${absoluteUrl}`;
1313
+ return {
1314
+ whatsapp: `https://wa.me/?text=${encodeURIComponent(message)}`,
1315
+ gmail: `https://mail.google.com/mail/?view=cm&fs=1&su=${encodeURIComponent(`Share: ${target.name}`)}&body=${encodeURIComponent(message)}`
1316
+ };
1317
+ }, [absoluteUrl, target.name]);
1318
+ const handleCopy = async () => {
1319
+ if (!absoluteUrl) {
1320
+ setCopyStatus("failed");
1321
+ return;
1322
+ }
1323
+ setShareError(null);
1324
+ const copied = await copyText(absoluteUrl);
1325
+ setCopyStatus(copied ? "idle" : "failed");
1326
+ if (copied) setToastId((id) => id + 1);
1327
+ };
1328
+ const dismissToast = (0, import_react10.useCallback)(() => setToastId(0), []);
1329
+ const handleNativeShare = async () => {
1330
+ if (!absoluteUrl || !navigator.share) return;
1331
+ setShareError(null);
1332
+ try {
1333
+ await navigator.share({ title: target.name, url: absoluteUrl });
1334
+ } catch (error) {
1335
+ if (error instanceof Error && error.name === "AbortError") return;
1336
+ setShareError("Could not open the share options.");
1337
+ }
1338
+ };
1339
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(import_jsx_runtime17.Fragment, { children: [
1340
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
1341
+ BasePopup_default,
1342
+ {
1343
+ title: "Share",
1344
+ onClose,
1345
+ isDismissable: true,
1346
+ trapFocus: true,
1347
+ closeOnEscape: true,
1348
+ closeOnBackdrop: true,
1349
+ className: "!w-full !max-w-[28rem] overflow-hidden !rounded-[2rem] !bg-[var(--color-surface)] shadow-2xl",
1350
+ overlayClassName: "!bg-black/50 p-4 backdrop-blur-[2px]",
1351
+ titleClassName: "!rounded-none !bg-transparent !px-6 !pb-2 !pt-6",
1352
+ titleTextClassName: "!text-xl !font-semibold !text-[var(--color-text)]",
1353
+ closeButtonClassName: "!h-10 !w-10 !rounded-none !border-0 !bg-transparent !p-0 !text-[var(--color-text)] hover:!bg-transparent hover:!text-[var(--color-text)]",
1354
+ contentClassName: "!rounded-none !bg-transparent !px-6 !pb-6 !pt-2",
1355
+ children: /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex flex-col gap-4", children: [
1356
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(SharePreviewCard, { target, url: absoluteUrl || url }),
1357
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "grid grid-cols-3 gap-1 rounded-3xl bg-[var(--color-surface)] p-3 sm:grid-cols-4", children: [
1358
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
1359
+ ShareDestinationButton,
1360
+ {
1361
+ label: "Copy Link",
1362
+ icon: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react5.Link2, { "aria-hidden": "true", className: "h-6 w-6" }),
1363
+ onClick: () => void handleCopy(),
1364
+ ariaLabel: "Copy link",
1365
+ disabled: !absoluteUrl
1366
+ }
1367
+ ),
1368
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
1369
+ ShareDestinationButton,
1370
+ {
1371
+ label: "WhatsApp",
1372
+ icon: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_fa2.FaWhatsapp, { "aria-hidden": "true", className: "h-9 w-9 text-[#25D366]" }),
1373
+ href: destinationUrls?.whatsapp,
1374
+ ariaLabel: `Share ${target.name} via WhatsApp`,
1375
+ disabled: !absoluteUrl
1376
+ }
1377
+ ),
1378
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
1379
+ ShareDestinationButton,
1380
+ {
1381
+ label: "Gmail",
1382
+ icon: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_si2.SiGmail, { "aria-hidden": "true", className: "h-8 w-8 text-[#EA4335]" }),
1383
+ href: destinationUrls?.gmail,
1384
+ ariaLabel: `Email ${target.name} with Gmail`,
1385
+ disabled: !absoluteUrl
1386
+ }
1387
+ ),
1388
+ canUseNativeShare && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
1389
+ ShareDestinationButton,
1390
+ {
1391
+ label: "More",
1392
+ icon: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react5.Share2, { "aria-hidden": "true", className: "h-6 w-6" }),
1393
+ onClick: () => void handleNativeShare(),
1394
+ ariaLabel: "More sharing options",
1395
+ disabled: !absoluteUrl
1396
+ }
1397
+ )
1398
+ ] }),
1399
+ copyStatus === "failed" && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { role: "alert", className: "text-center text-sm text-[var(--color-red-500)]", children: "Could not copy the link." }),
1400
+ shareError && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { role: "alert", className: "text-center text-sm text-[var(--color-red-500)]", children: shareError })
1401
+ ] })
1402
+ }
1403
+ ),
1404
+ toastId > 0 && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(Toast, { message: "Link copied", onClose: dismissToast }, toastId)
1405
+ ] });
1406
+ }
1407
+
1408
+ // src/genre-tree/ShareActionButton.tsx
1409
+ var import_jsx_runtime18 = require("react/jsx-runtime");
1410
+ function ShareActionButton({ target, url }) {
1411
+ const { showPopup, hidePopup } = (0, import_popup4.usePopup)();
1412
+ const openSharePopup = () => {
1413
+ showPopup(/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(SharePopup, { target, url, onClose: hidePopup }));
1414
+ };
1415
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
1416
+ "button",
1417
+ {
1418
+ type: "button",
1419
+ onClick: openSharePopup,
1420
+ className: "group flex flex-col items-center gap-1 w-16 text-xs text-center text-[var(--color-text)] focus-visible:outline-none",
1421
+ "aria-label": `Share ${target.name}`,
1422
+ title: `Share ${target.name}`,
1423
+ children: [
1424
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("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: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react6.Share2, { "aria-hidden": "true", className: "w-5 h-5" }) }),
1425
+ "Share"
1426
+ ]
1427
+ }
1428
+ );
1429
+ }
1430
+
1431
+ // src/genre-tree/GenreActionBand.tsx
1432
+ var import_jsx_runtime19 = require("react/jsx-runtime");
1063
1433
  function GenreActionBand({
1064
1434
  node,
1065
1435
  onPlayPause,
@@ -1068,7 +1438,12 @@ function GenreActionBand({
1068
1438
  onAddChild,
1069
1439
  onRename,
1070
1440
  onChangeParent,
1071
- additionalActions
1441
+ side,
1442
+ onToggleSide,
1443
+ toggleSideDisabledReason,
1444
+ additionalActions,
1445
+ shareTarget,
1446
+ shareUrl
1072
1447
  }) {
1073
1448
  const items = [];
1074
1449
  const adminItems = [];
@@ -1076,7 +1451,7 @@ function GenreActionBand({
1076
1451
  items.push({
1077
1452
  key: "play-pause",
1078
1453
  label: isPlaying ? "Pause" : "Play",
1079
- icon: isPlaying ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react4.Pause, { "aria-hidden": "true", className: "w-5 h-5" }) : /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react4.Play, { "aria-hidden": "true", className: "w-5 h-5" }),
1454
+ icon: isPlaying ? /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react7.Pause, { "aria-hidden": "true", className: "w-5 h-5" }) : /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react7.Play, { "aria-hidden": "true", className: "w-5 h-5" }),
1080
1455
  onClick: onPlayPause
1081
1456
  });
1082
1457
  }
@@ -1084,18 +1459,28 @@ function GenreActionBand({
1084
1459
  items.push({
1085
1460
  key: "zoom-in",
1086
1461
  label: "Focus",
1087
- icon: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react4.ZoomIn, { "aria-hidden": "true", className: "w-5 h-5" }),
1462
+ icon: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react7.ZoomIn, { "aria-hidden": "true", className: "w-5 h-5" }),
1088
1463
  onClick: onZoomIn
1089
1464
  });
1090
1465
  }
1091
1466
  if (onAddChild) {
1092
- adminItems.push({ key: "add-child", label: "Add", icon: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react4.Plus, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onAddChild });
1467
+ adminItems.push({ key: "add-child", label: "Add", icon: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react7.Plus, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onAddChild });
1093
1468
  }
1094
1469
  if (onRename) {
1095
- adminItems.push({ key: "rename", label: "Rename", icon: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react4.Pencil, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onRename });
1470
+ adminItems.push({ key: "rename", label: "Rename", icon: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react7.Pencil, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onRename });
1096
1471
  }
1097
1472
  if (onChangeParent) {
1098
- adminItems.push({ key: "change-parent", label: "Parent", icon: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react4.MoveUpRight, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onChangeParent });
1473
+ adminItems.push({ key: "change-parent", label: "Parent", icon: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react7.MoveUpRight, { "aria-hidden": "true", className: "w-5 h-5" }), onClick: onChangeParent });
1474
+ }
1475
+ if (onToggleSide) {
1476
+ adminItems.push({
1477
+ key: "toggle-side",
1478
+ label: side === "pop" ? "Make core" : "Make pop",
1479
+ icon: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react7.ArrowLeftRight, { "aria-hidden": "true", className: "w-5 h-5" }),
1480
+ onClick: onToggleSide,
1481
+ disabled: !!toggleSideDisabledReason,
1482
+ title: toggleSideDisabledReason
1483
+ });
1099
1484
  }
1100
1485
  for (const action of additionalActions?.(node) ?? []) {
1101
1486
  items.push({
@@ -1106,31 +1491,35 @@ function GenreActionBand({
1106
1491
  disabled: action.enabled ? !action.enabled(node) : false
1107
1492
  });
1108
1493
  }
1109
- if (items.length === 0 && adminItems.length === 0) return null;
1110
- const renderItems = (list) => list.map((item) => /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
1494
+ if (items.length === 0 && (!shareTarget || !shareUrl) && adminItems.length === 0) return null;
1495
+ const renderItems = (list) => list.map((item) => /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
1111
1496
  "button",
1112
1497
  {
1113
1498
  type: "button",
1114
1499
  disabled: item.disabled,
1500
+ title: item.title,
1115
1501
  onClick: item.onClick,
1116
1502
  className: "group flex flex-col items-center gap-1 w-16 text-xs text-center text-[var(--color-text)] focus-visible:outline-none disabled:opacity-50",
1117
1503
  children: [
1118
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("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)] group-disabled:group-hover:bg-[var(--color-surface-muted)]", children: item.icon }),
1504
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("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)] group-disabled:group-hover:bg-[var(--color-surface-muted)]", children: item.icon }),
1119
1505
  item.label
1120
1506
  ]
1121
1507
  },
1122
1508
  item.key
1123
1509
  ));
1124
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: [
1125
- items.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: renderItems(items) }),
1126
- adminItems.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
1510
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: [
1511
+ (items.length > 0 || shareTarget && shareUrl) && /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: [
1512
+ renderItems(items),
1513
+ shareTarget && shareUrl && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(ShareActionButton, { target: shareTarget, url: shareUrl })
1514
+ ] }),
1515
+ adminItems.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
1127
1516
  "section",
1128
1517
  {
1129
1518
  "aria-label": "Admin actions",
1130
1519
  className: `px-3.5 py-3 ${items.length > 0 ? "border-t border-[var(--color-border)]" : ""}`,
1131
1520
  children: [
1132
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("h3", { className: "mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text-muted)]", children: "Admin" }),
1133
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "flex flex-wrap gap-1", children: renderItems(adminItems) })
1521
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("h3", { className: "mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text-muted)]", children: "Admin" }),
1522
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "flex flex-wrap gap-1", children: renderItems(adminItems) })
1134
1523
  ]
1135
1524
  }
1136
1525
  )
@@ -1138,16 +1527,16 @@ function GenreActionBand({
1138
1527
  }
1139
1528
 
1140
1529
  // src/genre-tree/PanelEdgeTab.tsx
1141
- var import_lucide_react5 = require("lucide-react");
1530
+ var import_lucide_react8 = require("lucide-react");
1142
1531
  var import_tailwind_merge = require("tailwind-merge");
1143
- var import_jsx_runtime16 = require("react/jsx-runtime");
1532
+ var import_jsx_runtime20 = require("react/jsx-runtime");
1144
1533
  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)]";
1145
- function PanelEdgeTab({ side, edge, center, controls, className }) {
1534
+ function PanelEdgeTab({ side, edge, center, controls, className: className2 }) {
1146
1535
  if (!edge && !center) return null;
1147
1536
  const isLeft = side === "left";
1148
- const EdgeIcon = isLeft ? import_lucide_react5.ChevronLeft : import_lucide_react5.ChevronRight;
1149
- const CenterIcon = isLeft ? import_lucide_react5.ChevronRight : import_lucide_react5.ChevronLeft;
1150
- const renderButton = (action, icon) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1537
+ const EdgeIcon = isLeft ? import_lucide_react8.ChevronLeft : import_lucide_react8.ChevronRight;
1538
+ const CenterIcon = isLeft ? import_lucide_react8.ChevronRight : import_lucide_react8.ChevronLeft;
1539
+ const renderButton = (action, icon) => /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
1151
1540
  "button",
1152
1541
  {
1153
1542
  type: "button",
@@ -1160,20 +1549,20 @@ function PanelEdgeTab({ side, edge, center, controls, className }) {
1160
1549
  children: icon
1161
1550
  }
1162
1551
  );
1163
- const edgeButton = edge && renderButton(edge, /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(EdgeIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1164
- const centerButton = center && renderButton(center, /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(CenterIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1552
+ const edgeButton = edge && renderButton(edge, /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(EdgeIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1553
+ const centerButton = center && renderButton(center, /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(CenterIcon, { "aria-hidden": "true", className: "h-5 w-5" }));
1165
1554
  const buttons = isLeft ? [edgeButton, centerButton] : [centerButton, edgeButton];
1166
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
1555
+ return /* @__PURE__ */ (0, import_jsx_runtime20.jsxs)(
1167
1556
  "div",
1168
1557
  {
1169
1558
  className: (0, import_tailwind_merge.twMerge)(
1170
1559
  "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)]",
1171
1560
  isLeft ? "rounded-r-lg" : "rounded-l-lg [clip-path:inset(-0.5rem_0_-0.5rem_-0.5rem)]",
1172
- className
1561
+ className2
1173
1562
  ),
1174
1563
  children: [
1175
1564
  buttons[0],
1176
- buttons[0] && buttons[1] && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { role: "presentation", className: "h-px shrink-0 bg-[var(--color-border)]" }),
1565
+ buttons[0] && buttons[1] && /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("div", { role: "presentation", className: "h-px shrink-0 bg-[var(--color-border)]" }),
1177
1566
  buttons[1]
1178
1567
  ]
1179
1568
  }
@@ -1181,67 +1570,82 @@ function PanelEdgeTab({ side, edge, center, controls, className }) {
1181
1570
  }
1182
1571
 
1183
1572
  // src/genre-tree/ChangeParentPopup.tsx
1184
- var import_react8 = require("react");
1185
- var import_genre_tree11 = require("@behindthemusictree/app-kit/genre-tree");
1186
- var import_jsx_runtime17 = require("react/jsx-runtime");
1573
+ var import_react11 = require("react");
1574
+ var import_genre_tree10 = require("@behindthemusictree/app-kit/genre-tree");
1575
+ var import_jsx_runtime21 = require("react/jsx-runtime");
1187
1576
  function ChangeParentPopup({
1188
1577
  genrePlaylist,
1189
1578
  genrePlaylists,
1190
1579
  scope,
1191
1580
  getBackendBaseUrl,
1192
- onClose
1581
+ onClose,
1582
+ onParentChanged
1193
1583
  }) {
1194
- const { mutate, isPending, formErrors } = (0, import_genre_tree11.useUpdateGenre)(scope, getBackendBaseUrl);
1195
- const candidates = (0, import_react8.useMemo)(
1196
- () => (0, import_genre_tree11.getReparentCandidates)(genrePlaylists, genrePlaylist.uuid),
1584
+ const { mutate, isPending, formErrors } = (0, import_genre_tree10.useUpdateGenre)(scope, getBackendBaseUrl);
1585
+ const candidates = (0, import_react11.useMemo)(
1586
+ () => (0, import_genre_tree10.getReparentCandidates)(genrePlaylists, genrePlaylist.uuid),
1197
1587
  [genrePlaylists, genrePlaylist.uuid]
1198
1588
  );
1199
- (0, import_react8.useEffect)(() => {
1589
+ const handleSelect = (newParent) => {
1590
+ const genre = genrePlaylist.criteria.uuid;
1591
+ const parentPlaylistUuid = genrePlaylist.parent?.uuid;
1592
+ const from = parentPlaylistUuid ? genrePlaylists.find(({ uuid }) => uuid === parentPlaylistUuid)?.criteria?.uuid ?? null : null;
1593
+ mutate(
1594
+ { uuid: genre, data: { parent: newParent.criteria.uuid } },
1595
+ {
1596
+ onSuccess: () => {
1597
+ onClose();
1598
+ onParentChanged?.({ genre, from, to: newParent.criteria.uuid });
1599
+ }
1600
+ }
1601
+ );
1602
+ };
1603
+ (0, import_react11.useEffect)(() => {
1200
1604
  const handleKeyDown = (event) => {
1201
1605
  if (event.key === "Escape") onClose();
1202
1606
  };
1203
1607
  window.addEventListener("keydown", handleKeyDown);
1204
1608
  return () => window.removeEventListener("keydown", handleKeyDown);
1205
1609
  }, [onClose]);
1206
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(BasePopup_default, { title: `Change parent of ${genrePlaylist.name}`, onClose, showCancelButton: true, fitContent: true, children: [
1207
- /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("p", { className: "mb-3 text-sm text-[var(--color-text-muted)]", children: [
1610
+ return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(BasePopup_default, { title: `Change parent of ${genrePlaylist.name}`, onClose, showCancelButton: true, fitContent: true, children: [
1611
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("p", { className: "mb-3 text-sm text-[var(--color-text-muted)]", children: [
1208
1612
  "Current parent: ",
1209
1613
  genrePlaylist.parent?.name ?? "none"
1210
1614
  ] }),
1211
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { "aria-busy": isPending, className: isPending ? "pointer-events-none opacity-60" : void 0, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
1615
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { "aria-busy": isPending, className: isPending ? "pointer-events-none opacity-60" : void 0, children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
1212
1616
  GenreSearch,
1213
1617
  {
1214
1618
  genrePlaylists: candidates,
1215
- onSelect: (newParent) => mutate({ uuid: genrePlaylist.criteria.uuid, data: { parent: newParent.criteria.uuid } }, { onSuccess: onClose }),
1619
+ onSelect: handleSelect,
1216
1620
  placeholder: "Search a new parent\u2026"
1217
1621
  }
1218
1622
  ) }),
1219
- formErrors.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { role: "alert", className: "mt-3 text-sm text-[var(--color-red-500)]", children: formErrors.map(({ message }) => message).join(" ") })
1623
+ formErrors.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { role: "alert", className: "mt-3 text-sm text-[var(--color-red-500)]", children: formErrors.map(({ message }) => message).join(" ") })
1220
1624
  ] });
1221
1625
  }
1222
1626
 
1223
1627
  // src/genre-tree/artist/useArtistPanel.tsx
1224
- var import_ui12 = require("@behindthemusictree/ui");
1225
- var import_genre_tree16 = require("@behindthemusictree/app-kit/genre-tree");
1628
+ var import_ui11 = require("@behindthemusictree/ui");
1629
+ var import_genre_tree15 = require("@behindthemusictree/app-kit/genre-tree");
1226
1630
 
1227
1631
  // src/genre-tree/artist/ArtistHeader.tsx
1228
1632
  var import_ui7 = require("@behindthemusictree/ui");
1229
1633
 
1230
1634
  // src/genre-tree/artist/ArtistLinks.tsx
1231
- var import_lucide_react6 = require("lucide-react");
1232
- var import_si = require("react-icons/si");
1635
+ var import_lucide_react9 = require("lucide-react");
1636
+ var import_si3 = require("react-icons/si");
1233
1637
  var import_ui6 = require("@behindthemusictree/ui");
1234
- var import_jsx_runtime18 = require("react/jsx-runtime");
1638
+ var import_jsx_runtime22 = require("react/jsx-runtime");
1235
1639
  var LINK_KINDS = {
1236
- official: { label: "Official website", Icon: import_lucide_react6.Globe },
1237
- wikipedia: { label: "Wikipedia", Icon: import_si.SiWikipedia },
1238
- wikidata: { label: "Wikidata", Icon: import_si.SiWikidata },
1239
- spotify: { label: "Spotify", Icon: import_si.SiSpotify },
1240
- youtube: { label: "YouTube", Icon: import_si.SiYoutube },
1241
- bandcamp: { label: "Bandcamp", Icon: import_si.SiBandcamp },
1242
- soundcloud: { label: "SoundCloud", Icon: import_si.SiSoundcloud },
1243
- discogs: { label: "Discogs", Icon: import_si.SiDiscogs },
1244
- social: { label: "Social", Icon: import_lucide_react6.Share2 }
1640
+ official: { label: "Official website", Icon: import_lucide_react9.Globe },
1641
+ wikipedia: { label: "Wikipedia", Icon: import_si3.SiWikipedia },
1642
+ wikidata: { label: "Wikidata", Icon: import_si3.SiWikidata },
1643
+ spotify: { label: "Spotify", Icon: import_si3.SiSpotify },
1644
+ youtube: { label: "YouTube", Icon: import_si3.SiYoutube },
1645
+ bandcamp: { label: "Bandcamp", Icon: import_si3.SiBandcamp },
1646
+ soundcloud: { label: "SoundCloud", Icon: import_si3.SiSoundcloud },
1647
+ discogs: { label: "Discogs", Icon: import_si3.SiDiscogs },
1648
+ social: { label: "Social", Icon: import_lucide_react9.Share2 }
1245
1649
  };
1246
1650
  function socialLabel(url) {
1247
1651
  try {
@@ -1261,14 +1665,14 @@ function artistLinks(artist) {
1261
1665
  links.push({
1262
1666
  key: "musicbrainz",
1263
1667
  url: `https://musicbrainz.org/artist/${artist.musicbrainzArtistId}`,
1264
- Icon: import_si.SiMusicbrainz,
1668
+ Icon: import_si3.SiMusicbrainz,
1265
1669
  label: "MusicBrainz"
1266
1670
  });
1267
1671
  }
1268
1672
  return links;
1269
1673
  }
1270
1674
  function ArtistLinks({ links }) {
1271
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("ul", { className: "flex flex-wrap gap-1.5", children: links.map(({ key, url, Icon, label }) => /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_ui6.Chip, { href: url, icon: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Icon, { className: "h-3.5 w-3.5 shrink-0", "aria-hidden": "true" }), children: label }) }, key)) });
1675
+ return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("ul", { className: "flex flex-wrap gap-1.5", children: links.map(({ key, url, Icon, label }) => /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(import_ui6.Chip, { href: url, icon: /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Icon, { className: "h-3.5 w-3.5 shrink-0", "aria-hidden": "true" }), children: label }) }, key)) });
1272
1676
  }
1273
1677
 
1274
1678
  // src/genre-tree/artist/formatYears.ts
@@ -1279,7 +1683,7 @@ function formatYears(beginYear, endYear, ended) {
1279
1683
  }
1280
1684
 
1281
1685
  // src/genre-tree/artist/ArtistHeader.tsx
1282
- var import_jsx_runtime19 = require("react/jsx-runtime");
1686
+ var import_jsx_runtime23 = require("react/jsx-runtime");
1283
1687
  var countryNames = new Intl.DisplayNames(["en"], { type: "region" });
1284
1688
  function ArtistHeader({ artist }) {
1285
1689
  const fields = [];
@@ -1289,35 +1693,35 @@ function ArtistHeader({ artist }) {
1289
1693
  const years = formatYears(artist.beginYear, artist.endYear, artist.ended);
1290
1694
  if (years) fields.push({ label: "Active", value: years });
1291
1695
  const links = artistLinks(artist);
1292
- if (links.length > 0) fields.push({ label: "Links", value: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(ArtistLinks, { links }) });
1696
+ if (links.length > 0) fields.push({ label: "Links", value: /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(ArtistLinks, { links }) });
1293
1697
  if (fields.length === 0) return null;
1294
- return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_ui7.FieldList, { items: fields });
1698
+ return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_ui7.FieldList, { items: fields });
1295
1699
  }
1296
1700
 
1297
1701
  // src/genre-tree/artist/ArtistPhoto.tsx
1298
- var import_jsx_runtime20 = require("react/jsx-runtime");
1702
+ var import_jsx_runtime24 = require("react/jsx-runtime");
1299
1703
  function ExternalLink({ href, children }) {
1300
- return /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("a", { href, target: "_blank", rel: "noopener noreferrer", className: "underline-offset-2 hover:underline", children });
1704
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("a", { href, target: "_blank", rel: "noopener noreferrer", className: "underline-offset-2 hover:underline", children });
1301
1705
  }
1302
1706
  function ArtistPhoto({ image, name }) {
1303
- return /* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("figure", { className: "flex flex-col gap-1.5 px-6 py-2.5", children: [
1304
- /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("img", { src: image.url, alt: name, className: "h-64 w-full object-contain bg-[var(--color-surface-muted)] rounded" }),
1305
- /* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("figcaption", { className: "text-xs text-[var(--color-text-muted)]", children: [
1707
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("figure", { className: "flex flex-col gap-1.5 px-6 py-2.5", children: [
1708
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("img", { src: image.url, alt: name, className: "h-64 w-full object-contain bg-[var(--color-surface-muted)] rounded" }),
1709
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("figcaption", { className: "text-xs text-[var(--color-text-muted)]", children: [
1306
1710
  "Photo: ",
1307
- /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(ExternalLink, { href: image.pageUrl, children: image.author ?? "Wikimedia Commons" }),
1711
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(ExternalLink, { href: image.pageUrl, children: image.author ?? "Wikimedia Commons" }),
1308
1712
  ",",
1309
1713
  " ",
1310
- image.licenseUrl ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(ExternalLink, { href: image.licenseUrl, children: image.license }) : image.license
1714
+ image.licenseUrl ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(ExternalLink, { href: image.licenseUrl, children: image.license }) : image.license
1311
1715
  ] })
1312
1716
  ] });
1313
1717
  }
1314
1718
 
1315
1719
  // src/genre-tree/artist/ArtistMembers.tsx
1316
1720
  var import_ui8 = require("@behindthemusictree/ui");
1317
- var import_jsx_runtime21 = require("react/jsx-runtime");
1721
+ var import_jsx_runtime25 = require("react/jsx-runtime");
1318
1722
  function MembershipList({ title, memberships, renderArtistLink }) {
1319
1723
  if (memberships.length === 0) return null;
1320
- return /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_ui8.PanelGroup, { title, children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("ul", { children: memberships.map((membership, index) => {
1724
+ return /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_ui8.PanelGroup, { title, children: /* @__PURE__ */ (0, import_jsx_runtime25.jsx)("ul", { children: memberships.map((membership, index) => {
1321
1725
  const details = [
1322
1726
  formatYears(membership.beginYear, membership.endYear, membership.ended),
1323
1727
  ...membership.roles
@@ -1325,9 +1729,9 @@ function MembershipList({ title, memberships, renderArtistLink }) {
1325
1729
  const detailsText = details.filter(Boolean).join(" \xB7 ");
1326
1730
  return (
1327
1731
  // A member can have several periods in the same group.
1328
- /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("li", { className: "truncate py-0.5", title: detailsText || void 0, children: [
1329
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "font-medium text-[var(--color-text)]", children: renderArtistLink(membership.artist) }),
1330
- detailsText && /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("span", { className: "text-[var(--color-text-muted)]", children: [
1732
+ /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("li", { className: "truncate py-0.5", title: detailsText || void 0, children: [
1733
+ /* @__PURE__ */ (0, import_jsx_runtime25.jsx)("span", { className: "font-medium text-[var(--color-text)]", children: renderArtistLink(membership.artist) }),
1734
+ detailsText && /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("span", { className: "text-[var(--color-text-muted)]", children: [
1331
1735
  " \u2014 ",
1332
1736
  detailsText
1333
1737
  ] })
@@ -1338,9 +1742,9 @@ function MembershipList({ title, memberships, renderArtistLink }) {
1338
1742
  function ArtistMembers({ title, memberships, renderArtistLink }) {
1339
1743
  if (memberships.length === 0) return null;
1340
1744
  const isCurrent = (membership) => !membership.ended && membership.endYear === null;
1341
- return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(import_ui8.PanelSection, { title, children: [
1342
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(MembershipList, { title: "Current", memberships: memberships.filter(isCurrent), renderArtistLink }),
1343
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
1745
+ return /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(import_ui8.PanelSection, { title, children: [
1746
+ /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(MembershipList, { title: "Current", memberships: memberships.filter(isCurrent), renderArtistLink }),
1747
+ /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
1344
1748
  MembershipList,
1345
1749
  {
1346
1750
  title: "Past",
@@ -1353,7 +1757,7 @@ function ArtistMembers({ title, memberships, renderArtistLink }) {
1353
1757
 
1354
1758
  // src/genre-tree/artist/ArtistDiscography.tsx
1355
1759
  var import_ui9 = require("@behindthemusictree/ui");
1356
- var import_jsx_runtime22 = require("react/jsx-runtime");
1760
+ var import_jsx_runtime26 = require("react/jsx-runtime");
1357
1761
  var GROUPS = ["Albums", "EPs", "Singles", "Live", "Compilations", "Other"];
1358
1762
  var PRIMARY_GROUPS = { album: "Albums", ep: "EPs", single: "Singles" };
1359
1763
  function discographyGroup({ primaryType, secondaryTypes }) {
@@ -1373,77 +1777,85 @@ function byYear(a, b) {
1373
1777
  function ArtistDiscography({ albums }) {
1374
1778
  if (albums.length === 0) return null;
1375
1779
  const sorted = [...albums].sort(byYear);
1376
- return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(import_ui9.PanelSection, { title: "Discography", children: GROUPS.map((group) => {
1780
+ return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_ui9.PanelSection, { title: "Discography", children: GROUPS.map((group) => {
1377
1781
  const groupAlbums = sorted.filter((album) => discographyGroup(album) === group);
1378
1782
  if (groupAlbums.length === 0) return null;
1379
- return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(import_ui9.PanelGroup, { title: group, children: /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("ul", { children: groupAlbums.map((album) => /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("li", { className: "grid grid-cols-[2.5rem_minmax(0,1fr)] gap-2 py-0.5", children: [
1380
- /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "tabular-nums text-[var(--color-text-muted)]", children: album.year ?? "\u2014" }),
1381
- /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "truncate text-[var(--color-text)]", title: album.name, children: album.name })
1783
+ return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_ui9.PanelGroup, { title: group, children: /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("ul", { children: groupAlbums.map((album) => /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)("li", { className: "grid grid-cols-[2.5rem_minmax(0,1fr)] gap-2 py-0.5", children: [
1784
+ /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("span", { className: "tabular-nums text-[var(--color-text-muted)]", children: album.year ?? "\u2014" }),
1785
+ /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("span", { className: "truncate text-[var(--color-text)]", title: album.name, children: album.name })
1382
1786
  ] }, album.uuid)) }) }, group);
1383
1787
  }) });
1384
1788
  }
1385
1789
 
1386
1790
  // src/genre-tree/artist/ArtistSongs.tsx
1387
- var import_react9 = require("react");
1791
+ var import_react12 = require("react");
1388
1792
  var import_react_query2 = require("@tanstack/react-query");
1389
- var import_lucide_react7 = require("lucide-react");
1390
- var import_ui11 = require("@behindthemusictree/ui");
1793
+ var import_lucide_react10 = require("lucide-react");
1794
+ var import_ui10 = require("@behindthemusictree/ui");
1391
1795
  var import_player9 = require("@behindthemusictree/app-kit/player");
1392
- var import_genre_tree15 = require("@behindthemusictree/app-kit/genre-tree");
1796
+ var import_genre_tree14 = require("@behindthemusictree/app-kit/genre-tree");
1393
1797
  var import_transport7 = require("@behindthemusictree/app-kit/transport");
1394
1798
 
1395
1799
  // src/genre-tree/track-list-sidebar/TrackItem.tsx
1396
1800
  var import_player8 = require("@behindthemusictree/app-kit/player");
1397
- var import_genre_tree12 = require("@behindthemusictree/app-kit/genre-tree");
1801
+ var import_genre_tree11 = require("@behindthemusictree/app-kit/genre-tree");
1398
1802
 
1399
1803
  // src/genre-tree/TrackPositionPlayPause.tsx
1400
- var import_fa4 = require("react-icons/fa");
1804
+ var import_fa3 = require("react-icons/fa");
1401
1805
  var import_player6 = require("@behindthemusictree/app-kit/player");
1402
1806
  var import_player7 = require("@behindthemusictree/app-kit/player");
1403
- var import_ui10 = require("@behindthemusictree/ui");
1404
- var import_jsx_runtime23 = require("react/jsx-runtime");
1405
- function TrackPositionPlayPause({ position, uuid, handlePlayPauseClick }) {
1406
- const { playerTrackObject, playState } = (0, import_player6.usePlayer)();
1807
+ var import_jsx_runtime27 = require("react/jsx-runtime");
1808
+ function TrackPositionPlayPause({
1809
+ position,
1810
+ uuid,
1811
+ handlePlayPauseClick,
1812
+ isSelected = false
1813
+ }) {
1814
+ const { playerTrackObject, playbackConfirmedTrackId, playState, isLoading: isPlayerLoading } = (0, import_player6.usePlayer)();
1407
1815
  const isThisTrack = playerTrackObject?.track.id === uuid;
1408
- return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)(
1816
+ const isConfirmedPlayable = playbackConfirmedTrackId === uuid;
1817
+ const isAwaitingPlayback = (isSelected || isThisTrack) && !isConfirmedPlayable && !(isThisTrack && playerTrackObject?.loadError) && (!isThisTrack || isPlayerLoading || playState === import_player7.PlayStates.LOADING || playState === import_player7.PlayStates.PLAYING);
1818
+ const isPlaying = isThisTrack && isConfirmedPlayable && playState === import_player7.PlayStates.PLAYING;
1819
+ const isPaused = isThisTrack && isConfirmedPlayable && playState === import_player7.PlayStates.PAUSED;
1820
+ const isCurrentTrackActive = isPlaying || isPaused;
1821
+ return /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
1409
1822
  "div",
1410
1823
  {
1411
- className: "track-position-play-pause flex items-center justify-center text-lg w-16 cursor-pointer",
1824
+ className: "track-position-play-pause relative flex items-center justify-center gap-2 text-lg w-16 cursor-pointer",
1412
1825
  style: { minWidth: "4rem", maxWidth: "4rem" },
1413
1826
  onClick: handlePlayPauseClick,
1414
1827
  children: [
1415
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "group-hover:hidden", children: /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { children: isThisTrack && playState !== import_player7.PlayStates.STOPPED ? /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "flex space-x-1 items-end", children: [
1416
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
1417
- "div",
1418
- {
1419
- className: `w-playingbar bg-[var(--color-green-500)] h-3 origin-bottom ${playState === import_player7.PlayStates.PLAYING ? "animate-scale-pulse" : ""}`
1420
- }
1421
- ),
1422
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
1423
- "div",
1424
- {
1425
- className: `w-playingbar bg-[var(--color-green-500)] h-4 origin-bottom ${playState === import_player7.PlayStates.PLAYING ? "animate-scale-pulse delay-200" : ""}`,
1426
- style: { animationDelay: "0.2s" }
1427
- }
1428
- ),
1429
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
1430
- "div",
1431
- {
1432
- className: `w-playingbar bg-[var(--color-green-500)] h-3 origin-bottom ${playState === import_player7.PlayStates.PLAYING ? "animate-scale-pulse delay-400" : ""}`,
1433
- style: { animationDelay: "0.3s" }
1434
- }
1435
- )
1436
- ] }) : position }) }),
1437
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "hidden group-hover:flex items-center justify-center", children: isThisTrack && playState === import_player7.PlayStates.LOADING ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_ui10.RingLoader, { size: 16 }) : isThisTrack && playState === import_player7.PlayStates.PLAYING ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_fa4.FaPause, {}) : /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_fa4.FaPlay, {}) })
1828
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("div", { className: "track-position-play-pause__status group-hover:hidden flex items-center justify-center", children: isAwaitingPlayback ? /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("span", { className: "track-position-play-pause__spinner", role: "status", "aria-label": "Loading track" }) : isCurrentTrackActive ? /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
1829
+ "span",
1830
+ {
1831
+ className: "track-position-play-pause__bars",
1832
+ "data-playing": isPlaying,
1833
+ role: "img",
1834
+ "aria-label": isPlaying ? "Playing" : "Paused",
1835
+ children: [
1836
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("span", { className: "track-position-play-pause__bar" }),
1837
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("span", { className: "track-position-play-pause__bar" }),
1838
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("span", { className: "track-position-play-pause__bar" })
1839
+ ]
1840
+ }
1841
+ ) : position }),
1842
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
1843
+ "div",
1844
+ {
1845
+ className: `hidden group-hover:flex items-center justify-center ${isCurrentTrackActive ? "" : "absolute inset-0"}`,
1846
+ "aria-hidden": "true",
1847
+ children: isAwaitingPlayback ? /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("span", { className: "track-position-play-pause__spinner" }) : isPlaying ? /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(import_fa3.FaPause, {}) : /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(import_fa3.FaPlay, {})
1848
+ }
1849
+ )
1438
1850
  ]
1439
1851
  }
1440
1852
  );
1441
1853
  }
1442
1854
 
1443
1855
  // src/genre-tree/track-list-sidebar/TrackItem.tsx
1856
+ var import_genre_tree12 = require("@behindthemusictree/app-kit/genre-tree");
1444
1857
  var import_genre_tree13 = require("@behindthemusictree/app-kit/genre-tree");
1445
- var import_genre_tree14 = require("@behindthemusictree/app-kit/genre-tree");
1446
- var import_jsx_runtime24 = require("react/jsx-runtime");
1858
+ var import_jsx_runtime28 = require("react/jsx-runtime");
1447
1859
  function TrackItem({
1448
1860
  track,
1449
1861
  position,
@@ -1452,25 +1864,26 @@ function TrackItem({
1452
1864
  renderArtistLink
1453
1865
  }) {
1454
1866
  const { handlePlayPauseAction, playerTrackObject, loadTrackForPlayer } = (0, import_player8.usePlayer)();
1455
- const { trackList, toTrackAtPosition } = (0, import_genre_tree12.useTrackList)();
1867
+ const { trackList, selectedTrack, toTrackAtPosition, runtimeUnplayableTrackIds } = (0, import_genre_tree11.useTrackList)();
1456
1868
  const scope = trackList?.origin?.scope ?? null;
1457
- const unplayableReason = (0, import_genre_tree13.isSong)(track) ? (0, import_genre_tree13.songUnplayableReason)(track) : null;
1869
+ const isCurrentTrack = playerTrackObject?.track.id === track.uuid;
1870
+ const hasPlaybackError = isCurrentTrack && !!playerTrackObject?.loadError;
1871
+ const isUnplayable = hasPlaybackError || runtimeUnplayableTrackIds?.has(track.uuid) === true || (0, import_genre_tree12.isSong)(track) && (0, import_genre_tree12.songUnplayableReason)(track) !== null;
1458
1872
  const handlePlayPauseClick = (event) => {
1459
1873
  event.stopPropagation();
1460
- if (playerTrackObject && playerTrackObject.track.id === track.uuid) {
1874
+ if (isCurrentTrack) {
1461
1875
  handlePlayPauseAction();
1462
1876
  } else if (scope != null) {
1463
1877
  toTrackAtPosition(position);
1464
1878
  loadTrackForPlayer(track.uuid);
1465
1879
  }
1466
1880
  };
1467
- return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)(
1881
+ return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(
1468
1882
  "div",
1469
1883
  {
1470
- className: `track-item flex h-14 text-[var(--color-text)] ${unplayableReason ? "opacity-50" : "hover:bg-[var(--color-surface-muted)] group"}`,
1471
- title: unplayableReason ?? void 0,
1884
+ className: `track-item flex h-14 text-[var(--color-text)] ${isUnplayable ? "opacity-50" : "hover:bg-[var(--color-surface-muted)] group"}`,
1472
1885
  children: [
1473
- unplayableReason ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
1886
+ isUnplayable ? /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
1474
1887
  "div",
1475
1888
  {
1476
1889
  className: "flex items-center justify-center text-lg w-16",
@@ -1478,18 +1891,21 @@ function TrackItem({
1478
1891
  "aria-disabled": "true",
1479
1892
  children: position
1480
1893
  }
1481
- ) : /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(TrackPositionPlayPause, { position, uuid: track.uuid, handlePlayPauseClick }),
1482
- /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: "title-artist-container flex flex-col items-start justify-center min-w-0 flex-1", children: [
1483
- /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: "flex max-w-full items-center gap-2", children: [
1484
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "title text-lg font-bold text-[var(--color-text)] max-w-full truncate", children: track.title }),
1485
- (0, import_genre_tree13.isSong)(track) && /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(MusicBrainzRecordingLink, { song: track })
1486
- ] }),
1487
- track.artists && track.artists.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "artist text-base max-w-full truncate text-[var(--color-text-muted)]", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(import_genre_tree14.ArtistNames, { artists: track.artists, renderArtistLink }) }) : "",
1488
- unplayableReason && /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "text-xs", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(UnplayableReason, { reason: unplayableReason }) })
1894
+ ) : /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
1895
+ TrackPositionPlayPause,
1896
+ {
1897
+ position,
1898
+ uuid: track.uuid,
1899
+ handlePlayPauseClick,
1900
+ isSelected: selectedTrack?.uuid === track.uuid
1901
+ }
1902
+ ),
1903
+ /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "title-artist-container flex flex-col items-start justify-center min-w-0 flex-1", children: [
1904
+ /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "flex max-w-full items-center gap-2", children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "title text-lg font-bold text-[var(--color-text)] max-w-full truncate", children: (0, import_genre_tree12.isSong)(track) ? /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(MusicBrainzRecordingLink, { song: track, className: "hover:underline focus-visible:underline", children: track.title }) : track.title }) }),
1905
+ track.artists && track.artists.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "artist text-base max-w-full truncate text-[var(--color-text-muted)]", children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(import_genre_tree13.ArtistNames, { artists: track.artists, renderArtistLink }) }) : ""
1489
1906
  ] }),
1490
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "album-name items-start justify-center w-1/3 min-w-0 ml-2 max-w-full truncate text-[var(--color-text-muted)]", children: track.album ? track.album.name : "" }),
1491
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "genre-name-container flex items-center justify-end w-1/6 min-w-0 shrink-0", children: track.genre ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "genre-name font-bold p-1 border border-[var(--color-text-muted)] text-xs max-w-full truncate", children: track.genre.name }) : "" }),
1492
- renderDuration && /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
1907
+ /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "genre-name-container flex items-center justify-start w-[21.6%] min-w-0 shrink-0", children: track.genre ? /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "genre-name max-w-full whitespace-normal break-words p-1 text-xs font-bold", children: track.genre.name }) : "" }),
1908
+ renderDuration && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
1493
1909
  "div",
1494
1910
  {
1495
1911
  className: "duration flex text-base w-16 items-center justify-center",
@@ -1497,28 +1913,28 @@ function TrackItem({
1497
1913
  children: renderDuration(track)
1498
1914
  }
1499
1915
  ),
1500
- renderActions && /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "edit flex text-base w-6 items-center justify-center mr-2 cursor-pointer", children: renderActions(track) })
1916
+ renderActions && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "edit flex text-base w-6 items-center justify-center mr-2 cursor-pointer", children: renderActions(track) })
1501
1917
  ]
1502
1918
  }
1503
1919
  );
1504
1920
  }
1505
1921
 
1506
1922
  // src/genre-tree/artist/ArtistSongs.tsx
1507
- var import_jsx_runtime25 = require("react/jsx-runtime");
1508
- var SongPageSchema2 = (0, import_transport7.PaginatedResponseSchema)(import_genre_tree15.SongDetailedSchema);
1923
+ var import_jsx_runtime29 = require("react/jsx-runtime");
1924
+ var SongPageSchema2 = (0, import_transport7.PaginatedResponseSchema)(import_genre_tree14.SongDetailedSchema);
1509
1925
  var SORTS = [
1510
1926
  { ordering: "title", label: "Title" },
1511
1927
  { ordering: "-albumYear", label: "Year" }
1512
1928
  ];
1513
1929
  function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1514
- const [page, setPage] = (0, import_react9.useState)(1);
1515
- const [ordering, setOrdering] = (0, import_react9.useState)("title");
1930
+ const [page, setPage] = (0, import_react12.useState)(1);
1931
+ const [ordering, setOrdering] = (0, import_react12.useState)("title");
1516
1932
  const { fetch } = (0, import_transport7.useFetchWrapper)(getBackendBaseUrl);
1517
- const { playNewTrackListFromArtist } = (0, import_genre_tree15.useTrackList)();
1933
+ const { playNewTrackListFromArtist } = (0, import_genre_tree14.useTrackList)();
1518
1934
  const { playerTrackObject } = (0, import_player9.usePlayer)();
1519
1935
  const { data, isPending, isError } = (0, import_transport7.useQueryWithParse)({
1520
1936
  queryKey: ["referenceSongs", "artist", artist.uuid, ordering, page],
1521
- queryFn: () => fetch(import_genre_tree15.libraryEndpoints.reference.songs.list(), true, false, void 0, {
1937
+ queryFn: () => fetch(import_genre_tree14.libraryEndpoints.reference.songs.list(), true, false, void 0, {
1522
1938
  artist: artist.uuid,
1523
1939
  ordering,
1524
1940
  page
@@ -1533,25 +1949,25 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1533
1949
  });
1534
1950
  const handleRowClickCapture = (event, song) => {
1535
1951
  if (!(event.target instanceof Element) || !event.target.closest(".track-position-play-pause")) return;
1536
- if (playerTrackObject?.track.id === song.uuid || (0, import_genre_tree15.songUnplayableReason)(song) !== null) return;
1952
+ if (playerTrackObject?.track.id === song.uuid || (0, import_genre_tree14.songUnplayableReason)(song) !== null) return;
1537
1953
  event.stopPropagation();
1538
1954
  void play(song.uuid);
1539
1955
  };
1540
- return /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_ui11.PanelSection, { title: "Songs", children: isPending ? /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_ui11.Skeleton, { className: "h-14", "aria-busy": "true" }) : isError ? /* @__PURE__ */ (0, import_jsx_runtime25.jsx)("p", { className: "text-red-600", children: "Could not load songs." }) : data.results.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime25.jsx)("p", { className: "text-[var(--color-text-muted)]", children: "No songs yet." }) : /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(import_jsx_runtime25.Fragment, { children: [
1541
- /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-2 mb-2", children: [
1542
- /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(
1956
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_ui10.PanelSection, { title: "Songs", children: isPending ? /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_ui10.Skeleton, { className: "h-14", "aria-busy": "true" }) : isError ? /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("p", { className: "text-red-600", children: "Could not load songs." }) : data.results.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("p", { className: "text-[var(--color-text-muted)]", children: "No songs yet." }) : /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(import_jsx_runtime29.Fragment, { children: [
1957
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-2 mb-2", children: [
1958
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
1543
1959
  "button",
1544
1960
  {
1545
1961
  type: "button",
1546
1962
  onClick: () => void play(),
1547
1963
  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",
1548
1964
  children: [
1549
- /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_lucide_react7.Play, { className: "h-3.5 w-3.5", "aria-hidden": "true" }),
1965
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_lucide_react10.Play, { className: "h-3.5 w-3.5", "aria-hidden": "true" }),
1550
1966
  "Play all"
1551
1967
  ]
1552
1968
  }
1553
1969
  ),
1554
- /* @__PURE__ */ (0, import_jsx_runtime25.jsx)("div", { role: "group", "aria-label": "Sort songs by", className: "flex gap-1 text-xs", children: SORTS.map((sort) => /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
1970
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { role: "group", "aria-label": "Sort songs by", className: "flex gap-1 text-xs", children: SORTS.map((sort) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
1555
1971
  "button",
1556
1972
  {
1557
1973
  type: "button",
@@ -1566,7 +1982,7 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1566
1982
  sort.ordering
1567
1983
  )) })
1568
1984
  ] }),
1569
- /* @__PURE__ */ (0, import_jsx_runtime25.jsx)("ul", { className: "overflow-hidden mb-2 bg-zinc-950 rounded-lg", children: data.results.map((song, index) => /* @__PURE__ */ (0, import_jsx_runtime25.jsx)("li", { onClickCapture: (event) => handleRowClickCapture(event, song), children: /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
1985
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("ul", { className: "overflow-hidden mb-2 bg-zinc-950 rounded-lg", children: data.results.map((song, index) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("li", { onClickCapture: (event) => handleRowClickCapture(event, song), children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
1570
1986
  TrackItem,
1571
1987
  {
1572
1988
  track: song,
@@ -1574,21 +1990,23 @@ function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink }) {
1574
1990
  renderArtistLink
1575
1991
  }
1576
1992
  ) }, song.uuid)) }),
1577
- /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_ui11.Pagination, { currentPage: page, totalPages: data.totalPages, onPageChange: setPage })
1993
+ /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_ui10.Pagination, { currentPage: page, totalPages: data.totalPages, onPageChange: setPage })
1578
1994
  ] }) });
1579
1995
  }
1580
1996
 
1581
1997
  // src/genre-tree/artist/useArtistPanel.tsx
1582
- var import_jsx_runtime26 = require("react/jsx-runtime");
1998
+ var import_jsx_runtime30 = require("react/jsx-runtime");
1583
1999
  function useArtistPanel({
1584
2000
  uuid,
2001
+ scope,
1585
2002
  getBackendBaseUrl,
2003
+ getShareUrl,
1586
2004
  renderArtistLink,
1587
2005
  expanded,
1588
2006
  onExpandedChange,
1589
2007
  onClose
1590
2008
  }) {
1591
- const result = (0, import_genre_tree16.useArtist)(uuid, getBackendBaseUrl);
2009
+ const result = (0, import_genre_tree15.useArtist)(uuid, getBackendBaseUrl);
1592
2010
  const panel = { expanded, onExpandedChange, onClose };
1593
2011
  switch (result.status) {
1594
2012
  case "idle":
@@ -1597,38 +2015,41 @@ function useArtistPanel({
1597
2015
  return {
1598
2016
  ...panel,
1599
2017
  title: "Artist",
1600
- content: /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)("div", { "aria-busy": "true", className: "flex flex-col gap-2.5 px-6 py-2.5", children: [
1601
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_ui12.Skeleton, { className: "h-3 w-16" }),
1602
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_ui12.Skeleton, { className: "h-4 w-32" }),
1603
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_ui12.Skeleton, { className: "h-3 w-16" }),
1604
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_ui12.Skeleton, { className: "h-4 w-24" }),
1605
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_ui12.Skeleton, { className: "h-16 mt-2.5" }),
1606
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_ui12.Skeleton, { className: "h-16" })
2018
+ content: /* @__PURE__ */ (0, import_jsx_runtime30.jsxs)("div", { "aria-busy": "true", className: "flex flex-col gap-2.5 px-6 py-2.5", children: [
2019
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_ui11.Skeleton, { className: "h-3 w-16" }),
2020
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_ui11.Skeleton, { className: "h-4 w-32" }),
2021
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_ui11.Skeleton, { className: "h-3 w-16" }),
2022
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_ui11.Skeleton, { className: "h-4 w-24" }),
2023
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_ui11.Skeleton, { className: "h-16 mt-2.5" }),
2024
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_ui11.Skeleton, { className: "h-16" })
1607
2025
  ] })
1608
2026
  };
1609
2027
  case "not-found":
1610
2028
  return {
1611
2029
  ...panel,
1612
2030
  title: "Artist not found",
1613
- content: /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("p", { className: "px-6 py-2.5 text-[var(--color-text-muted)]", children: "This artist does not exist." })
2031
+ content: /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("p", { className: "px-6 py-2.5 text-[var(--color-text-muted)]", children: "This artist does not exist." })
1614
2032
  };
1615
2033
  case "error":
1616
2034
  return {
1617
2035
  ...panel,
1618
2036
  title: "Artist",
1619
- content: /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("p", { className: "px-6 py-2.5 text-red-600", children: "Could not load this artist." })
2037
+ content: /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("p", { className: "px-6 py-2.5 text-red-600", children: "Could not load this artist." })
1620
2038
  };
1621
2039
  case "success": {
1622
2040
  const { artist } = result;
2041
+ const shareTarget = scope ? { kind: "artist", uuid: artist.uuid, name: artist.name, scope } : void 0;
2042
+ const shareUrl = shareTarget ? getShareUrl?.(shareTarget) : void 0;
1623
2043
  return {
1624
2044
  ...panel,
1625
2045
  title: artist.name,
1626
- content: /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)(import_jsx_runtime26.Fragment, { children: [
1627
- artist.image && /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(ArtistPhoto, { image: artist.image, name: artist.name }),
1628
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(ArtistHeader, { artist }),
1629
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(ArtistMembers, { title: "Members", memberships: artist.members, renderArtistLink }),
1630
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(ArtistMembers, { title: "Member of", memberships: artist.memberOf, renderArtistLink }),
1631
- artist.tracksCount > 0 && /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
2046
+ content: /* @__PURE__ */ (0, import_jsx_runtime30.jsxs)(import_jsx_runtime30.Fragment, { children: [
2047
+ shareUrl && /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("div", { className: "genre-action-band border-b border-[var(--color-border)]", children: /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("div", { className: "flex flex-wrap gap-1 px-3.5 py-3", children: shareTarget && /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(ShareActionButton, { target: shareTarget, url: shareUrl }) }) }),
2048
+ artist.image && /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(ArtistPhoto, { image: artist.image, name: artist.name }),
2049
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(ArtistHeader, { artist }),
2050
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(ArtistMembers, { title: "Members", memberships: artist.members, renderArtistLink }),
2051
+ /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(ArtistMembers, { title: "Member of", memberships: artist.memberOf, renderArtistLink }),
2052
+ artist.tracksCount > 0 && /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(
1632
2053
  ArtistSongs,
1633
2054
  {
1634
2055
  artist: { uuid: artist.uuid, name: artist.name },
@@ -1637,14 +2058,36 @@ function useArtistPanel({
1637
2058
  }
1638
2059
  )
1639
2060
  ] }),
1640
- extraContent: artist.albums.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(ArtistDiscography, { albums: artist.albums }) : void 0
2061
+ extraContent: artist.albums.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(ArtistDiscography, { albums: artist.albums }) : void 0
1641
2062
  };
1642
2063
  }
1643
2064
  }
1644
2065
  }
1645
2066
 
2067
+ // src/genre-tree/SizeMetricToggle.tsx
2068
+ var import_md = require("react-icons/md");
2069
+ var import_ui12 = require("@behindthemusictree/ui");
2070
+ var import_jsx_runtime31 = require("react/jsx-runtime");
2071
+ function SizeMetricToggle({
2072
+ value,
2073
+ onChange
2074
+ }) {
2075
+ return /* @__PURE__ */ (0, import_jsx_runtime31.jsx)(
2076
+ import_ui12.SegmentedControl,
2077
+ {
2078
+ "aria-label": "Size nodes by",
2079
+ value,
2080
+ onChange,
2081
+ options: [
2082
+ { value: "songs", label: /* @__PURE__ */ (0, import_jsx_runtime31.jsx)(import_md.MdMusicNote, { size: 18, "aria-hidden": true }), "aria-label": "Size by songs", title: "Node size by song count" },
2083
+ { value: "views", label: /* @__PURE__ */ (0, import_jsx_runtime31.jsx)(import_md.MdVisibility, { size: 18, "aria-hidden": true }), "aria-label": "Size by views", title: "Node size by song views" }
2084
+ ]
2085
+ }
2086
+ );
2087
+ }
2088
+
1646
2089
  // src/genre-tree/GenreTreeView.tsx
1647
- var import_jsx_runtime27 = require("react/jsx-runtime");
2090
+ var import_jsx_runtime32 = require("react/jsx-runtime");
1648
2091
  var PREVIOUS_BUTTON_CSS = `
1649
2092
  .genre-tree-view .search-overlay .gtv-info-panel-previous { display: none; }
1650
2093
  .genre-tree-view:has(.search-overlay .gtv-info-panel-previous) .genre-search input { padding-left: 2.75rem; }
@@ -1666,7 +2109,7 @@ function GenreNodeActionBand({ genrePlaylist, scope, playback, ...bandProps }) {
1666
2109
  console.error("Failed to load genre playlist tracks:", error);
1667
2110
  });
1668
2111
  };
1669
- return /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2112
+ return /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
1670
2113
  GenreActionBand,
1671
2114
  {
1672
2115
  ...bandProps,
@@ -1681,6 +2124,7 @@ function GenreTreeView({
1681
2124
  scope,
1682
2125
  handleGenreCreationAction,
1683
2126
  handleGenreRenameAction,
2127
+ onParentChanged,
1684
2128
  getBackendBaseUrl,
1685
2129
  additionalActions,
1686
2130
  viewMode: controlledViewMode,
@@ -1689,18 +2133,22 @@ function GenreTreeView({
1689
2133
  defaultViewport,
1690
2134
  onViewportChange,
1691
2135
  readOnly = false,
1692
- className,
2136
+ className: className2,
1693
2137
  showDisplayOptions = true,
1694
2138
  showAddRoot = true,
1695
2139
  allowWheelRotation: controlledAllowWheelRotation,
1696
2140
  criteriaOverviewSchema,
1697
2141
  renderGenreDetailExtras,
1698
2142
  controlsOrientation = "horizontal",
2143
+ floatingControlsLeading,
2144
+ sizeMetric: controlledSizeMetric,
2145
+ onSizeMetricChange,
1699
2146
  renderArtistLink,
1700
2147
  artistUuid: controlledArtistUuid,
1701
2148
  onArtistUuidChange,
1702
2149
  artistExpanded: controlledArtistExpanded,
1703
2150
  onArtistExpandedChange,
2151
+ getShareUrl,
1704
2152
  artistSearch = true,
1705
2153
  onArtistSelect,
1706
2154
  artistSearchBaseUrl,
@@ -1709,10 +2157,6 @@ function GenreTreeView({
1709
2157
  renderSongLeading,
1710
2158
  renderSongActions,
1711
2159
  renderTracksToolbar,
1712
- editMode,
1713
- onEditModeChange,
1714
- renderTitleEditor,
1715
- renderParentsEditor,
1716
2160
  renderDangerZone,
1717
2161
  expanded,
1718
2162
  onExpandedChange,
@@ -1722,15 +2166,25 @@ function GenreTreeView({
1722
2166
  placement = "default",
1723
2167
  ...focus
1724
2168
  }) {
1725
- const [internalArtistUuid, setInternalArtistUuid] = (0, import_react10.useState)(null);
2169
+ const getShareUrlRef = (0, import_react13.useRef)(getShareUrl);
2170
+ getShareUrlRef.current = getShareUrl;
2171
+ const [internalArtistUuid, setInternalArtistUuid] = (0, import_react13.useState)(null);
1726
2172
  const isArtistControlled = controlledArtistUuid !== void 0;
1727
2173
  const artistUuid = isArtistControlled ? controlledArtistUuid : internalArtistUuid;
2174
+ const [internalSizeMetric, setInternalSizeMetric] = (0, import_react13.useState)("songs");
2175
+ const sizeMetric = controlledSizeMetric ?? internalSizeMetric;
2176
+ const deferredSizeMetric = (0, import_react13.useDeferredValue)(sizeMetric);
2177
+ const changeSizeMetric = (metric) => {
2178
+ if (controlledSizeMetric === void 0) setInternalSizeMetric(metric);
2179
+ onSizeMetricChange?.(metric);
2180
+ };
1728
2181
  const changeArtist = (uuid) => {
1729
2182
  if (!isArtistControlled) setInternalArtistUuid(uuid);
1730
2183
  onArtistUuidChange?.(uuid);
1731
2184
  };
1732
2185
  const {
1733
2186
  isLoading,
2187
+ isFetching: isTreeFetching,
1734
2188
  genrePlaylistByNodeId,
1735
2189
  visibleGenrePlaylists,
1736
2190
  groupedGenrePlaylistsByRoot,
@@ -1746,7 +2200,7 @@ function GenreTreeView({
1746
2200
  allowWheelRotation: internalAllowWheelRotation,
1747
2201
  toggleWheelRotation,
1748
2202
  onNodeHover: handleNodeHover
1749
- } = (0, import_genre_tree18.useGenreTreeViewState)({
2203
+ } = (0, import_genre_tree17.useGenreTreeViewState)({
1750
2204
  scope,
1751
2205
  getBackendBaseUrl,
1752
2206
  viewMode: controlledViewMode,
@@ -1755,23 +2209,31 @@ function GenreTreeView({
1755
2209
  criteriaOverviewSchema,
1756
2210
  artistUuid
1757
2211
  });
1758
- const artistResult = (0, import_genre_tree18.useArtist)(artistUuid, artistSearchBaseUrl ?? getBackendBaseUrl);
2212
+ const isGenreUpdating = (0, import_react_query3.useIsMutating)({ mutationKey: import_genre_tree17.genreUpdateMutationKey }) > 0;
2213
+ const { mutate: updateGenre } = (0, import_genre_tree17.useUpdateGenre)(scope, getBackendBaseUrl);
2214
+ const busy = deferredSizeMetric !== sizeMetric || isTreeFetching || isGenreUpdating;
2215
+ const isArtistFiltered = artistUuid !== null;
2216
+ const canSizeByViews = !isArtistFiltered && hasViewsCount(visibleGenrePlaylists);
2217
+ const artistResult = (0, import_genre_tree17.useArtist)(artistUuid, artistSearchBaseUrl ?? getBackendBaseUrl);
1759
2218
  const filterArtistName = artistResult.status === "success" ? artistResult.artist.name : null;
1760
- const filterArtist = (0, import_react10.useMemo)(
2219
+ const filterArtist = (0, import_react13.useMemo)(
1761
2220
  () => artistUuid !== null && filterArtistName !== null ? { uuid: artistUuid, name: filterArtistName } : null,
1762
2221
  [artistUuid, filterArtistName]
1763
2222
  );
2223
+ const hasSelectedGenreNode = selectedGenrePlaylist !== void 0 && visibleGenrePlaylists.some(({ uuid }) => uuid === selectedGenrePlaylist.uuid);
1764
2224
  const playback = useGenrePlaylistPlayback({ scope, genrePlaylists: visibleGenrePlaylists, artist: filterArtist });
1765
2225
  const openArtist = (uuid) => {
1766
2226
  if (selectedNodeId !== null) clearSelection();
1767
2227
  changeArtist(uuid);
1768
2228
  };
1769
- const [expandedArtistUuid, setExpandedArtistUuid] = (0, import_react10.useState)(null);
2229
+ const [expandedArtistUuid, setExpandedArtistUuid] = (0, import_react13.useState)(null);
1770
2230
  const artistBaseUrl = artistSearchBaseUrl ?? getBackendBaseUrl;
1771
2231
  const artistPanel = useArtistPanel({
1772
2232
  uuid: artistUuid,
2233
+ scope,
1773
2234
  getBackendBaseUrl: artistBaseUrl,
1774
- renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
2235
+ getShareUrl: getShareUrlRef.current,
2236
+ renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
1775
2237
  expanded: controlledArtistExpanded ?? (artistUuid !== null && expandedArtistUuid === artistUuid),
1776
2238
  onExpandedChange: (on) => {
1777
2239
  if (controlledArtistExpanded === void 0) setExpandedArtistUuid(on ? artistUuid : null);
@@ -1779,10 +2241,10 @@ function GenreTreeView({
1779
2241
  },
1780
2242
  onClose: () => changeArtist(null)
1781
2243
  });
1782
- const builtInArtistPanel = overlayPanel || selectedNodeId !== null ? void 0 : artistPanel;
2244
+ const builtInArtistPanel = overlayPanel || hasSelectedGenreNode ? void 0 : artistPanel;
1783
2245
  const activeOverlayPanel = overlayPanel ?? (builtInArtistPanel && { ...builtInArtistPanel, hideTitle: true, hideClose: true });
1784
- const [uncontrolledCollapsed, setUncontrolledCollapsed] = (0, import_react10.useState)(false);
1785
- const [uncontrolledExpanded, setUncontrolledExpanded] = (0, import_react10.useState)(false);
2246
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = (0, import_react13.useState)(false);
2247
+ const [uncontrolledExpanded, setUncontrolledExpanded] = (0, import_react13.useState)(false);
1786
2248
  const isCollapsed = collapsed ?? uncontrolledCollapsed;
1787
2249
  const isExpanded = expanded ?? uncontrolledExpanded;
1788
2250
  const changeCollapsed = (next) => {
@@ -1793,11 +2255,11 @@ function GenreTreeView({
1793
2255
  setUncontrolledExpanded(next);
1794
2256
  onExpandedChange?.(next);
1795
2257
  };
1796
- (0, import_react10.useEffect)(() => {
2258
+ (0, import_react13.useEffect)(() => {
1797
2259
  setUncontrolledCollapsed(false);
1798
2260
  }, [selectedNodeId, artistUuid]);
1799
- const isDetailHidden = isCollapsed || !selectedNodeId && !activeOverlayPanel;
1800
- (0, import_react10.useEffect)(() => {
2261
+ const isDetailHidden = isCollapsed || !hasSelectedGenreNode && !activeOverlayPanel;
2262
+ (0, import_react13.useEffect)(() => {
1801
2263
  if (placement !== "regions" || !isDetailHidden) return void 0;
1802
2264
  const root = document.documentElement;
1803
2265
  root.style.setProperty("--layout-left-w", "0px");
@@ -1809,10 +2271,6 @@ function GenreTreeView({
1809
2271
  const detailExpanded = detailOverlay ? !!detailOverlay.expanded : isExpanded;
1810
2272
  const onDetailExpandedChange = detailOverlay ? detailOverlay.onExpandedChange : changeExpanded;
1811
2273
  const infoPanelCuration = {
1812
- editMode,
1813
- onEditModeChange,
1814
- renderTitleEditor,
1815
- renderParentsEditor,
1816
2274
  renderDangerZone,
1817
2275
  expanded: placement === "regions" ? isExpanded : expanded,
1818
2276
  onExpandedChange: placement === "regions" ? changeExpanded : onExpandedChange,
@@ -1820,29 +2278,38 @@ function GenreTreeView({
1820
2278
  hideExpandButton: placement === "regions" ? true : void 0,
1821
2279
  overlayPanel: detailOverlay ? { ...detailOverlay, collapsed: isCollapsed, hideExpandButton: true } : activeOverlayPanel
1822
2280
  };
1823
- const [isWheelRevealed, setIsWheelRevealed] = (0, import_react10.useState)(false);
2281
+ const [isWheelRevealed, setIsWheelRevealed] = (0, import_react13.useState)(false);
1824
2282
  const usesHandoff = viewMode === "wheel" || viewMode === "pop-core";
1825
2283
  const isTreeReady = !isLoading && (!usesHandoff || isWheelRevealed);
1826
- const rootRef = (0, import_react10.useRef)(null);
2284
+ const rootRef = (0, import_react13.useRef)(null);
1827
2285
  const handlePrevious = () => {
1828
2286
  rootRef.current?.querySelector(".search-overlay .gtv-info-panel-previous")?.click();
1829
2287
  };
1830
- const { showPopup, hidePopup } = (0, import_popup4.usePopup)();
1831
- const [searchText, setSearchText] = (0, import_react10.useState)("");
1832
- const artistSearchResult = (0, import_genre_tree17.useArtistSearch)(artistBaseUrl, artistSearch ? searchText : "");
2288
+ (0, import_react13.useEffect)(() => {
2289
+ if (!isTreeReady || viewMode !== "pop-core" || !hasSelectedGenreNode || !selectedGenrePlaylist) return;
2290
+ const root = rootRef.current;
2291
+ const centerChip = root?.querySelector(".gtv-wheel-center-node .gtv-wheel-chip");
2292
+ if (!centerChip || centerChip.getAttribute("aria-label") !== selectedGenrePlaylist.name) return;
2293
+ const panelTitle = root?.querySelector(".gtv-info-panel-title");
2294
+ if (panelTitle?.textContent?.trim().startsWith(selectedGenrePlaylist.name)) return;
2295
+ centerChip.click();
2296
+ }, [hasSelectedGenreNode, isTreeReady, selectedGenrePlaylist, viewMode]);
2297
+ const { showPopup, hidePopup } = (0, import_popup5.usePopup)();
2298
+ const [searchText, setSearchText] = (0, import_react13.useState)("");
2299
+ const artistSearchResult = (0, import_genre_tree16.useArtistSearch)(artistBaseUrl, artistSearch ? searchText : "");
1833
2300
  const artistExtraResults = artistSearch ? {
1834
2301
  label: "Artists",
1835
2302
  items: artistSearchResult.artists.map((artist) => ({
1836
2303
  key: artist.uuid,
1837
- content: /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(import_jsx_runtime27.Fragment, { children: [
1838
- /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("span", { className: "block truncate font-medium", children: [
2304
+ content: /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)(import_jsx_runtime32.Fragment, { children: [
2305
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("span", { className: "block truncate font-medium", children: [
1839
2306
  artist.name,
1840
- artist.disambiguation && /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("span", { className: "font-normal text-[var(--color-neutral-500)]", children: [
2307
+ artist.disambiguation && /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("span", { className: "font-normal text-[var(--color-neutral-500)]", children: [
1841
2308
  " \xB7 ",
1842
2309
  artist.disambiguation
1843
2310
  ] })
1844
2311
  ] }),
1845
- artist.matchedAlias && /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [
2312
+ artist.matchedAlias && /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("span", { className: "block truncate text-xs text-[var(--color-neutral-500)]", children: [
1846
2313
  "also known as ",
1847
2314
  artist.matchedAlias
1848
2315
  ] })
@@ -1852,7 +2319,8 @@ function GenreTreeView({
1852
2319
  onArtistSelect?.(artist);
1853
2320
  }
1854
2321
  })),
1855
- status: artistSearchResult.isError ? "Search failed" : artistSearchResult.settled && artistSearchResult.artists.length === 0 ? "No artists found" : ""
2322
+ status: artistSearchResult.isError ? "Search failed" : artistSearchResult.settled && artistSearchResult.artists.length === 0 ? "No artists found" : "",
2323
+ loading: artistSearchResult.pending
1856
2324
  } : void 0;
1857
2325
  const handleSelectedNodeChange = (node) => {
1858
2326
  selectNode(node);
@@ -1867,51 +2335,72 @@ function GenreTreeView({
1867
2335
  const handleGenreSearchZoomIn = () => {
1868
2336
  if (selectedUuid) focus.onFocusGenreChange?.(selectedUuid, "zoom-in");
1869
2337
  };
1870
- const zoomInRef = (0, import_react10.useRef)(void 0);
2338
+ const zoomInRef = (0, import_react13.useRef)(void 0);
1871
2339
  zoomInRef.current = canZoomIn ? handleGenreSearchZoomIn : void 0;
1872
- const renderInfoPanelBody = (0, import_react10.useCallback)(
2340
+ const renderInfoPanelBody = (0, import_react13.useCallback)(
1873
2341
  (node, { toolbar, ancestors, family }) => {
1874
2342
  const genrePlaylist = genrePlaylistByNodeId.get(node.id);
1875
2343
  const criteria = genrePlaylist?.criteria;
1876
- if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(import_jsx_runtime27.Fragment, { children: [
2344
+ if (!genrePlaylist || !criteria) return { primary: /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)(import_jsx_runtime32.Fragment, { children: [
1877
2345
  toolbar,
1878
2346
  family
1879
2347
  ] }), aside: ancestors };
1880
2348
  const genreUuid = criteria.uuid;
2349
+ const parentPlaylist = visibleGenrePlaylists.find(({ uuid }) => uuid === genrePlaylist.parent?.uuid);
2350
+ const canToggleSide = !readOnly && parentPlaylist !== void 0 && parentPlaylist.parent === null;
2351
+ const side = criteria.side ?? "core";
2352
+ const shareTarget = {
2353
+ kind: "genre",
2354
+ genrePlaylistUuid: genrePlaylist.uuid,
2355
+ name: criteria.name,
2356
+ scope,
2357
+ artistUuid
2358
+ };
2359
+ const shareUrl = getShareUrlRef.current?.(shareTarget) ?? void 0;
2360
+ const popSibling = canToggleSide && side === "core" ? visibleGenrePlaylists.find(
2361
+ (sibling) => sibling.parent?.uuid === parentPlaylist.uuid && sibling.uuid !== genrePlaylist.uuid && sibling.criteria?.side === "pop"
2362
+ ) : void 0;
1881
2363
  return {
1882
- primary: /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(import_jsx_runtime27.Fragment, { children: [
2364
+ primary: /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)(import_jsx_runtime32.Fragment, { children: [
1883
2365
  toolbar,
1884
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2366
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
1885
2367
  GenreNodeActionBand,
1886
2368
  {
1887
2369
  node,
1888
2370
  genrePlaylist,
1889
2371
  scope,
2372
+ shareTarget: shareUrl ? shareTarget : void 0,
2373
+ shareUrl,
1890
2374
  onZoomIn: zoomInRef.current,
1891
2375
  onAddChild: readOnly ? void 0 : () => handleGenreCreationAction(criteria),
1892
2376
  onRename: readOnly ? void 0 : () => handleGenreRenameAction(criteria),
1893
2377
  onChangeParent: readOnly ? void 0 : () => showPopup(
1894
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2378
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
1895
2379
  ChangeParentPopup,
1896
2380
  {
1897
2381
  genrePlaylist: { ...genrePlaylist, criteria },
1898
2382
  genrePlaylists: visibleGenrePlaylists,
1899
2383
  scope,
1900
2384
  getBackendBaseUrl,
1901
- onClose: hidePopup
2385
+ onClose: hidePopup,
2386
+ onParentChanged
1902
2387
  }
1903
2388
  )
1904
2389
  ),
2390
+ side,
2391
+ onToggleSide: canToggleSide ? () => updateGenre({ uuid: genreUuid, data: { side: side === "pop" ? "core" : "pop" } }) : void 0,
2392
+ toggleSideDisabledReason: popSibling ? `${popSibling.name} is already the pop side of ${parentPlaylist?.name}` : void 0,
1905
2393
  additionalActions,
1906
2394
  playback
1907
2395
  }
1908
2396
  ),
1909
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2397
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
1910
2398
  GenreDetailExtras,
1911
2399
  {
1912
2400
  family,
1913
2401
  genreUuid,
1914
2402
  genrePlaylist,
2403
+ wikidataId: criteria.wikidataId,
1915
2404
  scope,
1916
2405
  getBackendBaseUrl,
1917
2406
  criteriaOverviewSchema,
@@ -1938,7 +2427,9 @@ function GenreTreeView({
1938
2427
  handleGenreRenameAction,
1939
2428
  showPopup,
1940
2429
  hidePopup,
2430
+ onParentChanged,
1941
2431
  visibleGenrePlaylists,
2432
+ updateGenre,
1942
2433
  playback,
1943
2434
  filterArtist,
1944
2435
  scope,
@@ -1955,7 +2446,7 @@ function GenreTreeView({
1955
2446
  );
1956
2447
  const isRotationControlled = controlledAllowWheelRotation !== void 0;
1957
2448
  const allowWheelRotation = controlledAllowWheelRotation ?? internalAllowWheelRotation;
1958
- const detailTab = placement === "regions" && (selectedNodeId || detailOverlay) ? /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2449
+ const detailTab = placement === "regions" && (hasSelectedGenreNode || detailOverlay) ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
1959
2450
  PanelEdgeTab,
1960
2451
  {
1961
2452
  side: "left",
@@ -1963,25 +2454,14 @@ function GenreTreeView({
1963
2454
  center: detailExpanded ? null : isCollapsed ? { label: "Show panel", onClick: () => changeCollapsed(false) } : onDetailExpandedChange ? { label: "Extend panel", onClick: () => onDetailExpandedChange(true) } : null
1964
2455
  }
1965
2456
  ) : null;
1966
- const actions = /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(import_jsx_runtime27.Fragment, { children: [
1967
- !isLoading && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
1968
- GenreSearch,
1969
- {
1970
- genrePlaylists: visibleGenrePlaylists,
1971
- onSelect: handleGenreSearchSelect,
1972
- selectedName: selectedGenrePlaylist?.name ?? builtInArtistPanel?.title ?? null,
1973
- onClear: builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
1974
- onPrevious: handlePrevious,
1975
- onQueryChange: artistSearch ? setSearchText : void 0,
1976
- extraResults: artistExtraResults
1977
- }
1978
- ),
1979
- !isLoading && filterArtist && /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
1980
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2457
+ const renderArtistFilter = () => {
2458
+ if (isLoading || !filterArtist) return null;
2459
+ return /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("span", { className: "inline-flex items-center overflow-hidden rounded-full bg-[var(--color-neutral-100)] text-sm", children: [
2460
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
1981
2461
  "button",
1982
2462
  {
1983
2463
  type: "button",
1984
- className: "py-1 pl-3 pr-2 hover:underline",
2464
+ className: "py-1 pl-3 pr-2 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] hover:underline",
1985
2465
  "aria-label": `Show artist ${filterArtist.name}`,
1986
2466
  onClick: () => {
1987
2467
  if (selectedNodeId !== null) clearSelection();
@@ -1989,86 +2469,174 @@ function GenreTreeView({
1989
2469
  children: filterArtist.name
1990
2470
  }
1991
2471
  ),
1992
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2472
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
1993
2473
  "button",
1994
2474
  {
1995
2475
  type: "button",
1996
- className: "py-1 pl-1 pr-3 hover:text-[var(--color-text)]",
2476
+ className: "py-1 pl-1 pr-3 [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] hover:text-[var(--color-text)]",
1997
2477
  "aria-label": `Clear artist filter ${filterArtist.name}`,
1998
2478
  onClick: () => changeArtist(null),
1999
2479
  children: "\xD7"
2000
2480
  }
2001
2481
  )
2002
- ] }),
2003
- !isLoading && !isControlled && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2004
- import_ui13.SegmentedControl,
2005
- {
2006
- "aria-label": "Tree view mode",
2007
- value: viewMode,
2008
- onChange: setViewMode,
2009
- options: [
2010
- { value: "pop-core", label: "Pop/Core", disabled: !canShowPopCore, title: canShowPopCore ? void 0 : NO_POP_ROOT_TITLE },
2011
- { value: "wheel", label: "Wheel" },
2012
- { value: "stacked", label: "Stacked" },
2013
- { value: "outline", label: "Outline", disabled: !canShowPopCore, title: canShowPopCore ? void 0 : NO_POP_ROOT_TITLE }
2014
- ]
2015
- }
2016
- ),
2017
- !isLoading && showDisplayOptions && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2018
- "div",
2482
+ ] });
2483
+ };
2484
+ const actions = /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)(import_jsx_runtime32.Fragment, { children: [
2485
+ !isLoading && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2486
+ GenreSearch,
2019
2487
  {
2020
- className: "flex items-center gap-1",
2021
- role: "group",
2022
- "aria-label": "Tree display options",
2023
- children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2024
- import_ui13.Button,
2025
- {
2026
- variant: allowWheelRotation ? "default" : "outline",
2027
- size: "sm",
2028
- onClick: toggleWheelRotation,
2029
- children: "Rotation"
2030
- }
2031
- )
2488
+ genrePlaylists: visibleGenrePlaylists,
2489
+ onSelect: handleGenreSearchSelect,
2490
+ selectedName: selectedGenrePlaylist?.name ?? builtInArtistPanel?.title ?? null,
2491
+ onClear: builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
2492
+ onPrevious: handlePrevious,
2493
+ onQueryChange: artistSearch ? setSearchText : void 0,
2494
+ extraResults: artistExtraResults
2032
2495
  }
2033
2496
  ),
2034
- !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2035
- import_ui13.IconTextButton,
2036
- {
2037
- icon: import_lucide_react8.Plus,
2038
- text: "Add root",
2039
- onClick: () => handleGenreCreationAction(null)
2040
- }
2041
- )
2497
+ !isLoading && filterArtist && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
2498
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("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: [
2499
+ !isLoading && !isControlled && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2500
+ import_ui13.SegmentedControl,
2501
+ {
2502
+ "aria-label": "Tree view mode",
2503
+ value: viewMode,
2504
+ onChange: setViewMode,
2505
+ options: [
2506
+ { value: "pop-core", label: "Pop/Core", disabled: !canShowPopCore, title: canShowPopCore ? void 0 : NO_POP_ROOT_TITLE },
2507
+ { value: "wheel", label: "Wheel" },
2508
+ { value: "stacked", label: "Stacked" },
2509
+ { value: "outline", label: "Outline", disabled: !canShowPopCore, title: canShowPopCore ? void 0 : NO_POP_ROOT_TITLE }
2510
+ ]
2511
+ }
2512
+ ),
2513
+ !isLoading && showDisplayOptions && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2514
+ "div",
2515
+ {
2516
+ className: "flex items-center gap-1",
2517
+ role: "group",
2518
+ "aria-label": "Tree display options",
2519
+ children: !isRotationControlled && viewMode !== "stacked" && viewMode !== "outline" && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2520
+ import_ui13.Button,
2521
+ {
2522
+ variant: allowWheelRotation ? "default" : "outline",
2523
+ size: "sm",
2524
+ onClick: toggleWheelRotation,
2525
+ children: "Rotation"
2526
+ }
2527
+ )
2528
+ }
2529
+ ),
2530
+ !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2531
+ import_ui13.IconTextButton,
2532
+ {
2533
+ icon: import_lucide_react11.Plus,
2534
+ text: "Add root",
2535
+ onClick: () => handleGenreCreationAction(null)
2536
+ }
2537
+ )
2538
+ ] })
2042
2539
  ] });
2043
- return /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
2540
+ return /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)(
2044
2541
  "div",
2045
2542
  {
2046
2543
  ref: rootRef,
2047
- className: ["genre-tree-view relative flex flex-col h-full", placement === "regions" && REGIONS_CLASS, className].filter(Boolean).join(" "),
2544
+ className: ["genre-tree-view relative flex flex-col h-full", placement === "regions" && REGIONS_CLASS, className2].filter(Boolean).join(" "),
2048
2545
  children: [
2049
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("style", { children: PREVIOUS_BUTTON_CSS }),
2050
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2546
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("style", { children: PREVIOUS_BUTTON_CSS }),
2547
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2051
2548
  "div",
2052
2549
  {
2053
- 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))]" : "left-5 top-5"}`,
2550
+ 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"}`,
2054
2551
  children: actions
2055
2552
  }
2056
2553
  ),
2057
- detailTab && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2554
+ !isLoading && filterArtist && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2555
+ "div",
2556
+ {
2557
+ 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"}`,
2558
+ children: renderArtistFilter()
2559
+ }
2560
+ ),
2561
+ detailTab && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2058
2562
  "div",
2059
2563
  {
2060
- className: `absolute top-1/2 z-30 -translate-y-1/2 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))]"}`,
2564
+ 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))]"}`,
2061
2565
  children: detailTab
2062
2566
  }
2063
2567
  ),
2064
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("div", { className: "tree-view-container flex-1 min-w-0 flex flex-col h-full", children: isLoading ? /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(import_genre_tree_view4.GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
2065
- // The info panel's top edge aligns with the tree's; the search bar floats `--layout-panel-inset` below it,
2066
- // and the header inset (the bar's height plus that inset) extends the header's fill behind the bar,
2067
- // Google Maps style; the 8px bottom padding mirrors the inset above the bar, and the hidden title's height is zeroed.
2068
- // The panel's own Zoom in button is hidden: the search bar carries it.
2069
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("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__ */ (0, import_jsx_runtime27.jsx)(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(import_genre_tree_view4.GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2070
- GenrePlaylistTreeWheel,
2568
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("div", { className: "tree-view-container relative flex-1 min-w-0 flex flex-col h-full", children: [
2569
+ !isLoading && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
2570
+ isLoading ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_genre_tree_view4.GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
2571
+ // The info panel's top edge aligns with the tree's; the search bar floats `--layout-panel-inset` below it,
2572
+ // and the header inset (the bar's height plus that inset) extends the header's fill behind the bar,
2573
+ // Google Maps style; the 8px bottom padding mirrors the inset above the bar, and the hidden title's height is zeroed.
2574
+ // The panel's own Zoom in button is hidden: the search bar carries it.
2575
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("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__ */ (0, import_jsx_runtime32.jsx)(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_genre_tree_view4.GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2576
+ GenrePlaylistTreeWheel,
2577
+ {
2578
+ scope,
2579
+ genrePlaylists: visibleGenrePlaylists,
2580
+ handleGenreCreationAction,
2581
+ handleGenreRenameAction,
2582
+ additionalActions,
2583
+ onSelectedNodeChange: handleSelectedNodeChange,
2584
+ hideInfoPanelClose: true,
2585
+ hideInfoPanelTitle: true,
2586
+ onNodeHover: handleNodeHover,
2587
+ renderInfoPanelBody,
2588
+ playback,
2589
+ selectedNodeId,
2590
+ readOnly,
2591
+ allowWheelRotation,
2592
+ controlsOrientation,
2593
+ sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2594
+ busy,
2595
+ floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("div", { className: "flex items-center gap-2", children: [
2596
+ floatingControlsLeading,
2597
+ canSizeByViews && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2598
+ ] }) : void 0,
2599
+ defaultViewport,
2600
+ onViewportChange,
2601
+ ...infoPanelCuration
2602
+ }
2603
+ ) }) })
2604
+ ) : viewMode === "pop-core" ? (
2605
+ // Same as the wheel: the header inset extends its fill behind the floating search bar, Google Maps style.
2606
+ /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("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__ */ (0, import_jsx_runtime32.jsx)(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_genre_tree_view4.GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2607
+ GenrePlaylistTreeWheelRadialPopCore,
2608
+ {
2609
+ scope,
2610
+ genrePlaylists: visibleGenrePlaylists,
2611
+ handleGenreCreationAction,
2612
+ handleGenreRenameAction,
2613
+ additionalActions,
2614
+ onSelectedNodeChange: handleSelectedNodeChange,
2615
+ hideInfoPanelClose: true,
2616
+ hideInfoPanelTitle: true,
2617
+ onNodeHover: handleNodeHover,
2618
+ renderInfoPanelBody,
2619
+ playback,
2620
+ selectedNodeId,
2621
+ readOnly,
2622
+ allowWheelRotation,
2623
+ controlsOrientation,
2624
+ sizeMetric: isArtistFiltered || !canSizeByViews ? "songs" : deferredSizeMetric,
2625
+ busy,
2626
+ floatingControlsLeading: floatingControlsLeading || canSizeByViews ? /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("div", { className: "flex items-center gap-2", children: [
2627
+ floatingControlsLeading,
2628
+ canSizeByViews && /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(SizeMetricToggle, { value: sizeMetric, onChange: changeSizeMetric })
2629
+ ] }) : void 0,
2630
+ defaultViewport,
2631
+ onViewportChange,
2632
+ ...infoPanelCuration,
2633
+ ...focus
2634
+ }
2635
+ ) }) })
2636
+ ) : viewMode === "outline" ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("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__ */ (0, import_jsx_runtime32.jsx)(
2637
+ GenrePlaylistTreeWheelRadialPopCore,
2071
2638
  {
2639
+ outline: true,
2072
2640
  scope,
2073
2641
  genrePlaylists: visibleGenrePlaylists,
2074
2642
  handleGenreCreationAction,
@@ -2082,94 +2650,45 @@ function GenreTreeView({
2082
2650
  playback,
2083
2651
  selectedNodeId,
2084
2652
  readOnly,
2085
- allowWheelRotation,
2086
- controlsOrientation,
2087
- defaultViewport,
2088
- onViewportChange,
2089
2653
  ...infoPanelCuration
2090
2654
  }
2091
- ) }) })
2092
- ) : viewMode === "pop-core" ? (
2093
- // Same as the wheel: the header inset extends its fill behind the floating search bar, Google Maps style.
2094
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("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__ */ (0, import_jsx_runtime27.jsx)(GenreTreeWheelHandoff, { skeleton: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(import_genre_tree_view4.GenreTreeWheelSkeleton, {}), onRevealedChange: setIsWheelRevealed, children: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2095
- GenrePlaylistTreeWheelRadialPopCore,
2096
- {
2097
- scope,
2098
- genrePlaylists: visibleGenrePlaylists,
2099
- handleGenreCreationAction,
2100
- handleGenreRenameAction,
2101
- additionalActions,
2102
- onSelectedNodeChange: handleSelectedNodeChange,
2103
- hideInfoPanelClose: true,
2104
- hideInfoPanelTitle: true,
2105
- onNodeHover: handleNodeHover,
2106
- renderInfoPanelBody,
2107
- playback,
2108
- selectedNodeId,
2109
- readOnly,
2110
- allowWheelRotation,
2111
- controlsOrientation,
2112
- defaultViewport,
2113
- onViewportChange,
2114
- ...infoPanelCuration,
2115
- ...focus
2655
+ ) }) : /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("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(
2656
+ ([uuid, genrePlaylistTreePerRoot], rootIndex) => {
2657
+ return /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2658
+ "div",
2659
+ {
2660
+ className: "tree-per-root-container relative shrink-0 h-[28rem] mt-2 mr-16 p-2 bg-[var(--color-neutral-100)] rounded-lg",
2661
+ children: /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("div", { className: "graph-container relative z-10 h-full", children: /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
2662
+ GenrePlaylistTreePerRoot,
2663
+ {
2664
+ scope,
2665
+ rootUuid: uuid,
2666
+ genrePlaylistTreePerRoot,
2667
+ handleGenreCreationAction,
2668
+ handleGenreRenameAction,
2669
+ additionalActions,
2670
+ onSelectedNodeChange: handleSelectedNodeChange,
2671
+ hideInfoPanelClose: true,
2672
+ hideInfoPanelTitle: true,
2673
+ onNodeHover: handleNodeHover,
2674
+ renderInfoPanelBody,
2675
+ playback,
2676
+ selectedNodeId: uuid === selectedGenrePlaylist?.root.uuid ? selectedNodeId : null,
2677
+ readOnly,
2678
+ wheelZoom: "modifier",
2679
+ controlsOrientation,
2680
+ defaultViewport: uuid === selectedGenrePlaylist?.root.uuid ? defaultViewport : void 0,
2681
+ onViewportChange: uuid === selectedGenrePlaylist?.root.uuid ? onViewportChange : void 0,
2682
+ ...infoPanelCuration,
2683
+ overlayPanel: rootIndex === 0 ? activeOverlayPanel : void 0
2684
+ }
2685
+ ) })
2686
+ },
2687
+ uuid
2688
+ );
2116
2689
  }
2117
- ) }) })
2118
- ) : viewMode === "outline" ? /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("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__ */ (0, import_jsx_runtime27.jsx)(
2119
- GenrePlaylistTreeWheelRadialPopCore,
2120
- {
2121
- outline: true,
2122
- scope,
2123
- genrePlaylists: visibleGenrePlaylists,
2124
- handleGenreCreationAction,
2125
- handleGenreRenameAction,
2126
- additionalActions,
2127
- onSelectedNodeChange: handleSelectedNodeChange,
2128
- hideInfoPanelClose: true,
2129
- hideInfoPanelTitle: true,
2130
- onNodeHover: handleNodeHover,
2131
- renderInfoPanelBody,
2132
- playback,
2133
- selectedNodeId,
2134
- readOnly,
2135
- ...infoPanelCuration
2136
- }
2137
- ) }) : /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("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(
2138
- ([uuid, genrePlaylistTreePerRoot], rootIndex) => {
2139
- return /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2140
- "div",
2141
- {
2142
- className: "tree-per-root-container relative shrink-0 h-[28rem] mt-2 mr-16 p-2 bg-[var(--color-neutral-100)] rounded-lg",
2143
- children: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("div", { className: "graph-container relative z-10 h-full", children: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
2144
- GenrePlaylistTreePerRoot,
2145
- {
2146
- scope,
2147
- rootUuid: uuid,
2148
- genrePlaylistTreePerRoot,
2149
- handleGenreCreationAction,
2150
- handleGenreRenameAction,
2151
- additionalActions,
2152
- onSelectedNodeChange: handleSelectedNodeChange,
2153
- hideInfoPanelClose: true,
2154
- hideInfoPanelTitle: true,
2155
- onNodeHover: handleNodeHover,
2156
- renderInfoPanelBody,
2157
- playback,
2158
- selectedNodeId: uuid === selectedGenrePlaylist?.root.uuid ? selectedNodeId : null,
2159
- readOnly,
2160
- wheelZoom: "modifier",
2161
- controlsOrientation,
2162
- defaultViewport: uuid === selectedGenrePlaylist?.root.uuid ? defaultViewport : void 0,
2163
- onViewportChange: uuid === selectedGenrePlaylist?.root.uuid ? onViewportChange : void 0,
2164
- ...infoPanelCuration,
2165
- overlayPanel: rootIndex === 0 ? activeOverlayPanel : void 0
2166
- }
2167
- ) })
2168
- },
2169
- uuid
2170
- );
2171
- }
2172
- ) }) }) })
2690
+ ) })
2691
+ ] }) })
2173
2692
  ]
2174
2693
  }
2175
2694
  );
@@ -2179,24 +2698,35 @@ function GenreTreeView({
2179
2698
  var import_genre_tree_view5 = require("@behindthemusictree/genre-tree-view");
2180
2699
 
2181
2700
  // src/genre-tree/track-list-sidebar/TrackListSidebar.tsx
2182
- var import_react11 = require("react");
2701
+ var import_react14 = require("react");
2702
+ var import_lucide_react12 = require("lucide-react");
2703
+ var import_genre_tree18 = require("@behindthemusictree/app-kit/genre-tree");
2183
2704
  var import_genre_tree19 = require("@behindthemusictree/app-kit/genre-tree");
2184
- var import_genre_tree20 = require("@behindthemusictree/app-kit/genre-tree");
2185
- var import_genre_tree21 = require("@behindthemusictree/app-kit/genre-tree");
2186
2705
  var import_track_noun6 = require("@behindthemusictree/app-kit/track-noun");
2187
- var import_jsx_runtime28 = require("react/jsx-runtime");
2706
+ var import_jsx_runtime33 = require("react/jsx-runtime");
2707
+ var useBrowserLayoutEffect = typeof window === "undefined" ? import_react14.useEffect : import_react14.useLayoutEffect;
2188
2708
  function TrackListSidebar({
2189
- className = "",
2709
+ className: className2 = "",
2190
2710
  renderDuration,
2191
2711
  renderActions,
2192
2712
  layout = "fixed",
2713
+ isCollapsed = false,
2714
+ onExpand,
2715
+ onMinimize,
2193
2716
  renderArtistLink
2194
2717
  }) {
2195
- const { trackList, loadMore } = (0, import_genre_tree19.useTrackList)();
2718
+ const { trackList, loadMore, autoAdvanceScrollRequest } = (0, import_genre_tree18.useTrackList)();
2196
2719
  const trackNoun = (0, import_track_noun6.useTrackNoun)();
2197
- const loadMoreSentinelRef = (0, import_react11.useRef)(null);
2720
+ const trackListRef = (0, import_react14.useRef)(null);
2721
+ const previousOriginRef = (0, import_react14.useRef)(trackList?.origin ?? null);
2722
+ const handledAutoAdvanceRequestIdRef = (0, import_react14.useRef)(null);
2723
+ const shouldFollowPlaybackRef = (0, import_react14.useRef)(true);
2724
+ const programmaticScrollTopRef = (0, import_react14.useRef)(null);
2725
+ const userScrollIntentRef = (0, import_react14.useRef)(false);
2726
+ const userScrollIntentTimerRef = (0, import_react14.useRef)(null);
2727
+ const loadMoreSentinelRef = (0, import_react14.useRef)(null);
2198
2728
  const hasMore = !!trackList && trackList.nextPage !== null;
2199
- (0, import_react11.useEffect)(() => {
2729
+ (0, import_react14.useEffect)(() => {
2200
2730
  const sentinel = loadMoreSentinelRef.current;
2201
2731
  if (!hasMore || !sentinel) return;
2202
2732
  const observer = new IntersectionObserver((entries) => {
@@ -2207,44 +2737,115 @@ function TrackListSidebar({
2207
2737
  observer.observe(sentinel);
2208
2738
  return () => observer.disconnect();
2209
2739
  }, [hasMore, loadMore]);
2210
- const { hideTrackListSidebar } = (0, import_genre_tree20.useTrackListSidebarVisibility)();
2211
- const positionClasses = layout === "inline" ? "relative w-full flex h-full flex-col" : "fixed right-0 w-[50rem]";
2212
- return trackList ? /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(
2740
+ useBrowserLayoutEffect(() => {
2741
+ const origin = trackList?.origin ?? null;
2742
+ if (previousOriginRef.current === origin) return;
2743
+ previousOriginRef.current = origin;
2744
+ handledAutoAdvanceRequestIdRef.current = null;
2745
+ shouldFollowPlaybackRef.current = true;
2746
+ programmaticScrollTopRef.current = null;
2747
+ if (trackListRef.current) trackListRef.current.scrollTop = 0;
2748
+ }, [trackList?.origin]);
2749
+ (0, import_react14.useEffect)(() => {
2750
+ const list = trackListRef.current;
2751
+ if (!isCollapsed && list?.scrollTop === 0) shouldFollowPlaybackRef.current = true;
2752
+ }, [isCollapsed]);
2753
+ (0, import_react14.useEffect)(
2754
+ () => () => {
2755
+ if (userScrollIntentTimerRef.current !== null) clearTimeout(userScrollIntentTimerRef.current);
2756
+ },
2757
+ []
2758
+ );
2759
+ useBrowserLayoutEffect(() => {
2760
+ if (!autoAdvanceScrollRequest || handledAutoAdvanceRequestIdRef.current === autoAdvanceScrollRequest.id) {
2761
+ return;
2762
+ }
2763
+ if (isCollapsed) return;
2764
+ const list = trackListRef.current;
2765
+ if (!list) return;
2766
+ if (list.scrollTop <= 1) shouldFollowPlaybackRef.current = true;
2767
+ if (!shouldFollowPlaybackRef.current) {
2768
+ handledAutoAdvanceRequestIdRef.current = autoAdvanceScrollRequest.id;
2769
+ return;
2770
+ }
2771
+ const row = Array.from(list.querySelectorAll("[data-track-uuid]")).find(
2772
+ (element) => element.dataset.trackUuid === autoAdvanceScrollRequest.trackUuid
2773
+ );
2774
+ if (!row) return;
2775
+ handledAutoAdvanceRequestIdRef.current = autoAdvanceScrollRequest.id;
2776
+ const rowTop = row.getBoundingClientRect().top - list.getBoundingClientRect().top;
2777
+ const targetScrollTop = list.scrollTop + rowTop;
2778
+ programmaticScrollTopRef.current = targetScrollTop;
2779
+ list.scrollTop = targetScrollTop;
2780
+ programmaticScrollTopRef.current = list.scrollTop;
2781
+ }, [autoAdvanceScrollRequest, isCollapsed, trackList]);
2782
+ const markUserScrollIntent = () => {
2783
+ userScrollIntentRef.current = true;
2784
+ if (userScrollIntentTimerRef.current !== null) clearTimeout(userScrollIntentTimerRef.current);
2785
+ userScrollIntentTimerRef.current = setTimeout(() => {
2786
+ userScrollIntentRef.current = false;
2787
+ userScrollIntentTimerRef.current = null;
2788
+ }, 250);
2789
+ };
2790
+ const handleTrackListScroll = (event) => {
2791
+ const list = event.currentTarget;
2792
+ if (userScrollIntentRef.current) {
2793
+ userScrollIntentRef.current = false;
2794
+ if (userScrollIntentTimerRef.current !== null) clearTimeout(userScrollIntentTimerRef.current);
2795
+ userScrollIntentTimerRef.current = null;
2796
+ programmaticScrollTopRef.current = null;
2797
+ shouldFollowPlaybackRef.current = list.scrollTop <= 1;
2798
+ return;
2799
+ }
2800
+ const programmaticScrollTop = programmaticScrollTopRef.current;
2801
+ if (programmaticScrollTop !== null) {
2802
+ if (Math.abs(list.scrollTop - programmaticScrollTop) <= 1) programmaticScrollTopRef.current = null;
2803
+ return;
2804
+ }
2805
+ };
2806
+ const positionClasses = layout === "inline" ? "relative w-full flex h-full flex-col" : "fixed right-0 w-[50rem] flex flex-col";
2807
+ return trackList ? /* @__PURE__ */ (0, import_jsx_runtime33.jsxs)(
2213
2808
  "div",
2214
2809
  {
2215
2810
  "data-theme": "dark",
2216
- className: `track-list-sidebar ${positionClasses} rounded-2xl bg-[var(--color-surface)] pb-1 ${className}`,
2217
- style: layout === "fixed" ? {
2218
- bottom: "79px"
2219
- /* Match player height exactly */
2220
- } : void 0,
2811
+ className: `track-list-sidebar ${positionClasses} rounded-2xl bg-[var(--color-surface)] pb-1 ${className2}`,
2812
+ style: layout === "fixed" ? { top: "63.5px", maxHeight: "calc(100vh - 63.5px - 79px)" } : void 0,
2221
2813
  children: [
2222
- /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "header flex h-16 px-4 py-2 text-[var(--color-text)]", children: [
2223
- /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "origin flex text-xl ", children: [
2224
- /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
2225
- /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("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__ */ (0, import_jsx_runtime28.jsx)("div", { className: "name max-w-full truncate", children: trackList.origin.label }) })
2226
- ] }),
2227
- /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "info flex flex-col justify-center items-center pt-1 mr-2 text-[var(--color-text-muted)]", children: [
2228
- trackList.origin.type === import_genre_tree21.TrackListOriginType.GENRE_PLAYLIST ? "\u2022 Genre playlist \u2022 " : trackList.origin.type === import_genre_tree21.TrackListOriginType.ARTIST ? "\u2022 Artist \u2022 " : `\u2022 ${trackNoun.singular} playlist \u2022 `,
2229
- `${trackList.total} ${trackList.total > 1 ? trackNoun.plural : trackNoun.singular} \u2022`
2814
+ /* @__PURE__ */ (0, import_jsx_runtime33.jsxs)("div", { className: "header flex h-16 items-center px-4 py-2 text-[var(--color-text)]", children: [
2815
+ /* @__PURE__ */ (0, import_jsx_runtime33.jsxs)("div", { className: "min-w-0 flex flex-1 flex-col justify-center", children: [
2816
+ /* @__PURE__ */ (0, import_jsx_runtime33.jsxs)("div", { className: "origin flex text-xl", children: [
2817
+ /* @__PURE__ */ (0, import_jsx_runtime33.jsx)("div", { className: "from h-auto flex flex-col justify-center items-center mr-2", children: "From" }),
2818
+ /* @__PURE__ */ (0, import_jsx_runtime33.jsx)("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__ */ (0, import_jsx_runtime33.jsx)("div", { className: "name max-w-full truncate", children: trackList.origin.label }) })
2819
+ ] }),
2820
+ /* @__PURE__ */ (0, import_jsx_runtime33.jsxs)("div", { className: "info flex items-center text-sm text-[var(--color-text-muted)]", children: [
2821
+ trackList.origin.type === import_genre_tree19.TrackListOriginType.GENRE_PLAYLIST ? "Genre \u2022 " : trackList.origin.type === import_genre_tree19.TrackListOriginType.ARTIST ? "Artist \u2022 " : `${trackNoun.singular} playlist \u2022 `,
2822
+ `${trackList.total} ${trackList.total > 1 ? trackNoun.plural : trackNoun.singular}`
2823
+ ] })
2230
2824
  ] }),
2231
- /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
2825
+ (isCollapsed && onExpand || !isCollapsed && onMinimize) && /* @__PURE__ */ (0, import_jsx_runtime33.jsx)(
2232
2826
  "button",
2233
2827
  {
2234
2828
  type: "button",
2235
- "aria-label": "Close track list",
2236
- className: "flex-grow flex flex-col items-end justify-center h-full p-3 cursor-pointer text-inherit",
2237
- onClick: hideTrackListSidebar,
2238
- children: "\u2715"
2829
+ "aria-label": isCollapsed ? "Show tracks" : "Hide tracks",
2830
+ title: isCollapsed ? "Show tracks" : "Hide tracks",
2831
+ "aria-expanded": !isCollapsed,
2832
+ 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"}`,
2833
+ onClick: isCollapsed ? onExpand : onMinimize,
2834
+ children: /* @__PURE__ */ (0, import_jsx_runtime33.jsx)(import_lucide_react12.ListMusic, { "aria-hidden": "true", className: "h-5 w-5" })
2239
2835
  }
2240
2836
  )
2241
2837
  ] }),
2242
- /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(
2838
+ !isCollapsed && /* @__PURE__ */ (0, import_jsx_runtime33.jsxs)(
2243
2839
  "ul",
2244
2840
  {
2245
- className: layout === "inline" ? "track-list flex-1 min-h-0 overflow-auto list-none p-0 m-0" : "track-list overflow-auto max-h-[calc(100vh-63.5px-79px-56px-3.5px)] list-none p-0 m-0",
2841
+ ref: trackListRef,
2842
+ onScroll: handleTrackListScroll,
2843
+ onWheel: markUserScrollIntent,
2844
+ onTouchMove: markUserScrollIntent,
2845
+ onPointerDown: markUserScrollIntent,
2846
+ 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",
2246
2847
  children: [
2247
- trackList.tracks.map((track, index) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
2848
+ trackList.tracks.map((track, index) => /* @__PURE__ */ (0, import_jsx_runtime33.jsx)("li", { "data-track-uuid": track.uuid, children: /* @__PURE__ */ (0, import_jsx_runtime33.jsx)(
2248
2849
  TrackItem,
2249
2850
  {
2250
2851
  track,
@@ -2254,7 +2855,7 @@ function TrackListSidebar({
2254
2855
  renderArtistLink
2255
2856
  }
2256
2857
  ) }, track.uuid)),
2257
- hasMore && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("li", { ref: loadMoreSentinelRef, "aria-hidden": "true", className: "h-px" })
2858
+ hasMore && /* @__PURE__ */ (0, import_jsx_runtime33.jsx)("li", { ref: loadMoreSentinelRef, "aria-hidden": "true", className: "h-px" })
2258
2859
  ]
2259
2860
  }
2260
2861
  )
@@ -2264,42 +2865,162 @@ function TrackListSidebar({
2264
2865
  }
2265
2866
 
2266
2867
  // src/genre-tree/PlayerPanel.tsx
2267
- var import_react12 = require("react");
2268
- var import_lucide_react9 = require("lucide-react");
2868
+ var import_react15 = require("react");
2269
2869
  var import_tailwind_merge2 = require("tailwind-merge");
2870
+ var import_player12 = require("@behindthemusictree/app-kit/player");
2871
+ var import_genre_tree21 = require("@behindthemusictree/app-kit/genre-tree");
2872
+
2873
+ // src/genre-tree/PlayerControls.tsx
2874
+ var import_lucide_react13 = require("lucide-react");
2875
+ var import_genre_tree20 = require("@behindthemusictree/app-kit/genre-tree");
2270
2876
  var import_player11 = require("@behindthemusictree/app-kit/player");
2271
- var import_genre_tree22 = require("@behindthemusictree/app-kit/genre-tree");
2272
- var import_jsx_runtime29 = require("react/jsx-runtime");
2877
+ var import_jsx_runtime34 = require("react/jsx-runtime");
2878
+ function PlayerControls() {
2879
+ const {
2880
+ duration,
2881
+ handlePlayPauseAction,
2882
+ isLoading,
2883
+ playState,
2884
+ playerTrackObject,
2885
+ seekTo,
2886
+ setVolume,
2887
+ volume
2888
+ } = (0, import_player11.usePlayer)();
2889
+ const { hasNextTrack, hasPreviousTrack, playNextTrack, playPreviousTrack } = (0, import_genre_tree20.useTrackList)();
2890
+ const currentTime = (0, import_player11.useCurrentTime)();
2891
+ const isBusy = isLoading || playState === import_player11.PlayStates.LOADING;
2892
+ const isReady = !!playerTrackObject?.isReady && !isBusy && !playerTrackObject.loadError;
2893
+ const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
2894
+ const safeCurrentTime = Number.isFinite(currentTime) ? Math.max(0, Math.min(currentTime, safeDuration)) : 0;
2895
+ const isPlaying = playState === import_player11.PlayStates.PLAYING;
2896
+ return /* @__PURE__ */ (0, import_jsx_runtime34.jsxs)(
2897
+ "section",
2898
+ {
2899
+ "aria-label": "Player controls",
2900
+ className: "player-controls flex shrink-0 flex-col gap-2 border-b border-zinc-800 px-3 py-2 text-zinc-200",
2901
+ children: [
2902
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsxs)("div", { className: "flex min-w-0 items-center gap-2 text-xs tabular-nums text-zinc-400", children: [
2903
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsx)("span", { "aria-label": "Elapsed time", children: (0, import_genre_tree20.formatTime)(safeCurrentTime) }),
2904
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(
2905
+ "input",
2906
+ {
2907
+ "aria-label": "Seek",
2908
+ className: "h-1 min-w-0 flex-1 cursor-pointer accent-white disabled:cursor-not-allowed disabled:opacity-40",
2909
+ type: "range",
2910
+ min: 0,
2911
+ max: safeDuration,
2912
+ step: 0.1,
2913
+ value: safeCurrentTime,
2914
+ disabled: !isReady || safeDuration <= 0,
2915
+ onChange: (event) => seekTo(Number(event.currentTarget.value))
2916
+ }
2917
+ ),
2918
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsx)("span", { "aria-label": "Duration", children: (0, import_genre_tree20.formatTime)(safeDuration) })
2919
+ ] }),
2920
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsxs)("div", { className: "grid min-h-9 grid-cols-[1fr_auto_1fr] items-center gap-3", children: [
2921
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsxs)("div", { className: "col-start-2 flex items-center justify-center gap-2", children: [
2922
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(
2923
+ "button",
2924
+ {
2925
+ type: "button",
2926
+ "aria-label": "Previous track",
2927
+ title: "Previous track",
2928
+ disabled: !isReady || !hasPreviousTrack,
2929
+ onClick: playPreviousTrack,
2930
+ 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",
2931
+ children: /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(import_lucide_react13.SkipBack, { "aria-hidden": "true", className: "h-4 w-4" })
2932
+ }
2933
+ ),
2934
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(
2935
+ "button",
2936
+ {
2937
+ type: "button",
2938
+ "aria-label": isPlaying ? "Pause" : "Play",
2939
+ title: isPlaying ? "Pause" : "Play",
2940
+ disabled: !isReady,
2941
+ onClick: handlePlayPauseAction,
2942
+ 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",
2943
+ children: isPlaying ? /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(import_lucide_react13.Pause, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" }) : /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(import_lucide_react13.Play, { "aria-hidden": "true", className: "h-4 w-4", fill: "currentColor" })
2944
+ }
2945
+ ),
2946
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(
2947
+ "button",
2948
+ {
2949
+ type: "button",
2950
+ "aria-label": "Next track",
2951
+ title: "Next track",
2952
+ disabled: !isReady || !hasNextTrack,
2953
+ onClick: playNextTrack,
2954
+ 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",
2955
+ children: /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(import_lucide_react13.SkipForward, { "aria-hidden": "true", className: "h-4 w-4" })
2956
+ }
2957
+ )
2958
+ ] }),
2959
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsxs)("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: [
2960
+ volume === 0 ? /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(import_lucide_react13.VolumeX, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }) : /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(import_lucide_react13.Volume2, { "aria-hidden": "true", className: "h-[0.7rem] w-[0.7rem] shrink-0" }),
2961
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsx)("span", { className: "sr-only", children: "Volume" }),
2962
+ /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(
2963
+ "input",
2964
+ {
2965
+ "aria-label": "Volume",
2966
+ className: "h-[3px] min-w-0 flex-1 cursor-pointer accent-white disabled:cursor-not-allowed disabled:opacity-40",
2967
+ type: "range",
2968
+ min: 0,
2969
+ max: 100,
2970
+ step: 1,
2971
+ value: volume,
2972
+ disabled: !isReady,
2973
+ onChange: (event) => setVolume(Number(event.currentTarget.value))
2974
+ }
2975
+ )
2976
+ ] })
2977
+ ] })
2978
+ ]
2979
+ }
2980
+ );
2981
+ }
2982
+
2983
+ // src/genre-tree/PlayerPanel.tsx
2984
+ var import_jsx_runtime35 = require("react/jsx-runtime");
2273
2985
  var PANEL_ID = "player-panel";
2274
- var iconButtonClassName = "flex h-6 w-6 shrink-0 items-center justify-center p-0 rounded-md hover:bg-white/10";
2275
2986
  var DEFAULT_EXTENDED_WIDTH = "calc(100vw - var(--layout-center-left) - var(--layout-gutter))";
2276
2987
  function PlayerPanel({
2277
- className,
2988
+ className: className2,
2278
2989
  isVisible = true,
2279
- onClose,
2280
2990
  expanded,
2281
2991
  onExpandedChange,
2282
2992
  extendedWidth = DEFAULT_EXTENDED_WIDTH,
2283
2993
  renderArtistLink
2284
2994
  }) {
2285
- const { playerTrackObject } = (0, import_player11.usePlayer)();
2286
- const { toggleTrackListSidebar, isTrackListSidebarVisible } = (0, import_genre_tree22.useTrackListSidebarVisibility)();
2287
- const [uncontrolledExtended, setUncontrolledExtended] = (0, import_react12.useState)(false);
2995
+ const { playerTrackObject } = (0, import_player12.usePlayer)();
2996
+ const { toggleTrackListSidebar, isTrackListSidebarVisible } = (0, import_genre_tree21.useTrackListSidebarVisibility)();
2997
+ const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = (0, import_react15.useState)("");
2998
+ const loadedTrackTitle = playerTrackObject?.track.title;
2999
+ (0, import_react15.useEffect)(() => {
3000
+ if (loadedTrackTitle) setLastLoadedTrackTitle(loadedTrackTitle);
3001
+ }, [loadedTrackTitle]);
3002
+ const [uncontrolledExtended, setUncontrolledExtended] = (0, import_react15.useState)(false);
2288
3003
  const isExtendRequested = expanded ?? uncontrolledExtended;
2289
3004
  const setIsExtendRequested = (next) => {
2290
3005
  setUncontrolledExtended(next);
2291
3006
  onExpandedChange?.(next);
2292
3007
  };
2293
3008
  const isExtended = isExtendRequested && isTrackListSidebarVisible;
2294
- const tabEdge = !isTrackListSidebarVisible ? null : isExtended ? {
2295
- label: "Shrink song list panel",
2296
- onClick: () => setIsExtendRequested(false)
2297
- } : { label: "Hide song list", onClick: toggleTrackListSidebar };
2298
- const tabCenter = !isTrackListSidebarVisible ? { label: "Show song list", onClick: toggleTrackListSidebar } : isExtended ? null : {
2299
- label: "Extend song list panel",
2300
- onClick: () => setIsExtendRequested(true)
3009
+ const hideSongList = () => {
3010
+ setIsExtendRequested(false);
3011
+ if (isTrackListSidebarVisible) toggleTrackListSidebar();
2301
3012
  };
2302
- return /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
3013
+ const tabEdge = isTrackListSidebarVisible && isExtended ? {
3014
+ label: "Shrink player panel",
3015
+ onClick: () => setIsExtendRequested(false),
3016
+ expanded: true
3017
+ } : null;
3018
+ const tabCenter = isTrackListSidebarVisible && !isExtended ? {
3019
+ label: "Extend player panel",
3020
+ onClick: () => setIsExtendRequested(true),
3021
+ expanded: false
3022
+ } : null;
3023
+ return /* @__PURE__ */ (0, import_jsx_runtime35.jsxs)(
2303
3024
  "div",
2304
3025
  {
2305
3026
  id: PANEL_ID,
@@ -2308,11 +3029,11 @@ function PlayerPanel({
2308
3029
  !isExtended && "w-[var(--layout-right-w)]",
2309
3030
  isTrackListSidebarVisible && "h-[calc(100vh-var(--layout-band-top)-3.5rem)]",
2310
3031
  !isVisible && "hidden",
2311
- className
3032
+ className2
2312
3033
  ),
2313
3034
  style: isExtended ? { width: extendedWidth } : void 0,
2314
3035
  children: [
2315
- /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
3036
+ /* @__PURE__ */ (0, import_jsx_runtime35.jsx)(
2316
3037
  PanelEdgeTab,
2317
3038
  {
2318
3039
  side: "right",
@@ -2322,7 +3043,7 @@ function PlayerPanel({
2322
3043
  className: "absolute right-full top-1/2 -translate-y-1/2"
2323
3044
  }
2324
3045
  ),
2325
- /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
3046
+ /* @__PURE__ */ (0, import_jsx_runtime35.jsxs)(
2326
3047
  "div",
2327
3048
  {
2328
3049
  className: (0, import_tailwind_merge2.twMerge)(
@@ -2330,29 +3051,21 @@ function PlayerPanel({
2330
3051
  isExtended ? "flex-row" : "flex-col"
2331
3052
  ),
2332
3053
  children: [
2333
- /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
2334
- /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-zinc-800 p-2", children: [
2335
- /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "min-w-0 truncate text-sm font-semibold", children: playerTrackObject?.track?.title }),
2336
- /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "flex shrink-0 items-center gap-1", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
2337
- "button",
2338
- {
2339
- type: "button",
2340
- onClick: onClose,
2341
- className: iconButtonClassName,
2342
- "aria-label": "Close player",
2343
- children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_lucide_react9.X, { size: 16 })
2344
- }
2345
- ) })
3054
+ /* @__PURE__ */ (0, import_jsx_runtime35.jsxs)("div", { className: "flex w-[var(--layout-right-w)] shrink-0 flex-col", children: [
3055
+ /* @__PURE__ */ (0, import_jsx_runtime35.jsx)("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-zinc-800 p-2", children: /* @__PURE__ */ (0, import_jsx_runtime35.jsx)("span", { className: "min-w-0 truncate text-sm font-semibold", children: loadedTrackTitle || lastLoadedTrackTitle }) }),
3056
+ /* @__PURE__ */ (0, import_jsx_runtime35.jsxs)("div", { className: "relative w-full shrink-0", children: [
3057
+ /* @__PURE__ */ (0, import_jsx_runtime35.jsx)(import_player12.PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
3058
+ playerTrackObject?.loadError && /* @__PURE__ */ (0, import_jsx_runtime35.jsx)("span", { className: "absolute bottom-2 left-2 right-2 text-xs text-red-400", children: playerTrackObject.loadError })
2346
3059
  ] }),
2347
- /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "relative w-full shrink-0", children: [
2348
- /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_player11.PlayerVideoSurface, { className: "aspect-video w-full bg-black" }),
2349
- playerTrackObject?.loadError && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("span", { className: "absolute bottom-2 left-2 right-2 text-xs text-red-400", children: playerTrackObject.loadError })
2350
- ] })
3060
+ /* @__PURE__ */ (0, import_jsx_runtime35.jsx)(PlayerControls, {})
2351
3061
  ] }),
2352
- isTrackListSidebarVisible && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
3062
+ /* @__PURE__ */ (0, import_jsx_runtime35.jsx)(
2353
3063
  TrackListSidebar,
2354
3064
  {
2355
3065
  layout: "inline",
3066
+ isCollapsed: !isTrackListSidebarVisible,
3067
+ onExpand: toggleTrackListSidebar,
3068
+ onMinimize: hideSongList,
2356
3069
  className: (0, import_tailwind_merge2.twMerge)(
2357
3070
  "min-h-0 min-w-0 flex-1",
2358
3071
  isExtended && "border-l border-zinc-800"
@@ -2371,33 +3084,33 @@ function PlayerPanel({
2371
3084
  // src/genre-tree/Rating.tsx
2372
3085
  var import_uuid = require("uuid");
2373
3086
  var import_ai = require("react-icons/ai");
2374
- var import_genre_tree23 = require("@behindthemusictree/app-kit/genre-tree");
2375
- var import_jsx_runtime30 = require("react/jsx-runtime");
3087
+ var import_genre_tree22 = require("@behindthemusictree/app-kit/genre-tree");
3088
+ var import_jsx_runtime36 = require("react/jsx-runtime");
2376
3089
  function Rating({ rating, handleChange }) {
2377
3090
  const instanceId = (0, import_uuid.v4)();
2378
- return /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("div", { className: "flex items-center", children: [...Array(6)].map((_, i) => /* @__PURE__ */ (0, import_jsx_runtime30.jsxs)("div", { className: `relative ${i === 0 ? "mr-4" : "mr-1"}`, children: [
2379
- /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(
3091
+ return /* @__PURE__ */ (0, import_jsx_runtime36.jsx)("div", { className: "flex items-center", children: [...Array(6)].map((_, i) => /* @__PURE__ */ (0, import_jsx_runtime36.jsxs)("div", { className: `relative ${i === 0 ? "mr-4" : "mr-1"}`, children: [
3092
+ /* @__PURE__ */ (0, import_jsx_runtime36.jsx)(
2380
3093
  "input",
2381
3094
  {
2382
3095
  id: `star-${instanceId}-${i}`,
2383
3096
  className: "w-6 h-6 opacity-0",
2384
3097
  type: "radio",
2385
3098
  name: "rating",
2386
- value: i === 0 ? import_genre_tree23.FORM_RATING_NULL_VALUE : i,
3099
+ value: i === 0 ? import_genre_tree22.FORM_RATING_NULL_VALUE : i,
2387
3100
  checked: rating === (i === 0 ? null : i),
2388
3101
  onChange: handleChange
2389
3102
  }
2390
3103
  ),
2391
- /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ (0, import_jsx_runtime30.jsxs)("div", { className: "flex", children: [
2392
- /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(
3104
+ /* @__PURE__ */ (0, import_jsx_runtime36.jsx)("label", { htmlFor: `star-${instanceId}-${i}`, className: "cursor-pointer", children: i === 0 ? /* @__PURE__ */ (0, import_jsx_runtime36.jsxs)("div", { className: "flex", children: [
3105
+ /* @__PURE__ */ (0, import_jsx_runtime36.jsx)(
2393
3106
  import_ai.AiOutlineStar,
2394
3107
  {
2395
- className: `w-7 h-7 absolute top-0 left-0 ${rating === import_genre_tree23.FORM_RATING_NULL_VALUE ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
3108
+ className: `w-7 h-7 absolute top-0 left-0 ${rating === import_genre_tree22.FORM_RATING_NULL_VALUE ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`
2396
3109
  }
2397
3110
  ),
2398
- /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("span", { className: "flex-grow w-8" }),
3111
+ /* @__PURE__ */ (0, import_jsx_runtime36.jsx)("span", { className: "flex-grow w-8" }),
2399
3112
  " "
2400
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(
3113
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime36.jsx)(
2401
3114
  import_ai.AiFillStar,
2402
3115
  {
2403
3116
  className: `w-7 h-7 absolute top-0 left-0 ${(rating ?? -1) >= i ? "text-[var(--color-accent-rating)]" : "text-[var(--color-text-muted)]"}`