feat: playing and pausing

This commit is contained in:
2025-04-17 22:32:37 +00:00
parent af980e74d4
commit 7bd1031e27
23 changed files with 554 additions and 12 deletions

View File

@@ -14,6 +14,7 @@
},
"devDependencies": {
"@lucide/svelte": "^0.488.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-cloudflare": "^5.0.1",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",

View File

@@ -1,5 +1,5 @@
<script>
import MusicPlayer from "@/components/app/music-player.svelte";
import MusicPlayer from "@/components/app/now-playing.svelte";
</script>
<div class="fixed bottom-5 left-2 right-2 z-50 flex items-center justify-between p-4 bg-white/10 backdrop-blur-lg rounded-xl shadow-lg">

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { state } from "@/state.svelte";
import { getGeneralData, getStationSongs } from "@/utils";
import { onMount } from "svelte";
// svelte-ignore non_reactive_update
let audioElement: HTMLAudioElement;
onMount(async () => {
const data = await getGeneralData();
if (data) {
state.presets = data.presets;
state.stations = data.stations;
state.backgrounds = data.backgrounds;
state.atmospheres = data.atmospheres;
if (data.stations.length > 0 && state.currentStation === null) {
state.currentStation = data.stations[0].id;
}
if (data.backgrounds.length > 0 && state.currentBackgroundId === null) {
const firstActiveBg = data.backgrounds.find(bg => bg.isActive && !bg.parentId);
state.currentBackgroundId = firstActiveBg ? firstActiveBg.id : (data.backgrounds[0]?.id || null);
}
} else {
state.error = "Failed to load initial data (empty response).";
}
const stationSongs = await getStationSongs(state.currentStation!);
if (stationSongs) {
state.songQueue = stationSongs;
} else {
state.error = "Failed to load songs.";
}
if (state.songQueue.length > 0) {
state.currentSong = state.songQueue[0];
state.duration = state.currentSong.duration;
} else {
state.error = "No songs available.";
}
const currentTime = new Date().getTime() / 1000;
const startTime = new Date(state.currentSong!.startTime).getTime() / 1000;
const endTime = new Date(state.currentSong!.endTime).getTime() / 1000;
const duration = endTime - startTime;
const elapsed = currentTime - startTime;
if (elapsed > 0 && elapsed < duration) {
state.currentTime = elapsed;
} else {
state.currentTime = 0;
}
state.isLoading = false;
});
$effect(() => {
console.log('asdf')
// Run this effect whenever state.isPlaying or audioElement changes
if (!audioElement) return;
if (state.isPlaying) {
audioElement.play().catch(() => {
state.error = "Audio playback failed. Please interact with the page first.";
state.isPlaying = false;
});
} else {
audioElement.pause();
}
if (state.currentTime > 0) {
audioElement.currentTime = state.currentTime;
}
});
</script>
{#if !state.hasInteracted}
<button class="flex flex-col h-screen w-full items-center justify-center space-y-2 cursor-pointer" onclick={() => {
state.hasInteracted = true
}}>
<p>Click anywhere on the screen</p>
</button>
{/if}
{#if !state.isLoading}
<audio
bind:this={audioElement}
src={`https://stream.chillhop.com/mp3/${state.currentSong!.fileId}`}
autoplay
onended={() => {
state.currentSong = null;
state.currentTime = 0;
state.isPlaying = false;
}}
ontimeupdate={() => {
const audio = document.querySelector("audio");
if (audio) {
state.currentTime = audio.currentTime;
}
}}
class="hidden"
></audio>
{/if}

View File

@@ -1,6 +0,0 @@
<script>
import { Button } from "@/components/ui/button";
import Pause from "@lucide/svelte/icons/pause";
</script>
<Button size="icon"><Pause /></Button>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Button } from "@/components/ui/button";
import { state } from "@/state.svelte";
import Pause from "@lucide/svelte/icons/pause";
import Play from "@lucide/svelte/icons/play";
function togglePlay() {
state.isPlaying = !state.isPlaying;
}
</script>
<Button size="icon" onclick={togglePlay} class="w-10 h-10 *:text-white">
{#if state.isPlaying}
<Pause />
{:else}
<Play />
{/if}
</Button>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import Check from "@lucide/svelte/icons/check";
import Minus from "@lucide/svelte/icons/minus";
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
checked = $bindable(false),
indeterminate = $bindable(false),
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<Minus class="size-4" />
{:else}
<Check class={cn("size-4", !checked && "text-transparent")} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
{sideOffset}
class={cn(
"bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none",
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: DropdownMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...restProps}
/>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { type WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from "bits-ui";
import Circle from "@lucide/svelte/icons/circle";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<Circle class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
class={cn("bg-muted -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { type WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
class={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
class={cn(
"bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-lg focus:outline-none",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from "bits-ui";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithoutChild<DropdownMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRight class="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,50 @@
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import Content from "./dropdown-menu-content.svelte";
import GroupHeading from "./dropdown-menu-group-heading.svelte";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
const Trigger = DropdownMenuPrimitive.Trigger;
const Group = DropdownMenuPrimitive.Group;
const RadioGroup = DropdownMenuPrimitive.RadioGroup;
export {
CheckboxItem,
Content,
Root as DropdownMenu,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
GroupHeading as DropdownMenuGroupHeading,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};

22
src/lib/state.svelte.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { Song, Atmosphere, Preset, Station, Background } from "./types"; // Added Preset, Station, Background
export const state = $state({
hasInteracted: false,
currentStation: null as number | null,
currentSong: null as Song | null,
songQueue: [] as Song[],
isPlaying: true,
volume: 0.5,
isMuted: false,
currentBackgroundId: null as string | null,
activeAtmospheres: {} as Record<string, number>, // { atmosphereId: volume (0-100) }
isLoading: true,
error: null as string | null,
currentTime: 0,
duration: 0,
presets: [] as Preset[],
stations: [] as Station[],
backgrounds: [] as Background[],
atmospheres: [] as Atmosphere[],
});

77
src/lib/types.ts Normal file
View File

@@ -0,0 +1,77 @@
export interface Song {
id: number
fileId: number
artists: string
title: string
image: string
likes: number
featured?: string
releaseDate: string
releaseDateText: string
duration: number
isrc: string
label: string
spotifyId: string
startTime: string
endTime: string
}
export interface Preset {
id: number;
userId: number;
name: string;
backgroundId: string;
stationId: string; // Represented as string in JSON
atmospheres: string; // JSON stringified object
sortOrder: number;
key: string;
}
export interface StationMetaSocials {
spotify: string | null;
apple: string | null;
}
export interface StationMetaIcon {
static: string;
}
// Structure within the base64 decoded 'meta' string for stations
export interface DecodedStationMeta {
shortDescription: string;
icon: StationMetaIcon;
socials: StationMetaSocials;
}
export interface Station {
name: string;
id: number;
meta: string; // Base64 encoded JSON string (DecodedStationMeta)
}
export interface Background {
id: string;
name: string;
parentId: string | null;
landscapeUrl: string;
portraitUrl: string;
thumbnailUrl: string;
sortOrder: number;
isActive: number; // 0 or 1
}
export interface Atmosphere {
id: string;
name: string;
url: string;
sortOrder: number;
urlMobile: string;
}
export interface ChillhopData {
presets: Preset[];
stations: Station[];
backgrounds: Background[];
atmospheres: Atmosphere[];
}

View File

@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import type { ChillhopData, Song } from "./types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -7,7 +8,12 @@ export function cn(...inputs: ClassValue[]) {
export async function getGeneralData() {
const res = await fetch('https://stream.chillhop.com/presets')
const data = await res.json();
const data = await res.json() as ChillhopData;
return data;
}
export async function getStationSongs(stationId: number) {
const res = await fetch(`https://stream.chillhop.com/live/${stationId}`)
const data = await res.json() as Song[];
return data;
}

View File

@@ -1,9 +1,29 @@
<script>
<script lang="ts">
import BgImage from "@/components/app/bg-image.svelte";
import BottomBar from "@/components/app/bottom-bar.svelte";
import Button from "@/components/ui/button/button.svelte";
import Daemon from "@/components/app/daemon.svelte";
import Spinner from '@lucide/svelte/icons/loader'
import { state } from "@/state.svelte";
</script>
<BgImage />
<BottomBar />
<Daemon />
{#if state.isLoading && !state.hasInteracted}
<div class="flex flex-col h-screen w-full items-center justify-center space-y-2">
<Spinner class="size-10" />
<p>Loading...</p>
</div>
{:else if state.isLoading && state.hasInteracted}
<div class="flex flex-col h-screen w-full items-center justify-center space-y-2">
<Spinner class="size-10" />
<p>Loading...</p>
</div>
{:else if state.error}
<div class="flex h-screen w-full items-center justify-center text-red-500">
<p>Error: {state.error}</p>
</div>
{:else}
<BottomBar />
{/if}

View File

@@ -1,4 +1,4 @@
import adapter from "@sveltejs/adapter-cloudflare";
import adapter from "@sveltejs/adapter-auto";
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */

View File

@@ -659,6 +659,13 @@
resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz#f518101d1b2e12ce80854f1cd850d3b9fb91d710"
integrity sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==
"@sveltejs/adapter-auto@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@sveltejs/adapter-auto/-/adapter-auto-6.0.0.tgz#aac1245053c00cb05552b8a3b0ef77643d120f03"
integrity sha512-7mR2/G7vlXakaOj6QBSG9dwBfTgWjV+UnEMB5Z6Xu0ZbdXda6c0su1fNkg0ab0zlilSkloMA2NjCna02/DR7sA==
dependencies:
import-meta-resolve "^4.1.0"
"@sveltejs/adapter-cloudflare@^5.0.1":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@sveltejs/adapter-cloudflare/-/adapter-cloudflare-5.1.0.tgz#c51a05d0af550a85197517f99e79975f8ae557a3"