feat: volume settings
This commit is contained in:
parent
8a69bbca88
commit
95df713b06
16 changed files with 1794 additions and 112 deletions
2
website/drizzle/0011_broken_risque.sql
Normal file
2
website/drizzle/0011_broken_risque.sql
Normal 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;
|
||||
1402
website/drizzle/meta/0011_snapshot.json
Normal file
1402
website/drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -78,6 +78,13 @@
|
|||
"when": 1748439588289,
|
||||
"tag": "0010_silent_shiva",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1748528211995,
|
||||
"tag": "0011_broken_risque",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -64,6 +64,8 @@ export const auth = betterAuth({
|
|||
banReason: { type: "string", required: false, input: false },
|
||||
baseCurrencyBalance: { type: "string", required: false, input: false },
|
||||
bio: { type: "string", required: false },
|
||||
volumeMaster: { type: "string", required: false, input: false },
|
||||
volumeMuted: { type: "boolean", required: false, input: false },
|
||||
}
|
||||
},
|
||||
session: {
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@
|
|||
import confetti from 'canvas-confetti';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatValue, playSound, showConfetti } from '$lib/utils';
|
||||
import { volumeSettings } from '$lib/stores/volume-settings';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface CoinflipResult {
|
||||
won: boolean;
|
||||
|
|
@ -311,6 +313,10 @@
|
|||
activeSoundTimeouts = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
volumeSettings.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
import confetti from 'canvas-confetti';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils';
|
||||
import { volumeSettings } from '$lib/stores/volume-settings';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface SlotsResult {
|
||||
won: boolean;
|
||||
|
|
@ -208,6 +210,10 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
volumeSettings.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
|
|
|
|||
7
website/src/lib/components/ui/slider/index.ts
Normal file
7
website/src/lib/components/ui/slider/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./slider.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider,
|
||||
};
|
||||
52
website/src/lib/components/ui/slider/slider.svelte
Normal file
52
website/src/lib/components/ui/slider/slider.svelte
Normal 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>
|
||||
|
|
@ -22,6 +22,9 @@ export const user = pgTable("user", {
|
|||
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(),
|
||||
|
||||
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 }),
|
||||
totalRewardsClaimed: decimal("total_rewards_claimed", {
|
||||
precision: 20,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export type User = {
|
|||
|
||||
baseCurrencyBalance: number;
|
||||
bio: string;
|
||||
|
||||
volumeMaster: number;
|
||||
volumeMuted: boolean;
|
||||
} | null;
|
||||
|
||||
export const USER_DATA = writable<User>(undefined);
|
||||
53
website/src/lib/stores/volume-settings.ts
Normal file
53
website/src/lib/stores/volume-settings.ts
Normal 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();
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT } from "$env/static/public";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { volumeSettings } from '$lib/stores/volume-settings';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
|
@ -235,16 +237,13 @@ export function getTimeframeInSeconds(timeframe: string): number {
|
|||
let availableSounds = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
export function playRandomFireworkSound() {
|
||||
// If no sounds available, reset the array
|
||||
if (availableSounds.length === 0) {
|
||||
availableSounds = [1, 2, 3, 4, 5, 6, 7];
|
||||
}
|
||||
|
||||
// Pick a random sound from available ones
|
||||
const randomIndex = Math.floor(Math.random() * availableSounds.length);
|
||||
const soundNumber = availableSounds[randomIndex];
|
||||
|
||||
// Remove the sound from available array to prevent repetition
|
||||
availableSounds = availableSounds.filter((_, index) => index !== randomIndex);
|
||||
|
||||
playSound(`firework${soundNumber}`);
|
||||
|
|
@ -252,8 +251,14 @@ export function playRandomFireworkSound() {
|
|||
|
||||
export function playSound(sound: string) {
|
||||
try {
|
||||
const audio = new Audio(`sound/${sound}.mp3`);
|
||||
audio.volume = 0.3; // TODO: volume control
|
||||
const settings = get(volumeSettings);
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Error playing sound:', error);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import AppSidebar from '$lib/components/self/AppSidebar.svelte';
|
||||
|
||||
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 { ModeWatcher } from 'mode-watcher';
|
||||
import { page } from '$app/state';
|
||||
|
|
@ -18,12 +18,10 @@
|
|||
children: any;
|
||||
}>();
|
||||
|
||||
USER_DATA.set(data?.userSession ?? null);
|
||||
|
||||
$effect(() => {
|
||||
if (data?.userSession) {
|
||||
USER_DATA.set(data.userSession);
|
||||
} else {
|
||||
USER_DATA.set(null);
|
||||
}
|
||||
USER_DATA.set(data?.userSession ?? null);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
|
|
|
|||
68
website/src/routes/api/settings/volume/+server.ts
Normal file
68
website/src/routes/api/settings/volume/+server.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -7,27 +7,28 @@
|
|||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { CheckIcon } from 'lucide-svelte';
|
||||
import { CheckIcon, Volume2Icon, VolumeXIcon } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
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);
|
||||
let bio = $state(data.user.bio ?? '');
|
||||
let username = $state(data.user.username);
|
||||
|
||||
const initialUsername = data.user.username;
|
||||
const initialUsername = $USER_DATA?.username || '';
|
||||
let avatarFile: FileList | undefined = $state(undefined);
|
||||
|
||||
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(
|
||||
name !== data.user.name ||
|
||||
bio !== (data.user.bio ?? '') ||
|
||||
username !== data.user.username ||
|
||||
name !== ($USER_DATA?.name || '') ||
|
||||
bio !== ($USER_DATA?.bio ?? '') ||
|
||||
username !== ($USER_DATA?.username || '') ||
|
||||
avatarFile !== undefined
|
||||
);
|
||||
|
||||
|
|
@ -37,6 +38,9 @@
|
|||
let usernameAvailable: boolean | null = $state(null);
|
||||
let checkingUsername = $state(false);
|
||||
|
||||
let masterVolume = $state(($USER_DATA?.volumeMaster || 0) * 100);
|
||||
let isMuted = $state($USER_DATA?.volumeMuted || false);
|
||||
|
||||
function beforeUnloadHandler(e: BeforeUnloadEvent) {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
|
|
@ -45,6 +49,8 @@
|
|||
|
||||
onMount(() => {
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler);
|
||||
volumeSettings.setMaster($USER_DATA?.volumeMaster || 0);
|
||||
volumeSettings.setMuted($USER_DATA?.volumeMuted || false);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -121,10 +127,46 @@
|
|||
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>
|
||||
|
||||
<div class="container mx-auto max-w-2xl p-6">
|
||||
<h1 class="mb-6 text-2xl font-bold">Settings</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Profile Settings</Card.Title>
|
||||
|
|
@ -210,4 +252,42 @@
|
|||
</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">
|
||||
Controls all game sounds including effects and background audio
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Reference in a new issue