1<script lang="ts">
2 import {
3 ChevronDown,
4 List,
5 Pause,
6 Play,
7 Repeat,
8 Repeat1,
9 Shuffle,
10 SkipBack,
11 SkipForward,
12 Volume2,
13 type Icon as LucideIcon,
14 } from "@lucide/svelte";
15 import { Slider } from "bits-ui";
16 import cn from "clsx";
17
18 let expanded = $state(true);
19 let playing = $state(false);
20 let shuffle = $state(false);
21 let repeat: "none" | "all" | "one" = $state("none");
22 let interval: ReturnType<typeof setInterval>;
23
24 const MainIcon = $derived(playing ? Pause : Play);
25 const RepeatIcon = $derived.by(() => (repeat === "one" ? Repeat1 : Repeat));
26
27 // TODO: separate progress state so that the thumb does not jump around to it's real value while the user is dragging it.
28 // Probably done through checking click state of the thumb and temporarily disconnecting the progress?
29 const songLength = 256;
30 let playback = $state(0);
31
32 $effect(() => {
33 // console.log({ playback });
34
35 if (playing)
36 interval = setInterval(() => {
37 playback++;
38 if (playback > songLength) playback = 0;
39 }, 1000);
40 else clearInterval(interval);
41 });
42
43 // TODO: see if I need to i18n time format.
44 const formatTime = (inputSeconds: number) => {
45 const minutes = Math.floor(inputSeconds / 60);
46 const seconds = `${inputSeconds % 60}`.padStart(2, "0");
47
48 return `${minutes}:${seconds}`;
49 };
50
51 const cycleRepeat = () => {
52 if (repeat === "none") repeat = "all";
53 else if (repeat === "all") repeat = "one";
54 else repeat = "none";
55 };
56</script>
57
58{#snippet plainButton(Icon: typeof LucideIcon, label: string)}
59 <button class="flex cursor-pointer" aria-label={label}>
60 <Icon />
61 </button>
62{/snippet}
63
64{#snippet clickable(content: string)}
65 <span class="cursor-pointer hover:underline">{content}</span>
66{/snippet}
67
68<!-- TODO: labelled by the artist & title -->
69<!-- TODO: keep width when collapsed? -->
70<aside
71 class="fixed bottom-2 left-2 flex flex-col gap-2 overflow-hidden rounded-lg border border-slate-300 bg-white"
72>
73 <header class="flex gap-2 bg-black p-2 text-slate-50">
74 <button
75 class={cn("flex cursor-pointer transition-transform", {
76 "rotate-180": !expanded,
77 })}
78 onclick={() => (expanded = !expanded)}
79 >
80 <ChevronDown />
81 </button>
82 {#if expanded}
83 <span class="font-bold">Now Playing</span>
84 <div class="flex-1"></div>
85
86 <button
87 class={cn("flex", "cursor-pointer", { "text-orange-500": shuffle })}
88 onclick={() => (shuffle = !shuffle)}
89 >
90 <Shuffle />
91 </button>
92 <button
93 class={cn("flex", "cursor-pointer", {
94 "text-orange-500": repeat !== "none",
95 })}
96 onclick={cycleRepeat}
97 >
98 <RepeatIcon />
99 </button>
100
101 <List class="cursor-pointer" />
102 <Volume2 class="cursor-pointer" />
103 {:else}
104 <div class="flex gap-1">
105 <button
106 class="flex cursor-pointer"
107 aria-label={playing ? "Pause current song" : "Play current song"}
108 onclick={() => (playing = !playing)}
109 >
110 <MainIcon />
111 </button>
112 <!-- TODO: scrolling text -->
113 <div class="line-clamp-1 max-w-[300px] text-ellipsis">
114 <span>Protostar, Laminar, imallryt</span>
115 -
116 <span>Blood in the Water</span>
117 </div>
118
119 <Volume2 class="cursor-pointer" />
120 </div>
121 {/if}
122 </header>
123
124 {#if expanded}
125 <div class="flex flex-col gap-2 px-4 py-2 text-slate-500">
126 <div class="flex items-center gap-4">
127 <div class="flex items-center gap-2 text-slate-900">
128 {@render plainButton(SkipBack, "Previous song")}
129 <button
130 class="flex cursor-pointer items-center justify-center rounded-full bg-orange-500 p-2 text-white"
131 aria-label={playing ? "Pause current song" : "Play current song"}
132 onclick={() => (playing = !playing)}
133 >
134 <MainIcon />
135 </button>
136 {@render plainButton(SkipForward, "Next song")}
137 </div>
138
139 <div class="flex items-center gap-2">
140 <img
141 src="https://lh3.googleusercontent.com/0z6Kg2GFi8hFgZYxWm3c3UNul0gyaCQjuqmY-p1oeFC1n5EMOf1dxrownTzhzk-_cdtO_FLLktQcMecwGQ=w544-h544-l90-rj"
142 class="h-12 w-12 rounded object-cover object-center"
143 alt=""
144 />
145 <!-- TODO: max width with scrolling texts -->
146 <div class="flex flex-col">
147 <span class="text-sm font-semibold text-slate-900 opacity-70">
148 {@render clickable("Protostar")}, {@render clickable("Laminar")} &
149 {@render clickable("imallryt")}
150 </span>
151 <span class="font-bolder text-sm font-semibold text-slate-900">
152 {@render clickable("Blood in the Water")}
153 <!-- <span class="opacity-50">| {@render clickable("Epic Album")}</span> -->
154 </span>
155 </div>
156 </div>
157 </div>
158
159 <div class="flex w-full gap-2 py-2">
160 <Slider.Root
161 type="single"
162 max={songLength}
163 class="relative flex flex-1 touch-none items-center select-none"
164 value={playback}
165 onValueCommit={(value) => (playback = value)}
166 >
167 {#snippet children()}
168 <span
169 class="relative h-1 w-full cursor-pointer overflow-hidden rounded-full bg-slate-200"
170 >
171 <Slider.Range
172 class="absolute h-full rounded-full bg-orange-500"
173 />
174 </span>
175 <Slider.Thumb
176 index={0}
177 class="block size-4 cursor-pointer rounded-full border border-slate-900 bg-slate-50 focus-visible:ring focus-visible:ring-orange-500 focus-visible:ring-offset-2 "
178 />
179 {/snippet}
180 </Slider.Root>
181
182 <span class="text-sm">
183 {formatTime(playback)}/{formatTime(songLength)}
184 </span>
185
186 <!-- <button
187 class={cn("flex", "cursor-pointer", { "text-orange-500": shuffle })}
188 onclick={() => (shuffle = !shuffle)}
189 >
190 <Shuffle />
191 </button>
192 <button
193 class={cn("flex", "cursor-pointer", { "text-orange-500": repeat })}
194 onclick={() => (repeat = !repeat)}
195 >
196 <Repeat />
197 </button> -->
198 </div>
199 </div>
200 {/if}
201</aside>