feat: volume settings

This commit is contained in:
Face 2025-05-29 17:41:09 +03:00
parent 8a69bbca88
commit 95df713b06
16 changed files with 1794 additions and 112 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE "user" ADD COLUMN "volume_master" numeric(3, 2) DEFAULT '0.70' NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "volume_muted" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -78,6 +78,13 @@
"when": 1748439588289, "when": 1748439588289,
"tag": "0010_silent_shiva", "tag": "0010_silent_shiva",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1748528211995,
"tag": "0011_broken_risque",
"breakpoints": true
} }
] ]
} }

View file

@ -64,6 +64,8 @@ export const auth = betterAuth({
banReason: { type: "string", required: false, input: false }, banReason: { type: "string", required: false, input: false },
baseCurrencyBalance: { type: "string", required: false, input: false }, baseCurrencyBalance: { type: "string", required: false, input: false },
bio: { type: "string", required: false }, bio: { type: "string", required: false },
volumeMaster: { type: "string", required: false, input: false },
volumeMuted: { type: "boolean", required: false, input: false },
} }
}, },
session: { session: {

View file

@ -162,6 +162,8 @@
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { formatValue, playSound, showConfetti } from '$lib/utils'; import { formatValue, playSound, showConfetti } from '$lib/utils';
import { volumeSettings } from '$lib/stores/volume-settings';
import { onMount } from 'svelte';
interface CoinflipResult { interface CoinflipResult {
won: boolean; won: boolean;
@ -311,6 +313,10 @@
activeSoundTimeouts = []; activeSoundTimeouts = [];
} }
} }
onMount(() => {
volumeSettings.load();
});
</script> </script>
<Card> <Card>

View file

@ -11,6 +11,8 @@
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils'; import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils';
import { volumeSettings } from '$lib/stores/volume-settings';
import { onMount } from 'svelte';
interface SlotsResult { interface SlotsResult {
won: boolean; won: boolean;
@ -208,6 +210,10 @@
} }
} }
}); });
onMount(() => {
volumeSettings.load();
});
</script> </script>
<Card> <Card>

View file

@ -0,0 +1,7 @@
import Root from "./slider.svelte";
export {
Root,
//
Root as Slider,
};

View file

@ -0,0 +1,52 @@
<script lang="ts">
import { Slider as SliderPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
orientation = "horizontal",
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
"relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ thumbs })}
<span
data-orientation={orientation}
data-slot="slider-track"
class={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</span>
{#each thumbs as thumb (thumb)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb}
class="border-primary bg-background ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>

View file

@ -21,6 +21,9 @@ export const user = pgTable("user", {
}).notNull().default("10000.00000000"), // 10,000 *BUSS }).notNull().default("10000.00000000"), // 10,000 *BUSS
bio: varchar("bio", { length: 160 }).default("Hello am 48 year old man from somalia. Sorry for my bed england. I selled my wife for internet connection for play “conter stirk”"), bio: varchar("bio", { length: 160 }).default("Hello am 48 year old man from somalia. Sorry for my bed england. I selled my wife for internet connection for play “conter stirk”"),
username: varchar("username", { length: 30 }).notNull().unique(), username: varchar("username", { length: 30 }).notNull().unique(),
volumeMaster: decimal("volume_master", { precision: 3, scale: 2 }).notNull().default("0.70"),
volumeMuted: boolean("volume_muted").notNull().default(false),
lastRewardClaim: timestamp("last_reward_claim", { withTimezone: true }), lastRewardClaim: timestamp("last_reward_claim", { withTimezone: true }),
totalRewardsClaimed: decimal("total_rewards_claimed", { totalRewardsClaimed: decimal("total_rewards_claimed", {

View file

@ -13,6 +13,9 @@ export type User = {
baseCurrencyBalance: number; baseCurrencyBalance: number;
bio: string; bio: string;
volumeMaster: number;
volumeMuted: boolean;
} | null; } | null;
export const USER_DATA = writable<User>(undefined); export const USER_DATA = writable<User>(undefined);

View file

@ -0,0 +1,53 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface VolumeSettings {
master: number;
muted: boolean;
}
const defaultSettings: VolumeSettings = {
master: 0.7,
muted: false
};
function createVolumeSettings() {
const { subscribe, set, update } = writable<VolumeSettings>(defaultSettings);
return {
subscribe,
load: () => {
if (browser) {
const stored = localStorage.getItem('volume-settings');
if (stored) {
try {
const settings = JSON.parse(stored);
set({ ...defaultSettings, ...settings });
} catch (e) {
console.error('Failed to parse volume settings:', e);
}
}
}
},
setMaster: (value: number) => {
update(settings => {
const newSettings = { ...settings, master: Math.max(0, Math.min(1, value)) };
if (browser) {
localStorage.setItem('volume-settings', JSON.stringify(newSettings));
}
return newSettings;
});
},
setMuted: (value: boolean) => {
update(settings => {
const newSettings = { ...settings, muted: value };
if (browser) {
localStorage.setItem('volume-settings', JSON.stringify(newSettings));
}
return newSettings;
});
}
};
}
export const volumeSettings = createVolumeSettings();

View file

@ -1,6 +1,8 @@
import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT } from "$env/static/public"; import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT } from "$env/static/public";
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { volumeSettings } from '$lib/stores/volume-settings';
import { get } from 'svelte/store';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@ -235,16 +237,13 @@ export function getTimeframeInSeconds(timeframe: string): number {
let availableSounds = [1, 2, 3, 4, 5, 6, 7]; let availableSounds = [1, 2, 3, 4, 5, 6, 7];
export function playRandomFireworkSound() { export function playRandomFireworkSound() {
// If no sounds available, reset the array
if (availableSounds.length === 0) { if (availableSounds.length === 0) {
availableSounds = [1, 2, 3, 4, 5, 6, 7]; availableSounds = [1, 2, 3, 4, 5, 6, 7];
} }
// Pick a random sound from available ones
const randomIndex = Math.floor(Math.random() * availableSounds.length); const randomIndex = Math.floor(Math.random() * availableSounds.length);
const soundNumber = availableSounds[randomIndex]; const soundNumber = availableSounds[randomIndex];
// Remove the sound from available array to prevent repetition
availableSounds = availableSounds.filter((_, index) => index !== randomIndex); availableSounds = availableSounds.filter((_, index) => index !== randomIndex);
playSound(`firework${soundNumber}`); playSound(`firework${soundNumber}`);
@ -252,8 +251,14 @@ export function playRandomFireworkSound() {
export function playSound(sound: string) { export function playSound(sound: string) {
try { try {
const audio = new Audio(`sound/${sound}.mp3`); const settings = get(volumeSettings);
audio.volume = 0.3; // TODO: volume control
if (settings.muted) {
return;
}
const audio = new Audio(`/sound/${sound}.mp3`);
audio.volume = Math.max(0, Math.min(1, settings.master));
audio.play().catch(console.error); audio.play().catch(console.error);
} catch (error) { } catch (error) {
console.error('Error playing sound:', error); console.error('Error playing sound:', error);

View file

@ -7,7 +7,7 @@
import AppSidebar from '$lib/components/self/AppSidebar.svelte'; import AppSidebar from '$lib/components/self/AppSidebar.svelte';
import { USER_DATA } from '$lib/stores/user-data'; import { USER_DATA } from '$lib/stores/user-data';
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte'; // onDestroy is already imported
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
import { page } from '$app/state'; import { page } from '$app/state';
@ -18,12 +18,10 @@
children: any; children: any;
}>(); }>();
USER_DATA.set(data?.userSession ?? null);
$effect(() => { $effect(() => {
if (data?.userSession) { USER_DATA.set(data?.userSession ?? null);
USER_DATA.set(data.userSession);
} else {
USER_DATA.set(null);
}
}); });
onMount(() => { onMount(() => {
@ -84,7 +82,7 @@
const titleMap: Record<string, string> = { const titleMap: Record<string, string> = {
'/': 'Home', '/': 'Home',
'/market': 'Market', '/market': 'Market',
'/portfolio': 'Portfolio', '/portfolio': 'Portfolio',
'/leaderboard': 'Leaderboard', '/leaderboard': 'Leaderboard',
'/coin/create': 'Create Coin', '/coin/create': 'Create Coin',
'/settings': 'Settings', '/settings': 'Settings',

View file

@ -0,0 +1,68 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export async function POST({ request }) {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
try {
const { master, muted } = await request.json();
if (typeof master !== 'number' || master < 0 || master > 1) {
throw error(400, 'Invalid master volume');
}
if (typeof muted !== 'boolean') {
throw error(400, 'Invalid muted setting');
}
await db.update(user)
.set({
volumeMaster: master.toString(),
volumeMuted: muted,
updatedAt: new Date()
})
.where(eq(user.id, Number(session.user.id)));
return json({ success: true });
} catch (e) {
console.error('Volume settings save failed:', e);
throw error(500, 'Failed to save volume settings');
}
}
export async function GET({ request }) {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
try {
const userData = await db.select({
volumeMaster: user.volumeMaster,
volumeMuted: user.volumeMuted
}).from(user).where(eq(user.id, Number(session.user.id)));
if (!userData[0]) {
throw error(404, 'User not found');
}
return json({
master: parseFloat(userData[0].volumeMaster),
muted: userData[0].volumeMuted
});
} catch (e) {
console.error('Volume settings load failed:', e);
throw error(500, 'Failed to load volume settings');
}
}

View file

@ -1,12 +0,0 @@
import { auth } from '$lib/auth';
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
const session = await auth.api.getSession({
headers: event.request.headers
});
if (!session?.user) throw error(401, 'Not authenticated');
return { user: session.user };
};

View file

@ -7,27 +7,28 @@
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import * as Avatar from '$lib/components/ui/avatar'; import * as Avatar from '$lib/components/ui/avatar';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Slider } from '$lib/components/ui/slider';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { CheckIcon } from 'lucide-svelte'; import { CheckIcon, Volume2Icon, VolumeXIcon } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { MAX_FILE_SIZE } from '$lib/data/constants'; import { MAX_FILE_SIZE } from '$lib/data/constants';
import { volumeSettings } from '$lib/stores/volume-settings';
import { USER_DATA } from '$lib/stores/user-data';
let { data } = $props(); let name = $state($USER_DATA?.name || '');
let bio = $state($USER_DATA?.bio ?? '');
let username = $state($USER_DATA?.username || '');
let name = $state(data.user.name); const initialUsername = $USER_DATA?.username || '';
let bio = $state(data.user.bio ?? '');
let username = $state(data.user.username);
const initialUsername = data.user.username;
let avatarFile: FileList | undefined = $state(undefined); let avatarFile: FileList | undefined = $state(undefined);
let previewUrl: string | null = $state(null); let previewUrl: string | null = $state(null);
let currentAvatarUrl = $derived(previewUrl || getPublicUrl(data.user.image ?? null)); let currentAvatarUrl = $derived(previewUrl || getPublicUrl($USER_DATA?.image ?? null));
let isDirty = $derived( let isDirty = $derived(
name !== data.user.name || name !== ($USER_DATA?.name || '') ||
bio !== (data.user.bio ?? '') || bio !== ($USER_DATA?.bio ?? '') ||
username !== data.user.username || username !== ($USER_DATA?.username || '') ||
avatarFile !== undefined avatarFile !== undefined
); );
@ -37,6 +38,9 @@
let usernameAvailable: boolean | null = $state(null); let usernameAvailable: boolean | null = $state(null);
let checkingUsername = $state(false); let checkingUsername = $state(false);
let masterVolume = $state(($USER_DATA?.volumeMaster || 0) * 100);
let isMuted = $state($USER_DATA?.volumeMuted || false);
function beforeUnloadHandler(e: BeforeUnloadEvent) { function beforeUnloadHandler(e: BeforeUnloadEvent) {
if (isDirty) { if (isDirty) {
e.preventDefault(); e.preventDefault();
@ -45,6 +49,8 @@
onMount(() => { onMount(() => {
window.addEventListener('beforeunload', beforeUnloadHandler); window.addEventListener('beforeunload', beforeUnloadHandler);
volumeSettings.setMaster($USER_DATA?.volumeMaster || 0);
volumeSettings.setMuted($USER_DATA?.volumeMuted || false);
}); });
onDestroy(() => { onDestroy(() => {
@ -121,93 +127,167 @@
loading = false; loading = false;
} }
} }
const debouncedSaveVolume = debounce(async (settings: { master: number; muted: boolean }) => {
try {
const response = await fetch('/api/settings/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (!response.ok) {
throw new Error('Failed to save volume settings');
}
} catch (error) {
console.error('Failed to save volume settings:', error);
toast.error('Failed to save volume settings');
}
}, 500);
async function saveVolumeToServer(settings: { master: number; muted: boolean }) {
debouncedSaveVolume(settings);
}
function handleMasterVolumeChange(value: number) {
masterVolume = value;
const normalizedValue = value / 100;
volumeSettings.setMaster(normalizedValue);
saveVolumeToServer({ master: normalizedValue, muted: isMuted });
}
function toggleMute() {
isMuted = !isMuted;
volumeSettings.setMuted(isMuted);
saveVolumeToServer({ master: masterVolume / 100, muted: isMuted });
}
</script> </script>
<div class="container mx-auto max-w-2xl p-6"> <div class="container mx-auto max-w-2xl p-6">
<h1 class="mb-6 text-2xl font-bold">Settings</h1> <h1 class="mb-6 text-2xl font-bold">Settings</h1>
<Card.Root>
<Card.Header> <div class="space-y-6">
<Card.Title>Profile Settings</Card.Title> <Card.Root>
<Card.Description>Update your profile information</Card.Description> <Card.Header>
</Card.Header> <Card.Title>Profile Settings</Card.Title>
<Card.Content> <Card.Description>Update your profile information</Card.Description>
<div class="mb-6 flex items-center gap-4"> </Card.Header>
<div <Card.Content>
class="group relative cursor-pointer" <div class="mb-6 flex items-center gap-4">
role="button"
tabindex="0"
onclick={handleAvatarClick}
onkeydown={(e) => e.key === 'Enter' && handleAvatarClick()}
>
<Avatar.Root class="size-20">
<Avatar.Image src={currentAvatarUrl} alt={name} />
<Avatar.Fallback>?</Avatar.Fallback>
</Avatar.Root>
<div <div
class="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100" class="group relative cursor-pointer"
role="button"
tabindex="0"
onclick={handleAvatarClick}
onkeydown={(e) => e.key === 'Enter' && handleAvatarClick()}
> >
<span class="text-xs text-white">Change</span> <Avatar.Root class="size-20">
</div> <Avatar.Image src={currentAvatarUrl} alt={name} />
</div> <Avatar.Fallback>?</Avatar.Fallback>
<div> </Avatar.Root>
<h3 class="text-lg font-semibold">{name}</h3> <div
<p class="text-muted-foreground text-sm">@{username}</p> class="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
</div>
</div>
<input
type="file"
accept="image/*"
class="hidden"
bind:this={fileInput}
onchange={handleAvatarChange}
/>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="name">Display Name</Label>
<Input id="name" bind:value={name} required />
</div>
<div class="space-y-2">
<Label for="username">Username</Label>
<div class="relative">
<span class="text-muted-foreground absolute left-3 top-4 -translate-y-1/2 transform"
>@</span
> >
<Input <span class="text-xs text-white">Change</span>
id="username"
bind:value={username}
required
pattern={'^[a-zA-Z0-9_]{3,30}$'}
class="pl-8"
/>
<div class="absolute right-3 top-1.5">
{#if checkingUsername}
<span class="text-muted-foreground text-sm">Checking…</span>
{:else if username !== initialUsername}
{#if usernameAvailable}
<CheckIcon class="text-success" />
{:else}
<span class="text-destructive text-sm">Taken</span>
{/if}
{/if}
</div> </div>
</div> </div>
<div>
<h3 class="text-lg font-semibold">{name}</h3>
<p class="text-muted-foreground text-sm">@{username}</p>
</div>
</div>
<input
type="file"
accept="image/*"
class="hidden"
bind:this={fileInput}
onchange={handleAvatarChange}
/>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="name">Display Name</Label>
<Input id="name" bind:value={name} required />
</div>
<div class="space-y-2">
<Label for="username">Username</Label>
<div class="relative">
<span class="text-muted-foreground absolute left-3 top-4 -translate-y-1/2 transform"
>@</span
>
<Input
id="username"
bind:value={username}
required
pattern={'^[a-zA-Z0-9_]{3,30}$'}
class="pl-8"
/>
<div class="absolute right-3 top-1.5">
{#if checkingUsername}
<span class="text-muted-foreground text-sm">Checking…</span>
{:else if username !== initialUsername}
{#if usernameAvailable}
<CheckIcon class="text-success" />
{:else}
<span class="text-destructive text-sm">Taken</span>
{/if}
{/if}
</div>
</div>
<p class="text-muted-foreground text-xs">
Only letters, numbers, underscores. 330 characters.
</p>
</div>
<div class="space-y-2">
<Label for="bio">Bio</Label>
<Textarea id="bio" bind:value={bio} rows={4} placeholder="Tell us about yourself" />
</div>
<Button type="submit" disabled={loading || !isDirty}>
{loading ? 'Saving…' : 'Save Changes'}
</Button>
</form>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Audio Settings</Card.Title>
<Card.Description>Adjust volume for game sounds</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-base font-medium">Volume</Label>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" onclick={toggleMute} class="h-8 w-8 p-0">
{#if isMuted}
<VolumeXIcon class="h-4 w-4" />
{:else}
<Volume2Icon class="h-4 w-4" />
{/if}
</Button>
<span class="text-muted-foreground w-10 text-right text-sm"
>{Math.round(masterVolume)}%</span
>
</div>
</div>
<Slider
type="single"
value={masterVolume}
onValueChange={handleMasterVolumeChange}
max={100}
step={1}
disabled={isMuted}
/>
<p class="text-muted-foreground text-xs"> <p class="text-muted-foreground text-xs">
Only letters, numbers, underscores. 330 characters. Controls all game sounds including effects and background audio
</p> </p>
</div> </div>
</Card.Content>
<div class="space-y-2"> </Card.Root>
<Label for="bio">Bio</Label> </div>
<Textarea id="bio" bind:value={bio} rows={4} placeholder="Tell us about yourself" />
</div>
<Button type="submit" disabled={loading || !isDirty}>
{loading ? 'Saving…' : 'Save Changes'}
</Button>
</form>
</Card.Content>
</Card.Root>
</div> </div>