WIP. A little custom music server
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: migrate album/[id] page

+269 -9
+61
web-tanstack/src/components/song-row.tsx
··· 1 + "use client"; 2 + 3 + import { AudioLines, Pause, Play } from "lucide-react"; 4 + import { usePlayerStore } from "@/store/player"; 5 + 6 + type Props = Readonly<{ 7 + title: string; 8 + fileId: string; 9 + trackNumber: number; 10 + artists: Array<{ id: string; name: string }>; 11 + isPaused?: boolean; 12 + }>; 13 + 14 + export function SongRow(props: Props) { 15 + const currentTrack = usePlayerStore((state) => state.currentTrack); 16 + const isPlaying = usePlayerStore((state) => state.isPlaying); 17 + const setTrack = usePlayerStore((state) => state.setTrack); 18 + 19 + const isCurrent = currentTrack === props.fileId; 20 + const isPaused = isCurrent && !isPlaying; 21 + 22 + const handleClick = () => { 23 + setTrack(props.fileId); 24 + }; 25 + 26 + return ( 27 + <li className="w-full"> 28 + <button 29 + onClick={handleClick} 30 + data-is-current={isCurrent} 31 + className="track-row-grid p-4 gap-x-4 gap-y-1 group transition-all duration-200 cursor-pointer hover:bg-muted data-[is-current=true]:bg-primary-foreground w-full text-left" 32 + > 33 + <div 34 + data-is-current={isCurrent} 35 + className="data-[id-current=true]:text-primary grid place-items-center h-full w-6 text-center" 36 + style={{ gridArea: "number" }} 37 + > 38 + <div className="w-4 h-4 group-hover:hidden grid place-items-center"> 39 + {isPaused ? ( 40 + <Pause className="w-4 h-4 text-primary" /> 41 + ) : isCurrent ? ( 42 + <AudioLines className="w-4 h-4 text-primary animate-pulse" /> 43 + ) : ( 44 + <> 45 + <span>{props.trackNumber}</span> 46 + </> 47 + )} 48 + </div> 49 + <Play className="w-4 h-4 text-primary hidden group-hover:block" /> 50 + </div> 51 + <span data-id-current={isCurrent} className="font-semibold data-[id-current=true]:text-primary"> 52 + {props.title} 53 + </span> 54 + <span className="text-sm">{props.artists.map((a) => a.name).join(", ")}</span> 55 + <span style={{ gridArea: "duration" }} className="text-right"> 56 + 0:00 57 + </span> 58 + </button> 59 + </li> 60 + ); 61 + }
+24
web-tanstack/src/data/get-album-by-id.ts
··· 1 + import { createServerFn } from "@tanstack/react-start"; 2 + import { client } from "@/lib/api"; 3 + import { FetchFailedError } from "@/lib/errors"; 4 + import { Effect, pipe } from "effect"; 5 + 6 + export const getAlbumByIdFn = createServerFn({ 7 + method: "GET", 8 + }) 9 + .inputValidator((data: { id: string }) => data) 10 + .handler(({ data }) => 11 + pipe( 12 + Effect.tryPromise({ 13 + try: () => client.album({ id: data.id }).get(), 14 + catch: (err) => 15 + new FetchFailedError({ 16 + cause: err, 17 + message: "Failed to fetch album", 18 + }), 19 + }), 20 + Effect.map((x) => x.data), 21 + Effect.flatMap(Effect.fromNullable), 22 + Effect.runPromise, 23 + ), 24 + );
+15
web-tanstack/src/lib/utils.ts
··· 4 4 export function cn(...inputs: ClassValue[]) { 5 5 return twMerge(clsx(inputs)); 6 6 } 7 + 8 + import { Schema } from "effect"; 9 + 10 + export const NormalizedFloat = Schema.Number.pipe( 11 + Schema.between(0, 1, { 12 + identifier: "NormalizedFloat", 13 + description: "floating point number between 0 and 1", 14 + }), 15 + ); 16 + 17 + export type NormalizedFloatType = typeof NormalizedFloat.Type; 18 + 19 + export function scale(x: number, fromMax: number, toMax: number) { 20 + return (x / fromMax) * toMax; 21 + }
+77 -7
web-tanstack/src/routes/album/$albumId.tsx
··· 1 - import { createFileRoute } from '@tanstack/react-router' 1 + import { getAlbumByIdFn } from "@/data/get-album-by-id"; 2 + import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; 3 + import { createFileRoute, notFound } from "@tanstack/react-router"; 4 + import { useServerFn } from "@tanstack/react-start"; 5 + import { Fragment, Suspense } from "react"; 6 + import { SongRow } from "@/components/song-row"; 2 7 3 - export const Route = createFileRoute('/album/$albumId')({ 4 - component: RouteComponent, 5 - }) 8 + export const Route = createFileRoute("/album/$albumId")({ 9 + component: AlbumPage, 10 + }); 6 11 7 - function RouteComponent() { 8 - const {albumId} = Route.useParams() 12 + const cover = "https://writteninmusic.com/wp-content/uploads/2025/09/TOP-Breach.jpg"; 9 13 10 - return <div>Hello "/album/{albumId}"!</div> 14 + function AlbumPage() { 15 + return ( 16 + <Suspense fallback={"Loading ..."}> 17 + <SuspendedAlbumPage /> 18 + </Suspense> 19 + ); 20 + } 21 + 22 + function SuspendedAlbumPage() { 23 + const { albumId } = Route.useParams(); 24 + 25 + const getAlbumById = useServerFn(getAlbumByIdFn); 26 + 27 + const { data: album } = useSuspenseQuery({ 28 + queryKey: ["album", albumId], 29 + queryFn: () => getAlbumById({ data: { id: albumId } }), 30 + }); 31 + 32 + return ( 33 + <div className="grid grid-cols-1 lg:grid-cols-[1fr_4fr] max-w-6xl w-full gap-x-8 px-8 py-12 mx-auto"> 34 + {/* Left section*/} 35 + <div className="relative"> 36 + <div className="flex flex-col gap-5 sticky top-12"> 37 + <img 38 + src={cover} 39 + width={900} 40 + height={900} 41 + className="max-w-[400px] max-h-[400px] aspect-square min-w-[400px] min-h-[400px] rounded-2xl transform rotate-1 hover:rotate-0 transition-transform duration-500 object-cover" 42 + /> 43 + 44 + <div className="space-y-3"> 45 + <h1 className="text-4xl lg:text-5xl font-bold text-balance leading-tight">{album.title}</h1> 46 + <h2 className="text-xl font-medium text-primary"> 47 + {album.artists.map((artist, idx) => ( 48 + <Fragment key={artist.id}> 49 + {/* @ts-ignore */} 50 + <a href={`#`}> 51 + {idx !== 0 ? ", " : ""} 52 + {artist.name} 53 + </a> 54 + </Fragment> 55 + ))} 56 + </h2> 57 + </div> 58 + </div> 59 + </div> 60 + 61 + {/* Right section*/} 62 + <div className="space-y-5"> 63 + <div> 64 + <h2 className="text-2xl font-semibold">Tracks</h2> 65 + </div> 66 + 67 + <ul className="bg-white shadow-md rounded-2xl w-full divide-y divide-border overflow-hidden"> 68 + {album.songs.map((song, idx) => ( 69 + <SongRow 70 + key={song.id} 71 + fileId={song.fileId} 72 + title={song.title} 73 + trackNumber={idx + 1} 74 + artists={song.artists.map((x) => ({ name: x.name, id: x.id }))} 75 + /> 76 + ))} 77 + </ul> 78 + </div> 79 + </div> 80 + ); 11 81 }
+81
web-tanstack/src/store/player.ts
··· 1 + import { create } from "zustand"; 2 + import { scale } from "@/lib/utils"; 3 + 4 + const MAX_VOLUME = 0.15; 5 + 6 + // ## Volume Helpers 7 + 8 + function perceptualVolume(x: number) { 9 + //return (Math.exp(x) - 1) / (Math.E - 1); 10 + return (Math.exp(x) - 1) * 0.5819767; // 1/(e-1) 11 + } 12 + 13 + function amplitudeVolume(x: number) { 14 + //return Math.log(x * (Math.E - 1) + 1); 15 + return Math.log(x * 1.71828 + 1); // e-1 16 + } 17 + 18 + function clamp(x: number, min: number, max: number) { 19 + if (x > max) { 20 + return max; 21 + } 22 + if (x < min) { 23 + return min; 24 + } 25 + return x; 26 + } 27 + 28 + function volumeForward(valuePercent: number, maxVolume = MAX_VOLUME) { 29 + const clamped = clamp(valuePercent, 0, 100); 30 + const normalized = scale(clamped, 100, 1); 31 + const perceptual = perceptualVolume(normalized); 32 + const scaled = scale(perceptual, 1, maxVolume); 33 + const clamped2 = clamp(scaled, 0, maxVolume); 34 + return clamped2; 35 + } 36 + 37 + function volumeBackwards(value: number, maxVolume = MAX_VOLUME) { 38 + const clamped = clamp(value, 0, maxVolume); 39 + const scaled = scale(clamped, maxVolume, 1); 40 + const actual = amplitudeVolume(scaled); 41 + const denormalized = scale(actual, 1, 100); 42 + const clamped2 = clamp(denormalized, 0, 100); 43 + return clamped2; 44 + } 45 + 46 + // ## Store 47 + 48 + interface PlayerState { 49 + // State 50 + isPlaying: boolean; 51 + currentTrack: string | null; 52 + volume: number; // 0-1 range 53 + 54 + // Actions 55 + play: () => void; 56 + pause: () => void; 57 + togglePlayPause: () => void; 58 + setTrack: (fileId: string) => void; 59 + setVolume: (volume: number) => void; 60 + setVolumePercent: (percent: number) => void; 61 + } 62 + 63 + export const usePlayerStore = create<PlayerState>((set) => ({ 64 + // Initial state 65 + isPlaying: false, 66 + currentTrack: null, 67 + volume: 0.02, 68 + 69 + // Actions 70 + play: () => set({ isPlaying: true }), 71 + pause: () => set({ isPlaying: false }), 72 + togglePlayPause: () => set((state) => ({ isPlaying: !state.isPlaying })), 73 + setTrack: (fileId: string) => set({ currentTrack: fileId, isPlaying: true }), 74 + setVolume: (volume: number) => set({ volume }), 75 + setVolumePercent: (percent: number) => set({ volume: volumeForward(percent) }), 76 + })); 77 + 78 + // Helper to get volume as percentage (0-100) 79 + export function getVolumePercent(volume: number): number { 80 + return volumeBackwards(volume); 81 + }
+11
web-tanstack/src/styles.css
··· 135 135 @apply bg-background text-foreground; 136 136 } 137 137 } 138 + 139 + .track-row-grid { 140 + grid-template-areas: 141 + "number title duration" 142 + "number artists duration"; 143 + 144 + display: grid; 145 + grid-template-columns: auto 1fr auto; 146 + grid-template-rows: repeat(minmax(0 auto), 2); 147 + align-items: center; 148 + }
-2
web/src/components/song-row.tsx
··· 1 - "use client"; 2 - 3 1 import { AudioLines, Pause, Play } from "lucide-react"; 4 2 import { usePlayerStore } from "@/stores/player"; 5 3