Music streaming on ATProto!

feat: dummy index page; navbar; player layout mockup

ovyerus.com e115f0d6 0d2c4088

verified
Changed files
+354 -6
src
+5
src/lib/components/Navbar.svelte
··· 1 + <nav 2 + class="m-2 flex items-center rounded-lg border border-slate-300 bg-white p-4 font-bold text-slate-700" 3 + > 4 + Comet 5 + </nav>
+107
src/lib/components/Player1.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + List, 4 + Pause, 5 + Play, 6 + Repeat, 7 + Shuffle, 8 + SkipBack, 9 + SkipForward, 10 + Volume2, 11 + type Icon as LucideIcon, 12 + } from "@lucide/svelte"; 13 + import { Slider } from "bits-ui"; 14 + import cn from "clsx"; 15 + 16 + let playing = $state(true); 17 + let shuffle = $state(false); 18 + let repeat = $state(false); 19 + 20 + const MainIcon = $derived(playing ? Pause : Play); 21 + 22 + const songLength = 256; 23 + let playback = $state(0); 24 + </script> 25 + 26 + {#snippet plainButton(Icon: typeof LucideIcon, label: string)} 27 + <button class="flex cursor-pointer" aria-label={label}> 28 + <Icon /> 29 + </button> 30 + {/snippet} 31 + 32 + {#snippet clickable(content: string)} 33 + <span class="cursor-pointer hover:underline">{content}</span> 34 + {/snippet} 35 + 36 + <!-- TODO: labelled by the artist & title --> 37 + <aside 38 + class="fixed right-2 bottom-2 left-2 flex items-center gap-4 rounded-lg border border-slate-300 bg-white p-2 px-4 text-slate-500" 39 + > 40 + <div class="flex items-center gap-2 text-slate-900"> 41 + {@render plainButton(SkipBack, "Previous song")} 42 + <button 43 + class="flex cursor-pointer items-center justify-center rounded-full bg-orange-500 p-2 text-white" 44 + aria-label="Play" 45 + onclick={() => (playing = !playing)} 46 + > 47 + <MainIcon /> 48 + </button> 49 + {@render plainButton(SkipForward, "Next song")} 50 + </div> 51 + 52 + <div class="flex items-center gap-2"> 53 + <img 54 + src="https://lh3.googleusercontent.com/0z6Kg2GFi8hFgZYxWm3c3UNul0gyaCQjuqmY-p1oeFC1n5EMOf1dxrownTzhzk-_cdtO_FLLktQcMecwGQ=w544-h544-l90-rj" 55 + class="h-12 w-12 rounded object-cover object-center" 56 + alt="" 57 + /> 58 + <div class="flex flex-col"> 59 + <span class="text-sm font-semibold text-slate-900 opacity-70"> 60 + {@render clickable("Protostar")}, {@render clickable("Laminar")} & {@render clickable( 61 + "imallryt", 62 + )} 63 + </span> 64 + <span class="font-bolder text-sm font-semibold text-slate-900"> 65 + {@render clickable("Blood in the Water")} 66 + <!-- <span class="opacity-50">| {@render clickable("Epic Album")}</span> --> 67 + </span> 68 + </div> 69 + </div> 70 + 71 + <div class="flex flex-1 px-30"> 72 + <Slider.Root 73 + type="single" 74 + bind:value={playback} 75 + max={songLength} 76 + class="relative flex flex-1 touch-none items-center select-none" 77 + > 78 + {#snippet children()} 79 + <span 80 + class="relative h-1 w-full cursor-pointer overflow-hidden rounded-full bg-slate-200" 81 + > 82 + <Slider.Range class="absolute h-full rounded-full bg-orange-500" /> 83 + </span> 84 + <Slider.Thumb 85 + index={0} 86 + 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 " 87 + /> 88 + {/snippet} 89 + </Slider.Root> 90 + </div> 91 + 92 + <button 93 + class={cn("flex", "cursor-pointer", { "text-orange-500": shuffle })} 94 + onclick={() => (shuffle = !shuffle)} 95 + > 96 + <Shuffle /> 97 + </button> 98 + <button 99 + class={cn("flex", "cursor-pointer", { "text-orange-500": repeat })} 100 + onclick={() => (repeat = !repeat)} 101 + > 102 + <Repeat /> 103 + </button> 104 + 105 + <List class="cursor-pointer" /> 106 + <Volume2 class="cursor-pointer" /> 107 + </aside>
+201
src/lib/components/Player2.svelte
··· 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>
+9 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 + import Navbar from "$lib/components/Navbar.svelte"; 3 + import Player1 from "$lib/components/Player1.svelte"; 4 + import Player2 from "$lib/components/Player2.svelte"; 2 5 import "../app.css"; 3 6 4 7 let { children } = $props(); 5 8 </script> 6 9 7 - {@render children()} 10 + <Navbar /> 11 + <main class="m-2 flex flex-col items-center"> 12 + {@render children()} 13 + </main> 14 + <!-- <Player1 /> --> 15 + <Player2 />
+32 -5
src/routes/+page.svelte
··· 1 - <h1>Welcome to SvelteKit</h1> 2 - <p> 3 - Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the 4 - documentation 5 - </p> 1 + <section class="flex flex-col items-center gap-2 pt-10"> 2 + <header class="flex flex-col items-center"> 3 + <h1 class="text-5xl font-bold tracking-tighter text-orange-600">Comet</h1> 4 + <p class="flex flex-col items-center text-center text-lg"> 5 + Your music, on ATProto. 6 + </p> 7 + </header> 8 + 9 + <div> 10 + <h2 class="text-2xl font-bold tracking-tighter text-orange-600">Why?</h2> 11 + <ol class="list-disc"> 12 + <li> 13 + free yourself from Big Tech™️; don't let them tell you what music you 14 + can and can't make 15 + </li> 16 + <li> 17 + Listen to music in full* quality, without being subject to horrendous 18 + sounding data compression. 19 + </li> 20 + <li> 21 + if I die, all your data is yours, and can be used by other projects! 22 + </li> 23 + <li> 24 + Integrate your playback with <span class="text-teal-800 underline"> 25 + teal.fm 26 + </span> 27 + and let everyone else know what you're listening to! 28 + </li> 29 + <li>borpa</li> 30 + </ol> 31 + </div> 32 + </section>