diff --git a/app/(app)/control/page.tsx b/app/(app)/control/page.tsx
index ad783a7..68cfb5e 100644
--- a/app/(app)/control/page.tsx
+++ b/app/(app)/control/page.tsx
@@ -1,5 +1,8 @@
+import type { Metadata } from "next";
import { ButtplugConsoleLoader as ButtplugConsole } from "@/components/control/ButtplugConsoleLoader";
+export const metadata: Metadata = { title: "Control" };
+
export default function ControlPage() {
return (
diff --git a/app/(app)/devices/page.tsx b/app/(app)/devices/page.tsx
index e6e9b47..d0fabfe 100644
--- a/app/(app)/devices/page.tsx
+++ b/app/(app)/devices/page.tsx
@@ -1,11 +1,20 @@
+import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DevicesTable } from "@/components/devices/DevicesTable";
-import { listDevices } from "@/lib/db/queries/devices";
+import { PageNav } from "@/components/shared/PageNav";
+import { listDevicesPage } from "@/lib/db/queries/devices";
+import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Devices" };
-export default async function DevicesPage() {
- const devices = await listDevices();
+export default async function DevicesPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>;
+}) {
+ const page = parsePage((await searchParams).page);
+ const { items: devices, pageSize, total } = await listDevicesPage(page);
return (
@@ -17,8 +26,9 @@ export default async function DevicesPage() {
Known devices
-
+
+
diff --git a/app/(app)/recordings/[id]/page.tsx b/app/(app)/recordings/[id]/page.tsx
index 46d9e9e..0ec54da 100644
--- a/app/(app)/recordings/[id]/page.tsx
+++ b/app/(app)/recordings/[id]/page.tsx
@@ -1,12 +1,19 @@
import Link from "next/link";
import { notFound } from "next/navigation";
+import type { Metadata } from "next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { getRecording } from "@/lib/db/queries/recordings";
+import { getRecording, getRecordingName } from "@/lib/db/queries/recordings";
import { Play } from "lucide-react";
export const dynamic = "force-dynamic";
+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise
{
+ const { id } = await params;
+ const name = await getRecordingName(Number(id));
+ return { title: name ?? "Recording" };
+}
+
function formatDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
diff --git a/app/(app)/recordings/[id]/replay/page.tsx b/app/(app)/recordings/[id]/replay/page.tsx
index 1c1b8c1..f2f37fc 100644
--- a/app/(app)/recordings/[id]/replay/page.tsx
+++ b/app/(app)/recordings/[id]/replay/page.tsx
@@ -1,4 +1,12 @@
+import type { Metadata } from "next";
import { ReplayPlayerLoader } from "@/components/recordings/ReplayPlayerLoader";
+import { getRecordingName } from "@/lib/db/queries/recordings";
+
+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise {
+ const { id } = await params;
+ const name = await getRecordingName(Number(id));
+ return { title: name ? `Replay ${name}` : "Replay" };
+}
export default async function ReplayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
diff --git a/app/(app)/recordings/page.tsx b/app/(app)/recordings/page.tsx
index e97c585..d1d8965 100644
--- a/app/(app)/recordings/page.tsx
+++ b/app/(app)/recordings/page.tsx
@@ -1,11 +1,20 @@
+import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RecordingsTable } from "@/components/recordings/RecordingsTable";
-import { listRecordings } from "@/lib/db/queries/recordings";
+import { PageNav } from "@/components/shared/PageNav";
+import { listRecordingsPage } from "@/lib/db/queries/recordings";
+import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Recordings" };
-export default async function RecordingsPage() {
- const recordings = await listRecordings();
+export default async function RecordingsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>;
+}) {
+ const page = parsePage((await searchParams).page);
+ const { items: recordings, pageSize, total } = await listRecordingsPage(page);
return (
@@ -17,8 +26,9 @@ export default async function RecordingsPage() {
Library
-
+
+
diff --git a/app/(app)/sessions/[id]/page.tsx b/app/(app)/sessions/[id]/page.tsx
index 7760426..bd8d341 100644
--- a/app/(app)/sessions/[id]/page.tsx
+++ b/app/(app)/sessions/[id]/page.tsx
@@ -1,11 +1,18 @@
import { notFound } from "next/navigation";
+import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
-import { getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
+import { getPlaySessionDetail, getPlaySessionName } from "@/lib/db/queries/play-sessions";
import { getSessionTimeline } from "@/lib/db/queries/stats";
export const dynamic = "force-dynamic";
+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise {
+ const { id } = await params;
+ const name = await getPlaySessionName(Number(id));
+ return { title: name ?? `Session #${id}` };
+}
+
function formatDuration(ms: number | null): string {
if (ms === null) return "-";
const totalSeconds = Math.round(ms / 1000);
diff --git a/app/(app)/sessions/page.tsx b/app/(app)/sessions/page.tsx
index 216cdde..881ad51 100644
--- a/app/(app)/sessions/page.tsx
+++ b/app/(app)/sessions/page.tsx
@@ -1,11 +1,20 @@
+import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionsTable } from "@/components/sessions/SessionsTable";
-import { listPlaySessions } from "@/lib/db/queries/play-sessions";
+import { PageNav } from "@/components/shared/PageNav";
+import { listPlaySessionsPage } from "@/lib/db/queries/play-sessions";
+import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Sessions" };
-export default async function SessionsPage() {
- const sessions = [...(await listPlaySessions())].reverse();
+export default async function SessionsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>;
+}) {
+ const page = parsePage((await searchParams).page);
+ const { items: sessions, pageSize, total } = await listPlaySessionsPage(page);
return (
@@ -17,8 +26,9 @@ export default async function SessionsPage() {
History
-
+
+
diff --git a/app/(app)/stats/page.tsx b/app/(app)/stats/page.tsx
index 80b6d55..87d7c42 100644
--- a/app/(app)/stats/page.tsx
+++ b/app/(app)/stats/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from "next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards";
import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable";
@@ -5,6 +6,7 @@ import { RecordingLibraryStats } from "@/components/stats/RecordingLibraryStats"
import { getDeviceCommandCounts, getDeviceUsageStats, getRecordingLibraryStats, getSessionsSummary } from "@/lib/db/queries/stats";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Stats" };
export default async function StatsPage() {
const [sessionsSummary, deviceUsage, commandCounts, recordingStats] = await Promise.all([
diff --git a/app/api/play-sessions/[id]/route.ts b/app/api/play-sessions/[id]/route.ts
index 150bc9f..ccd7804 100644
--- a/app/api/play-sessions/[id]/route.ts
+++ b/app/api/play-sessions/[id]/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
+import { getRecordingsForSession } from "@/lib/db/queries/recordings";
const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) });
@@ -22,16 +23,18 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
return NextResponse.json({ playSession: updated });
}
-export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
+export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
+ const cascade = new URL(req.url).searchParams.get("cascade") === "true";
try {
- await deletePlaySession(Number(id));
+ await deletePlaySession(Number(id), { cascade });
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) {
+ const blockingRecordings = await getRecordingsForSession(Number(id));
return NextResponse.json(
- { error: "cannot delete a session that a saved recording still references" },
+ { error: "cannot delete a session that a saved recording still references", recordings: blockingRecordings },
{ status: 409 },
);
}
diff --git a/app/globals.css b/app/globals.css
index 666358b..b6ce796 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -176,6 +176,17 @@
.bp-led[data-on="true"] {
background: var(--primary);
}
+/* Header connection indicator: state-driven color, distinct from the
+ transmit-dot's boolean data-on above. */
+.bp-led[data-state="scanning"] {
+ background: var(--muted-foreground);
+}
+.bp-led[data-state="connected"] {
+ background: var(--primary);
+}
+.bp-led[data-state="recording"] {
+ background: var(--destructive);
+}
/* Thin panel seam - a hardware "trim line," not a plain UI divider. */
.bp-hairline {
@@ -193,15 +204,21 @@
@keyframes bp-pulse-glow {
0%,
100% {
- box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 55%, transparent);
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--bp-pulse-color, var(--primary)) 55%, transparent);
}
50% {
- box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 0%, transparent);
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--bp-pulse-color, var(--primary)) 0%, transparent);
}
}
.bp-pulse {
animation: bp-pulse-glow 2s ease-in-out infinite;
}
+.bp-led[data-state="scanning"] {
+ --bp-pulse-color: var(--muted-foreground);
+}
+.bp-led[data-state="recording"] {
+ --bp-pulse-color: var(--destructive);
+}
@media (prefers-reduced-motion: reduce) {
.bp-pulse {
diff --git a/app/layout.tsx b/app/layout.tsx
index 94cbe8e..23bfcb6 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -10,7 +10,7 @@ const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-displ
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const metadata: Metadata = {
- title: "SEXY",
+ title: { default: "Sexy", template: "%s · Sexy" },
description: "Bluetooth toy control console",
};
diff --git a/app/login/page.tsx b/app/login/page.tsx
index bc3b0cb..303768f 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -1,8 +1,11 @@
import { Suspense } from "react";
+import type { Metadata } from "next";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LoginForm } from "@/components/auth/LoginForm";
import { BrandMark } from "@/components/layout/BrandMark";
+export const metadata: Metadata = { title: "Log in" };
+
export default function LoginPage() {
return (
@@ -10,7 +13,7 @@ export default function LoginPage() {
- SEXY
+ Sexy
Enter the shared access secret to continue.
diff --git a/app/manifest.ts b/app/manifest.ts
index 7c3af8b..225e90e 100644
--- a/app/manifest.ts
+++ b/app/manifest.ts
@@ -2,8 +2,8 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
- name: "SEXY",
- short_name: "SEXY",
+ name: "Sexy",
+ short_name: "Sexy",
description: "Bluetooth toy control console",
start_url: "/",
display: "standalone",
diff --git a/components/control/ButtplugConsole.tsx b/components/control/ButtplugConsole.tsx
index d9af230..82a933a 100644
--- a/components/control/ButtplugConsole.tsx
+++ b/components/control/ButtplugConsole.tsx
@@ -45,6 +45,8 @@ export function ButtplugConsole() {
const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
const batteryLevels = useButtplugStore((s) => s.batteryLevels);
const setBatteryLevel = useButtplugStore((s) => s.setBatteryLevel);
+ const removeDevice = useButtplugStore((s) => s.removeDevice);
+ const setRecording = useButtplugStore((s) => s.setRecording);
const storeError = useButtplugStore((s) => s.error);
const [activeSession, setActiveSession] = useState
(null);
@@ -136,6 +138,7 @@ export function ButtplugConsole() {
sessionDeviceIdByDeviceIndex,
});
setElapsedMs(0);
+ setRecording(true);
} catch {
toast.error("Could not start session.");
} finally {
@@ -147,6 +150,7 @@ export function ButtplugConsole() {
if (!activeSession) return;
setSessionBusy(true);
eventBuffer.stop();
+ setRecording(false);
await fetch(`/api/play-sessions/${activeSession.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -197,6 +201,17 @@ export function ButtplugConsole() {
device.actuators.forEach((a) => setActuatorValue(device.index, a.featureIndex, 0));
}
+ async function handleDisconnectDevice(device: ConnectedDeviceInfo) {
+ // Buttplug's protocol has no per-device disconnect message - only a
+ // whole-client disconnect() and stop() (halts actuators). Stopping it and
+ // removing it from local state is the closest equivalent: the card
+ // disappears and it stops receiving commands, though the underlying BLE
+ // link may persist until the whole client disconnects.
+ const liveDevice = await getDevice(device.index);
+ await liveDevice?.stop().catch(() => {});
+ removeDevice(device.index);
+ }
+
if (!supported) {
return (
@@ -249,6 +264,7 @@ export function ButtplugConsole() {
}
onActuatorChange={(featureIndex, value) => void handleActuatorChange(device, featureIndex, value)}
onStop={() => void handleStopDevice(device)}
+ onDisconnect={() => void handleDisconnectDevice(device)}
/>
))}
diff --git a/components/control/DeviceCard.tsx b/components/control/DeviceCard.tsx
index 647f5e2..fd08201 100644
--- a/components/control/DeviceCard.tsx
+++ b/components/control/DeviceCard.tsx
@@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ActuatorSlider } from "./ActuatorSlider";
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
-import { Battery, BatteryFull, BatteryLow, BatteryMedium, BatteryWarning } from "lucide-react";
+import { Battery, BatteryFull, BatteryLow, BatteryMedium, BatteryWarning, Unlink } from "lucide-react";
interface DeviceCardProps {
device: ConnectedDeviceInfo;
@@ -13,6 +13,7 @@ interface DeviceCardProps {
transmittingFeatureIndex: number | null;
onActuatorChange: (featureIndex: number, value: number) => void;
onStop: () => void;
+ onDisconnect: () => void;
}
function BatteryIndicator({ level }: { level: number }) {
@@ -32,6 +33,7 @@ export function DeviceCard({
transmittingFeatureIndex,
onActuatorChange,
onStop,
+ onDisconnect,
}: DeviceCardProps) {
return (
@@ -47,6 +49,9 @@ export function DeviceCard({
Stop
+
+
+
diff --git a/components/devices/DevicesTable.tsx b/components/devices/DevicesTable.tsx
index 1a82ba0..a55112e 100644
--- a/components/devices/DevicesTable.tsx
+++ b/components/devices/DevicesTable.tsx
@@ -38,8 +38,8 @@ function DeviceNameCell({ device }: { device: DeviceRow }) {
return (
- setValue(e.target.value)} className="h-8 max-w-48" />
-
+ setValue(e.target.value)} className="h-8 w-40 min-w-40" />
+
Save
diff --git a/components/layout/ConnectionStatus.tsx b/components/layout/ConnectionStatus.tsx
index 545cf5d..e13460c 100644
--- a/components/layout/ConnectionStatus.tsx
+++ b/components/layout/ConnectionStatus.tsx
@@ -1,18 +1,28 @@
"use client";
import { useButtplugStore } from "@/lib/buttplug/store";
-import { cn } from "@/lib/utils";
+
+type IndicatorState = "scanning" | "connected" | "recording";
export function ConnectionStatus() {
- const connected = useButtplugStore((s) => s.connected);
+ const scanning = useButtplugStore((s) => s.scanning);
+ const recording = useButtplugStore((s) => s.recording);
const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length);
- return (
-
-
-
- {connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "not connected"}
-
-
- );
+ // Priority: an active recording is the most important thing to surface,
+ // then whether toys are actually connected, then a bare scan-in-progress -
+ // this is deliberately independent of the Buttplug client's own `connected`
+ // flag, which goes true as soon as the embedded server initializes (i.e. as
+ // soon as scanning starts), not when a device is actually paired.
+ const state: IndicatorState | null = recording
+ ? "recording"
+ : deviceCount > 0
+ ? "connected"
+ : scanning
+ ? "scanning"
+ : null;
+
+ if (!state) return null;
+
+ return ;
}
diff --git a/components/layout/NavBar.tsx b/components/layout/NavBar.tsx
index 431d9d2..0fa6f7d 100644
--- a/components/layout/NavBar.tsx
+++ b/components/layout/NavBar.tsx
@@ -57,7 +57,7 @@ export function NavBar() {
- SEXY
+ Sexy
diff --git a/components/recordings/ReplayPlayer.tsx b/components/recordings/ReplayPlayer.tsx
index cef166c..6849712 100644
--- a/components/recordings/ReplayPlayer.tsx
+++ b/components/recordings/ReplayPlayer.tsx
@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { DeviceScanPanel } from "@/components/control/DeviceScanPanel";
import { DeviceRemapDialog } from "./DeviceRemapDialog";
-import { getButtplugClientHandle, startScanning, stopScanning } from "@/lib/buttplug/client";
+import { disconnectAll, getButtplugClientHandle, getDevice, startScanning, stopScanning } from "@/lib/buttplug/client";
import { eventBuffer } from "@/lib/buttplug/event-buffer";
import { RecordingPlayer, type RecordingEventRow } from "@/lib/buttplug/player";
import { useButtplugStore } from "@/lib/buttplug/store";
@@ -35,13 +35,19 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
const router = useRouter();
const scanning = useButtplugStore((s) => s.scanning);
const devices = useButtplugStore((s) => s.devices);
+ const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
+ const removeDevice = useButtplugStore((s) => s.removeDevice);
const connectedDevices = useMemo(() => Object.values(devices), [devices]);
const [data, setData] = useState(null);
const [showRemap, setShowRemap] = useState(false);
+ const [starting, setStarting] = useState(false);
const [player, setPlayer] = useState(null);
const [playing, setPlaying] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
+ const [replayTargets, setReplayTargets] = useState<{ slotLabel: string; deviceName: string }[]>([]);
+ const [replayDeviceIndexes, setReplayDeviceIndexes] = useState([]);
+ const [activeSessionId, setActiveSessionId] = useState(null);
useEffect(() => {
fetch(`/api/recordings/${recordingId}`)
@@ -50,69 +56,111 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
.catch(() => toast.error("Could not load recording"));
}, [recordingId]);
+ useEffect(() => {
+ return () => {
+ void disconnectAll();
+ };
+ }, []);
+
+ async function resetReplayDevices() {
+ await Promise.all(
+ replayDeviceIndexes.map(async (deviceIndex) => {
+ const liveDevice = await getDevice(deviceIndex);
+ await liveDevice?.stop().catch(() => {});
+ devices[deviceIndex]?.actuators.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0));
+ }),
+ );
+ }
+
async function handleConfirmRemap(mapping: Map) {
if (!data) return;
setShowRemap(false);
+ setStarting(true);
- const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId));
- const body = {
- kind: "replay" as const,
- replayedRecordingId: data.recording.id,
- devices: orderedSlots.map((slot) => {
+ try {
+ const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId));
+ const targets = orderedSlots.map((slot) => {
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
const device = connectedDevices.find((d) => d.index === deviceIndex)!;
- return { slotLabel: slot.slotLabel, bleName: device.name };
- }),
- };
+ return { slotLabel: slot.slotLabel, deviceName: device.displayName ?? device.name };
+ });
- const res = await fetch("/api/play-sessions", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- toast.error("Could not start replay session");
- return;
- }
- const result: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } =
- await res.json();
+ const body = {
+ kind: "replay" as const,
+ replayedRecordingId: data.recording.id,
+ devices: orderedSlots.map((slot, i) => ({ slotLabel: slot.slotLabel, bleName: targets[i].deviceName })),
+ };
- eventBuffer.start(result.session.id, result.session.startedAt);
- const sessionDeviceIdToDeviceIndex = new Map();
- orderedSlots.forEach((slot, i) => {
- const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
- const newSessionDeviceId = result.sessionDevices[i]?.id;
- if (newSessionDeviceId !== undefined) {
- eventBuffer.registerSessionDevice(deviceIndex, newSessionDeviceId);
- sessionDeviceIdToDeviceIndex.set(slot.sourceSessionDeviceId, deviceIndex);
+ const res = await fetch("/api/play-sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ toast.error("Could not start replay session");
+ return;
}
- });
+ const result: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } =
+ await res.json();
- const actuatorsByDeviceIndex = new Map(
- connectedDevices.map((d) => [d.index, d.actuators]),
- );
+ eventBuffer.start(result.session.id, result.session.startedAt);
+ const sessionDeviceIdToDeviceIndex = new Map();
+ orderedSlots.forEach((slot, i) => {
+ const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
+ const newSessionDeviceId = result.sessionDevices[i]?.id;
+ if (newSessionDeviceId !== undefined) {
+ eventBuffer.registerSessionDevice(deviceIndex, newSessionDeviceId);
+ sessionDeviceIdToDeviceIndex.set(slot.sourceSessionDeviceId, deviceIndex);
+ }
+ });
- const { runtime } = await getButtplugClientHandle();
- const instance = new RecordingPlayer({
- events: data.events,
- sessionDeviceIdToDeviceIndex,
- actuatorsByDeviceIndex,
- runtime,
- onProgress: (elapsed) => setElapsedMs(elapsed),
- onComplete: async () => {
- setPlaying(false);
- eventBuffer.stop();
- await fetch(`/api/play-sessions/${result.session.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ status: "completed" }),
- });
- toast.success("Replay finished");
- },
- });
- setPlayer(instance);
- instance.play();
- setPlaying(true);
+ const actuatorsByDeviceIndex = new Map(
+ connectedDevices.map((d) => [d.index, d.actuators]),
+ );
+
+ // Reset every actuator on every device that will take part in this replay before
+ // the first scheduled event fires, so playback always starts from a known-zero
+ // state rather than whatever intensity was left over from manual control.
+ const deviceIndexes = new Set(sessionDeviceIdToDeviceIndex.values());
+ await Promise.all(
+ [...deviceIndexes].map(async (deviceIndex) => {
+ const liveDevice = await getDevice(deviceIndex);
+ await liveDevice?.stop().catch(() => {});
+ actuatorsByDeviceIndex.get(deviceIndex)?.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0));
+ }),
+ );
+
+ const { runtime } = await getButtplugClientHandle();
+ const instance = new RecordingPlayer({
+ events: data.events,
+ durationMs: data.recording.durationMs,
+ sessionDeviceIdToDeviceIndex,
+ actuatorsByDeviceIndex,
+ runtime,
+ onProgress: (elapsed) => setElapsedMs(elapsed),
+ onError: (message) => toast.error(`Replay command failed: ${message}`),
+ onComplete: async () => {
+ setPlaying(false);
+ eventBuffer.stop();
+ await fetch(`/api/play-sessions/${result.session.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status: "completed" }),
+ }).catch(() => {});
+ toast.success("Replay finished");
+ },
+ });
+ setReplayTargets(targets);
+ setReplayDeviceIndexes([...deviceIndexes]);
+ setActiveSessionId(result.session.id);
+ setPlayer(instance);
+ instance.play();
+ setPlaying(true);
+ } catch {
+ toast.error("Could not start replay");
+ } finally {
+ setStarting(false);
+ }
}
if (!data) {
@@ -136,41 +184,98 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
void startScanning()} onStopScan={() => void stopScanning()} />
{
- await getButtplugClientHandle();
- setShowRemap(true);
+ try {
+ await getButtplugClientHandle();
+ setShowRemap(true);
+ } catch {
+ toast.error("Could not connect to Buttplug client");
+ }
}}
- disabled={connectedDevices.length === 0}
+ disabled={connectedDevices.length === 0 || starting}
>
- Match devices & replay
+ {starting ? "Starting..." : "Match devices & replay"}
>
) : (
+ {replayTargets.length > 0 && (
+
+ {replayTargets.map((t) => (
+
+ {t.slotLabel} → {t.deviceName}
+
+ ))}
+
+ )}
player.seek(v)}
/>
-
+
{formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)}
- {
- if (playing) {
- player.pause();
+
+
{
+ if (playing) {
+ player.pause();
+ setPlaying(false);
+ } else {
+ player.play();
+ setPlaying(true);
+ }
+ // Toggling either way leaves the toy holding whatever intensity was
+ // last sent - zero it out so pause always actually stops the device,
+ // and resume always starts from a clean, known state.
+ await resetReplayDevices();
+ }}
+ aria-label={playing ? "Pause" : "Play"}
+ >
+ {playing ? : }
+
+
{
+ player.stop();
setPlaying(false);
- } else {
- player.play();
- setPlaying(true);
- }
- }}
- >
- {playing ? : }
-
+ setElapsedMs(0);
+ eventBuffer.stop();
+ if (activeSessionId !== null) {
+ await fetch(`/api/play-sessions/${activeSessionId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status: "aborted" }),
+ }).catch(() => {});
+ }
+ // Fully disconnect every device that took part in this replay,
+ // not just stop the player - see handleDisconnectDevice in
+ // ButtplugConsole for why "stop + remove from store" is the
+ // closest equivalent Buttplug's protocol allows per-device.
+ await Promise.all(
+ replayDeviceIndexes.map(async (deviceIndex) => {
+ const liveDevice = await getDevice(deviceIndex);
+ await liveDevice?.stop().catch(() => {});
+ removeDevice(deviceIndex);
+ }),
+ );
+ setPlayer(null);
+ setReplayTargets([]);
+ setReplayDeviceIndexes([]);
+ setActiveSessionId(null);
+ }}
+ >
+ Stop
+
+
)}
diff --git a/components/sessions/SessionsTable.tsx b/components/sessions/SessionsTable.tsx
index 93be365..ff0a8c2 100644
--- a/components/sessions/SessionsTable.tsx
+++ b/components/sessions/SessionsTable.tsx
@@ -1,13 +1,27 @@
"use client";
+import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
+interface BlockingRecording {
+ id: number;
+ name: string;
+}
+
export interface SessionRow {
id: number;
name: string | null;
@@ -27,6 +41,8 @@ function formatDuration(ms: number | null): string {
export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
const router = useRouter();
+ const [conflict, setConflict] = useState<{ id: number; recordings: BlockingRecording[] } | null>(null);
+ const [deleting, setDeleting] = useState(false);
async function handleDelete(id: number) {
const res = await fetch(`/api/play-sessions/${id}`, { method: "DELETE" });
@@ -34,7 +50,22 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
toast.success("Session deleted");
router.refresh();
} else if (res.status === 409) {
- toast.error("A saved recording still references this session");
+ const body: { recordings?: BlockingRecording[] } = await res.json().catch(() => ({}));
+ setConflict({ id, recordings: body.recordings ?? [] });
+ } else {
+ toast.error("Could not delete session");
+ }
+ }
+
+ async function handleCascadeDelete() {
+ if (!conflict) return;
+ setDeleting(true);
+ const res = await fetch(`/api/play-sessions/${conflict.id}?cascade=true`, { method: "DELETE" });
+ setDeleting(false);
+ if (res.ok) {
+ toast.success("Session and recording deleted");
+ setConflict(null);
+ router.refresh();
} else {
toast.error("Could not delete session");
}
@@ -45,41 +76,66 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
}
return (
-
-
-
- Session
- Kind
- Status
- Started
- Duration
- Actions
-
-
-
- {sessions.map((s) => (
-
-
-
- {s.name ?? `Session #${s.id}`}
-
-
-
- {s.kind}
-
- {s.status}
-
- {new Date(s.startedAt).toLocaleString()}
-
- {formatDuration(s.durationMs)}
-
- void handleDelete(s.id)} aria-label="Delete">
-
-
-
+ <>
+
+
+
+ Session
+ Kind
+ Status
+ Started
+ Duration
+ Actions
- ))}
-
-
+
+
+ {sessions.map((s) => (
+
+
+
+ {s.name ?? `Session #${s.id}`}
+
+
+
+ {s.kind}
+
+ {s.status}
+
+ {new Date(s.startedAt).toLocaleString()}
+
+ {formatDuration(s.durationMs)}
+
+ void handleDelete(s.id)} aria-label="Delete">
+
+
+
+
+ ))}
+
+
+
+ !open && setConflict(null)}>
+
+
+ Delete session and recording?
+
+ This session has a saved recording
+ {conflict && conflict.recordings.length > 0
+ ? ` (${conflict.recordings.map((r) => r.name).join(", ")})`
+ : ""}{" "}
+ that still points to it. Deleting the session will also delete that recording.
+
+
+
+ setConflict(null)}>
+ Cancel
+
+ void handleCascadeDelete()} disabled={deleting}>
+ {deleting ? "Deleting..." : "Delete both"}
+
+
+
+
+ >
);
}
diff --git a/components/shared/PageNav.tsx b/components/shared/PageNav.tsx
new file mode 100644
index 0000000..a5025e4
--- /dev/null
+++ b/components/shared/PageNav.tsx
@@ -0,0 +1,53 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+interface PageNavProps {
+ basePath: string;
+ page: number;
+ pageSize: number;
+ total: number;
+}
+
+export function PageNav({ basePath, page, pageSize, total }: PageNavProps) {
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ if (totalPages <= 1) return null;
+
+ const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
+ const to = Math.min(page * pageSize, total);
+
+ return (
+
+
+ {from}-{to} of {total}
+
+
+ {page > 1 ? (
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {page} / {totalPages}
+
+ {page < totalPages ? (
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/lib/buttplug/player.ts b/lib/buttplug/player.ts
index cc3041b..cf17163 100644
--- a/lib/buttplug/player.ts
+++ b/lib/buttplug/player.ts
@@ -14,6 +14,11 @@ export interface RecordingEventRow {
export interface PlayerOptions {
events: RecordingEventRow[];
+ /** The recording's actual duration (recordings.durationMs from the DB) - this is the source
+ * of truth for playback length, NOT the last event's timestamp: a recording can run for a
+ * while after its last command (e.g. the user stopped the toy but let the session continue),
+ * so deriving duration from events would end playback early and misreport it as "finished". */
+ durationMs: number;
/** Recording's session_device_id -> currently-connected device index, from the remap step. */
sessionDeviceIdToDeviceIndex: Map;
actuatorsByDeviceIndex: Map;
@@ -21,6 +26,7 @@ export interface PlayerOptions {
speed?: number;
onProgress?: (elapsedMs: number, durationMs: number) => void;
onComplete?: () => void;
+ onError?: (message: string) => void;
}
/**
@@ -37,7 +43,7 @@ export class RecordingPlayer {
private playing = false;
constructor(private readonly options: PlayerOptions) {
- this.durationMs = options.events.at(-1)?.tsMs ?? 0;
+ this.durationMs = options.durationMs;
}
get isPlaying(): boolean {
@@ -108,29 +114,32 @@ export class RecordingPlayer {
const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId);
if (deviceIndex === undefined) return;
- const device = await getDevice(deviceIndex);
- if (!device) return;
+ try {
+ const device = await getDevice(deviceIndex);
+ if (!device) return;
- eventBuffer.record({
- deviceIndex,
- commandType: event.commandType,
- featureIndex: event.featureIndex,
- value: event.value,
- durationMs: event.durationMs ?? undefined,
- });
+ if (event.commandType === "stop") {
+ await device.stop();
+ } else {
+ const actuator = this.options.actuatorsByDeviceIndex
+ .get(deviceIndex)
+ ?.find((a) => a.featureIndex === event.featureIndex);
+ const feature = findFeature(device, event.featureIndex);
+ if (!actuator || !feature) return;
- if (event.commandType === "stop") {
- await device.stop();
- return;
+ const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined);
+ await feature.runOutput(cmd);
+ }
+
+ eventBuffer.record({
+ deviceIndex,
+ commandType: event.commandType,
+ featureIndex: event.featureIndex,
+ value: event.value,
+ durationMs: event.durationMs ?? undefined,
+ });
+ } catch (err) {
+ this.options.onError?.(err instanceof Error ? err.message : String(err));
}
-
- const actuator = this.options.actuatorsByDeviceIndex
- .get(deviceIndex)
- ?.find((a) => a.featureIndex === event.featureIndex);
- const feature = findFeature(device, event.featureIndex);
- if (!actuator || !feature) return;
-
- const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined);
- await feature.runOutput(cmd);
}
}
diff --git a/lib/buttplug/store.ts b/lib/buttplug/store.ts
index ba5126e..4524502 100644
--- a/lib/buttplug/store.ts
+++ b/lib/buttplug/store.ts
@@ -7,6 +7,8 @@ export const actuatorKey = (deviceIndex: number, featureIndex: number): string =
interface ButtplugStoreState {
connected: boolean;
scanning: boolean;
+ /** Whether a play session is actively being recorded (see ButtplugConsole's session lifecycle). */
+ recording: boolean;
devices: Record;
/** Last-known slider value per `${deviceIndex}:${featureIndex}`, for UI display only. */
actuatorValues: Record;
@@ -15,6 +17,7 @@ interface ButtplugStoreState {
error: string | null;
setConnected: (connected: boolean) => void;
setScanning: (scanning: boolean) => void;
+ setRecording: (recording: boolean) => void;
upsertDevice: (device: ConnectedDeviceInfo) => void;
removeDevice: (index: number) => void;
setActuatorValue: (deviceIndex: number, featureIndex: number, value: number) => void;
@@ -26,12 +29,14 @@ interface ButtplugStoreState {
export const useButtplugStore = create((set) => ({
connected: false,
scanning: false,
+ recording: false,
devices: {},
actuatorValues: {},
batteryLevels: {},
error: null,
setConnected: (connected) => set({ connected }),
setScanning: (scanning) => set({ scanning }),
+ setRecording: (recording) => set({ recording }),
upsertDevice: (device) => set((s) => ({ devices: { ...s.devices, [device.index]: device } })),
removeDevice: (index) =>
set((s) => {
@@ -48,5 +53,6 @@ export const useButtplugStore = create((set) => ({
setBatteryLevel: (deviceIndex, level) =>
set((s) => ({ batteryLevels: { ...s.batteryLevels, [deviceIndex]: level } })),
setError: (message) => set({ error: message }),
- reset: () => set({ connected: false, scanning: false, devices: {}, actuatorValues: {}, batteryLevels: {} }),
+ reset: () =>
+ set({ connected: false, scanning: false, recording: false, devices: {}, actuatorValues: {}, batteryLevels: {} }),
}));
diff --git a/lib/db/queries/devices.ts b/lib/db/queries/devices.ts
index 6f250c5..1756fc2 100644
--- a/lib/db/queries/devices.ts
+++ b/lib/db/queries/devices.ts
@@ -1,11 +1,23 @@
-import { eq } from "drizzle-orm";
+import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { devices, type DeviceCapabilities } from "@/lib/db/schema";
+import { PAGE_SIZE, type Page } from "@/lib/pagination";
export async function listDevices() {
return db.select().from(devices).orderBy(devices.lastConnectedAt);
}
+export async function listDevicesPage(page: number, pageSize = PAGE_SIZE): Promise> {
+ const [{ count }] = await db.select({ count: sql`count(*)` }).from(devices);
+ const items = await db
+ .select()
+ .from(devices)
+ .orderBy(desc(devices.lastConnectedAt))
+ .limit(pageSize)
+ .offset((page - 1) * pageSize);
+ return { items, page, pageSize, total: count };
+}
+
export async function getDevice(id: number) {
const [row] = await db.select().from(devices).where(eq(devices.id, id));
return row;
diff --git a/lib/db/queries/play-sessions.ts b/lib/db/queries/play-sessions.ts
index 06197a1..72cb4e2 100644
--- a/lib/db/queries/play-sessions.ts
+++ b/lib/db/queries/play-sessions.ts
@@ -1,9 +1,10 @@
-import { eq } from "drizzle-orm";
+import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
-import { playSessions, sessionDevices, devices } from "@/lib/db/schema";
+import { playSessions, sessionDevices, devices, recordings } from "@/lib/db/schema";
import { upsertDeviceByBleName } from "./devices";
import { incrementPlayCount } from "./recordings";
import type { DeviceCapabilities } from "@/lib/db/schema";
+import { PAGE_SIZE, type Page } from "@/lib/pagination";
export interface StartSessionDeviceInput {
slotLabel: string;
@@ -86,6 +87,23 @@ export async function listPlaySessions() {
return db.select().from(playSessions).orderBy(playSessions.startedAt);
}
+export async function listPlaySessionsPage(page: number, pageSize = PAGE_SIZE): Promise> {
+ const [{ count }] = await db.select({ count: sql`count(*)` }).from(playSessions);
+ const items = await db
+ .select()
+ .from(playSessions)
+ .orderBy(desc(playSessions.startedAt))
+ .limit(pageSize)
+ .offset((page - 1) * pageSize);
+ return { items, page, pageSize, total: count };
+}
+
+/** Lightweight name-only lookup for page titles - avoids getPlaySessionDetail's device join. */
+export async function getPlaySessionName(id: number): Promise {
+ const [row] = await db.select({ name: playSessions.name }).from(playSessions).where(eq(playSessions.id, id));
+ return row?.name;
+}
+
export async function getPlaySessionDetail(id: number) {
const [session] = await db.select().from(playSessions).where(eq(playSessions.id, id));
if (!session) return undefined;
@@ -107,6 +125,9 @@ export async function getPlaySessionDetail(id: number) {
return { session, devices: sessionDeviceRows };
}
-export async function deletePlaySession(id: number) {
+export async function deletePlaySession(id: number, options?: { cascade?: boolean }) {
+ if (options?.cascade) {
+ await db.delete(recordings).where(eq(recordings.sourcePlaySessionId, id));
+ }
await db.delete(playSessions).where(eq(playSessions.id, id));
}
diff --git a/lib/db/queries/recordings.ts b/lib/db/queries/recordings.ts
index c191c34..c6cbc99 100644
--- a/lib/db/queries/recordings.ts
+++ b/lib/db/queries/recordings.ts
@@ -1,7 +1,8 @@
-import { desc, eq } from "drizzle-orm";
+import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { recordings, sessionDevices, devices, playSessions, type RecordingDeviceSlot } from "@/lib/db/schema";
import { getEventsForSession } from "./session-events";
+import { PAGE_SIZE, type Page } from "@/lib/pagination";
/**
* A recording is a thin pointer over an already-captured play_session, not a
@@ -60,6 +61,30 @@ export async function listRecordings() {
return db.select().from(recordings).orderBy(desc(recordings.createdAt));
}
+export async function listRecordingsPage(page: number, pageSize = PAGE_SIZE): Promise> {
+ const [{ count }] = await db.select({ count: sql`count(*)` }).from(recordings);
+ const items = await db
+ .select()
+ .from(recordings)
+ .orderBy(desc(recordings.createdAt))
+ .limit(pageSize)
+ .offset((page - 1) * pageSize);
+ return { items, page, pageSize, total: count };
+}
+
+export async function getRecordingsForSession(playSessionId: number) {
+ return db
+ .select({ id: recordings.id, name: recordings.name })
+ .from(recordings)
+ .where(eq(recordings.sourcePlaySessionId, playSessionId));
+}
+
+/** Lightweight name-only lookup for page titles - avoids getRecording's event-table join. */
+export async function getRecordingName(id: number): Promise {
+ const [row] = await db.select({ name: recordings.name }).from(recordings).where(eq(recordings.id, id));
+ return row?.name;
+}
+
export async function getRecording(id: number) {
const [recording] = await db.select().from(recordings).where(eq(recordings.id, id));
if (!recording) return undefined;
diff --git a/lib/pagination.ts b/lib/pagination.ts
new file mode 100644
index 0000000..d0067d4
--- /dev/null
+++ b/lib/pagination.ts
@@ -0,0 +1,13 @@
+export const PAGE_SIZE = 20;
+
+export function parsePage(value: string | string[] | undefined): number {
+ const n = Number(Array.isArray(value) ? value[0] : value);
+ return Number.isInteger(n) && n > 0 ? n : 1;
+}
+
+export interface Page {
+ items: T[];
+ page: number;
+ pageSize: number;
+ total: number;
+}
diff --git a/package.json b/package.json
index 69a2a24..9561c90 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sexy",
- "version": "0.2.1",
+ "version": "0.3.0",
"private": true,
"scripts": {
"dev": "next dev",