Listen to and share the music in the Atmosphere. musicsky.up.railway.app/
nextjs atproto music typescript react
3
fork

Configure Feed

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

feat: player bar component

+466 -30
+31 -1
package-lock.json
··· 27 27 "react-dom": "^19.2.4", 28 28 "react-hook-form": "^7.71.2", 29 29 "tailwind-merge": "^3.5.0", 30 - "zod": "^4.3.6" 30 + "zod": "^4.3.6", 31 + "zustand": "^5.0.11" 31 32 }, 32 33 "devDependencies": { 33 34 "@tailwindcss/postcss": "^4", ··· 13693 13694 }, 13694 13695 "peerDependencies": { 13695 13696 "zod": "^3.25.0 || ^4.0.0" 13697 + } 13698 + }, 13699 + "node_modules/zustand": { 13700 + "version": "5.0.11", 13701 + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", 13702 + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", 13703 + "license": "MIT", 13704 + "engines": { 13705 + "node": ">=12.20.0" 13706 + }, 13707 + "peerDependencies": { 13708 + "@types/react": ">=18.0.0", 13709 + "immer": ">=9.0.6", 13710 + "react": ">=18.0.0", 13711 + "use-sync-external-store": ">=1.2.0" 13712 + }, 13713 + "peerDependenciesMeta": { 13714 + "@types/react": { 13715 + "optional": true 13716 + }, 13717 + "immer": { 13718 + "optional": true 13719 + }, 13720 + "react": { 13721 + "optional": true 13722 + }, 13723 + "use-sync-external-store": { 13724 + "optional": true 13725 + } 13696 13726 } 13697 13727 } 13698 13728 }
+2 -1
package.json
··· 35 35 "react-dom": "^19.2.4", 36 36 "react-hook-form": "^7.71.2", 37 37 "tailwind-merge": "^3.5.0", 38 - "zod": "^4.3.6" 38 + "zod": "^4.3.6", 39 + "zustand": "^5.0.11" 39 40 }, 40 41 "devDependencies": { 41 42 "@tailwindcss/postcss": "^4",
+34 -2
src/app/(main)/[handle]/info.tsx
··· 1 + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 2 + import { Agent } from "@atproto/api"; 3 + import { notFound } from "next/navigation"; 4 + 5 + async function getProfile(handle: string) { 6 + const agent = new Agent("https://public.api.bsky.app"); 7 + try { 8 + const { data: identity } = await agent.resolveHandle({ handle }); 9 + const { data: profile } = await agent.getProfile({ actor: identity.did }); 10 + return profile; 11 + } catch (error) { 12 + console.error("Failed to fetch DID for", handle, error); 13 + return null; 14 + } 15 + } 16 + 1 17 export async function Info({ 2 18 params, 3 19 }: { 4 20 params: Promise<{ handle: string }>; 5 21 }) { 6 22 const { handle } = await params; 23 + const profile = await getProfile(handle); 24 + if (profile === null) { 25 + notFound(); 26 + } 7 27 return ( 8 - <div> 9 - <h3 className="text-2xl font-semibold">Songs by {handle}</h3> 28 + <div className="flex flex-col gap-4"> 29 + <div className="flex flex-row gap-4 items-center"> 30 + <Avatar className="size-16"> 31 + <AvatarImage src={profile.avatar} alt={profile.handle} /> 32 + <AvatarFallback>{profile.handle.slice(0, 2)}</AvatarFallback> 33 + </Avatar> 34 + <div> 35 + <h3 className="text-2xl font-semibold">{profile.displayName}</h3> 36 + <p className="text-sm">@{profile.handle}</p> 37 + </div> 38 + </div> 39 + <p className="wrap-break-word overflow-clip text-ellipsis"> 40 + {profile.description} 41 + </p> 10 42 </div> 11 43 ); 12 44 }
+1 -3
src/app/(main)/[handle]/songs-list.tsx
··· 61 61 rkey: getRkey(record.uri), 62 62 title: value.title, 63 63 slug: value.slug, 64 - coverArt: value.coverArt 65 - ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt?.ref?.toString()}` 66 - : null, 64 + coverArt: `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt?.ref?.toString()}`, 67 65 audio: `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.audio.ref.toString()}`, 68 66 genre: value.genre ?? null, 69 67 duration: value.duration,
+6 -3
src/app/(main)/layout.tsx
··· 5 5 import { Suspense } from "react"; 6 6 import { ThemeToggle } from "@/components/theme-toggle"; 7 7 import { HomeIcon } from "lucide-react"; 8 + import { PlayerBar } from "@/components/player-bar"; 8 9 9 10 export default function MainLayout({ 10 11 children, ··· 12 13 children: React.ReactNode; 13 14 }>) { 14 15 return ( 15 - <div className="flex flex-row gap-4 min-h-screen items-start justify-center bg-background"> 16 + <div className="flex flex-row min-h-screen items-start justify-center bg-background"> 16 17 <div className="flex flex-col justify-between gap-4 w-60 border-r border-border h-screen p-4"> 17 18 <div className="flex flex-col gap-4"> 18 19 <div className="space-y-2"> ··· 31 32 </Link> 32 33 </Button> 33 34 </div> 34 - 35 35 <div className="flex flex-col gap-4"> 36 36 <Suspense fallback={<Skeleton className="w-full h-9 rounded-full" />}> 37 37 <AvatarSection /> 38 38 </Suspense> 39 39 </div> 40 40 </div> 41 - <div className="max-w-lg w-full p-4">{children}</div> 41 + <div className="max-w-lg flex flex-col w-full h-screen border-r border-border"> 42 + <div className="p-4 overflow-y-auto h-full">{children}</div> 43 + <PlayerBar /> 44 + </div> 42 45 </div> 43 46 ); 44 47 }
+13 -6
src/app/(main)/page.tsx
··· 1 + import Link from "next/link"; 2 + 1 3 export default function Home() { 2 4 return ( 3 5 <main className="flex flex-col gap-4 max-w-lg"> ··· 8 10 Atmosphere. 9 11 </h2> 10 12 </div> 13 + <p>This is a work in progress.</p> 11 14 <p> 12 - This is a work in progress. For now you can log in, upload music and 13 - listen to it on your profile. Listening to other people&apos;s music by 14 - typing out their handle in the URL should work too. <br /> 15 + For now you can log in, upload music and listen to it on your profile 16 + (currently all the profile data is fetched from Bluesky, will have to 17 + implement Musicsky&apos;s own profile data). Listening to other 18 + people&apos;s music by typing out their handle in the URL should work 19 + too. <br /> 15 20 <br /> 16 21 Below should be a feed of newly uploaded songs, but this requires a 17 - dedicated backend which I will implement in the future once I implement 18 - the basic functionality, like liking songs, etc. 22 + dedicated backend which will be implement in the future once the basic 23 + functionality is there, like liking songs, comments etc. 19 24 <br /> 20 25 Please check back later for more updates. <br /> 21 26 <br /> 22 27 Thank you for checking this out! 23 28 <br /> 24 - Maciej 29 + <Link href="https://bsky.app/profile/did:plc:ix2e4nkbttdtyurtuvxbeqpw"> 30 + - Maciej 31 + </Link> 25 32 </p> 26 33 </main> 27 34 );
+1
src/components/player-bar/index.tsx
··· 1 + export { PlayerBar } from "./player-bar";
+139
src/components/player-bar/player-bar.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + useEffect, 5 + useRef, 6 + useOptimistic, 7 + useTransition, 8 + useState, 9 + } from "react"; 10 + import { usePlayerStore } from "@/stores/player-store"; 11 + import { 12 + likeAction, 13 + unlikeAction, 14 + repostAction, 15 + unrepostAction, 16 + } from "@/components/song/interaction-actions"; 17 + import { SongInfo } from "./song-info"; 18 + import { PlayerControls } from "./player-controls"; 19 + import { ProgressBar } from "./progress-bar"; 20 + 21 + export function PlayerBar() { 22 + const currentSong = usePlayerStore((store) => store.currentSong); 23 + const isPlaying = usePlayerStore((store) => store.isPlaying); 24 + const pause = usePlayerStore((store) => store.pause); 25 + const resume = usePlayerStore((store) => store.resume); 26 + const stop = usePlayerStore((store) => store.stop); 27 + const setLikeRkey = usePlayerStore((store) => store.setLikeRkey); 28 + const setRepostRkey = usePlayerStore((store) => store.setRepostRkey); 29 + 30 + const audioRef = useRef<HTMLAudioElement>(null); 31 + const [, startTransition] = useTransition(); 32 + const [currentTime, setCurrentTime] = useState(0); 33 + 34 + const [optimisticLiked, setOptimisticLiked] = useOptimistic( 35 + !!currentSong?.likeRkey, 36 + ); 37 + const [optimisticReposted, setOptimisticReposted] = useOptimistic( 38 + !!currentSong?.repostRkey, 39 + ); 40 + 41 + useEffect(() => { 42 + const audio = audioRef.current; 43 + if (!audio) return; 44 + 45 + if (isPlaying) { 46 + audio.play(); 47 + } else { 48 + audio.pause(); 49 + } 50 + }, [isPlaying, currentSong?.audio]); 51 + 52 + useEffect(() => { 53 + const audio = audioRef.current; 54 + if (!audio) return; 55 + 56 + const onTimeUpdate = () => setCurrentTime(audio.currentTime); 57 + audio.addEventListener("timeupdate", onTimeUpdate); 58 + return () => audio.removeEventListener("timeupdate", onTimeUpdate); 59 + }, [currentSong?.audio]); 60 + 61 + if (!currentSong) return null; 62 + 63 + function handleLike() { 64 + if (!currentSong) return; 65 + startTransition(async () => { 66 + if (optimisticLiked) { 67 + setOptimisticLiked(false); 68 + if (currentSong.likeRkey) { 69 + await unlikeAction(currentSong.likeRkey, currentSong.author); 70 + setLikeRkey(null); 71 + } 72 + } else { 73 + setOptimisticLiked(true); 74 + const newRkey = await likeAction( 75 + currentSong.uri, 76 + currentSong.cid, 77 + currentSong.author, 78 + ); 79 + setLikeRkey(newRkey ?? null); 80 + } 81 + }); 82 + } 83 + 84 + function handleRepost() { 85 + if (!currentSong) return; 86 + startTransition(async () => { 87 + if (optimisticReposted) { 88 + setOptimisticReposted(false); 89 + if (currentSong.repostRkey) { 90 + await unrepostAction(currentSong.repostRkey, currentSong.author); 91 + setRepostRkey(null); 92 + } 93 + } else { 94 + setOptimisticReposted(true); 95 + const newRkey = await repostAction( 96 + currentSong.uri, 97 + currentSong.cid, 98 + currentSong.author, 99 + ); 100 + setRepostRkey(newRkey ?? null); 101 + } 102 + }); 103 + } 104 + 105 + function handleSeek(time: number) { 106 + setCurrentTime(time); 107 + if (audioRef.current) audioRef.current.currentTime = time; 108 + } 109 + 110 + return ( 111 + <> 112 + <audio ref={audioRef} src={currentSong.audio} onEnded={stop} /> 113 + <div className="bg-background flex flex-col gap-4 p-4 w-full border-r border-t border-border"> 114 + <div className="flex items-center justify-between gap-4"> 115 + <SongInfo 116 + coverArt={currentSong.coverArt} 117 + title={currentSong.title} 118 + author={currentSong.author} 119 + /> 120 + <PlayerControls 121 + isPlaying={isPlaying} 122 + optimisticLiked={optimisticLiked} 123 + optimisticReposted={optimisticReposted} 124 + onPlay={resume} 125 + onPause={pause} 126 + onStop={stop} 127 + onLike={handleLike} 128 + onRepost={handleRepost} 129 + /> 130 + </div> 131 + <ProgressBar 132 + currentTime={currentTime} 133 + duration={currentSong.duration} 134 + onSeek={handleSeek} 135 + /> 136 + </div> 137 + </> 138 + ); 139 + }
+63
src/components/player-bar/player-controls.tsx
··· 1 + import { 2 + PlayIcon, 3 + PauseIcon, 4 + XIcon, 5 + HeartIcon, 6 + RepeatIcon, 7 + } from "lucide-react"; 8 + import { cn } from "@/lib/utils"; 9 + 10 + interface PlayerControlsProps { 11 + isPlaying: boolean; 12 + optimisticLiked: boolean; 13 + optimisticReposted: boolean; 14 + onPlay: () => void; 15 + onPause: () => void; 16 + onStop: () => void; 17 + onLike: () => void; 18 + onRepost: () => void; 19 + } 20 + 21 + export function PlayerControls({ 22 + isPlaying, 23 + optimisticLiked, 24 + optimisticReposted, 25 + onPlay, 26 + onPause, 27 + onStop, 28 + onLike, 29 + onRepost, 30 + }: PlayerControlsProps) { 31 + return ( 32 + <div className="flex items-center gap-4"> 33 + <button onClick={onRepost} aria-label="Repost" className="cursor-pointer"> 34 + <RepeatIcon 35 + size={18} 36 + className={cn(optimisticReposted && "text-green-500")} 37 + fill={optimisticReposted ? "currentColor" : "none"} 38 + /> 39 + </button> 40 + <button onClick={onLike} aria-label="Like" className="cursor-pointer"> 41 + <HeartIcon 42 + size={18} 43 + className={cn(optimisticLiked && "text-red-500")} 44 + fill={optimisticLiked ? "currentColor" : "none"} 45 + /> 46 + </button> 47 + <button 48 + onClick={isPlaying ? onPause : onPlay} 49 + aria-label={isPlaying ? "Pause" : "Play"} 50 + className="cursor-pointer" 51 + > 52 + {isPlaying ? <PauseIcon size={22} /> : <PlayIcon size={22} />} 53 + </button> 54 + <button 55 + onClick={onStop} 56 + aria-label="Close player" 57 + className="cursor-pointer" 58 + > 59 + <XIcon size={18} /> 60 + </button> 61 + </div> 62 + ); 63 + }
+38
src/components/player-bar/progress-bar.tsx
··· 1 + interface ProgressBarProps { 2 + currentTime: number; 3 + duration: number; 4 + onSeek: (time: number) => void; 5 + } 6 + 7 + function formatTime(seconds: number) { 8 + if (!isFinite(seconds)) return "0:00"; 9 + const minutes = Math.floor(seconds / 60); 10 + const secs = Math.floor(seconds % 60); 11 + return `${minutes}:${secs.toString().padStart(2, "0")}`; 12 + } 13 + 14 + export function ProgressBar({ 15 + currentTime, 16 + duration, 17 + onSeek, 18 + }: ProgressBarProps) { 19 + return ( 20 + <div className="flex items-center gap-2"> 21 + <span className="text-xs text-muted-foreground tabular-nums"> 22 + {formatTime(currentTime)} 23 + </span> 24 + <input 25 + type="range" 26 + min={0} 27 + max={duration} 28 + value={currentTime} 29 + step={1} 30 + onChange={(event) => onSeek(Number(event.target.value))} 31 + className="flex-1 h-1 accent-primary cursor-pointer" 32 + /> 33 + <span className="text-xs text-muted-foreground tabular-nums"> 34 + {formatTime(duration)} 35 + </span> 36 + </div> 37 + ); 38 + }
+32
src/components/player-bar/song-info.tsx
··· 1 + import { PUBLIC_URL } from "@/lib/api"; 2 + import Image from "next/image"; 3 + import Link from "next/link"; 4 + 5 + interface SongInfoProps { 6 + coverArt: string; 7 + title: string; 8 + author: string; 9 + } 10 + 11 + export function SongInfo({ coverArt, title, author }: SongInfoProps) { 12 + return ( 13 + <div className="flex flex-row items-center gap-4"> 14 + <Image 15 + className="rounded size-12" 16 + src={coverArt} 17 + alt={title} 18 + width={48} 19 + height={48} 20 + /> 21 + <div className="flex flex-col"> 22 + <span className="text-sm font-semibold truncate">{title}</span> 23 + <Link 24 + href={`${PUBLIC_URL}/${author}`} 25 + className="text-xs text-muted-foreground truncate" 26 + > 27 + {author} 28 + </Link> 29 + </div> 30 + </div> 31 + ); 32 + }
+4 -2
src/components/song/interaction-actions.ts
··· 9 9 if (!session) return; 10 10 11 11 const agent = new Agent(session); 12 - await agent.com.atproto.repo.createRecord({ 12 + const result = await agent.com.atproto.repo.createRecord({ 13 13 repo: agent.assertDid, 14 14 collection: "app.musicsky.temp.like", 15 15 record: { ··· 19 19 }, 20 20 }); 21 21 revalidatePath(`/${handle}`); 22 + return result.data.uri.split("/").at(-1); 22 23 } 23 24 24 25 export async function unlikeAction(rkey: string, handle: string) { ··· 39 40 if (!session) return; 40 41 41 42 const agent = new Agent(session); 42 - await agent.com.atproto.repo.createRecord({ 43 + const result = await agent.com.atproto.repo.createRecord({ 43 44 repo: agent.assertDid, 44 45 collection: "app.musicsky.temp.repost", 45 46 record: { ··· 49 50 }, 50 51 }); 51 52 revalidatePath(`/${handle}`); 53 + return result.data.uri.split("/").at(-1); 52 54 } 53 55 54 56 export async function unrepostAction(rkey: string, handle: string) {
+55 -11
src/components/song/song.tsx
··· 1 1 "use client"; 2 2 3 3 import Image from "next/image"; 4 - import { RepeatIcon, HeartIcon } from "lucide-react"; 5 - import { useOptimistic, useTransition } from "react"; 4 + import { RepeatIcon, HeartIcon, PlayIcon, PauseIcon } from "lucide-react"; 5 + import { useOptimistic, useTransition, useEffect } from "react"; 6 + import { usePlayerStore } from "@/stores/player-store"; 6 7 import { 7 8 likeAction, 8 9 unlikeAction, ··· 14 15 import { SharePopover } from "./share-popover"; 15 16 import { SongMenu } from "./song-menu"; 16 17 import type { SongProps } from "@/types/song"; 18 + import { Button } from "../ui/button"; 17 19 18 20 export function Song({ 19 21 uri, ··· 33 35 }: SongProps) { 34 36 const shareUrl = `${PUBLIC_URL}/${author}/${slug}`; 35 37 const [, startTransition] = useTransition(); 38 + const currentSong = usePlayerStore((song) => song.currentSong); 39 + const isPlaying = usePlayerStore((song) => song.isPlaying); 40 + const isCurrentSong = currentSong?.rkey === rkey; 41 + 42 + function handlePlay() { 43 + if (isCurrentSong && isPlaying) { 44 + usePlayerStore.getState().pause(); 45 + } else if (isCurrentSong) { 46 + usePlayerStore.getState().resume(); 47 + } else { 48 + usePlayerStore.getState().playSong({ 49 + uri, 50 + cid, 51 + rkey, 52 + title, 53 + coverArt, 54 + audio, 55 + duration, 56 + author, 57 + likeRkey, 58 + repostRkey, 59 + }); 60 + } 61 + } 62 + 63 + useEffect(() => { 64 + if (isCurrentSong) { 65 + usePlayerStore.getState().setLikeRkey(likeRkey); 66 + usePlayerStore.getState().setRepostRkey(repostRkey); 67 + } 68 + }, [isCurrentSong, likeRkey, repostRkey]); 36 69 const [optimisticLiked, setOptimisticLiked] = useOptimistic( 37 70 likeRkey !== null, 38 71 ); ··· 68 101 69 102 return ( 70 103 <div key={title} className="flex flex-col gap-4"> 71 - <div className="w-full flex flex-row gap-4"> 72 - {coverArt && ( 104 + <div className="w-full flex flex-row justify-between gap-4"> 105 + <div className="flex flex-row gap-4"> 73 106 <Image 74 107 className="rounded-md size-24" 75 108 src={coverArt} ··· 77 110 width={100} 78 111 height={100} 79 112 /> 80 - )} 81 - <div className="flex flex-col"> 82 - <h2 className="text-xl font-semibold">{title}</h2> 83 - {genre && <h3>{genre}</h3>} 84 - {description && <p>{description}</p>} 85 - <p>{formattedDuration}</p> 113 + <div className="flex flex-col"> 114 + <h2 className="text-xl font-semibold">{title}</h2> 115 + {genre && <h3>{genre}</h3>} 116 + {description && <p>{description}</p>} 117 + <p>{formattedDuration}</p> 118 + </div> 86 119 </div> 120 + <Button 121 + onClick={handlePlay} 122 + variant={"outline"} 123 + aria-label={isCurrentSong && isPlaying ? "Pause" : "Play"} 124 + > 125 + {isCurrentSong && isPlaying ? ( 126 + <PauseIcon size={18} /> 127 + ) : ( 128 + <PlayIcon size={18} /> 129 + )} 130 + {isCurrentSong && isPlaying ? "Now Playing" : "Play"} 131 + </Button> 87 132 </div> 88 133 <div className="flex flex-row items-center justify-between"> 89 134 <div className="flex flex-row gap-12"> ··· 115 160 <SongMenu isOwner={isOwner} rkey={rkey} /> 116 161 </div> 117 162 </div> 118 - <audio controls src={audio} /> 119 163 </div> 120 164 ); 121 165 }
+46
src/stores/player-store.ts
··· 1 + import { create } from "zustand"; 2 + 3 + export interface PlayerSong { 4 + uri: string; 5 + cid: string; 6 + rkey: string; 7 + title: string; 8 + coverArt: string; 9 + audio: string; 10 + duration: number; 11 + author: string; 12 + likeRkey: string | null; 13 + repostRkey: string | null; 14 + } 15 + 16 + interface PlayerState { 17 + currentSong: PlayerSong | null; 18 + isPlaying: boolean; 19 + playSong: (song: PlayerSong) => void; 20 + pause: () => void; 21 + resume: () => void; 22 + stop: () => void; 23 + setLikeRkey: (rkey: string | null) => void; 24 + setRepostRkey: (rkey: string | null) => void; 25 + } 26 + 27 + export const usePlayerStore = create<PlayerState>((set) => ({ 28 + currentSong: null, 29 + isPlaying: false, 30 + playSong: (song) => set({ currentSong: song, isPlaying: true }), 31 + pause: () => set({ isPlaying: false }), 32 + resume: () => set({ isPlaying: true }), 33 + stop: () => set({ currentSong: null, isPlaying: false }), 34 + setLikeRkey: (rkey) => 35 + set((state) => ({ 36 + currentSong: state.currentSong 37 + ? { ...state.currentSong, likeRkey: rkey } 38 + : null, 39 + })), 40 + setRepostRkey: (rkey) => 41 + set((state) => ({ 42 + currentSong: state.currentSong 43 + ? { ...state.currentSong, repostRkey: rkey } 44 + : null, 45 + })), 46 + }));
+1 -1
src/types/song.ts
··· 16 16 rkey: string; 17 17 title: string; 18 18 slug: string; 19 - coverArt: string | null; 19 + coverArt: string; 20 20 audio: string; 21 21 genre: string | null; 22 22 duration: number;