- TRip: various ui/ux enhancements
- other minor changes
This commit is contained in:
@@ -401,6 +401,84 @@ Custom
|
|||||||
border-color: #f87171;
|
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 {
|
.lyric-search-input.has-ready .p-autocomplete-input {
|
||||||
border-color: #34d399;
|
border-color: #34d399;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,34 +11,50 @@ import { JoyUIRootIsland } from "./Components"
|
|||||||
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
||||||
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
|
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;
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<SEO
|
<SEO
|
||||||
title={title}
|
title={shareTitle}
|
||||||
titleTemplate=`%s | ${metaData.title}`
|
titleTemplate=`%s | ${metaData.title}`
|
||||||
titleDefault={metaData.title}
|
titleDefault={metaData.title}
|
||||||
description={description}
|
description={shareDescription}
|
||||||
charset="UTF-8"
|
charset="UTF-8"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
basic: {
|
basic: {
|
||||||
title: title || metaData.title,
|
title: shareTitle,
|
||||||
type: "website",
|
type: "website",
|
||||||
image: "",
|
image: shareImage,
|
||||||
url: url,
|
url: canonicalUrl,
|
||||||
},
|
},
|
||||||
optional: {
|
optional: {
|
||||||
description,
|
description: shareDescription,
|
||||||
siteName: "codey.lol",
|
siteName: metaData.name,
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
extend={{
|
extend={{
|
||||||
// extending the default link tags
|
link: [
|
||||||
link: [{ rel: "icon", href: "https://codey.lol/images/favicon.png" }],
|
{ rel: "icon", href: "https://codey.lol/images/favicon.png" },
|
||||||
|
{ rel: "canonical", href: canonicalUrl },
|
||||||
|
],
|
||||||
|
meta: [
|
||||||
|
{ property: "og:image:alt", content: shareImageAlt },
|
||||||
|
{ name: "twitter:card", content: "summary_large_image" },
|
||||||
|
{ name: "twitter:title", content: shareTitle },
|
||||||
|
{ name: "twitter:description", content: shareDescription },
|
||||||
|
{ name: "twitter:image", content: shareImage },
|
||||||
|
{ name: "twitter:image:alt", content: shareImageAlt },
|
||||||
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { AutoComplete } from "primereact/autocomplete";
|
|||||||
import { authFetch } from "@/utils/authFetch";
|
import { authFetch } from "@/utils/authFetch";
|
||||||
import BreadcrumbNav from "./BreadcrumbNav";
|
import BreadcrumbNav from "./BreadcrumbNav";
|
||||||
import { API_URL, ENVIRONMENT } from "@/config";
|
import { API_URL, ENVIRONMENT } from "@/config";
|
||||||
import "./MediaRequestForm.css";
|
|
||||||
|
|
||||||
export default function MediaRequestForm() {
|
export default function MediaRequestForm() {
|
||||||
const [type, setType] = useState("artist");
|
const [type, setType] = useState("artist");
|
||||||
@@ -25,12 +24,66 @@ export default function MediaRequestForm() {
|
|||||||
const [loadingAlbumId, setLoadingAlbumId] = useState(null);
|
const [loadingAlbumId, setLoadingAlbumId] = useState(null);
|
||||||
const [expandedAlbums, setExpandedAlbums] = useState([]);
|
const [expandedAlbums, setExpandedAlbums] = useState([]);
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
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 debounceTimeout = useRef(null);
|
||||||
const autoCompleteRef = useRef(null);
|
const autoCompleteRef = useRef(null);
|
||||||
const metadataFetchToastId = 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 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 = () => (
|
const Spinner = () => (
|
||||||
@@ -40,6 +93,164 @@ export default function MediaRequestForm() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const InlineSpinner = ({ sizeClass = "h-3 w-3" }) => (
|
||||||
|
<span
|
||||||
|
className={`inline-block ${sizeClass} border-2 border-t-2 border-gray-300 border-t-blue-600 rounded-full animate-spin`}
|
||||||
|
aria-label="Loading audio"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PlayIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" role="presentation" className="w-4 h-4" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PauseIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" role="presentation" className="w-4 h-4" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ShuffleIcon = ({ active }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
role="presentation"
|
||||||
|
className={`w-4 h-4 ${active ? "text-blue-600" : "text-neutral-500"}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M17 3h4v4h-2V6.41l-4.29 4.3-1.42-1.42L17.59 5H17V3zm0 11h-1.59l-2.7-2.7 1.42-1.42 2.99 3h1.88v-1.59L21 14l-3.99 3V15zm-7.12-1.71L4.41 5H3V3h2.59l6.12 6.12-1.71 1.17zM3 19v-2h1.41l4.7-4.7 1.42 1.41L6.41 19H3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
// Fetch artist suggestions for autocomplete
|
||||||
const searchArtists = (e) => {
|
const searchArtists = (e) => {
|
||||||
const query = e.query.trim();
|
const query = e.query.trim();
|
||||||
@@ -187,6 +398,16 @@ export default function MediaRequestForm() {
|
|||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
|
resetQueueState();
|
||||||
|
setShuffleAlbums({});
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.removeAttribute("src");
|
||||||
|
audioRef.current.load();
|
||||||
|
}
|
||||||
|
setIsAudioPlaying(false);
|
||||||
|
setAudioLoadingTrackId(null);
|
||||||
|
setCurrentTrackId(null);
|
||||||
try {
|
try {
|
||||||
if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current);
|
if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -219,6 +440,7 @@ export default function MediaRequestForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setAlbums(data);
|
setAlbums(data);
|
||||||
|
setShuffleAlbums({});
|
||||||
setTracksByAlbum({});
|
setTracksByAlbum({});
|
||||||
setExpandedAlbums([]);
|
setExpandedAlbums([]);
|
||||||
|
|
||||||
@@ -259,18 +481,100 @@ export default function MediaRequestForm() {
|
|||||||
setIsSearching(false);
|
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 {
|
try {
|
||||||
const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}?quality=${quality}`);
|
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 {
|
||||||
|
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");
|
if (!res.ok) throw new Error("Failed to fetch track URL");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.stream_url) {
|
if (!data.stream_url) {
|
||||||
// Use plain fetch for public resource
|
throw new Error("No stream URL returned for this track.");
|
||||||
|
}
|
||||||
|
|
||||||
const fileResponse = await fetch(data.stream_url, {
|
const fileResponse = await fetch(data.stream_url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
mode: "cors", // ensure cross-origin is allowed
|
mode: "cors",
|
||||||
credentials: "omit", // do NOT send cookies or auth
|
credentials: "omit",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fileResponse.ok) {
|
if (!fileResponse.ok) {
|
||||||
@@ -279,30 +583,142 @@ export default function MediaRequestForm() {
|
|||||||
|
|
||||||
const blob = await fileResponse.blob();
|
const blob = await fileResponse.blob();
|
||||||
const url = URL.createObjectURL(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");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
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;
|
link.download = filename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} else {
|
|
||||||
toast.error("No stream URL returned for this track.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to get track download URL.");
|
|
||||||
console.error(error);
|
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 allTracksLoaded = albums.every(({ id }) => Array.isArray(tracksByAlbum[id]) && tracksByAlbum[id].length > 0);
|
||||||
|
|
||||||
const handleToggleAllAlbums = () => {
|
const handleToggleAllAlbums = () => {
|
||||||
@@ -378,7 +794,7 @@ export default function MediaRequestForm() {
|
|||||||
setTracksByAlbum((prev) => ({ ...prev, [album.id]: data }));
|
setTracksByAlbum((prev) => ({ ...prev, [album.id]: data }));
|
||||||
setSelectedTracks((prev) => ({
|
setSelectedTracks((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[album.id]: data.map((t) => t.id),
|
[album.id]: data.map((t) => String(t.id)),
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Failed to fetch tracks for album ${album.album}.`);
|
toast.error(`Failed to fetch tracks for album ${album.album}.`);
|
||||||
@@ -661,7 +1077,7 @@ export default function MediaRequestForm() {
|
|||||||
activeIndex={expandedAlbums}
|
activeIndex={expandedAlbums}
|
||||||
onTabChange={(e) => setExpandedAlbums(e.index)}
|
onTabChange={(e) => setExpandedAlbums(e.index)}
|
||||||
>
|
>
|
||||||
{albums.map(({ album, id, release_date }) => {
|
{albums.map(({ album, id, release_date }, albumIndex) => {
|
||||||
const allTracks = tracksByAlbum[id] || [];
|
const allTracks = tracksByAlbum[id] || [];
|
||||||
const selected = selectedTracks[id];
|
const selected = selectedTracks[id];
|
||||||
|
|
||||||
@@ -676,7 +1092,7 @@ export default function MediaRequestForm() {
|
|||||||
<AccordionTab
|
<AccordionTab
|
||||||
key={id}
|
key={id}
|
||||||
header={
|
header={
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex flex-wrap items-center gap-3 w-full text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allChecked}
|
checked={allChecked}
|
||||||
@@ -685,9 +1101,48 @@ export default function MediaRequestForm() {
|
|||||||
}}
|
}}
|
||||||
onChange={() => toggleAlbum(id)}
|
onChange={() => toggleAlbum(id)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="cursor-pointer"
|
className="trip-checkbox cursor-pointer"
|
||||||
aria-label={`Select all tracks for album ${album}`}
|
aria-label={`Select all tracks for album ${album}`}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleAlbumPlayPause(id, albumIndex);
|
||||||
|
}}
|
||||||
|
className={`w-7 h-7 flex items-center justify-center rounded-full border transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${queueAlbumId === id && isAudioPlaying
|
||||||
|
? "border-green-500 text-green-500"
|
||||||
|
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
||||||
|
aria-label={`${queueAlbumId === id && isAudioPlaying ? "Pause" : "Play"} album ${album}`}
|
||||||
|
aria-pressed={queueAlbumId === id && isAudioPlaying}
|
||||||
|
disabled={albumPlaybackLoadingId === id}
|
||||||
|
>
|
||||||
|
{albumPlaybackLoadingId === id ? (
|
||||||
|
<InlineSpinner sizeClass="h-3 w-3" />
|
||||||
|
) : queueAlbumId === id && isAudioPlaying ? (
|
||||||
|
<PauseIcon />
|
||||||
|
) : (
|
||||||
|
<PlayIcon />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleAlbumShuffle(id);
|
||||||
|
}}
|
||||||
|
className={`w-7 h-7 flex items-center justify-center rounded-full border transition-colors ${shuffleAlbums[id]
|
||||||
|
? "border-blue-600 text-blue-600"
|
||||||
|
: "border-neutral-400 text-neutral-500 hover:text-blue-600 hover:border-blue-600"}`}
|
||||||
|
aria-label={`Toggle shuffle for album ${album}`}
|
||||||
|
aria-pressed={!!shuffleAlbums[id]}
|
||||||
|
>
|
||||||
|
<ShuffleIcon active={!!shuffleAlbums[id]} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span className="flex items-center" title={album}>
|
<span className="flex items-center" title={album}>
|
||||||
{truncate(album, 32)}
|
{truncate(album, 32)}
|
||||||
{loadingAlbumId === id && <Spinner />}
|
{loadingAlbumId === id && <Spinner />}
|
||||||
@@ -706,33 +1161,93 @@ export default function MediaRequestForm() {
|
|||||||
>
|
>
|
||||||
{allTracks.length > 0 ? (
|
{allTracks.length > 0 ? (
|
||||||
<ul className="text-sm">
|
<ul className="text-sm">
|
||||||
{allTracks.map((track) => (
|
{allTracks.map((track) => {
|
||||||
<li key={track.id} className="py-1 flex items-center gap-2">
|
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 (
|
||||||
|
<li key={track.id} className="py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected?.includes(String(track.id))}
|
checked={selected?.includes(String(track.id))}
|
||||||
onChange={() => toggleTrack(id, track.id)}
|
onChange={() => toggleTrack(id, track.id)}
|
||||||
className="cursor-pointer"
|
className="trip-checkbox cursor-pointer"
|
||||||
aria-label={`Select track ${track.title} `}
|
aria-label={`Select track ${track.title} `}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTrackClick(track.id, selectedArtist.artist, track.title)}
|
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
||||||
className="font-medium text-blue-600 hover:underline cursor-pointer bg-transparent border-none p-0"
|
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
|
||||||
aria-label={`Download track ${track.title} `}
|
? "border-green-600 text-green-600"
|
||||||
|
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
||||||
|
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
|
||||||
|
aria-pressed={isCurrentTrack && isAudioPlaying}
|
||||||
|
disabled={audioLoadingTrackId === track.id}
|
||||||
>
|
>
|
||||||
{truncate(track.title, 80)}
|
{audioLoadingTrackId === track.id ? (
|
||||||
</button>
|
<InlineSpinner sizeClass="h-4 w-4" />
|
||||||
<span className="text-xs text-neutral-500">{quality}</span>
|
) : isCurrentTrack && isAudioPlaying ? (
|
||||||
{track.version && (
|
<PauseIcon />
|
||||||
<span className="text-xs text-neutral-400">({track.version})</span>
|
) : (
|
||||||
|
<PlayIcon />
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<span className="block truncate" title={track.title}>
|
||||||
|
{truncate(track.title, 80)}
|
||||||
|
</span>
|
||||||
|
{track.version && (
|
||||||
|
<span className="block text-[0.7rem] text-neutral-500 truncate" title={track.version}>
|
||||||
|
{track.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 ml-auto text-xs text-neutral-500">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTrackDownload(track)}
|
||||||
|
className="text-neutral-500 hover:text-blue-600 underline whitespace-nowrap cursor-pointer"
|
||||||
|
aria-label={`Download ${track.title}`}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<span>{quality}</span>
|
||||||
{track.duration && (
|
{track.duration && (
|
||||||
<span className="text-xs text-neutral-500 ml-auto tabular-nums">{track.duration}</span>
|
<span className="tabular-nums">{track.duration}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showProgress && (
|
||||||
|
<div className="mt-2 pr-6 pl-16">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={safeProgress.duration}
|
||||||
|
value={safeProgress.current}
|
||||||
|
step="0.25"
|
||||||
|
onChange={(e) => {
|
||||||
|
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}`}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[0.65rem] text-neutral-500 mt-1">
|
||||||
|
<span>{formatTime(safeProgress.current)}</span>
|
||||||
|
<span>{formatTime(safeProgress.duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm italic text-neutral-600 dark:text-neutral-400">
|
<div className="text-sm italic text-neutral-600 dark:text-neutral-400">
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ export const metaData = {
|
|||||||
title: "CODEY STUFF",
|
title: "CODEY STUFF",
|
||||||
name: "codey.lol",
|
name: "codey.lol",
|
||||||
owner: "codey",
|
owner: "codey",
|
||||||
ogImage: "/opengraph-image.png",
|
ogImage: "/images/favicon.png",
|
||||||
description:
|
description: "CODEY STUFF!",
|
||||||
"codey.lol",
|
shareTitle: "CODEY STUFF",
|
||||||
|
shareDescription: "CODEY STUFF!",
|
||||||
|
shareImageAlt: "/images/favicon.png",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const API_URL = "https://api.codey.lol";
|
export const API_URL = "https://api.codey.lol";
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import "@/assets/styles/nav.css";
|
|||||||
const user = await requireAuthHook(Astro);
|
const user = await requireAuthHook(Astro);
|
||||||
const isLoggedIn = Boolean(user);
|
const isLoggedIn = Boolean(user);
|
||||||
const userDisplayName = user?.user ?? null;
|
const userDisplayName = user?.user ?? null;
|
||||||
const userInitial = userDisplayName ? String(userDisplayName).charAt(0).toUpperCase() : '?';
|
|
||||||
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Home", href: "/" },
|
{ label: "Home", href: "/" },
|
||||||
|
|||||||
Reference in New Issue
Block a user