diff --git a/packages/frontend/src/lib/i18n/locales/en.ts b/packages/frontend/src/lib/i18n/locales/en.ts
index 5bc2904..f98f138 100644
--- a/packages/frontend/src/lib/i18n/locales/en.ts
+++ b/packages/frontend/src/lib/i18n/locales/en.ts
@@ -822,11 +822,19 @@ export default {
questions_email: "support@pivoine.art",
},
play: {
- title: "SexyPlay",
+ title: "Play",
description: "Bring your toys.",
scan: "Start Scan",
scanning: "Scanning...",
- no_results: "No Devices founds",
+ no_results: "No devices found",
+ no_results_description: "Start a scan to discover nearby Bluetooth devices",
+ nav: {
+ play: "Play",
+ recordings: "Recordings",
+ leaderboard: "Leaderboard",
+ back_to_site: "Back to site",
+ back_mobile: "Site",
+ },
},
error: {
not_found: "Oops! Page Not Found",
diff --git a/packages/frontend/src/routes/leaderboard/+page.server.ts b/packages/frontend/src/routes/leaderboard/+page.server.ts
index cf819c1..bf9358f 100644
--- a/packages/frontend/src/routes/leaderboard/+page.server.ts
+++ b/packages/frontend/src/routes/leaderboard/+page.server.ts
@@ -1,66 +1,5 @@
import { redirect } from "@sveltejs/kit";
-import type { PageServerLoad } from "./$types";
-import { gql } from "graphql-request";
-import { getGraphQLClient } from "$lib/api";
-const LEADERBOARD_QUERY = gql`
- query Leaderboard($limit: Int, $offset: Int) {
- leaderboard(limit: $limit, offset: $offset) {
- user_id
- display_name
- avatar
- total_weighted_points
- total_raw_points
- recordings_count
- playbacks_count
- achievements_count
- rank
- }
- }
-`;
-
-export const load: PageServerLoad = async ({ fetch, url, locals }) => {
- // Guard: Redirect to login if not authenticated
- if (!locals.authStatus.authenticated) {
- throw redirect(302, "/login");
- }
-
- try {
- const limit = parseInt(url.searchParams.get("limit") || "100");
- const offset = parseInt(url.searchParams.get("offset") || "0");
-
- const client = getGraphQLClient(fetch);
- const data = await client.request<{
- leaderboard: {
- user_id: string;
- display_name: string | null;
- avatar: string | null;
- total_weighted_points: number | null;
- total_raw_points: number | null;
- recordings_count: number | null;
- playbacks_count: number | null;
- achievements_count: number | null;
- rank: number;
- }[];
- }>(LEADERBOARD_QUERY, { limit, offset });
-
- return {
- leaderboard: data.leaderboard || [],
- pagination: {
- limit,
- offset,
- hasMore: data.leaderboard?.length === limit,
- },
- };
- } catch (error) {
- console.error("Leaderboard load error:", error);
- return {
- leaderboard: [],
- pagination: {
- limit: 100,
- offset: 0,
- hasMore: false,
- },
- };
- }
-};
+export function load() {
+ throw redirect(301, "/play/leaderboard");
+}
diff --git a/packages/frontend/src/routes/me/+layout.svelte b/packages/frontend/src/routes/me/+layout.svelte
index 98632e6..de83ef9 100644
--- a/packages/frontend/src/routes/me/+layout.svelte
+++ b/packages/frontend/src/routes/me/+layout.svelte
@@ -10,11 +10,6 @@
const navLinks = $derived([
{ name: $_("me.nav.profile"), href: "/me/profile", icon: "icon-[ri--user-line]" },
{ name: $_("me.nav.security"), href: "/me/security", icon: "icon-[ri--shield-keyhole-line]" },
- {
- name: $_("me.nav.recordings"),
- href: "/me/recordings",
- icon: "icon-[ri--play-list-2-line]",
- },
...(data.isModel
? [
{
diff --git a/packages/frontend/src/routes/me/recordings/+page.server.ts b/packages/frontend/src/routes/me/recordings/+page.server.ts
index c87da3e..037d34b 100644
--- a/packages/frontend/src/routes/me/recordings/+page.server.ts
+++ b/packages/frontend/src/routes/me/recordings/+page.server.ts
@@ -1,7 +1,5 @@
-import { getRecordings } from "$lib/services";
+import { redirect } from "@sveltejs/kit";
-export async function load({ fetch }) {
- return {
- recordings: await getRecordings(fetch).catch(() => []),
- };
+export function load() {
+ throw redirect(301, "/play/recordings");
}
diff --git a/packages/frontend/src/routes/play/+layout.server.ts b/packages/frontend/src/routes/play/+layout.server.ts
new file mode 100644
index 0000000..01f84b6
--- /dev/null
+++ b/packages/frontend/src/routes/play/+layout.server.ts
@@ -0,0 +1,8 @@
+import { redirect } from "@sveltejs/kit";
+
+export async function load({ locals }) {
+ if (!locals.authStatus.authenticated) {
+ throw redirect(302, "/login");
+ }
+ return { authStatus: locals.authStatus };
+}
diff --git a/packages/frontend/src/routes/play/+layout.svelte b/packages/frontend/src/routes/play/+layout.svelte
new file mode 100644
index 0000000..6abbb40
--- /dev/null
+++ b/packages/frontend/src/routes/play/+layout.svelte
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+
diff --git a/packages/frontend/src/routes/play/+page.server.ts b/packages/frontend/src/routes/play/+page.server.ts
index 2ad78d5..2702c1b 100644
--- a/packages/frontend/src/routes/play/+page.server.ts
+++ b/packages/frontend/src/routes/play/+page.server.ts
@@ -1,20 +1,5 @@
-import { getRecording } from "$lib/services";
-import type { Recording } from "$lib/types";
+import { redirect } from "@sveltejs/kit";
-export async function load({ locals, url, fetch }) {
- const recordingId = url.searchParams.get("recording");
-
- let recording: Recording | null = null;
- if (recordingId && locals.authStatus.authenticated) {
- try {
- recording = await getRecording(recordingId, fetch);
- } catch (error) {
- console.error("Failed to load recording:", error);
- }
- }
-
- return {
- authStatus: locals.authStatus,
- recording,
- };
+export function load() {
+ throw redirect(302, "/play/buttplug");
}
diff --git a/packages/frontend/src/routes/play/buttplug/+page.server.ts b/packages/frontend/src/routes/play/buttplug/+page.server.ts
new file mode 100644
index 0000000..1c2f5ec
--- /dev/null
+++ b/packages/frontend/src/routes/play/buttplug/+page.server.ts
@@ -0,0 +1,19 @@
+import { getRecording } from "$lib/services";
+import type { Recording } from "$lib/types";
+
+export async function load({ url, fetch }) {
+ const recordingId = url.searchParams.get("recording");
+
+ let recording: Recording | null = null;
+ if (recordingId) {
+ try {
+ recording = await getRecording(recordingId, fetch);
+ } catch (error) {
+ console.error("Failed to load recording:", error);
+ }
+ }
+
+ return {
+ recording,
+ };
+}
diff --git a/packages/frontend/src/routes/play/+page.svelte b/packages/frontend/src/routes/play/buttplug/+page.svelte
similarity index 52%
rename from packages/frontend/src/routes/play/+page.svelte
rename to packages/frontend/src/routes/play/buttplug/+page.svelte
index 20bb266..3ca5a4d 100644
--- a/packages/frontend/src/routes/play/+page.svelte
+++ b/packages/frontend/src/routes/play/buttplug/+page.svelte
@@ -4,14 +4,13 @@
import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
- import { goto } from "$app/navigation";
import DeviceCard from "$lib/components/device-card/device-card.svelte";
- import RecordingSaveDialog from "./components/recording-save-dialog.svelte";
- import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
+ import RecordingSaveDialog from "../components/recording-save-dialog.svelte";
+ import DeviceMappingDialog from "../components/device-mapping-dialog.svelte";
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner";
import { createRecording } from "$lib/services";
- import SexyBackground from "$lib/components/background/background.svelte";
+ import * as Empty from "$lib/components/ui/empty";
// Runtime buttplug values — loaded dynamically from the buttplug nginx container
let client: ButtplugTypes.ButtplugClient;
@@ -41,7 +40,6 @@
async function init() {
const connector = new ButtplugWasmClientConnector();
- // await ButtplugWasmClientConnector.activateLogging("info");
await client.connect(connector);
client.on("deviceadded", onDeviceAdded);
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
@@ -62,7 +60,6 @@
const device = convertDevice(dev);
devices.push(device);
- // Try to read battery level — access through the reactive array so Svelte detects the mutation
const idx = devices.length - 1;
if (device.hasBattery) {
try {
@@ -95,16 +92,13 @@
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
- // Capture event if recording
if (isRecording && recordingStartTime) {
captureEvent(device, actuatorIdx, value);
}
}
function startRecording() {
- if (devices.length === 0) {
- return;
- }
+ if (devices.length === 0) return;
isRecording = true;
recordingStartTime = performance.now();
recordedEvents = [];
@@ -131,7 +125,7 @@
device_name: device.name,
actuator_index: actuatorIdx,
actuator_type: actuator.outputType,
- value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
+ value: (value / actuator.maxSteps) * 100,
});
}
@@ -166,7 +160,11 @@
};
}
- async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) {
+ async function handleSaveRecording(saveData: {
+ title: string;
+ description: string;
+ tags: string[];
+ }) {
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
name: d.name,
index: d.info.index,
@@ -175,12 +173,12 @@
try {
await createRecording({
- title: data.title,
- description: data.description,
+ title: saveData.title,
+ description: saveData.description,
duration: Math.round(recordingDuration),
events: recordedEvents,
device_info: deviceInfo,
- tags: data.tags,
+ tags: saveData.tags,
status: "draft",
});
@@ -188,9 +186,6 @@
showSaveDialog = false;
recordedEvents = [];
recordingDuration = 0;
-
- // Optionally navigate to dashboard
- // goto("/me?tab=recordings");
} catch (error) {
console.error("Failed to save recording:", error);
toast.error("Failed to save recording. Please try again.");
@@ -203,24 +198,19 @@
recordingDuration = 0;
}
- // Playback functions
function startPlayback() {
- if (!data.recording) {
- return;
- }
+ if (!data.recording) return;
if (devices.length === 0) {
toast.error("Please connect devices before playing recording");
return;
}
- // Check if we need to map devices
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
showMappingDialog = true;
return;
}
- // Start playback with existing mappings
beginPlayback();
}
@@ -250,8 +240,6 @@
}
playbackProgress = 0;
currentEventIndex = 0;
-
- // Stop all devices
devices.forEach((device) => handleStop(device));
}
@@ -265,7 +253,6 @@
function resumePlayback() {
if (!data.recording) return;
-
isPlaying = true;
playbackStartTime = performance.now() - playbackProgress;
scheduleNextEvent();
@@ -286,12 +273,10 @@
const delay = event.timestamp - currentTime;
if (delay <= 0) {
- // Execute event immediately
executeEvent(event);
currentEventIndex++;
scheduleNextEvent();
} else {
- // Schedule event
playbackTimeoutId = setTimeout(() => {
executeEvent(event);
currentEventIndex++;
@@ -302,31 +287,25 @@
}
function executeEvent(event: RecordedEvent) {
- // Get mapped device
const device = deviceMappings.get(event.device_name);
if (!device) {
console.warn(`No device mapping for: ${event.device_name}`);
return;
}
- // Find matching actuator by type
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
if (!actuator) {
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
return;
}
- // Convert normalized value (0-100) back to device scale
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
-
- // Send command to device via feature
const feature = device.info.features.get(actuator.featureIndex);
if (feature) {
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
}
- // Update UI
actuator.value = deviceValue;
}
@@ -336,7 +315,6 @@
const targetTime = (percentage / 100) * data.recording.duration;
playbackProgress = targetTime;
- // Find the event index at this time
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
if (currentEventIndex === -1) {
@@ -355,10 +333,6 @@
const { data } = $props();
onMount(async () => {
- if (!data.authStatus.authenticated) {
- goto("/login");
- return;
- }
// Concatenation prevents Rollup from statically resolving this URL at build time
const buttplugUrl = "/buttplug/" + "dist/index.js";
const bp = await import(/* @vite-ignore */ buttplugUrl);
@@ -373,240 +347,203 @@
-
-
+
+
+
+
{$_("play.title")}
+
-
-
-
-
-
+ {#if devices.length > 0 && !data.recording}
+
+ {#if !isRecording}
+
-
-
- {#if data.recording}
-
-
-
- {data.recording.title}
-
- {#if data.recording.description}
-
- {data.recording.description}
-
- {/if}
-
-
-
-
-
-
- {Math.floor(playbackProgress / 1000 / 60)}:{(
- Math.floor(playbackProgress / 1000) % 60
- )
- .toString()
- .padStart(2, "0")}
-
-
{
- const rect = e.currentTarget.getBoundingClientRect();
- const percentage = ((e.clientX - rect.left) / rect.width) * 100;
- seek(percentage);
- }}
- onkeydown={(e) => {
- if (e.key === "ArrowRight")
- seek(((playbackProgress + 1) / data.recording.duration) * 100);
- else if (e.key === "ArrowLeft")
- seek(((playbackProgress - 1) / data.recording.duration) * 100);
- }}
- >
-
-
-
- {Math.floor(data.recording.duration / 1000 / 60)}:{(
- Math.floor(data.recording.duration / 1000) % 60
- )
- .toString()
- .padStart(2, "0")}
-
-
-
-
-
-
-
-
-
- {#if !isPlaying}
- 0 ? resumePlayback : startPlayback}
- disabled={devices.length === 0}
- class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
- >
-
- {playbackProgress > 0 ? "Resume" : "Play"}
-
- {:else}
-
-
- Pause
-
- {/if}
-
-
-
-
-
-
Events
-
{data.recording.events?.length ?? 0}
-
-
-
Devices
-
{data.recording.device_info?.length ?? 0}
-
-
-
Status
-
{data.recording.status}
-
-
-
- {/if}
-
-
-
-
- {#if devices}
- {#each devices as device (device.name)}
- handleChange(device, scalarIndex, val)}
- onStop={() => handleStop(device)}
- />
- {/each}
+
+ Start Recording
+
+ {:else}
+
+
+ Stop Recording ({recordedEvents.length} events)
+
{/if}
+ {/if}
- {#if devices?.length === 0}
-
-
- {$_("play.no_results")}
-
-
- {/if}
-
-
-
-
({
- name: d.name,
- index: d.info.index,
- capabilities: d.actuators.map((a) => a.outputType),
- }))}
- duration={recordingDuration}
- onSave={handleSaveRecording}
- onCancel={handleCancelSave}
- />
-
-
+
{#if data.recording}
-
+
+
+
+ {data.recording.title}
+
+ {#if data.recording.description}
+
+ {data.recording.description}
+
+ {/if}
+
+
+
+
+
+
+ {Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60)
+ .toString()
+ .padStart(2, "0")}
+
+
{
+ const rect = e.currentTarget.getBoundingClientRect();
+ const percentage = ((e.clientX - rect.left) / rect.width) * 100;
+ seek(percentage);
+ }}
+ onkeydown={(e) => {
+ if (e.key === "ArrowRight")
+ seek(((playbackProgress + 1) / data.recording.duration) * 100);
+ else if (e.key === "ArrowLeft")
+ seek(((playbackProgress - 1) / data.recording.duration) * 100);
+ }}
+ >
+
+
+
+ {Math.floor(data.recording.duration / 1000 / 60)}:{(
+ Math.floor(data.recording.duration / 1000) % 60
+ )
+ .toString()
+ .padStart(2, "0")}
+
+
+
+
+
+
+
+
+
+ {#if !isPlaying}
+ 0 ? resumePlayback : startPlayback}
+ disabled={devices.length === 0}
+ class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
+ >
+
+ {playbackProgress > 0 ? "Resume" : "Play"}
+
+ {:else}
+
+
+ Pause
+
+ {/if}
+
+
+
+
+
+
Events
+
{data.recording.events?.length ?? 0}
+
+
+
Devices
+
{data.recording.device_info?.length ?? 0}
+
+
+
Status
+
{data.recording.status}
+
+
+
+ {/if}
+
+
+ {#if devices.length > 0}
+
+ {#each devices as device (device.name)}
+ handleChange(device, scalarIndex, val)}
+ onStop={() => handleStop(device)}
+ />
+ {/each}
+
+ {:else}
+
+
+
+
+
+ {$_("play.no_results")}
+ {$_("play.no_results_description")}
+
+
+
+ {#if scanning}
+
+ {$_("play.scanning")}
+ {:else}
+
+ {$_("play.scan")}
+ {/if}
+
+
+
{/if}
+
+
+
({
+ name: d.name,
+ index: d.info.index,
+ capabilities: d.actuators.map((a) => a.outputType),
+ }))}
+ duration={recordingDuration}
+ onSave={handleSaveRecording}
+ onCancel={handleCancelSave}
+/>
+
+
+{#if data.recording}
+
+{/if}
diff --git a/packages/frontend/src/routes/play/leaderboard/+page.server.ts b/packages/frontend/src/routes/play/leaderboard/+page.server.ts
new file mode 100644
index 0000000..acf1368
--- /dev/null
+++ b/packages/frontend/src/routes/play/leaderboard/+page.server.ts
@@ -0,0 +1,60 @@
+import type { PageServerLoad } from "./$types";
+import { gql } from "graphql-request";
+import { getGraphQLClient } from "$lib/api";
+
+const LEADERBOARD_QUERY = gql`
+ query Leaderboard($limit: Int, $offset: Int) {
+ leaderboard(limit: $limit, offset: $offset) {
+ user_id
+ display_name
+ avatar
+ total_weighted_points
+ total_raw_points
+ recordings_count
+ playbacks_count
+ achievements_count
+ rank
+ }
+ }
+`;
+
+export const load: PageServerLoad = async ({ fetch, url }) => {
+ try {
+ const limit = parseInt(url.searchParams.get("limit") || "100");
+ const offset = parseInt(url.searchParams.get("offset") || "0");
+
+ const client = getGraphQLClient(fetch);
+ const data = await client.request<{
+ leaderboard: {
+ user_id: string;
+ display_name: string | null;
+ avatar: string | null;
+ total_weighted_points: number | null;
+ total_raw_points: number | null;
+ recordings_count: number | null;
+ playbacks_count: number | null;
+ achievements_count: number | null;
+ rank: number;
+ }[];
+ }>(LEADERBOARD_QUERY, { limit, offset });
+
+ return {
+ leaderboard: data.leaderboard || [],
+ pagination: {
+ limit,
+ offset,
+ hasMore: data.leaderboard?.length === limit,
+ },
+ };
+ } catch (error) {
+ console.error("Leaderboard load error:", error);
+ return {
+ leaderboard: [],
+ pagination: {
+ limit: 100,
+ offset: 0,
+ hasMore: false,
+ },
+ };
+ }
+};
diff --git a/packages/frontend/src/routes/play/leaderboard/+page.svelte b/packages/frontend/src/routes/play/leaderboard/+page.svelte
new file mode 100644
index 0000000..d5a7eb4
--- /dev/null
+++ b/packages/frontend/src/routes/play/leaderboard/+page.svelte
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+
+
{$_("gamification.leaderboard")}
+
{$_("gamification.leaderboard_subtitle")}
+
+
+
+
+
+
+
+ {$_("gamification.top_players")}
+
+
+
+ {#if data.leaderboard.length === 0}
+
+
+
{$_("gamification.no_rankings_yet")}
+
+ {:else}
+
+
+ {#if data.pagination.hasMore}
+
+
+ {$_("common.load_more")}
+
+
+ {/if}
+ {/if}
+
+
+
+
+
+
+
+
+ {$_("gamification.how_it_works")}
+
+
+ {$_("gamification.how_it_works_description")}
+
+
+
+
+
+
{$_("gamification.earn_by_creating")}
+
{$_("gamification.earn_by_creating_desc")}
+
+
+
+
+
+
{$_("gamification.earn_by_playing")}
+
{$_("gamification.earn_by_playing_desc")}
+
+
+
+
+
+
{$_("gamification.stay_active")}
+
{$_("gamification.stay_active_desc")}
+
+
+
+
+
+
diff --git a/packages/frontend/src/routes/play/recordings/+page.server.ts b/packages/frontend/src/routes/play/recordings/+page.server.ts
new file mode 100644
index 0000000..c87da3e
--- /dev/null
+++ b/packages/frontend/src/routes/play/recordings/+page.server.ts
@@ -0,0 +1,7 @@
+import { getRecordings } from "$lib/services";
+
+export async function load({ fetch }) {
+ return {
+ recordings: await getRecordings(fetch).catch(() => []),
+ };
+}
diff --git a/packages/frontend/src/routes/play/recordings/+page.svelte b/packages/frontend/src/routes/play/recordings/+page.svelte
new file mode 100644
index 0000000..a6b7534
--- /dev/null
+++ b/packages/frontend/src/routes/play/recordings/+page.svelte
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
{$_("me.recordings.title")}
+
+
+ {#if recordings.length === 0}
+
+
+
+
+
+ {$_("me.recordings.no_recordings")}
+ {$_("me.recordings.no_recordings_description")}
+
+
+
+
+ {$_("me.recordings.go_to_play")}
+
+
+
+ {:else}
+
+ {#each recordings as recording (recording.id)}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+ {$_("me.recordings.delete_confirm")}
+ This cannot be undone.
+
+
+ (deleteOpen = false)}>
+ {$_("common.cancel")}
+
+
+ {deleting ? "Deleting…" : $_("common.delete")}
+
+
+
+
diff --git a/packages/frontend/src/routes/users/[id]/+page.svelte b/packages/frontend/src/routes/users/[id]/+page.svelte
index 182c6ad..3a05fe7 100644
--- a/packages/frontend/src/routes/users/[id]/+page.svelte
+++ b/packages/frontend/src/routes/users/[id]/+page.svelte
@@ -131,7 +131,7 @@
{$_("gamification.stats")}
-
+
{$_("gamification.leaderboard")}