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 (
!already && handleAdd(p.id)} disabled={already}>
{p.name}
{already ? "Sudah ada ✓" : `${p.tracks.length} lagu`}
);
})}
)}
);
}
/* ====================== 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 (
);
}
function ErrorState({ message }) {
return (
✕
Gagal memuat. {message}
);
}
function EmptyState({ message }) {
return (
);
}
/* ====================== Cards & Crate ====================== */
function EntityCard({ image, title, subtitle, meta, round, badge, onClick }) {
return (
{image ?
:
♪
}
{badge &&
{badge} }
{title}
{subtitle && {subtitle}
}
{meta && {meta}
}
);
}
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 (
);
}
function FilterChips({ value, onChange, options }) {
return (
{options.map((opt) => (
onChange(opt.key)}
>
{opt.label}
))}
);
}
/* ====================== Views ====================== */
function HomeView({ nav }) {
return (
Buka sampul, putar sesuatu .
Telusuri katalog Spotify — lagu, album, artis, dan playlist — disusun seperti krat piringan hitam.
nav("search")}> Mulai Cari
Coba:
{["Melukis Senja", "Fynn Jamal", "Budi Doremi"].map((s) => (
nav("search")}>{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) => (
{ setInput(s); setQuery(s); }}>{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={
{ e.stopPropagation(); play(t.id, t); }}
>
{ e.stopPropagation(); setModalTrack(t); }}
>
+
}
/>
);
}
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 nav("album", t.album?.id)}>{t.album?.name || "—"}
Diputar {fmtNumber(t.playcount)}×
Nomor trek {t.track_number || "—"}
{t.artists.map((a) => nav("artist", a.id)}>{a.name} )}
play(t.id, t)}> Putar Lagu
setShowAddModal(true)}>+ Tambah ke Playlist
nav("card", t.id)}>Bikin kartu bagikan ↗
{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) => nav("artist", ar.id)}>{ar.name} )}
Daftar Trek
{a.tracks.map((t, i) => (
nav("track", t.id)}>
{(i + 1).toString().padStart(2, "0")}
{t.name}
{t.explicit && E }
{fmtDuration(t.duration_ms)}
))}
{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) => (
nav("track", t.id)}>
{(i + 1).toString().padStart(2, "0")}
{pickImage(t.album?.images) && }
{t.name}
{fmtNumber(t.playcount)}×
))}
);
}
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) => (
nav("track", t.id)}>
{(i + 1).toString().padStart(2, "0")}
{pickImage(t.album?.images) && }
{t.name} — {artistNames(t.artists)}
{t.explicit && E }
{fmtDuration(t.duration_ms)}
))}
);
}
/* ====================== 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 ? (
setCreating(true)}>+ Buat Playlist Baru
) : (
)}
{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] && (
playQueue(playlist.tracks, 0)}> Putar Semua
)}
{ deletePlaylist(playlist.id); nav("library"); }}
>
Hapus Playlist
Isi Playlist
{playlist.tracks.length === 0 && }
{playlist.tracks.map((t, i) => (
playQueue(playlist.tracks, i)}>
{pickImage(t.album?.images) &&
}
nav("track", t.id)}>{t.name} — {artistNames(t.artists)}
{fmtDuration(t.duration_ms)}
removeTrackFromPlaylist(playlist.id, t.id)}>
))}
);
}
/* ====================== 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 ? (
Pasang ke Layar Utama
) : 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)}
Unduh sebagai gambar ↓
)}
);
}
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 (
setSheetOpen(true)}>
{track.cover ? : ♪
}
{track.title}
{track.artist}
{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 (
setSheetOpen(false)}>
SEDANG DIPUTAR
{track.artist}
setShowAddModal(true)}>+
{track.cover ?
:
♪
}
{track.title}
{track.artist}
{loading ? : }
Berhenti
{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 (
{NAV_ITEMS.map((item) => (
nav(item.key)}
>
{item.label}
))}
);
}
/* ====================== 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 && (
← Kembali
)}
{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 (
);
}