import { useState, useEffect, useRef, useCallback, createContext, useContext } from "react"; const API = "/api/spotify"; // Ambil link download, dengan retry otomatis kalau backend belum siap / gagal. // Dipisah jadi fungsi sendiri (sama seperti pola di music.html) supaya loadTrack tetap ringkas. async function fetchDownloadLink(spotifyUrl) { const maxAttempts = 3; let lastError = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const res = await fetch(`${API}?endpoint=spotify-download&q=${encodeURIComponent(spotifyUrl)}`); const json = await res.json(); if (json?.status && json.result?.dl) { return json.result.dl; } lastError = new Error("Link download tidak tersedia"); } catch (e) { lastError = e; console.warn(`Percobaan ${attempt} gagal:`, e.message); } if (attempt < maxAttempts) { await new Promise((r) => setTimeout(r, attempt * 1000)); // 1s, lalu 2s } } throw lastError || new Error("Gagal mendapatkan link download"); } /* ====================== Player Context (diperbarui) ====================== */ const PlayerContext = createContext(null); function PlayerProvider({ children }) { const [track, setTrack] = useState(null); const [loading, setLoading] = useState(false); const [playing, setPlaying] = useState(false); const [ready, setReady] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [sheetOpen, setSheetOpen] = useState(false); const [queue, setQueue] = useState([]); const [queueIndex, setQueueIndex] = useState(-1); const audioRef = useRef(null); const retryCountRef = useRef(0); const stallTimeoutRef = useRef(null); const previewRetryRef = useRef(false); const loadTrack = useCallback(async (trackId, knownMeta = null, isRetry = false) => { if (!isRetry) { retryCountRef.current = 0; previewRetryRef.current = false; } setLoading(true); try { // Kalau metadata belum diketahui, ambil DULU (sekuensial, aman dari tabrakan backend) let t = knownMeta; if (!t) { const metaJson = await fetch(`${API}?endpoint=spotify-track&q=${encodeURIComponent(trackId)}`).then((r) => r.json()); if (!metaJson?.status || !metaJson.result) { alert("Gagal memuat metadata lagu."); setLoading(false); return; } t = metaJson.result; } // Baru ambil link download SETELAH metadata selesai — download dulu, baru diputar const spotifyUrl = `https://open.spotify.com/track/${trackId}`; const dlUrl = await fetchDownloadLink(spotifyUrl); setTrack({ id: t.id || trackId, title: t.name || t.title, artist: t.artists?.map((a) => a.name).join(", ") || "Tidak diketahui", cover: t.album?.images?.[0]?.url || t.cover || null, album: t.album?.name || t.album || null, dl: dlUrl, }); } catch (err) { console.error(err); alert(`Gagal memuat lagu: ${err.message}`); setLoading(false); } }, []); const play = useCallback((trackId, meta = null) => { setQueue([{ id: trackId, meta }]); setQueueIndex(0); loadTrack(trackId, meta); }, [loadTrack]); const playQueue = useCallback((tracks, startIndex = 0) => { if (!tracks || !tracks.length) return; const entries = tracks.map((t) => ({ id: t.id, meta: t })); setQueue(entries); setQueueIndex(startIndex); loadTrack(entries[startIndex].id, entries[startIndex].meta); }, [loadTrack]); const next = useCallback(() => { const nextIdx = queueIndex + 1; if (nextIdx < queue.length) { setQueueIndex(nextIdx); loadTrack(queue[nextIdx].id, queue[nextIdx].meta); } }, [queue, queueIndex, loadTrack]); const previous = useCallback(() => { const prevIdx = queueIndex - 1; if (prevIdx >= 0) { setQueueIndex(prevIdx); loadTrack(queue[prevIdx].id, queue[prevIdx].meta); } }, [queue, queueIndex, loadTrack]); const hasNext = queueIndex >= 0 && queueIndex < queue.length - 1; const hasPrevious = queueIndex > 0; const togglePlay = useCallback(() => { if (!audioRef.current) return; if (audioRef.current.paused) { audioRef.current.play().catch(err => console.error(err)); } else { audioRef.current.pause(); } }, []); const seek = useCallback((time) => { if (!audioRef.current) return; audioRef.current.currentTime = time; setCurrentTime(time); }, []); const stop = useCallback(() => { if (stallTimeoutRef.current) clearTimeout(stallTimeoutRef.current); if (!audioRef.current) return; audioRef.current.pause(); audioRef.current.currentTime = 0; setTrack(null); setLoading(false); setPlaying(false); setReady(false); setCurrentTime(0); setDuration(0); setSheetOpen(false); setQueue([]); setQueueIndex(-1); }, []); // ====== Media Session API ====== useEffect(() => { if (!track) return; if (!("mediaSession" in navigator) || typeof MediaMetadata === "undefined") return; const setHandler = (action, handler) => { try { navigator.mediaSession.setActionHandler(action, handler); } catch (e) {} }; try { navigator.mediaSession.metadata = new MediaMetadata({ title: track.title, artist: track.artist, album: track.album || "Piringan", artwork: track.cover ? [64, 96, 128, 192, 256, 384, 512].map((size) => ({ src: track.cover, sizes: `${size}x${size}`, type: "image/jpeg" })) : [], }); } catch (e) { console.warn("Media Session metadata gagal diset:", e); } setHandler("play", () => { audioRef.current?.play().catch(() => {}); }); setHandler("pause", () => { audioRef.current?.pause(); }); setHandler("stop", () => stop()); setHandler("previoustrack", hasPrevious ? () => previous() : null); setHandler("nexttrack", hasNext ? () => next() : null); setHandler("seekto", (details) => { if (audioRef.current && typeof details.seekTime === "number") { audioRef.current.currentTime = details.seekTime; setCurrentTime(details.seekTime); } }); return () => { ["play", "pause", "stop", "previoustrack", "nexttrack", "seekto"].forEach((action) => { try { navigator.mediaSession.setActionHandler(action, null); } catch (e) {} }); }; }, [track?.id, hasNext, hasPrevious, next, previous, stop]); useEffect(() => { try { if (!("mediaSession" in navigator)) return; navigator.mediaSession.playbackState = playing ? "playing" : "paused"; } catch (e) {} }, [playing]); useEffect(() => { if (!("mediaSession" in navigator) || !("setPositionState" in navigator.mediaSession)) return; if (!ready || !duration) return; try { navigator.mediaSession.setPositionState({ duration, playbackRate: 1, position: Math.min(currentTime, duration) }); } catch (e) {} }, [currentTime, duration, ready]); // ====== Pemasangan listener + retry & stall-timeout + deteksi preview ====== useEffect(() => { const audio = audioRef.current; if (!audio || !track) return; const clearStallTimeout = () => { if (stallTimeoutRef.current) { clearTimeout(stallTimeoutRef.current); stallTimeoutRef.current = null; } }; const handleFailure = () => { if (retryCountRef.current < 1) { retryCountRef.current += 1; console.warn("Audio gagal/macet, mencoba ambil link baru... (percobaan ke-", retryCountRef.current, ")"); loadTrack(track.id, { id: track.id, name: track.title, artists: [{ name: track.artist }], album: { name: track.album, images: track.cover ? [{ url: track.cover }] : [] } }, true); } else { console.error("Audio tetap gagal setelah retry."); alert("Tidak dapat memutar lagu setelah beberapa percobaan. Coba lagi nanti."); setLoading(false); setReady(false); } }; const onCanPlay = () => { clearStallTimeout(); setReady(true); setLoading(false); audio.play().then(() => setPlaying(true)).catch(() => setPlaying(false)); }; const onPlay = () => setPlaying(true); const onPause = () => setPlaying(false); const onEnded = () => { const nextIdx = queueIndex + 1; if (nextIdx < queue.length) { setQueueIndex(nextIdx); loadTrack(queue[nextIdx].id, queue[nextIdx].meta); } else { stop(); } }; const onTimeUpdate = () => setCurrentTime(audio.currentTime || 0); const onLoadedMeta = () => { const dur = audio.duration || 0; setDuration(dur); // Deteksi kemungkinan preview URL (biasanya ~29-30 detik) if (dur > 0 && dur < 35 && !previewRetryRef.current) { previewRetryRef.current = true; console.warn("Terdeteksi kemungkinan link preview (durasi pendek), mencoba ambil ulang link full..."); loadTrack(track.id, { id: track.id, name: track.title, artists: [{ name: track.artist }], album: { name: track.album, images: track.cover ? [{ url: track.cover }] : [] } }, true); } }; const onError = () => { clearStallTimeout(); handleFailure(); }; audio.addEventListener("canplay", onCanPlay); audio.addEventListener("play", onPlay); audio.addEventListener("pause", onPause); audio.addEventListener("ended", onEnded); audio.addEventListener("timeupdate", onTimeUpdate); audio.addEventListener("loadedmetadata", onLoadedMeta); audio.addEventListener("error", onError); audio.src = track.dl; audio.load(); setReady(false); setCurrentTime(0); setLoading(true); clearStallTimeout(); stallTimeoutRef.current = setTimeout(() => { if (audio.readyState < 3) { console.warn("Audio stall timeout tercapai."); handleFailure(); } }, 12000); return () => { clearStallTimeout(); audio.removeEventListener("canplay", onCanPlay); audio.removeEventListener("play", onPlay); audio.removeEventListener("pause", onPause); audio.removeEventListener("ended", onEnded); audio.removeEventListener("timeupdate", onTimeUpdate); audio.removeEventListener("loadedmetadata", onLoadedMeta); audio.removeEventListener("error", onError); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [track?.dl]); return ( {children} ); } function usePlayer() { return useContext(PlayerContext); } /* ====================== Playlist Context (lokal, tersimpan di perangkat) ====================== */ const PlaylistContext = createContext(null); const PLAYLIST_STORE_KEY = "piringan_playlists_v1"; function loadPlaylists() { try { const raw = localStorage.getItem(PLAYLIST_STORE_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } function PlaylistProvider({ children }) { const [playlists, setPlaylists] = useState(loadPlaylists); useEffect(() => { try { localStorage.setItem(PLAYLIST_STORE_KEY, JSON.stringify(playlists)); } catch (e) { console.warn("Tidak bisa menyimpan playlist:", e); } }, [playlists]); const createPlaylist = useCallback((name) => { const clean = (name || "").trim(); if (!clean) return null; const id = `pl_${Date.now()}`; setPlaylists((prev) => [...prev, { id, name: clean, tracks: [], createdAt: Date.now() }]); return id; }, []); const deletePlaylist = useCallback((id) => { setPlaylists((prev) => prev.filter((p) => p.id !== id)); }, []); const addTrackToPlaylist = useCallback((playlistId, track) => { setPlaylists((prev) => prev.map((p) => { if (p.id !== playlistId) return p; if (p.tracks.some((t) => t.id === track.id)) return p; // sudah ada return { ...p, tracks: [...p.tracks, track] }; }) ); }, []); const removeTrackFromPlaylist = useCallback((playlistId, trackId) => { setPlaylists((prev) => prev.map((p) => (p.id === playlistId ? { ...p, tracks: p.tracks.filter((t) => t.id !== trackId) } : p)) ); }, []); return ( {children} ); } function usePlaylists() { return useContext(PlaylistContext); } /* ====================== Install Prompt Context (Add to Home Screen) ====================== */ const InstallContext = createContext(null); function InstallProvider({ children }) { const [installEvent, setInstallEvent] = useState(null); const [installed, setInstalled] = useState(false); const [platform, setPlatform] = useState("unknown"); // "android" | "ios" | "desktop" | "unknown" useEffect(() => { const ua = navigator.userAgent || ""; const isIos = /iphone|ipad|ipod/i.test(ua); const isStandalone = window.matchMedia?.("(display-mode: standalone)")?.matches || window.navigator.standalone === true; setPlatform(isIos ? "ios" : /android/i.test(ua) ? "android" : "desktop"); if (isStandalone) setInstalled(true); const onBeforeInstallPrompt = (e) => { e.preventDefault(); setInstallEvent(e); }; const onAppInstalled = () => { setInstalled(true); setInstallEvent(null); }; window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); window.addEventListener("appinstalled", onAppInstalled); return () => { window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); window.removeEventListener("appinstalled", onAppInstalled); }; }, []); const promptInstall = useCallback(async () => { if (!installEvent) return false; installEvent.prompt(); const choice = await installEvent.userChoice; setInstallEvent(null); if (choice.outcome === "accepted") setInstalled(true); return choice.outcome === "accepted"; }, [installEvent]); return ( {children} ); } function useInstallPrompt() { return useContext(InstallContext); } // Ubah objek trek (dari katalog ATAU dari player yang sedang berjalan) menjadi bentuk // ringkas yang konsisten untuk disimpan di playlist lokal. function toPlaylistTrack(t) { const isPlayerShape = typeof t.album !== "object"; return { id: t.id, name: t.name || t.title, artists: t.artists || (t.artist ? [{ name: t.artist }] : []), album: isPlayerShape ? (t.cover ? { name: t.album || null, images: [{ url: t.cover, width: 500 }] } : null) : t.album, duration_ms: t.duration_ms, explicit: t.explicit, }; } /* ====================== Modal: Tambah ke Playlist ====================== */ function AddToPlaylistModal({ track, onClose }) { const { playlists, createPlaylist, addTrackToPlaylist } = usePlaylists(); const [newName, setNewName] = useState(""); if (!track) return null; const handleAdd = (playlistId) => { addTrackToPlaylist(playlistId, toPlaylistTrack(track)); onClose(); }; const handleCreateAndAdd = (e) => { e.preventDefault(); const id = createPlaylist(newName); if (id) handleAdd(id); }; return (
e.stopPropagation()}>

Tambah ke Playlist

{track.name || track.title}

{playlists.length > 0 && (
{playlists.map((p) => { const already = p.tracks.some((t) => t.id === track.id); return ( ); })}
)}
setNewName(e.target.value)} />
); } /* ====================== helpers ====================== */ function fmtDuration(ms) { if (!ms && ms !== 0) return "--:--"; const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const r = s % 60; return `${m}:${r.toString().padStart(2, "0")}`; } function fmtNumber(n) { if (n === null || n === undefined) return "0"; return n.toLocaleString("id-ID"); } function pickImage(images, minWidth = 300) { if (!images || !images.length) return null; const withUrl = images.filter((i) => i && i.url); if (!withUrl.length) return null; const sized = withUrl.filter((i) => typeof i.width === "number"); if (!sized.length) return withUrl[0].url; const sorted = [...sized].sort((a, b) => a.width - b.width); const fit = sorted.find((i) => i.width >= minWidth); return (fit || sorted[sorted.length - 1]).url; } function artistNames(artists) { if (!artists || !artists.length) return "Tidak diketahui"; return artists.map((a) => a.name).filter(Boolean).join(", "); } async function apiGet(endpoint, q) { const url = `${API}?endpoint=${encodeURIComponent(endpoint)}&q=${encodeURIComponent(q)}`; const res = await fetch(url); const json = await res.json(); if (!json || !json.status || !json.result) { throw new Error(json?.error || "Data tidak ditemukan di katalog."); } return json.result; } function useApiFetch(endpoint, query) { const [state, setState] = useState({ status: "idle" }); useEffect(() => { if (!query) return; let cancelled = false; setState({ status: "loading" }); apiGet(endpoint, query) .then((data) => { if (!cancelled) setState({ status: "success", data }); }) .catch((err) => { if (!cancelled) setState({ status: "error", error: err.message || "Gagal terhubung ke katalog.", }); }); return () => { cancelled = true; }; }, [endpoint, query]); return state; } /* ====================== Vinyl Disc & Atoms ====================== */ function VinylDisc({ size = 40, spinning = false, cover = null }) { return (
{[44, 38, 32, 26].map((r) => ( ))} {cover ? ( <> ) : ( )}
); } function Chip({ children, tone = "muted" }) { return {children}; } function CatalogLabel({ n, children }) { return (
{n} {children}
); } function LoadingState({ label = "Memutar ulang katalog…" }) { return (

{label}

); } function ErrorState({ message }) { return (

Gagal memuat. {message}

); } function EmptyState({ message }) { return (

{message}

); } /* ====================== Cards & Crate ====================== */ function EntityCard({ image, title, subtitle, meta, round, badge, onClick }) { return ( ); } function CrateRow({ n, title, items, render }) { if (!items || !items.length) return null; return (
{title}
{items.map(render)}
); } /* ====================== Search ====================== */ function SearchBar({ value, onChange, onSubmit, autoFocus }) { return (
{ e.preventDefault(); onSubmit(value.trim()); }}> onChange(e.target.value)} />
); } function FilterChips({ value, onChange, options }) { return (
{options.map((opt) => ( ))}
); } /* ====================== Views ====================== */ function HomeView({ nav }) { return (

Buka sampul, putar sesuatu.

Telusuri katalog Spotify — lagu, album, artis, dan playlist — disusun seperti krat piringan hitam.

Coba: {["Melukis Senja", "Fynn Jamal", "Budi Doremi"].map((s) => ( ))}
); } function ResultsCrates({ data, query, nav }) { const anyResults = (data.top_results?.length || data.tracks?.length || data.albums?.length || data.artists?.length || data.playlists?.length || data.episodes?.length || data.podcasts?.length || data.genres?.length || data.users?.length); if (!anyResults) return ; return (
{ const clickable = ["Track", "Album", "Artist", "Playlist"].includes(item.type); return nav(item.type.toLowerCase(), item.id) : undefined} />; }} /> nav("track", t.id)} />} /> nav("album", a.id)} />} /> nav("artist", a.id)} />} /> nav("playlist", p.id)} />} /> } /> } /> } /> } />
); } function ResultRow({ image, round, title, subtitle, meta, badge, onClick, trailing }) { return (
{image ? :
}
{title}{badge && {badge}}
{subtitle &&
{subtitle}
}
{meta &&
{meta}
} {trailing}
); } const SEARCH_FILTERS = [ { key: "all", label: "Semua" }, { key: "tracks", label: "Lagu" }, { key: "albums", label: "Album" }, { key: "artists", label: "Artis" }, { key: "playlists", label: "Playlist" }, ]; function SearchView({ nav }) { const [input, setInput] = useState(""); const [query, setQuery] = useState(null); const [filter, setFilter] = useState("all"); const [modalTrack, setModalTrack] = useState(null); const { play } = usePlayer(); const state = useApiFetch("spotify-search", query); const rows = []; if (state.status === "success") { const d = state.data; const wantTracks = filter === "all" || filter === "tracks"; const wantAlbums = filter === "all" || filter === "albums"; const wantArtists = filter === "all" || filter === "artists"; const wantPlaylists = filter === "all" || filter === "playlists"; if (wantTracks) (d.tracks || []).forEach((t) => rows.push({ kind: "track", data: t })); if (wantAlbums) (d.albums || []).forEach((a) => rows.push({ kind: "album", data: a })); if (wantArtists) (d.artists || []).forEach((a) => rows.push({ kind: "artist", data: a })); if (wantPlaylists) (d.playlists || []).forEach((p) => rows.push({ kind: "playlist", data: p })); } return (

Cari

q && setQuery(q)} autoFocus /> {!query && (
Coba: {["Melukis Senja", "Fynn Jamal", "Budi Doremi"].map((s) => ( ))}
)} {query && state.status === "loading" && } {query && state.status === "error" && } {query && state.status === "success" && rows.length === 0 && ( )} {query && state.status === "success" && rows.length > 0 && (
{rows.map((r, i) => { if (r.kind === "track") { const t = r.data; return ( nav("track", t.id)} trailing={
} /> ); } if (r.kind === "album") { const a = r.data; return ( nav("album", a.id)} /> ); } if (r.kind === "artist") { const a = r.data; return nav("artist", a.id)} />; } const p = r.data; return ( nav("playlist", p.id)} /> ); })}
)} {modalTrack && setModalTrack(null)} />}
); } function TrackDetailView({ id, nav }) { const { play } = usePlayer(); const [showAddModal, setShowAddModal] = useState(false); const state = useApiFetch("spotify-track", id); if (state.status === "loading" || state.status === "idle") return ; if (state.status === "error") return ; const t = state.data; const cover = pickImage(t.album?.images, 500); return (
{cover ? :
}
Lagu

{t.name}

{artistNames(t.artists)}

A1 {t.name} {t.explicit && E} {fmtDuration(t.duration_ms)}
Album
Diputar{fmtNumber(t.playcount)}×
Nomor trek{t.track_number || "—"}
{t.artists.map((a) => )}
{showAddModal && setShowAddModal(false)} />}
); } function AlbumDetailView({ id, nav }) { const state = useApiFetch("spotify-album", `spotify:album:${id}`); if (state.status === "loading" || state.status === "idle") return ; if (state.status === "error") return ; const a = state.data; const cover = pickImage(a.images, 500); return (
{cover ? :
}
{a.type || "Album"}

{a.name}

{artistNames(a.artists)}

Rilis{a.release_date || "—"}
Label{a.label || "—"}
Trek{a.tracks?.length || 0}
{a.artists.map((ar) => )}
Daftar Trek {a.tracks.map((t, i) => ( ))}
{a.copyrights?.length > 0 &&

{a.copyrights.map((c) => c.text).join(" · ")}

}
); } function ArtistDetailView({ id, nav }) { const state = useApiFetch("spotify-artist", id); if (state.status === "loading" || state.status === "idle") return ; if (state.status === "error") return ; const a = state.data; const avatar = pickImage(a.images, 400); return (
{avatar ? :
}

{a.name}

{a.verified && Terverifikasi}
Pengikut{fmtNumber(a.statistics?.followers)}
Pendengar bulanan{fmtNumber(a.statistics?.monthly_listeners)}
Lagu Populer {a.top_tracks.map((t, i) => ( ))}
); } function PlaylistDetailView({ id, nav }) { const state = useApiFetch("spotify-playlist", id); if (state.status === "loading" || state.status === "idle") return ; if (state.status === "error") return ; const p = state.data; const cover = pickImage(p.images, 500); return (
{cover ? :
}
Playlist

{p.name}

{p.description &&

{p.description.replace(/<[^>]+>/g, "")}

}
Kurator{p.owner?.display_name || "—"}
Trek{p.tracks?.length || 0}
Isi Playlist {p.tracks.map((t, i) => ( ))}
); } /* ====================== Library (playlist lokal) ====================== */ function LibraryView({ nav }) { const { playlists, createPlaylist } = usePlaylists(); const [creating, setCreating] = useState(false); const [name, setName] = useState(""); const handleCreate = (e) => { e.preventDefault(); const id = createPlaylist(name); setName(""); setCreating(false); if (id) nav("mylist", id); }; return (

Library

{!creating ? ( ) : (
setName(e.target.value)} />
)} {playlists.length === 0 ? ( ) : (
{playlists.map((p) => { const cover = pickImage(p.tracks[0]?.album?.images); return ( nav("mylist", p.id)} /> ); })}
)}
); } function MyPlaylistView({ id, nav }) { const { playlists, deletePlaylist, removeTrackFromPlaylist } = usePlaylists(); const { play, playQueue } = usePlayer(); const playlist = playlists.find((p) => p.id === id); if (!playlist) return ; const cover = pickImage(playlist.tracks[0]?.album?.images, 500); return (
{cover ? :
}
Playlist Saya

{playlist.name}

{playlist.tracks.length} lagu

{playlist.tracks[0] && ( )}
Isi Playlist {playlist.tracks.length === 0 && } {playlist.tracks.map((t, i) => (
{pickImage(t.album?.images) && } {fmtDuration(t.duration_ms)}
))}
); } /* ====================== Info ====================== */ function InfoView() { const { canInstall, installed, platform, promptInstall } = useInstallPrompt(); return (

Info

PIRINGAN

Katalog metadata musik — lagu, album, artis, dan playlist disusun seperti krat piringan hitam.

{installed ? (
Piringan sudah terpasang di perangkat kamu.
) : canInstall ? ( ) : platform === "ios" ? (

Pasang di iPhone/iPad

Ketuk tombol Bagikan di Safari, lalu pilih "Tambah ke Layar Utama".

) : (

Pasang Piringan

Buka menu browser (⋮) lalu pilih "Instal aplikasi" atau "Tambahkan ke layar utama".

)}
  • Gunakan tab Search untuk menelusuri katalog.
  • Tekan tombol + pada sebuah lagu untuk menambahkannya ke playlist.
  • Kelola playlist kamu lewat tab Library.
  • Ketuk mini player untuk membuka tampilan Now Playing.

Piringan · katalog metadata, bukan pemutar. Musiknya tetap ada di rumahnya.

); } /* ====================== Card Maker ====================== */ function CardMakerView({ presetId, nav }) { const [input, setInput] = useState(""); const [query, setQuery] = useState(null); const [pickedId, setPickedId] = useState(presetId || null); const searchState = useApiFetch("spotify-search", query); const trackState = useApiFetch("spotify-track", pickedId); const canvasRef = useRef(null); const [bgColor, setBgColor] = useState("#2A241E"); useEffect(() => { if (trackState.status !== "success") return; const cover = pickImage(trackState.data.album?.images, 300); if (!cover) return; const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { try { const c = document.createElement("canvas"); c.width = 16; c.height = 16; const cx = c.getContext("2d"); cx.drawImage(img, 0, 0, 16, 16); const d = cx.getImageData(0, 0, 16, 16).data; let r = 0, g = 0, b = 0, n = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; n++; } r = Math.floor(r / n * 0.55); g = Math.floor(g / n * 0.55); b = Math.floor(b / n * 0.55); setBgColor(`rgb(${r},${g},${b})`); } catch (e) { setBgColor("#2A241E"); } }; img.onerror = () => setBgColor("#2A241E"); img.src = cover; }, [trackState.status, trackState.data]); const downloadCard = useCallback(() => { const t = trackState.data; if (!t) return; const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); const W = 640, H = 840; canvas.width = W; canvas.height = H; ctx.fillStyle = bgColor; ctx.fillRect(0, 0, W, H); const cardX = 40, cardY = 40, cardW = W - 80, cardH = H - 80, r = 28; ctx.fillStyle = "rgba(20,17,14,0.72)"; roundRect(ctx, cardX, cardY, cardW, cardH, r); ctx.fill(); const drawRest = (coverImg) => { const size = 400; const cx = cardX + 40, cy = cardY + 40; if (coverImg) { roundRect(ctx, cx, cy, size, size, 12); ctx.save(); ctx.clip(); ctx.drawImage(coverImg, cx, cy, size, size); ctx.restore(); } else { ctx.fillStyle = "#E8532B"; roundRect(ctx, cx, cy, size, size, 12); ctx.fill(); } ctx.fillStyle = "#F3E9D8"; ctx.font = "700 34px Georgia, serif"; wrapCanvasText(ctx, t.name, cx, cy + size + 56, size, 40); ctx.fillStyle = "rgba(243,233,216,0.72)"; ctx.font = "500 22px sans-serif"; ctx.fillText(artistNames(t.artists), cx, cy + size + 100); ctx.fillStyle = "rgba(243,233,216,0.5)"; ctx.font = "600 16px monospace"; ctx.fillText("PIRINGAN · " + fmtDuration(t.duration_ms), cx, cardY + cardH - 30); const link = document.createElement("a"); link.download = `${t.name.replace(/[^a-z0-9]/gi, "_")}-piringan.png`; link.href = canvas.toDataURL("image/png"); link.click(); }; const cover = pickImage(t.album?.images, 400); if (!cover) { drawRest(null); return; } const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => drawRest(img); img.onerror = () => drawRest(null); img.src = cover; }, [trackState.data, bgColor]); return (

Bikin kartu bagikan

Cari lagu, lalu unduh kartunya sebagai gambar.

q && setQuery(q)} />
{query && searchState.status === "loading" && } {query && searchState.status === "success" && (
{(searchState.data.tracks || []).slice(0, 8).map((t) => ( setPickedId(t.id)} /> ))}
)} {pickedId && trackState.status === "loading" && } {pickedId && trackState.status === "success" && (
{pickImage(trackState.data.album?.images, 300) ? :
}
{trackState.data.name}
{artistNames(trackState.data.artists)}
PIRINGAN · {fmtDuration(trackState.data.duration_ms)}
)}
); } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function wrapCanvasText(ctx, text, x, y, maxWidth, lineHeight) { const words = (text || "").split(" "); let line = ""; let cy = y; const lines = []; for (const w of words) { const test = line + w + " "; if (ctx.measureText(test).width > maxWidth && line) { ctx.fillText(line, x, cy); lines.push(line); line = w + " "; cy += lineHeight; } else { line = test; } } ctx.fillText(line, x, cy); lines.push(line); return lines; } /* ====================== Mini Player (diperbarui) ====================== */ function MiniPlayer() { const { track, loading, playing, ready, togglePlay, stop, setSheetOpen } = usePlayer(); // Jika tidak ada track, tidak tampil if (!track) return null; return (
{loading ? ( Memuat… ) : ( <> )}
); } /* ====================== Now Playing (tampilan penuh) ====================== */ function NowPlayingSheet() { const { track, sheetOpen, setSheetOpen, playing, ready, loading, currentTime, duration, togglePlay, stop, seek, next, previous, hasNext, hasPrevious } = usePlayer(); const [showAddModal, setShowAddModal] = useState(false); if (!sheetOpen || !track) return null; const pct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; return (
SEDANG DIPUTAR {track.artist}
{track.cover ? :
}

{track.title}

{track.artist}

seek(parseFloat(e.target.value))} disabled={!ready} style={{ "--pct": `${pct}%` }} />
{fmtDuration(currentTime * 1000)} {fmtDuration((duration || 0) * 1000)}
{showAddModal && setShowAddModal(false)} />}
); } /* ====================== Navigasi Bawah ====================== */ const NAV_ITEMS = [ { key: "home", label: "Home", icon: "fa-house" }, { key: "search", label: "Search", icon: "fa-magnifying-glass" }, { key: "card", label: "Card", icon: "fa-id-card" }, { key: "library", label: "Library", icon: "fa-bars-staggered" }, { key: "info", label: "Info", icon: "fa-circle-info" }, ]; function BottomNav({ current, nav }) { return ( ); } /* ====================== App Shell ====================== */ const TAB_OF = { home: "home", search: "search", card: "card", library: "library", info: "info", mylist: "library" }; const TAB_NAMES = ["home", "search", "card", "library", "info"]; function AppShell() { const { track } = usePlayer(); const [view, setView] = useState({ name: "home" }); const [history, setHistory] = useState([]); useEffect(() => { if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch((err) => console.warn("SW gagal daftar:", err)); } }, []); const nav = (name, id) => { if (!id && !["home", "card", "search", "library", "info"].includes(name)) return; window.scrollTo({ top: 0, behavior: "smooth" }); setHistory((h) => (TAB_NAMES.includes(name) ? [] : [...h, view])); setView({ name, id }); }; const goBack = () => { if (!history.length) { nav("home"); return; } const prev = history[history.length - 1]; setHistory((h) => h.slice(0, -1)); window.scrollTo({ top: 0, behavior: "smooth" }); setView(prev); }; const activeTab = TAB_OF[view.name] || null; const hasMiniPlayer = !!track; const isDetailView = !TAB_NAMES.includes(view.name); return (
{isDetailView && ( )} {view.name === "home" && } {view.name === "search" && } {view.name === "library" && } {view.name === "mylist" && } {view.name === "info" && } {view.name === "track" && } {view.name === "album" && } {view.name === "artist" && } {view.name === "playlist" && } {view.name === "card" && }
); } export default function App() { return ( ); } /* ====================== Fonts & Styles ====================== */ function Fonts() { return ( <> ); } function Styles() { return ( ); }