diff --git a/web/src/player/components/CircleButton.test.tsx b/web/src/player/components/CircleButton.test.tsx
new file mode 100644
index 00000000..f1d81657
--- /dev/null
+++ b/web/src/player/components/CircleButton.test.tsx
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { CircleButton } from "./CircleButton";
+
+describe("CircleButton", () => {
+ it("calls onClick when clicked", async () => {
+ const onClick = vi.fn();
+ render(
+
+ x
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Test" }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("applies the primary skin class for variant=primary", () => {
+ render(
+
+ ▶
+ ,
+ );
+ const btn = screen.getByRole("button", { name: "Play" });
+ expect(btn.className).toContain("player-disc-primary");
+ });
+
+ it("sets data-paused when prop is true", () => {
+ render(
+
+ ▶
+ ,
+ );
+ expect(screen.getByRole("button")).toHaveAttribute("data-paused", "true");
+ });
+});
diff --git a/web/src/player/components/CircleButton.tsx b/web/src/player/components/CircleButton.tsx
new file mode 100644
index 00000000..9a0d0b51
--- /dev/null
+++ b/web/src/player/components/CircleButton.tsx
@@ -0,0 +1,48 @@
+export type CircleButtonProps = {
+ size: "sm" | "md" | "lg";
+ variant: "primary" | "secondary";
+ ariaLabel: string;
+ onClick?: () => void;
+ disabled?: boolean;
+ children: React.ReactNode;
+ "data-paused"?: boolean;
+};
+
+/**
+ * Glass-disc button used in both the video and audiobook player transports.
+ * - `primary` = glossy white disc (play/pause).
+ * - `secondary` = subtle glass disc (skip, prev/next).
+ * Sizes: `sm` 40–44px in-bar secondaries, `md` 52–56px in-bar play,
+ * `lg` 80px for floating variants (e.g. Now Listening play button).
+ */
+export function CircleButton({
+ size,
+ variant,
+ ariaLabel,
+ onClick,
+ disabled,
+ children,
+ "data-paused": dataPaused,
+}: CircleButtonProps) {
+ const base =
+ "flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/75 disabled:opacity-40 disabled:cursor-not-allowed";
+ const sizing =
+ size === "lg"
+ ? "h-20 w-20"
+ : size === "md"
+ ? "h-12 w-12 sm:h-14 sm:w-14"
+ : "h-10 w-10 sm:h-11 sm:w-11";
+ const skin = variant === "primary" ? "player-disc-primary" : "player-disc-secondary";
+ return (
+
+ );
+}
diff --git a/web/src/player/components/PlayerControls.tsx b/web/src/player/components/PlayerControls.tsx
index 6d224765..5d921592 100644
--- a/web/src/player/components/PlayerControls.tsx
+++ b/web/src/player/components/PlayerControls.tsx
@@ -10,6 +10,7 @@ import {
SkipBack,
SkipForward,
} from "lucide-react";
+import { CircleButton } from "./CircleButton";
import { SeekBar, formatTime } from "./SeekBar";
import { VolumeControl } from "./VolumeControl";
import { QualityMenu } from "./QualityMenu";
@@ -361,52 +362,6 @@ export function PlayerControls({
Internal building blocks
───────────────────────────────────────────────────────────────────── */
-type CircleButtonProps = {
- size: "sm" | "md" | "lg";
- variant: "primary" | "secondary";
- ariaLabel: string;
- onClick?: () => void;
- children: React.ReactNode;
- "data-paused"?: boolean;
-};
-
-/**
- * Pill-circle button used in the centered playback cluster.
- * - `primary` = glossy white disc (play/pause).
- * - `secondary` = subtle glass disc (skip, prev/next episode).
- * Sizes: `sm` 40–44px for in-bar secondaries, `md` 52–56px for in-bar
- * play button, `lg` 80px reserved for a future floating variant.
- */
-function CircleButton({
- size,
- variant,
- ariaLabel,
- onClick,
- children,
- "data-paused": dataPaused,
-}: CircleButtonProps) {
- const base =
- "flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/75";
- const sizing =
- size === "lg"
- ? "h-20 w-20"
- : size === "md"
- ? "h-12 w-12 sm:h-14 sm:w-14"
- : "h-10 w-10 sm:h-11 sm:w-11";
- const skin = variant === "primary" ? "player-disc-primary" : "player-disc-secondary";
- return (
-
- );
-}
-
/** Invisible placeholder that reserves the exact footprint of a CircleButton
* so the playback cluster stays symmetric when a neighboring episode isn't
* available (first/last episode in a series). */