1// What a listener played lately (profile.mjs's playedLately): the you tab's 2// "recently played" for the signed-in listener, and a profile's "played 3// lately" (Profile.jsx), from the account's page (GET 4// /jev/data/accounts/<id>). Each take shows when it was played, its path 5// through the song's form and ▶ replay, its own replay link 6// (performance.mjs: a listener song's replays in the sandbox); a radio play 7// with no take shows when it started. Every song plays again as its card 8// plays it: a site song from this page's build, a listener's in the sandbox. 9import { useEffect, useState, useSyncExternalStore } from 'react'; 10import { account } from './account.mjs'; 11import { loadListenerSong as showListenerSong, playSong, readSongs } from './client.mjs'; 12import { LISTENER_PREFIX, loadListenerSong } from './listeners.mjs'; 13import { performancePath } from './performance.mjs'; 14import { loadProfile, playedLately } from './profile.mjs'; 15import { takeTime } from './takes.mjs'; 16 17const when = (ms) => new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); 18export const SHOWN = 20; 19 20// A song id's title: a site song's from this build, a listener song's as 21// the Worker named it, else what it is. 22function titleOf(entry, songs) { 23 if (entry.song.startsWith(LISTENER_PREFIX)) return entry.title ?? 'a listener’s song'; 24 return songs.find((s) => s.id === entry.song)?.title ?? entry.song; 25} 26 27export function PlayedList({ entries, context, empty }) { 28 const [error, setError] = useState(null); 29 const songs = readSongs(); 30 const editor = context.editorRef.current; 31 const play = async (songId) => { 32 setError(null); 33 if (songId.startsWith(LISTENER_PREFIX)) { 34 try { 35 await showListenerSong(editor, await loadListenerSong(songId.slice(LISTENER_PREFIX.length)), { play: true }); 36 } catch (e) { 37 setError(`that song could not be loaded: ${e.message}`); 38 } 39 return; 40 } 41 const song = songs.find((s) => s.id === songId); 42 if (song?.code) playSong(editor, song); 43 }; 44 if (!entries.length) return <p className="opacity-60">{empty}</p>; 45 return ( 46 <> 47 {error && <p className="text-red-400 text-xs">{error}</p>} 48 <ul className="space-y-1" data-testid="jev-played"> 49 {entries.map((e) => { 50 const playable = e.song.startsWith(LISTENER_PREFIX) || songs.some((s) => s.id === e.song && s.code); 51 return ( 52 <li key={e.take?.id ?? `${e.song}@${e.at}`} className="flex flex-wrap gap-x-2 items-baseline"> 53 <button 54 className="underline hover:opacity-60 text-left disabled:no-underline disabled:opacity-50" 55 disabled={!playable} 56 title={playable ? 'play it again, from the top' : 'no longer on the site'} 57 onClick={() => play(e.song)} 58 > 59 {titleOf(e, songs)} 60 </button> 61 <span className="opacity-60 text-xs">{e.take ? takeTime(e.take) : when(e.at)}</span> 62 {e.take && ( 63 <a 64 className="text-xs underline opacity-70 hover:opacity-100" 65 href={performancePath(e.song, e.take.id)} 66 title="replay this take: Jev's decisions as they were, with no Jev calls" 67 > 68 ▶ replay 69 </a> 70 )} 71 {e.take?.path && <span className="basis-full text-xs opacity-60 break-words">{e.take.path.join(' → ')}</span>} 72 </li> 73 ); 74 })} 75 </ul> 76 </> 77 ); 78} 79 80export function RecentlyPlayed({ context }) { 81 const { user } = useSyncExternalStore(account.subscribe, account.get, account.get); 82 const [entries, setEntries] = useState(null); 83 const [error, setError] = useState(null); 84 useEffect(() => { 85 if (!user) return setEntries(null); 86 let live = true; 87 setError(null); 88 loadProfile(user.id).then( 89 (a) => live && setEntries(playedLately(a.radio, a.takes, SHOWN)), 90 (e) => live && setError(e.message), 91 ); 92 return () => void (live = false); 93 }, [user?.id]); 94 if (!user) { 95 return <p className="opacity-70">Sign in, and what you play shows here, on every device, each take with its replay.</p>; 96 } 97 return ( 98 <div data-testid="jev-history"> 99 <h4 className="font-bold mb-1">recently played</h4> 100 {error && <p className="text-red-400 text-xs">could not read it: {error}</p>} 101 {entries === null && !error && <p className="opacity-60">reading…</p>} 102 {entries && ( 103 <PlayedList entries={entries} context={context} empty="Nothing yet: play a song, turn on autoplay, or press ⏭." /> 104 )} 105 </div> 106 ); 107}