Music streaming on ATProto!
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>