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: add playlist queue playback

Signed-off-by: mejsiejdev <mejsiejdev@gmail.com>

authored by

mejsiejdev and committed by tangled.org d16c5540 fa4ff0bc

+289 -165
+1 -1
.lintstagedrc
··· 1 1 { 2 - "apps/web/**/*.{ts,tsx}": "eslint --fix", 2 + "apps/web/**/*.{ts,tsx}": "eslint --config apps/web/eslint.config.mjs --fix", 3 3 "*": "prettier --ignore-unknown --write" 4 4 }
+27 -10
apps/web/src/app/(main)/[handle]/likes/likes-list.tsx
··· 1 1 import { Song } from "@/components/song"; 2 2 import { type SongProps, type TrackRecord } from "@/types/song"; 3 + import type { PlayerSong } from "@/stores/player-store"; 3 4 import { Agent } from "@atproto/api"; 4 5 import { cacheTag } from "next/cache"; 5 6 import { notFound } from "next/navigation"; ··· 18 19 COLLECTIONS, 19 20 } from "@/lib/atproto"; 20 21 import { HeartCrackIcon } from "lucide-react"; 22 + import { PlaylistQueueProvider } from "@/components/playlist/playlist-queue-context"; 21 23 22 24 interface LikeRecord { 23 25 subject: { ··· 162 164 ); 163 165 } 164 166 165 - return songs.map((song) => { 166 - const songProps: SongProps = { 167 - ...song, 168 - isOwner: isResourceOwner(session?.did, song.uri), 169 - loggedIn: session !== null, 170 - likeRkey: likedUris.get(song.uri) ?? null, 171 - repostRkey: repostedUris.get(song.uri) ?? null, 172 - }; 173 - return <Song key={song.uri} {...songProps} />; 174 - }); 167 + const queueSongs: PlayerSong[] = songs.map((song) => ({ 168 + uri: song.uri, 169 + cid: song.cid, 170 + rkey: song.rkey, 171 + title: song.title, 172 + coverArt: song.coverArt, 173 + audio: song.audio, 174 + duration: song.duration, 175 + author: song.author, 176 + })); 177 + 178 + return ( 179 + <PlaylistQueueProvider songs={queueSongs}> 180 + {songs.map((song) => { 181 + const songProps: SongProps = { 182 + ...song, 183 + isOwner: isResourceOwner(session?.did, song.uri), 184 + loggedIn: session !== null, 185 + likeRkey: likedUris.get(song.uri) ?? null, 186 + repostRkey: repostedUris.get(song.uri) ?? null, 187 + }; 188 + return <Song key={song.uri} {...songProps} />; 189 + })} 190 + </PlaylistQueueProvider> 191 + ); 175 192 }
+42 -25
apps/web/src/app/(main)/[handle]/playlists/[rkey]/playlist-view.tsx
··· 1 1 import Image from "next/image"; 2 2 import { Song } from "@/components/song"; 3 3 import { type PlaylistProps, type PlaylistRecord } from "@/types/playlist"; 4 + import type { PlayerSong } from "@/stores/player-store"; 4 5 import { Agent } from "@atproto/api"; 5 6 import { notFound } from "next/navigation"; 6 7 import { cacheTag } from "next/cache"; ··· 8 9 import { getDid, getPds } from "@/lib/songs"; 9 10 import { mapRecordToPlaylist, resolvePlaylistTracks } from "@/lib/playlists"; 10 11 import { PlaylistMenu } from "@/components/playlist/playlist-menu"; 12 + import { PlaylistQueueProvider } from "@/components/playlist/playlist-queue-context"; 11 13 import { ListMusicIcon } from "lucide-react"; 12 14 13 15 async function getPlaylist( ··· 70 72 const isOwner = session?.did === playlist.uri.split("/")[2]; 71 73 const resolvedTracks = await resolvePlaylistTracks(playlist.tracks, session); 72 74 75 + const queueSongs: PlayerSong[] = resolvedTracks 76 + .filter((track): track is NonNullable<typeof track> => track !== null) 77 + .map((track) => ({ 78 + uri: track.uri, 79 + cid: track.cid, 80 + rkey: track.rkey, 81 + title: track.title, 82 + coverArt: track.coverArt, 83 + audio: track.audio, 84 + duration: track.duration, 85 + author: track.author, 86 + })); 87 + 73 88 return ( 74 89 <div className="flex flex-col gap-6"> 75 90 <div className="flex flex-row items-start justify-between gap-4"> ··· 111 126 )} 112 127 </div> 113 128 114 - <div className="flex flex-col gap-4"> 115 - {resolvedTracks.map((track, index) => { 116 - if (!track) { 129 + <PlaylistQueueProvider songs={queueSongs}> 130 + <div className="flex flex-col gap-4"> 131 + {resolvedTracks.map((track, index) => { 132 + if (!track) { 133 + return ( 134 + <div 135 + key={index} 136 + className="flex flex-row items-center gap-4 opacity-50" 137 + > 138 + <div className="rounded-md size-24 bg-muted flex items-center justify-center"> 139 + <ListMusicIcon className="size-6 text-muted-foreground" /> 140 + </div> 141 + <p className="text-sm text-muted-foreground"> 142 + Track unavailable 143 + </p> 144 + </div> 145 + ); 146 + } 117 147 return ( 118 - <div 119 - key={index} 120 - className="flex flex-row items-center gap-4 opacity-50" 121 - > 122 - <div className="rounded-md size-24 bg-muted flex items-center justify-center"> 123 - <ListMusicIcon className="size-6 text-muted-foreground" /> 124 - </div> 125 - <p className="text-sm text-muted-foreground"> 126 - Track unavailable 127 - </p> 128 - </div> 148 + <Song 149 + key={track.uri} 150 + {...track} 151 + playlistRkey={isOwner ? rkey : undefined} 152 + isLastTrack={playlist.trackCount <= 1} 153 + /> 129 154 ); 130 - } 131 - return ( 132 - <Song 133 - key={track.uri} 134 - {...track} 135 - playlistRkey={isOwner ? rkey : undefined} 136 - isLastTrack={playlist.trackCount <= 1} 137 - /> 138 - ); 139 - })} 140 - </div> 155 + })} 156 + </div> 157 + </PlaylistQueueProvider> 141 158 </div> 142 159 ); 143 160 }
+27 -10
apps/web/src/app/(main)/[handle]/songs-list.tsx
··· 1 1 import { Song } from "@/components/song"; 2 2 import { type SongProps, type TrackRecord } from "@/types/song"; 3 + import type { PlayerSong } from "@/stores/player-store"; 3 4 import { Agent } from "@atproto/api"; 4 5 import { cacheTag } from "next/cache"; 5 6 import { notFound } from "next/navigation"; ··· 11 12 mapRecordToSong, 12 13 } from "@/lib/songs"; 13 14 import { COLLECTIONS, isResourceOwner } from "@/lib/atproto"; 15 + import { PlaylistQueueProvider } from "@/components/playlist/playlist-queue-context"; 14 16 15 17 async function getSongs(pds: string, did: string, handle: string) { 16 18 "use cache"; ··· 55 57 getUserInteractions(session), 56 58 ]); 57 59 58 - return songs.map((song) => { 59 - const songProps: SongProps = { 60 - ...song, 61 - isOwner: isResourceOwner(session?.did, song.uri), 62 - loggedIn: session !== null, 63 - likeRkey: likedUris.get(song.uri) ?? null, 64 - repostRkey: repostedUris.get(song.uri) ?? null, 65 - }; 66 - return <Song key={song.uri} {...songProps} />; 67 - }); 60 + const queueSongs: PlayerSong[] = songs.map((song) => ({ 61 + uri: song.uri, 62 + cid: song.cid, 63 + rkey: song.rkey, 64 + title: song.title, 65 + coverArt: song.coverArt, 66 + audio: song.audio, 67 + duration: song.duration, 68 + author: song.author, 69 + })); 70 + 71 + return ( 72 + <PlaylistQueueProvider songs={queueSongs}> 73 + {songs.map((song) => { 74 + const songProps: SongProps = { 75 + ...song, 76 + isOwner: isResourceOwner(session?.did, song.uri), 77 + loggedIn: session !== null, 78 + likeRkey: likedUris.get(song.uri) ?? null, 79 + repostRkey: repostedUris.get(song.uri) ?? null, 80 + }; 81 + return <Song key={song.uri} {...songProps} />; 82 + })} 83 + </PlaylistQueueProvider> 84 + ); 68 85 }
+11 -24
apps/web/src/components/player-bar/player-bar.tsx
··· 2 2 3 3 import { useEffect, useRef, useState } from "react"; 4 4 import { usePlayerStore } from "@/stores/player-store"; 5 - import { useInteraction } from "@/hooks/use-interaction"; 6 5 import { SongInfo } from "./song-info"; 7 6 import { PlayerControls } from "./player-controls"; 8 7 import { ProgressBar } from "./progress-bar"; ··· 10 9 export function PlayerBar() { 11 10 const currentSong = usePlayerStore((store) => store.currentSong); 12 11 const isPlaying = usePlayerStore((store) => store.isPlaying); 13 - const pause = usePlayerStore((store) => store.pause); 14 - const resume = usePlayerStore((store) => store.resume); 15 12 const stop = usePlayerStore((store) => store.stop); 13 + const next = usePlayerStore((store) => store.next); 16 14 17 15 const audioRef = useRef<HTMLAudioElement>(null); 18 16 const [currentTime, setCurrentTime] = useState(0); 19 - 20 - const { optimisticLiked, optimisticReposted, handleLike, handleRepost } = 21 - useInteraction({ 22 - uri: currentSong?.uri ?? "", 23 - cid: currentSong?.cid, 24 - author: currentSong?.author ?? "", 25 - likeRkey: currentSong?.likeRkey ?? null, 26 - repostRkey: currentSong?.repostRkey ?? null, 27 - }); 28 17 29 18 useEffect(() => { 30 19 const audio = audioRef.current; ··· 53 42 if (audioRef.current) audioRef.current.currentTime = time; 54 43 } 55 44 45 + function handleEnded() { 46 + if (usePlayerStore.getState().queue.length > 0) { 47 + next(); 48 + } else { 49 + stop(); 50 + } 51 + } 52 + 56 53 return ( 57 54 <> 58 - <audio ref={audioRef} src={currentSong.audio} onEnded={stop} /> 55 + <audio ref={audioRef} src={currentSong.audio} onEnded={handleEnded} /> 59 56 <div className="bg-background flex flex-col gap-4 p-4 w-full border-r border-t border-border"> 60 57 <div className="flex items-center justify-between gap-4"> 61 58 <SongInfo ··· 63 60 title={currentSong.title} 64 61 author={currentSong.author} 65 62 /> 66 - <PlayerControls 67 - isPlaying={isPlaying} 68 - optimisticLiked={optimisticLiked} 69 - optimisticReposted={optimisticReposted} 70 - canInteract={!!currentSong.cid} 71 - onPlay={resume} 72 - onPause={pause} 73 - onStop={stop} 74 - onLike={handleLike} 75 - onRepost={handleRepost} 76 - /> 63 + <PlayerControls /> 77 64 </div> 78 65 <ProgressBar 79 66 currentTime={currentTime}
+44 -50
apps/web/src/components/player-bar/player-controls.tsx
··· 1 + "use client"; 2 + 1 3 import { 2 4 PlayIcon, 3 5 PauseIcon, 4 6 XIcon, 5 - HeartIcon, 6 - RepeatIcon, 7 + ShuffleIcon, 8 + SkipBackIcon, 9 + SkipForwardIcon, 7 10 } from "lucide-react"; 11 + import { usePlayerStore } from "@/stores/player-store"; 8 12 import { cn } from "@/lib/utils"; 9 13 10 - interface PlayerControlsProps { 11 - isPlaying: boolean; 12 - optimisticLiked: boolean; 13 - optimisticReposted: boolean; 14 - canInteract: boolean; 15 - onPlay: () => void; 16 - onPause: () => void; 17 - onStop: () => void; 18 - onLike: () => void; 19 - onRepost: () => void; 20 - } 14 + export function PlayerControls() { 15 + const isPlaying = usePlayerStore((store) => store.isPlaying); 16 + const hasQueue = usePlayerStore((store) => store.queue.length > 0); 17 + const isShuffled = usePlayerStore((store) => store.isShuffled); 18 + const pause = usePlayerStore((store) => store.pause); 19 + const resume = usePlayerStore((store) => store.resume); 20 + const stop = usePlayerStore((store) => store.stop); 21 + const next = usePlayerStore((store) => store.next); 22 + const previous = usePlayerStore((store) => store.previous); 23 + const toggleShuffle = usePlayerStore((store) => store.toggleShuffle); 21 24 22 - export function PlayerControls({ 23 - isPlaying, 24 - optimisticLiked, 25 - optimisticReposted, 26 - canInteract, 27 - onPlay, 28 - onPause, 29 - onStop, 30 - onLike, 31 - onRepost, 32 - }: PlayerControlsProps) { 33 25 return ( 34 26 <div className="flex items-center gap-4"> 35 - <button 36 - onClick={onRepost} 37 - aria-label="Repost" 38 - disabled={!canInteract} 39 - className="cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" 40 - > 41 - <RepeatIcon 42 - size={18} 43 - className={cn(optimisticReposted && "text-green-500")} 44 - fill={optimisticReposted ? "currentColor" : "none"} 45 - /> 46 - </button> 27 + {hasQueue && ( 28 + <> 29 + <button 30 + onClick={toggleShuffle} 31 + aria-label="Toggle shuffle" 32 + className="cursor-pointer" 33 + > 34 + <ShuffleIcon 35 + size={18} 36 + className={cn(isShuffled && "text-primary")} 37 + /> 38 + </button> 39 + <button 40 + onClick={previous} 41 + aria-label="Previous" 42 + className="cursor-pointer" 43 + > 44 + <SkipBackIcon size={18} /> 45 + </button> 46 + </> 47 + )} 47 48 <button 48 - onClick={onLike} 49 - aria-label="Like" 50 - disabled={!canInteract} 51 - className="cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" 52 - > 53 - <HeartIcon 54 - size={18} 55 - className={cn(optimisticLiked && "text-red-500")} 56 - fill={optimisticLiked ? "currentColor" : "none"} 57 - /> 58 - </button> 59 - <button 60 - onClick={isPlaying ? onPause : onPlay} 49 + onClick={isPlaying ? pause : resume} 61 50 aria-label={isPlaying ? "Pause" : "Play"} 62 51 className="cursor-pointer" 63 52 > 64 53 {isPlaying ? <PauseIcon size={22} /> : <PlayIcon size={22} />} 65 54 </button> 55 + {hasQueue && ( 56 + <button onClick={next} aria-label="Next" className="cursor-pointer"> 57 + <SkipForwardIcon size={18} /> 58 + </button> 59 + )} 66 60 <button 67 - onClick={onStop} 61 + onClick={stop} 68 62 aria-label="Close player" 69 63 className="cursor-pointer" 70 64 >
+20
apps/web/src/components/playlist/playlist-queue-context.tsx
··· 1 + "use client"; 2 + 3 + import { createContext, use } from "react"; 4 + import type { PlayerSong } from "@/stores/player-store"; 5 + 6 + const PlaylistQueueContext = createContext<PlayerSong[] | null>(null); 7 + 8 + export function PlaylistQueueProvider({ 9 + songs, 10 + children, 11 + }: { 12 + songs: PlayerSong[]; 13 + children: React.ReactNode; 14 + }) { 15 + return <PlaylistQueueContext value={songs}>{children}</PlaylistQueueContext>; 16 + } 17 + 18 + export function usePlaylistQueue() { 19 + return use(PlaylistQueueContext); 20 + }
+22 -13
apps/web/src/components/song/song.tsx
··· 25 25 import { Badge } from "../ui/badge"; 26 26 import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; 27 27 import { formatDistanceToNow, format } from "date-fns"; 28 + import { usePlaylistQueue } from "@/components/playlist/playlist-queue-context"; 28 29 29 30 const AddToPlaylistDialog = dynamic( 30 31 () => ··· 93 94 const { optimisticLiked, optimisticReposted, handleLike, handleRepost } = 94 95 useInteraction({ uri, cid, author, likeRkey, repostRkey }); 95 96 97 + const queueSongs = usePlaylistQueue(); 96 98 const createdAtDate = new Date(createdAt); 97 99 98 100 function handlePlay() { ··· 101 103 } else if (isCurrentSong) { 102 104 usePlayerStore.getState().resume(); 103 105 } else { 104 - usePlayerStore.getState().playSong({ 105 - uri, 106 - cid, 107 - rkey, 108 - title, 109 - coverArt, 110 - audio, 111 - duration, 112 - author, 113 - likeRkey, 114 - repostRkey, 115 - }); 106 + const queueIndex = 107 + queueSongs?.findIndex((song) => song.uri === uri) ?? -1; 108 + if (queueSongs && queueIndex !== -1) { 109 + usePlayerStore.getState().playFromQueue(queueSongs, queueIndex); 110 + } else { 111 + usePlayerStore.getState().playSong({ 112 + uri, 113 + cid, 114 + rkey, 115 + title, 116 + coverArt, 117 + audio, 118 + duration, 119 + author, 120 + }); 121 + } 116 122 } 117 123 } 118 124 ··· 131 137 <div className="flex flex-col gap-1"> 132 138 <Link 133 139 href={`/${author}/${rkey}`} 134 - className="text-xl px-0 font-semibold hover:underline" 140 + className={cn( 141 + "text-xl px-0 font-semibold hover:underline", 142 + isCurrentSong && "text-primary", 143 + )} 135 144 > 136 145 {title} 137 146 </Link>
+2 -12
apps/web/src/hooks/use-interaction.ts
··· 1 1 "use client"; 2 2 3 3 import { useOptimistic, useTransition } from "react"; 4 - import { usePlayerStore } from "@/stores/player-store"; 5 - import { getRkeyFromUri } from "@/lib/atproto"; 6 4 import { 7 5 likeAction, 8 6 unlikeAction, ··· 32 30 const [optimisticReposted, setOptimisticReposted] = useOptimistic( 33 31 repostRkey !== null, 34 32 ); 35 - const currentSong = usePlayerStore((store) => store.currentSong); 36 - const isCurrentSong = currentSong?.rkey === getRkeyFromUri(uri); 37 33 38 34 function handleLike() { 39 35 startTransition(async () => { ··· 41 37 setOptimisticLiked(false); 42 38 if (likeRkey) { 43 39 await unlikeAction(likeRkey, author); 44 - if (isCurrentSong) usePlayerStore.getState().setLikeRkey(null); 45 40 } 46 41 } else { 47 42 if (!cid) return; 48 43 setOptimisticLiked(true); 49 - const newRkey = await likeAction(uri, cid, author); 50 - if (isCurrentSong) 51 - usePlayerStore.getState().setLikeRkey(newRkey ?? null); 44 + await likeAction(uri, cid, author); 52 45 } 53 46 }); 54 47 } ··· 59 52 setOptimisticReposted(false); 60 53 if (repostRkey) { 61 54 await unrepostAction(repostRkey, author); 62 - if (isCurrentSong) usePlayerStore.getState().setRepostRkey(null); 63 55 } 64 56 } else { 65 57 if (!cid) return; 66 58 setOptimisticReposted(true); 67 - const newRkey = await repostAction(uri, cid, author); 68 - if (isCurrentSong) 69 - usePlayerStore.getState().setRepostRkey(newRkey ?? null); 59 + await repostAction(uri, cid, author); 70 60 } 71 61 }); 72 62 }
+93 -20
apps/web/src/stores/player-store.ts
··· 1 1 import { create } from "zustand"; 2 2 3 - interface PlayerSong { 3 + export interface PlayerSong { 4 4 uri: string; 5 5 cid?: string; 6 6 rkey: string; ··· 9 9 audio: string; 10 10 duration: number; 11 11 author: string; 12 - likeRkey: string | null; 13 - repostRkey: string | null; 14 12 } 15 13 16 14 interface PlayerState { 17 15 currentSong: PlayerSong | null; 18 16 isPlaying: boolean; 17 + queue: PlayerSong[]; 18 + currentIndex: number; 19 + history: number[]; 20 + isShuffled: boolean; 19 21 playSong: (song: PlayerSong) => void; 22 + playFromQueue: (songs: PlayerSong[], startIndex: number) => void; 23 + next: () => void; 24 + previous: () => void; 25 + toggleShuffle: () => void; 20 26 pause: () => void; 21 27 resume: () => void; 22 28 stop: () => void; 23 - setLikeRkey: (rkey: string | null) => void; 24 - setRepostRkey: (rkey: string | null) => void; 25 29 } 26 30 27 - export const usePlayerStore = create<PlayerState>((set) => ({ 31 + export const usePlayerStore = create<PlayerState>((set, get) => ({ 28 32 currentSong: null, 29 33 isPlaying: false, 30 - playSong: (song) => set({ currentSong: song, isPlaying: true }), 34 + queue: [], 35 + currentIndex: -1, 36 + history: [], 37 + isShuffled: false, 38 + 39 + playSong: (song) => 40 + set({ 41 + currentSong: song, 42 + isPlaying: true, 43 + queue: [], 44 + currentIndex: -1, 45 + history: [], 46 + }), 47 + 48 + playFromQueue: (songs, startIndex) => 49 + set({ 50 + queue: songs, 51 + currentIndex: startIndex, 52 + currentSong: songs[startIndex], 53 + isPlaying: true, 54 + history: [startIndex], 55 + }), 56 + 57 + next: () => { 58 + const { queue, currentIndex, isShuffled, history } = get(); 59 + if (queue.length === 0) { 60 + get().stop(); 61 + return; 62 + } 63 + 64 + let nextIndex: number; 65 + if (isShuffled) { 66 + if (queue.length === 1) { 67 + nextIndex = 0; 68 + } else { 69 + do { 70 + nextIndex = Math.floor(Math.random() * queue.length); 71 + } while (nextIndex === currentIndex); 72 + } 73 + } else { 74 + nextIndex = (currentIndex + 1) % queue.length; 75 + } 76 + 77 + set({ 78 + currentIndex: nextIndex, 79 + currentSong: queue[nextIndex], 80 + isPlaying: true, 81 + history: [...history, nextIndex], 82 + }); 83 + }, 84 + 85 + previous: () => { 86 + const { queue, history } = get(); 87 + if (queue.length === 0) return; 88 + 89 + const newHistory = [...history]; 90 + newHistory.pop(); // remove current 91 + 92 + let prevIndex: number; 93 + if (newHistory.length > 0) { 94 + prevIndex = newHistory[newHistory.length - 1]!; 95 + } else { 96 + prevIndex = queue.length - 1; 97 + newHistory.push(prevIndex); 98 + } 99 + 100 + set({ 101 + currentIndex: prevIndex, 102 + currentSong: queue[prevIndex], 103 + isPlaying: true, 104 + history: newHistory, 105 + }); 106 + }, 107 + 108 + toggleShuffle: () => set((state) => ({ isShuffled: !state.isShuffled })), 31 109 pause: () => set({ isPlaying: false }), 32 110 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 - })), 111 + stop: () => 112 + set({ 113 + currentSong: null, 114 + isPlaying: false, 115 + queue: [], 116 + currentIndex: -1, 117 + history: [], 118 + }), 46 119 }));