From de50889b2c5863d16624a16bed06ae87e67ad3e1 Mon Sep 17 00:00:00 2001 From: codey Date: Wed, 26 Nov 2025 14:42:57 -0500 Subject: [PATCH] - TRip: various ui/ux enhancements - other minor changes --- src/assets/styles/global.css | 78 +++ src/components/BaseHead.astro | 38 +- src/components/TRip/MediaRequestForm.jsx | 645 ++++++++++++++++++++--- src/config.js | 8 +- src/layouts/Nav.astro | 2 - 5 files changed, 690 insertions(+), 81 deletions(-) diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index c23e3ef..fd3f500 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -401,6 +401,84 @@ Custom border-color: #f87171; } +.trip-checkbox { + appearance: none; + width: 2.1rem; + height: 1.1rem; + border-radius: 999px; + border: 1px solid #9ca3af; + background: #e5e7eb; + position: relative; + display: inline-flex; + align-items: center; + transition: background 0.2s ease, border-color 0.2s ease; + cursor: pointer; +} + +.trip-checkbox::after { + content: ""; + position: absolute; + width: 0.95rem; + height: 0.95rem; + border-radius: 999px; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + left: 2px; + top: 50%; + transform: translate(0, -50%); + transition: transform 0.2s ease; +} + +.trip-checkbox:hover { + border-color: #2563eb; +} + +.trip-checkbox:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.5); + outline-offset: 2px; +} + +.trip-checkbox:checked { + background: #2563eb; + border-color: #2563eb; +} + +.trip-checkbox:checked::after { + transform: translate(0.95rem, -50%); +} + +.trip-checkbox:indeterminate { + background: linear-gradient(90deg, #93c5fd, #2563eb); + border-color: #2563eb; +} + +.trip-checkbox:indeterminate::after { + width: 0.8rem; + height: 0.2rem; + border-radius: 0.2rem; + background: white; + transform: translate(0.65rem, -50%); + box-shadow: none; +} + +[data-theme="dark"] .trip-checkbox { + border-color: #4b5563; + background: #1f2937; +} + +[data-theme="dark"] .trip-checkbox::after { + background: #f8fafc; +} + +[data-theme="dark"] .trip-checkbox:checked { + background: #3b82f6; + border-color: #3b82f6; +} + +[data-theme="dark"] .trip-checkbox:checked::after { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + .lyric-search-input.has-ready .p-autocomplete-input { border-color: #34d399; } diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro index f2516d8..8a5863a 100644 --- a/src/components/BaseHead.astro +++ b/src/components/BaseHead.astro @@ -11,34 +11,50 @@ import { JoyUIRootIsland } from "./Components" import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr"; import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher"; -const { title, description = metaData.description, image } = Astro.props; +const { title, description, image } = Astro.props; -const { url, site } = Astro; +const { url } = Astro; + +const shareTitle = title ? `${title} | ${metaData.title}` : metaData.shareTitle ?? metaData.title; +const shareDescription = description ?? metaData.shareDescription ?? metaData.description; +const canonicalUrl = url?.href ?? metaData.baseUrl; +const shareImage = new URL(image ?? metaData.ogImage, metaData.baseUrl).toString(); +const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title; --- diff --git a/src/components/TRip/MediaRequestForm.jsx b/src/components/TRip/MediaRequestForm.jsx index 5acf1bf..a0a7c28 100644 --- a/src/components/TRip/MediaRequestForm.jsx +++ b/src/components/TRip/MediaRequestForm.jsx @@ -6,7 +6,6 @@ import { AutoComplete } from "primereact/autocomplete"; import { authFetch } from "@/utils/authFetch"; import BreadcrumbNav from "./BreadcrumbNav"; import { API_URL, ENVIRONMENT } from "@/config"; -import "./MediaRequestForm.css"; export default function MediaRequestForm() { const [type, setType] = useState("artist"); @@ -25,12 +24,66 @@ export default function MediaRequestForm() { const [loadingAlbumId, setLoadingAlbumId] = useState(null); const [expandedAlbums, setExpandedAlbums] = useState([]); const [isFetching, setIsFetching] = useState(false); + const [currentTrackId, setCurrentTrackId] = useState(null); + const [isAudioPlaying, setIsAudioPlaying] = useState(false); + const [audioLoadingTrackId, setAudioLoadingTrackId] = useState(null); + const [playbackQueue, setPlaybackQueue] = useState([]); + const [queueIndex, setQueueIndex] = useState(null); + const [queueAlbumId, setQueueAlbumId] = useState(null); + const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState(null); + const [shuffleAlbums, setShuffleAlbums] = useState({}); + const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 }); const debounceTimeout = useRef(null); const autoCompleteRef = useRef(null); const metadataFetchToastId = useRef(null); + const audioRef = useRef(null); + const audioSourcesRef = useRef({}); + const pendingTrackFetchesRef = useRef({}); + const playbackQueueRef = useRef([]); + const queueIndexRef = useRef(null); + const queueAlbumIdRef = useRef(null); + const albumHeaderRefs = useRef({}); + const suppressHashRef = useRef(false); + const lastUrlRef = useRef(""); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays + const sanitizeFilename = (text) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track"; + const formatTime = (seconds) => { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60) + .toString() + .padStart(2, "0"); + return `${mins}:${secs}`; + }; + + useEffect(() => { + if (typeof window === "undefined") return; + lastUrlRef.current = window.location.pathname + window.location.search + window.location.hash; + }, []); + + useEffect(() => { + if (typeof window === "undefined") return undefined; + + const handleHashChange = () => { + if (suppressHashRef.current) { + const fallback = + lastUrlRef.current || window.location.pathname + window.location.search; + if (typeof window.history?.replaceState === "function") { + window.history.replaceState(null, "", fallback); + } + lastUrlRef.current = fallback; + suppressHashRef.current = false; + } else { + lastUrlRef.current = + window.location.pathname + window.location.search + window.location.hash; + } + }; + + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, []); const Spinner = () => ( @@ -40,6 +93,164 @@ export default function MediaRequestForm() { /> ); + const InlineSpinner = ({ sizeClass = "h-3 w-3" }) => ( + + ); + + const PlayIcon = () => ( + + ); + + const PauseIcon = () => ( + + ); + + const ShuffleIcon = ({ active }) => ( + + ); + + const shuffleArray = (arr) => { + const clone = [...arr]; + for (let i = clone.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [clone[i], clone[j]] = [clone[j], clone[i]]; + } + return clone; + }; + + const ensureAlbumExpanded = (albumIndex) => { + if (typeof albumIndex !== "number") return; + let albumAdded = false; + setExpandedAlbums((prev) => { + const current = Array.isArray(prev) ? [...prev] : []; + if (current.includes(albumIndex)) return prev; + + if (typeof window !== "undefined") { + lastUrlRef.current = window.location.pathname + window.location.search + window.location.hash; + } + + suppressHashRef.current = true; + current.push(albumIndex); + albumAdded = true; + return current; + }); + + if (albumAdded) { + setTimeout(() => { + suppressHashRef.current = false; + }, 400); + } + }; + + const resetQueueState = () => { + setPlaybackQueue([]); + playbackQueueRef.current = []; + setQueueIndex(null); + queueIndexRef.current = null; + setQueueAlbumId(null); + queueAlbumIdRef.current = null; + setAlbumPlaybackLoadingId(null); + setAudioProgress({ current: 0, duration: 0 }); + }; + + const getTrackSource = async (trackId) => { + if (audioSourcesRef.current[trackId]) { + return audioSourcesRef.current[trackId]; + } + + if (!pendingTrackFetchesRef.current[trackId]) { + pendingTrackFetchesRef.current[trackId] = (async () => { + const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}?quality=${quality}`); + if (!res.ok) throw new Error("Failed to fetch track URL"); + const data = await res.json(); + + if (!data.stream_url) { + throw new Error("No stream URL returned for this track."); + } + + const fileResponse = await fetch(data.stream_url, { + method: "GET", + mode: "cors", + credentials: "omit", + }); + + if (!fileResponse.ok) { + throw new Error(`Failed to fetch track file: ${fileResponse.status}`); + } + + const blob = await fileResponse.blob(); + const sourceUrl = URL.createObjectURL(blob); + + if (audioSourcesRef.current[trackId]) { + URL.revokeObjectURL(audioSourcesRef.current[trackId]); + } + + audioSourcesRef.current[trackId] = sourceUrl; + return sourceUrl; + })().finally(() => { + delete pendingTrackFetchesRef.current[trackId]; + }); + } + + return pendingTrackFetchesRef.current[trackId]; + }; + + const prefetchTrack = async (track) => { + if (!track || audioSourcesRef.current[track.id] || pendingTrackFetchesRef.current[track.id]) { + return; + } + try { + await getTrackSource(track.id); + } catch (error) { + console.error("Prefetch failed", error); + } + }; + + const playTrack = async (track, { fromQueue = false } = {}) => { + const audio = audioRef.current; + if (!audio || !track) return; + + if (!fromQueue) { + resetQueueState(); + } + + setAudioLoadingTrackId(track.id); + try { + const sourceUrl = await getTrackSource(track.id); + audio.pause(); + audio.currentTime = 0; + audio.src = sourceUrl; + setAudioProgress({ current: 0, duration: 0 }); + setCurrentTrackId(track.id); + await audio.play(); + } catch (error) { + console.error(error); + toast.error("Failed to play track."); + if (!fromQueue) { + resetQueueState(); + } + } finally { + setAudioLoadingTrackId(null); + } + }; + // Fetch artist suggestions for autocomplete const searchArtists = (e) => { const query = e.query.trim(); @@ -187,6 +398,16 @@ export default function MediaRequestForm() { const handleSearch = async () => { toast.dismiss(); setIsSearching(true); + resetQueueState(); + setShuffleAlbums({}); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.removeAttribute("src"); + audioRef.current.load(); + } + setIsAudioPlaying(false); + setAudioLoadingTrackId(null); + setCurrentTrackId(null); try { if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current); } catch (err) { @@ -219,6 +440,7 @@ export default function MediaRequestForm() { ); setAlbums(data); + setShuffleAlbums({}); setTracksByAlbum({}); setExpandedAlbums([]); @@ -259,50 +481,244 @@ export default function MediaRequestForm() { setIsSearching(false); }; - const handleTrackClick = async (trackId, artist, title) => { + const handleTrackPlayPause = async (track, albumId = null, albumIndex = null) => { + const audio = audioRef.current; + if (!audio) return; + + if (typeof albumIndex === "number") { + ensureAlbumExpanded(albumIndex); + } + + if (currentTrackId === track.id) { + if (audio.paused) { + try { + await audio.play(); + } catch (error) { + console.error(error); + toast.error("Unable to resume playback."); + } + } else { + audio.pause(); + } + return; + } + + await playTrack(track, { fromQueue: false }); + }; + + const toggleAlbumShuffle = (albumId) => { + setShuffleAlbums((prev) => ({ ...prev, [albumId]: !prev[albumId] })); + }; + + const startAlbumPlayback = async (albumId, albumIndex) => { + const tracks = tracksByAlbum[albumId]; + if (!Array.isArray(tracks) || tracks.length === 0) { + toast.error("Tracks are still loading for this album."); + return; + } + + ensureAlbumExpanded(albumIndex); + + const shouldShuffle = !!shuffleAlbums[albumId]; + const queue = shouldShuffle ? shuffleArray(tracks) : [...tracks]; + setPlaybackQueue(queue); + playbackQueueRef.current = queue; + setQueueAlbumId(albumId); + queueAlbumIdRef.current = albumId; + setQueueIndex(0); + queueIndexRef.current = 0; + setAlbumPlaybackLoadingId(albumId); + try { - const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}?quality=${quality}`); + await playTrack(queue[0], { fromQueue: true }); + setAlbumPlaybackLoadingId(null); + if (queue[1]) prefetchTrack(queue[1]); + } catch (err) { + setAlbumPlaybackLoadingId(null); + } + }; + + const handleAlbumPlayPause = async (albumId, albumIndex) => { + const audio = audioRef.current; + if (!audio) return; + + ensureAlbumExpanded(albumIndex); + + if (queueAlbumId === albumId && playbackQueue.length > 0) { + if (audio.paused) { + try { + await audio.play(); + } catch (error) { + console.error(error); + toast.error("Unable to resume album playback."); + } + } else { + audio.pause(); + } + return; + } + + await startAlbumPlayback(albumId, albumIndex); + }; + + const handleTrackDownload = async (track) => { + try { + const res = await authFetch(`${API_URL}/trip/get_track_by_id/${track.id}?quality=${quality}`); if (!res.ok) throw new Error("Failed to fetch track URL"); const data = await res.json(); - if (data.stream_url) { - // Use plain fetch for public resource - const fileResponse = await fetch(data.stream_url, { - method: "GET", - mode: "cors", // ensure cross-origin is allowed - credentials: "omit", // do NOT send cookies or auth - }); - - if (!fileResponse.ok) { - throw new Error(`Failed to fetch track file: ${fileResponse.status}`); - } - - const blob = await fileResponse.blob(); - const url = URL.createObjectURL(blob); - - const link = document.createElement("a"); - link.href = url; - - const sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, "_"); - const urlPath = new URL(data.stream_url).pathname; - const extension = urlPath.split('.').pop().split('?')[0] || 'flac'; - const filename = `${sanitize(artist)} - ${sanitize(title)}.${extension}`; - - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - - URL.revokeObjectURL(url); - } else { - toast.error("No stream URL returned for this track."); + if (!data.stream_url) { + throw new Error("No stream URL returned for this track."); } + + const fileResponse = await fetch(data.stream_url, { + method: "GET", + mode: "cors", + credentials: "omit", + }); + + if (!fileResponse.ok) { + throw new Error(`Failed to fetch track file: ${fileResponse.status}`); + } + + const blob = await fileResponse.blob(); + const url = URL.createObjectURL(blob); + const artistName = track.artist || selectedArtist?.artist || "Unknown Artist"; + const urlPath = new URL(data.stream_url).pathname; + const extension = urlPath.split(".").pop().split("?")[0] || "flac"; + const filename = `${sanitizeFilename(artistName)} - ${sanitizeFilename(track.title)}.${extension}`; + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); } catch (error) { - toast.error("Failed to get track download URL."); console.error(error); + toast.error("Failed to download track."); } }; + useEffect(() => { + if (typeof Audio === "undefined") { + return undefined; + } + + const audio = new Audio(); + audio.preload = "auto"; + audioRef.current = audio; + + const handleEnded = async () => { + const queue = playbackQueueRef.current; + const index = queueIndexRef.current; + + if (Array.isArray(queue) && queue.length > 0 && index !== null && index + 1 < queue.length) { + const nextIndex = index + 1; + setQueueIndex(nextIndex); + queueIndexRef.current = nextIndex; + setAlbumPlaybackLoadingId(queueAlbumIdRef.current); + try { + await playTrack(queue[nextIndex], { fromQueue: true }); + setAlbumPlaybackLoadingId(null); + const upcoming = queue[nextIndex + 1]; + if (upcoming) prefetchTrack(upcoming); + } catch (error) { + console.error("Failed to advance queue", error); + setAlbumPlaybackLoadingId(null); + resetQueueState(); + } + return; + } + + setIsAudioPlaying(false); + setCurrentTrackId(null); + resetQueueState(); + }; + + const updateProgress = () => { + setAudioProgress({ current: audio.currentTime || 0, duration: audio.duration || 0 }); + }; + + const handlePause = () => { + setIsAudioPlaying(false); + updateProgress(); + }; + + const handlePlay = () => { + setIsAudioPlaying(true); + updateProgress(); + }; + + const handleTimeUpdate = () => { + updateProgress(); + const queue = playbackQueueRef.current; + const index = queueIndexRef.current; + if (!Array.isArray(queue) || queue.length === 0 || index === null) return; + const nextTrack = queue[index + 1]; + if (!nextTrack) return; + + const duration = audio.duration || 0; + const currentTime = audio.currentTime || 0; + const progress = duration > 0 ? currentTime / duration : 0; + if ((duration > 0 && progress >= 0.7) || (duration === 0 && currentTime >= 15)) { + prefetchTrack(nextTrack); + } + }; + + audio.addEventListener("ended", handleEnded); + audio.addEventListener("pause", handlePause); + audio.addEventListener("play", handlePlay); + audio.addEventListener("timeupdate", handleTimeUpdate); + audio.addEventListener("loadedmetadata", updateProgress); + audio.addEventListener("seeking", updateProgress); + audio.addEventListener("seeked", updateProgress); + + return () => { + audio.pause(); + audio.removeAttribute("src"); + audio.load(); + audio.removeEventListener("ended", handleEnded); + audio.removeEventListener("pause", handlePause); + audio.removeEventListener("play", handlePlay); + audio.removeEventListener("timeupdate", handleTimeUpdate); + audio.removeEventListener("loadedmetadata", updateProgress); + audio.removeEventListener("seeking", updateProgress); + audio.removeEventListener("seeked", updateProgress); + Object.values(audioSourcesRef.current).forEach((url) => URL.revokeObjectURL(url)); + audioSourcesRef.current = {}; + }; + }, []); + + + useEffect(() => { + playbackQueueRef.current = playbackQueue; + }, [playbackQueue]); + + useEffect(() => { + queueIndexRef.current = queueIndex; + }, [queueIndex]); + + useEffect(() => { + queueAlbumIdRef.current = queueAlbumId; + }, [queueAlbumId]); + + useEffect(() => { + Object.values(audioSourcesRef.current).forEach((url) => URL.revokeObjectURL(url)); + audioSourcesRef.current = {}; + pendingTrackFetchesRef.current = {}; + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.removeAttribute("src"); + audioRef.current.load(); + } + setIsAudioPlaying(false); + setAudioLoadingTrackId(null); + setCurrentTrackId(null); + resetQueueState(); + }, [quality]); + const allTracksLoaded = albums.every(({ id }) => Array.isArray(tracksByAlbum[id]) && tracksByAlbum[id].length > 0); const handleToggleAllAlbums = () => { @@ -378,7 +794,7 @@ export default function MediaRequestForm() { setTracksByAlbum((prev) => ({ ...prev, [album.id]: data })); setSelectedTracks((prev) => ({ ...prev, - [album.id]: data.map((t) => t.id), + [album.id]: data.map((t) => String(t.id)), })); } catch (err) { toast.error(`Failed to fetch tracks for album ${album.album}.`); @@ -661,7 +1077,7 @@ export default function MediaRequestForm() { activeIndex={expandedAlbums} onTabChange={(e) => setExpandedAlbums(e.index)} > - {albums.map(({ album, id, release_date }) => { + {albums.map(({ album, id, release_date }, albumIndex) => { const allTracks = tracksByAlbum[id] || []; const selected = selectedTracks[id]; @@ -676,7 +1092,7 @@ export default function MediaRequestForm() { +
toggleAlbum(id)} onClick={(e) => e.stopPropagation()} - className="cursor-pointer" + className="trip-checkbox cursor-pointer" aria-label={`Select all tracks for album ${album}`} /> +
e.stopPropagation()}> + + +
{truncate(album, 32)} {loadingAlbumId === id && } @@ -706,33 +1161,93 @@ export default function MediaRequestForm() { > {allTracks.length > 0 ? (
    - {allTracks.map((track) => ( -
  • - toggleTrack(id, track.id)} - className="cursor-pointer" - aria-label={`Select track ${track.title} `} - /> - - {quality} - {track.version && ( - ({track.version}) - )} - {track.duration && ( - {track.duration} - )} -
  • - ))} + {allTracks.map((track) => { + const isCurrentTrack = currentTrackId === track.id; + const showProgress = isCurrentTrack && audioProgress.duration > 0; + const safeProgress = { + current: Math.min(audioProgress.current, audioProgress.duration || 0), + duration: audioProgress.duration || 0, + }; + return ( +
  • +
    + toggleTrack(id, track.id)} + className="trip-checkbox cursor-pointer" + aria-label={`Select track ${track.title} `} + /> + +
    + + {truncate(track.title, 80)} + + {track.version && ( + + {track.version} + + )} +
    +
    + + {quality} + {track.duration && ( + {track.duration} + )} +
    +
    + {showProgress && ( +
    + { + if (!audioRef.current) return; + const nextValue = Number(e.target.value); + audioRef.current.currentTime = nextValue; + setAudioProgress((prev) => ({ ...prev, current: nextValue })); + }} + className="w-full h-1 cursor-pointer accent-blue-600" + aria-label={`Seek within ${track.title}`} + /> +
    + {formatTime(safeProgress.current)} + {formatTime(safeProgress.duration)} +
    +
    + )} +
  • + ); + })}
) : (
diff --git a/src/config.js b/src/config.js index 88cae93..778fd03 100644 --- a/src/config.js +++ b/src/config.js @@ -3,9 +3,11 @@ export const metaData = { title: "CODEY STUFF", name: "codey.lol", owner: "codey", - ogImage: "/opengraph-image.png", - description: - "codey.lol", + ogImage: "/images/favicon.png", + description: "CODEY STUFF!", + shareTitle: "CODEY STUFF", + shareDescription: "CODEY STUFF!", + shareImageAlt: "/images/favicon.png", }; export const API_URL = "https://api.codey.lol"; diff --git a/src/layouts/Nav.astro b/src/layouts/Nav.astro index 1aad17b..265c1f7 100644 --- a/src/layouts/Nav.astro +++ b/src/layouts/Nav.astro @@ -8,8 +8,6 @@ import "@/assets/styles/nav.css"; const user = await requireAuthHook(Astro); const isLoggedIn = Boolean(user); const userDisplayName = user?.user ?? null; -const userInitial = userDisplayName ? String(userDisplayName).charAt(0).toUpperCase() : '?'; - const navItems = [ { label: "Home", href: "/" },