diff --git a/CHANGELOG.md b/CHANGELOG.md
index d336d05..c5f631d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,11 +13,13 @@ easy to spot when bumping.
### 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.
diff --git a/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx b/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx
index 3037147..110f6d2 100644
--- a/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx
+++ b/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx
@@ -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();
+ fireEvent.click(screen.getByRole("button", { name: "Close player" }));
+ expect(stop).toHaveBeenCalledTimes(1);
+ });
+
it("hides the song list from the From header", () => {
setup(true);
render();
@@ -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(
@@ -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);
diff --git a/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx b/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx
index 4774ca9..83a14cb 100644
--- a/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx
+++ b/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import { X } from "lucide-react";
import { twMerge } from "tailwind-merge";
import {
usePlayer,
@@ -11,7 +12,7 @@ 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";
@@ -19,8 +20,6 @@ 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;
@@ -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("");
@@ -114,6 +113,11 @@ export default function PlayerPanel({
{loadedTrackTitle || lastLoadedTrackTitle}
+ {playerTrackObject && (
+
+
+
+ )}
diff --git a/packages/app-kit/src/player/PlayerContext.test.tsx b/packages/app-kit/src/player/PlayerContext.test.tsx
index ef19d5d..060eb7b 100644
--- a/packages/app-kit/src/player/PlayerContext.test.tsx
+++ b/packages/app-kit/src/player/PlayerContext.test.tsx
@@ -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 });
diff --git a/packages/app-kit/src/player/PlayerContext.tsx b/packages/app-kit/src/player/PlayerContext.tsx
index 384a98d..6715f51 100644
--- a/packages/app-kit/src/player/PlayerContext.tsx
+++ b/packages/app-kit/src/player/PlayerContext.tsx
@@ -98,6 +98,8 @@ interface PlayerContextType {
setPlayState: (state: PlayStates) => void;
handlePlayPauseAction: () => void;
loadTrackForPlayer: (trackId: string) => void;
+ /** Stops playback and unloads the current track, so `playerTrackObject` becomes `null`. */
+ stop: () => void;
isLoading: boolean;
handleNextTrack: (
trackList: PlayerTrack[],
@@ -215,6 +217,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) {
audioElementRef.current = audio;
audio.addEventListener("loadedmetadata", () => {
+ if (audioElementRef.current !== audio) return;
setDuration(audio.duration);
});
@@ -357,6 +360,28 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) {
[loadTrack, loadAudioTrack, loadYoutubeTrack, playback.key],
);
+ const stop = useCallback(() => {
+ generation.current++;
+ terminal.current = true;
+ currentTrackIdRef.current = null;
+ currentTimeRef.current = 0;
+ if (audioElementRef.current) {
+ audioElementRef.current.pause();
+ audioElementRef.current = null;
+ }
+ if (youtubePlayerRef.current) {
+ youtubePlayerRef.current.destroy();
+ youtubePlayerRef.current = null;
+ }
+ videoContainerRef.current?.replaceChildren();
+ setPlayerTrackObject(null);
+ setPlaybackConfirmedTrackId(null);
+ setDuration(0);
+ setIsLoading(false);
+ setPlayState(PlayStates.STOPPED);
+ setIsPlaying(false);
+ }, []);
+
const previousPlaybackKey = useRef(playback.key);
useEffect(() => {
if (previousPlaybackKey.current === playback.key) return;
@@ -470,6 +495,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) {
setPlayState,
handlePlayPauseAction,
loadTrackForPlayer,
+ stop,
isLoading,
handleNextTrack,
handlePreviousTrack,
@@ -489,6 +515,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) {
isLoading,
handlePlayPauseAction,
loadTrackForPlayer,
+ stop,
handleNextTrack,
handlePreviousTrack,
onTrackEnd,