Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4c84624
feat(genre-tree): add Make root to the Parent popup
Andreas-Garcia Nov 12, 2026
740416a
Merge origin/develop into feature/parent-popup-make-root
Andreas-Garcia Nov 12, 2026
2a1b1b2
refactor(genre-tree): use the shared Button in the Make root step
Andreas-Garcia Nov 12, 2026
75453a7
Merge pull request #544 from BehindTheMusicTree/release/back-merge-42…
Andreas-Garcia Nov 12, 2026
f3b5389
fix(transport): stop retrying requests rejected with AuthRequired
Andreas-Garcia Nov 12, 2026
7963ff0
fix(transport): keep single popup on failed track page; skip retry on…
Andreas-Garcia Nov 12, 2026
6c65baa
Merge pull request #546 from BehindTheMusicTree/feature/no-retry-on-a…
Andreas-Garcia Nov 12, 2026
ec1350b
Merge origin/develop into feature/parent-popup-make-root
Andreas-Garcia Nov 12, 2026
551d751
Merge pull request #545 from BehindTheMusicTree/feature/parent-popup-…
Andreas-Garcia Nov 12, 2026
697146c
feat(genre-tree): end Ancestors on the selected genre
Andreas-Garcia Nov 12, 2026
830b277
feat(player): add stop() and a Close player button to PlayerPanel
Andreas-Garcia Nov 12, 2026
6365278
fix(player): ignore late loadedmetadata after stop; hide Close when idle
Andreas-Garcia Nov 12, 2026
8e673b2
chore(deps): bump genre-tree-view to 10.0.0
Andreas-Garcia Nov 12, 2026
90212dc
Merge pull request #548 from BehindTheMusicTree/feature/player-stop-b…
Andreas-Garcia Nov 12, 2026
40d1435
Merge pull request #547 from BehindTheMusicTree/feature/genre-ancesto…
Andreas-Garcia Nov 12, 2026
3772b9e
chore(release): prepare 43.0.0
Andreas-Garcia Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,28 @@ easy to spot when bumping.

## [Unreleased]

## [43.0.0] - 2026-11-12

### Breaking

- `PlayerPanel` no longer accepts the already-ignored `onClose` prop; remove it. Closing is built in (see Added).
- `ParentChange.to` (app-kit-ui, `onParentChanged`) is now `GenreUuid | null`; `null` means the genre was made a root. Handle it before using `to`.
- `fetchWrapper` / `useFetchWrapper().fetch` / `usePlaybackFetch` now reject with the typed `AppError` on a failed GET or HEAD after reporting it to the global handler, instead of resolving `null`. Direct `await fetch(...)` callers on reads must handle the rejection (query functions need no change).

### Added

- `usePlayer().stop()` stops playback and unloads the current track (`playerTrackObject` becomes `null`); `PlayerPanel` shows a **Close player** header button that calls it.
- Parent popup (`ChangeParentPopup`): a **Make root** button, shown when the genre has a parent, with an inline confirm step.
- `isRetryableError` (transport) and `AUTH_DETAIL_ERROR_CODES`: one classification of which failures a retry can fix.

### Changed

- `GenreDetailPanel`: the Ancestors section now ends on the selected genre (non-clickable) and draws every ancestor at the Family section's size; a long parent label in Family wraps instead of being truncated. Requires `@behindthemusictree/genre-tree-view` 10.0.0 (catalog bumped).

### Fixed

- A rejected token (`AuthRequired`, BAC4010) no longer re-sends the request up to three times, re-raising the popup and clearing the session each time: `resourceRetry` skips `AuthRequired`, not-found, forbidden, bad-request, client and auth-detail errors.

## [42.0.2] - 2026-11-12

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions packages/app-kit-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@behindthemusictree/app-kit-ui",
"version": "42.0.2",
"version": "43.0.0",
"description": "Styled BehindTheMusicTree React components (popups, auth callback, genre tree) built on the headless @behindthemusictree/app-kit",
"repository": {
"type": "git",
Expand Down Expand Up @@ -47,7 +47,7 @@
"test": "vitest run --coverage"
},
"peerDependencies": {
"@behindthemusictree/app-kit": "^42.0.2",
"@behindthemusictree/app-kit": "^43.0.0",
"@behindthemusictree/ui": "^0.21.2",
"@tanstack/react-query": ">=5.0.0",
"next": ">=14.0.0",
Expand Down
39 changes: 39 additions & 0 deletions packages/app-kit-ui/src/genre-tree/ChangeParentPopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,45 @@ describe("ChangeParentPopup", () => {
});
});

describe("Make root", () => {
it("is hidden for a root genre", () => {
renderPopup({ genrePlaylist: blues });
expect(screen.queryByRole("button", { name: "Make root" })).not.toBeInTheDocument();
});

it("asks for confirmation, then sends a null parent and reports to: null", () => {
const onParentChanged = vi.fn();
const { onClose } = renderPopup({ onParentChanged });

fireEvent.click(screen.getByRole("button", { name: "Make root" }));
expect(screen.getByText(/Jazz and its descendants will no longer be under Rock/)).toBeInTheDocument();
expect(mutateMock).not.toHaveBeenCalled();

fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
expect(mutateMock).toHaveBeenCalledWith(
{ uuid: "c-jazz", data: { parent: null } },
{ onSuccess: expect.any(Function) },
);
mutateMock.mock.calls[0][1].onSuccess();
expect(onClose).toHaveBeenCalledTimes(1);
expect(onParentChanged).toHaveBeenCalledWith({ genre: "c-jazz", from: "c-rock", to: null });
});

it("goes back without mutating", () => {
renderPopup();
fireEvent.click(screen.getByRole("button", { name: "Make root" }));
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("button", { name: "Make root" })).toBeInTheDocument();
expect(mutateMock).not.toHaveBeenCalled();
});

it("shows a 400 message in the alert", () => {
mockMutation({ formErrors: [{ field: "parent", message: "Cannot make root" }] });
renderPopup();
expect(screen.getByRole("alert")).toHaveTextContent("Cannot make root");
});
});

it("marks the search busy while the update is pending", () => {
mockMutation({ isPending: true });
renderPopup();
Expand Down
38 changes: 33 additions & 5 deletions packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";

import {
CriteriaPlaylistSimple,
Expand All @@ -10,10 +10,12 @@ import {
useUpdateGenre,
} from "@behindthemusictree/app-kit/genre-tree";
import type { Scope } from "@behindthemusictree/app-kit/transport";
import { Button } from "@behindthemusictree/ui";
import BasePopup from "../popup/BasePopup";
import { PopupButtons } from "../popup/PopupButtons";
import GenreSearch from "./GenreSearch";

export type ParentChange = { genre: GenreUuid; from: GenreUuid | null; to: GenreUuid };
export type ParentChange = { genre: GenreUuid; from: GenreUuid | null; to: GenreUuid | null };

type ChangeParentPopupProps = {
genrePlaylist: ReparentCandidate;
Expand All @@ -38,23 +40,27 @@ export default function ChangeParentPopup({
[genrePlaylists, genrePlaylist.uuid],
);

const handleSelect = (newParent: ReparentCandidate) => {
const [confirmingRoot, setConfirmingRoot] = useState(false);

const handleMove = (to: GenreUuid | null) => {
const genre = genrePlaylist.criteria.uuid;
const parentPlaylistUuid = genrePlaylist.parent?.uuid;
const from = parentPlaylistUuid
? (genrePlaylists.find(({ uuid }) => uuid === parentPlaylistUuid)?.criteria?.uuid ?? null)
: null;
mutate(
{ uuid: genre, data: { parent: newParent.criteria.uuid } },
{ uuid: genre, data: { parent: to } },
{
onSuccess: () => {
onClose();
onParentChanged?.({ genre, from, to: newParent.criteria.uuid });
onParentChanged?.({ genre, from, to });
},
},
);
};

const handleSelect = (newParent: ReparentCandidate) => handleMove(newParent.criteria.uuid);

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
Expand All @@ -68,6 +74,28 @@ export default function ChangeParentPopup({
<p className="mb-3 text-body text-[var(--color-text-muted)]">
Current parent: {genrePlaylist.parent?.name ?? "none"}
</p>
{genrePlaylist.parent &&
(confirmingRoot ? (
<div className="mb-3 text-sm">
<p className="mb-2">
{genrePlaylist.name} and its descendants will no longer be under {genrePlaylist.parent.name}.
</p>
<PopupButtons alignment="left">
<Button disabled={isPending} onClick={() => handleMove(null)}>
Confirm
</Button>
<Button variant="secondary" disabled={isPending} onClick={() => setConfirmingRoot(false)}>
Back
</Button>
</PopupButtons>
</div>
) : (
<div className="mb-3">
<Button variant="secondary" onClick={() => setConfirmingRoot(true)}>
Make root
</Button>
</div>
))}
<div aria-busy={isPending} className={isPending ? "pointer-events-none opacity-60" : undefined}>
<GenreSearch
genrePlaylists={candidates}
Expand Down
10 changes: 9 additions & 1 deletion packages/app-kit-ui/src/genre-tree/GenreDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,15 @@ export function useGenreDetailContent<O extends CriteriaOverview>({
onSelectNode={selectGenre}
/>
);
const ancestors = <GenreNodeAncestors ancestorNodes={view.ancestorNodes} onSelectNode={selectGenre} />;
const ancestors = (
<GenreNodeAncestors
node={view.node}
fill={view.fill}
textColor={view.textColor}
ancestorNodes={view.ancestorNodes}
onSelectNode={selectGenre}
/>
);

return {
name: detail.name,
Expand Down
2 changes: 1 addition & 1 deletion packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export type GenreTreeViewProps<O extends CriteriaOverview = CriteriaOverview> =
handleGenreCreationAction: (parent: CriteriaMinimum | null) => void;
handleGenreRenameAction: (genre: CriteriaMinimum) => void;
getBackendBaseUrl: () => string;
/** Called after the Parent popup moved a genre; `from` and `to` are genre (criteria) uuids, `from` null for a root genre. */
/** Called after the Parent popup moved a genre; `from` and `to` are genre (criteria) uuids, `from` null for a root genre, `to` null when the genre was made a root. */
onParentChanged?: (change: ParentChange) => void;
additionalActions?: (node: GenreTreeNode) => GenreTreeAction[];
/** Controlled view mode. When provided, the internal Stacked/Wheel toggle is not rendered — the consumer owns that UI. */
Expand Down
12 changes: 10 additions & 2 deletions packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ describe("PlayerPanel", () => {
expect(screen.queryByRole("button", { name: "Extend song list panel" })).not.toBeInTheDocument();
});

it("stops playback from the close button", () => {
const stop = vi.fn();
setup(true);
usePlayerMock.mockReturnValue({ playerTrackObject: { track: { title: "Song A" } }, stop });
render(<PlayerPanel />);
fireEvent.click(screen.getByRole("button", { name: "Close player" }));
expect(stop).toHaveBeenCalledTimes(1);
});

it("hides the song list from the From header", () => {
setup(true);
render(<PlayerPanel />);
Expand Down Expand Up @@ -106,7 +115,7 @@ describe("PlayerPanel", () => {
expect(onExpandedChange).toHaveBeenCalledWith(false);
});

it("minimizes to video-only without closing or unmounting the player", () => {
it("minimizes to video-only without unmounting the player", () => {
setup(true);
const onExpandedChange = vi.fn();
const { container, rerender } = render(
Expand All @@ -124,7 +133,6 @@ describe("PlayerPanel", () => {
expect(container.firstChild).toHaveClass("w-[var(--layout-right-w)]");
expect(screen.getByTestId("video")).toBeInTheDocument();
expect(screen.getByTestId("sidebar")).toHaveAttribute("data-collapsed", "true");
expect(screen.queryByRole("button", { name: "Close player" })).not.toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Show tracks" }));
expect(toggleTrackListSidebar).toHaveBeenCalledTimes(2);
Expand Down
12 changes: 8 additions & 4 deletions packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { twMerge } from "tailwind-merge";
import {
usePlayer,
Expand All @@ -11,16 +12,14 @@ import {
type RenderArtistLink,
type RenderSongLink,
} from "@behindthemusictree/app-kit/genre-tree";
import { PanelEdgeTab } from "@behindthemusictree/ui";
import { IconButton, PanelEdgeTab } from "@behindthemusictree/ui";
import PlayerControls from "./PlayerControls";
import TrackListSidebar from "./track-list-sidebar/TrackListSidebar";

export interface PlayerPanelProps {
className?: string;
/** Hidden via CSS, never unmounted, so the video keeps playing while the host hides the panel. */
isVisible?: boolean;
/** @deprecated The player no longer closes; its header action minimizes to video-only mode. */
onClose?: () => void;
/** Controlled extended state; uncontrolled when omitted. */
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
Expand All @@ -46,7 +45,7 @@ export default function PlayerPanel({
renderArtistLink,
renderSongLink,
}: PlayerPanelProps) {
const { playerTrackObject, retryPlayback } = usePlayer();
const { playerTrackObject, retryPlayback, stop } = usePlayer();
const { toggleTrackListSidebar, isTrackListSidebarVisible } =
useTrackListSidebarVisibility();
const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState("");
Expand Down Expand Up @@ -114,6 +113,11 @@ export default function PlayerPanel({
<span className="min-w-0 truncate text-sm font-semibold">
{loadedTrackTitle || lastLoadedTrackTitle}
</span>
{playerTrackObject && (
<IconButton aria-label="Close player" onClick={stop}>
<X aria-hidden="true" className="h-4 w-4" />
</IconButton>
)}
</div>
<div className="relative w-full shrink-0">
<PlayerVideoSurface className="aspect-video w-full bg-black" />
Expand Down
2 changes: 1 addition & 1 deletion packages/app-kit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@behindthemusictree/app-kit",
"version": "42.0.2",
"version": "43.0.0",
"description": "Headless transport, auth, popup state, player, and genre-tree data plumbing for BehindTheMusicTree React apps",
"repository": {
"type": "git",
Expand Down
8 changes: 8 additions & 0 deletions packages/app-kit/src/genre-tree/TrackListContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ describe("TrackListContext", () => {
});
});

it("rejects with TracksFailureAlreadyReportedError when the request rejected with a reported AppError", async () => {
const { result } = renderHook(() => useTrackList(), { wrapper });
fetchMock.mockRejectedValueOnce(new BackendError(ErrorCode.BACKEND_UNAUTHORIZED));
await act(async () => {
await expect(result.current.playNewTrackListFromGenrePlaylist(genrePlaylist, "me")).rejects.toBeInstanceOf(TracksFailureAlreadyReportedError);
});
});

it("keeps a schema failure distinct from an already-reported failure when starting from a song", async () => {
const { result } = renderHook(() => useTrackList(), { wrapper });
fetchMock.mockResolvedValueOnce(null);
Expand Down
11 changes: 9 additions & 2 deletions packages/app-kit/src/genre-tree/TrackListContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { genreMusicQueryParams, type GenreMusicSelection } from "./lib/genre-mus
import { usePlaybackContext } from "../transport/PlaybackContext";
import { usePlaybackFetch } from "../transport/usePlaybackFetch";
import { usePlaybackQuery } from "../transport/lib/use-playback-query";
import { isNotFoundError } from "../transport/app-errors/app-error";
import { AppError, isNotFoundError } from "../transport/app-errors/app-error";
import { useSession } from "../auth/SessionContext";
import { PaginatedResponseSchema } from "../transport/lib/paginated-response";
import { parseWithLog } from "../transport/lib/parse-with-log";
Expand Down Expand Up @@ -232,7 +232,14 @@ export function TrackListProvider<T extends TrackBase>({
const endpoint = genrePlaylistEndpoints[scope].tracks(uuid);
if (scope === "me" && (selection || artistUuid)) throw new Error("Genre selections require reference scope");
const params = { page, pageSize: TRACKS_PAGE_SIZE, ...(from && { from }), ...genreMusicQueryParams(selection), ...(artistUuid && { artist: artistUuid }) };
const response = await (page === 1 ? fetchLatest : fetchBound)(endpoint, true, scope === "me", {}, params, false, !!from);
let response: unknown;
try {
response = await (page === 1 ? fetchLatest : fetchBound)(endpoint, true, scope === "me", {}, params, false, !!from);
} catch (error) {
// fetch-wrapper already reported a failed first-page read to the user; callers must not raise a second popup.
if (error instanceof AppError && !from) throw new TracksFailureAlreadyReportedError();
throw error;
}
if (response === null && !from) throw new TracksFailureAlreadyReportedError();
const parsed = parseWithLog(pageSchema, response, "fetchGenrePlaylistTracksPage");
return {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-kit/src/genre-tree/useGenrePlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type RawPaginatedResponse = {
* first page, then fetches the rest with at most FULL_LIST_CONCURRENCY requests in flight.
*/
const fetchAllPages = async (fetchPage: (page: number) => Promise<unknown>): Promise<unknown[]> => {
// fetch-wrapper resolves null (instead of throwing) when a handleError callback swallowed the failure.
// fetch-wrapper resolves null for an empty body (204) or a missing session.
const fetchRequiredPage = async (page: number) => {
const response = await fetchPage(page);
if (response == null) throw new Error(`Paginated list page ${page} returned no body`);
Expand Down
17 changes: 17 additions & 0 deletions packages/app-kit/src/player/PlayerContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,23 @@ describe("PlayerProvider - loadTrackForPlayer (audio)", () => {
expect(result.current.playbackConfirmedTrackId).toBe("audio-1");
});

it("stop() pauses and unloads the current track", async () => {
loadTrackMock.mockResolvedValue(makeAudioTrack());
const { result } = renderHook(() => usePlayer(), { wrapper });
await act(async () => {
result.current.loadTrackForPlayer("audio-1");
await Promise.resolve();
await Promise.resolve();
});

act(() => result.current.stop());

expect(audioInstances[0].pause).toHaveBeenCalled();
expect(result.current.playerTrackObject).toBeNull();
expect(result.current.isPlaying).toBe(false);
expect(result.current.playState).toBe(PlayStates.STOPPED);
});

it("sets duration and currentTimeRef from audio events", async () => {
loadTrackMock.mockResolvedValue(makeAudioTrack());
const { result } = renderHook(() => usePlayer(), { wrapper });
Expand Down
Loading
Loading