Compare commits

...

5 Commits

@ -16,60 +16,65 @@ interface getActivityArgs {
track_id: number; track_id: number;
} }
function getLastListens( async function handleJson<T>(r: Response): Promise<T> {
if (!r.ok) {
const err = await r.json();
throw Error(err.error);
}
return (await r.json()) as T;
}
async function getLastListens(
args: getItemsArgs args: getItemsArgs
): Promise<PaginatedResponse<Listen>> { ): Promise<PaginatedResponse<Listen>> {
return fetch( const r = await fetch(
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}` `/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Listen>>); );
return handleJson<PaginatedResponse<Listen>>(r);
} }
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> { async function getTopTracks(
if (args.artist_id) { args: getItemsArgs
return fetch( ): Promise<PaginatedResponse<Track>> {
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}` let url = `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`;
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else if (args.album_id) {
return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else {
return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
}
}
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> { if (args.artist_id) url += `&artist_id=${args.artist_id}`;
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`; else if (args.album_id) url += `&album_id=${args.album_id}`;
if (args.artist_id) {
return fetch(baseUri + `&artist_id=${args.artist_id}`).then( const r = await fetch(url);
(r) => r.json() as Promise<PaginatedResponse<Album>> return handleJson<PaginatedResponse<Track>>(r);
);
} else {
return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
} }
async function getTopAlbums(
args: getItemsArgs
): Promise<PaginatedResponse<Album>> {
let url = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
const r = await fetch(url);
return handleJson<PaginatedResponse<Album>>(r);
} }
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> { async function getTopArtists(
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`; args: getItemsArgs
return fetch(baseUri).then( ): Promise<PaginatedResponse<Artist>> {
(r) => r.json() as Promise<PaginatedResponse<Artist>> const url = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
); const r = await fetch(url);
return handleJson<PaginatedResponse<Artist>>(r);
} }
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> { async function getActivity(
return fetch( args: getActivityArgs
): Promise<ListenActivityItem[]> {
const r = await fetch(
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}` `/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
).then((r) => r.json() as Promise<ListenActivityItem[]>); );
return handleJson<ListenActivityItem[]>(r);
} }
function getStats(period: string): Promise<Stats> { async function getStats(period: string): Promise<Stats> {
return fetch(`/apis/web/v1/stats?period=${period}`).then( const r = await fetch(`/apis/web/v1/stats?period=${period}`);
(r) => r.json() as Promise<Stats>
); return handleJson<Stats>(r);
} }
function search(q: string): Promise<SearchResponse> { function search(q: string): Promise<SearchResponse> {
@ -416,4 +421,5 @@ export type {
ApiError, ApiError,
Config, Config,
NowPlaying, NowPlaying,
Stats,
}; };

@ -73,8 +73,14 @@ export default function ActivityGrid({
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) {
return (
<div className="w-[500px]">
<h2>Activity</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (isError) return <p className="error">Error:{error.message}</p>;
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/ // from https://css-tricks.com/snippets/javascript/lighten-darken-color/
function LightenDarkenColor(hex: string, lum: number) { function LightenDarkenColor(hex: string, lum: number) {

@ -1,12 +1,11 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { getStats } from "api/api" import { getStats, type Stats, type ApiError } from "api/api";
export default function AllTimeStats() { export default function AllTimeStats() {
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: ['stats', 'all_time'], queryKey: ["stats", "all_time"],
queryFn: ({ queryKey }) => getStats(queryKey[1]), queryFn: ({ queryKey }) => getStats(queryKey[1]),
}) });
if (isPending) { if (isPending) {
return ( return (
@ -14,32 +13,44 @@ export default function AllTimeStats() {
<h2>All Time Stats</h2> <h2>All Time Stats</h2>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
) );
} } else if (isError) {
if (isError) { return (
return <p className="error">Error:{error.message}</p> <>
<div>
<h2>All Time Stats</h2>
<p className="error">Error: {error.message}</p>
</div>
</>
);
} }
const numberClasses = 'header-font font-bold text-xl' const numberClasses = "header-font font-bold text-xl";
return ( return (
<div> <div>
<h2>All Time Stats</h2> <h2>All Time Stats</h2>
<div> <div>
<span className={numberClasses} title={data.minutes_listened + " minutes"}>{Math.floor(data.minutes_listened / 60)}</span> Hours Listened <span
className={numberClasses}
title={Math.floor(data.minutes_listened / 60) + " hours"}
>
{data.minutes_listened}
</span>{" "}
Minutes Listened
</div> </div>
<div> <div>
<span className={numberClasses}>{data.listen_count}</span> Plays <span className={numberClasses}>{data.listen_count}</span> Plays
</div> </div>
<div> <div>
<span className={numberClasses}>{data.artist_count}</span> Artists <span className={numberClasses}>{data.track_count}</span> Tracks
</div> </div>
<div> <div>
<span className={numberClasses}>{data.album_count}</span> Albums <span className={numberClasses}>{data.album_count}</span> Albums
</div> </div>
<div> <div>
<span className={numberClasses}>{data.track_count}</span> Tracks <span className={numberClasses}>{data.artist_count}</span> Artists
</div> </div>
</div> </div>
) );
} }

@ -67,12 +67,14 @@ export default function LastPlays(props: Props) {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) {
return (
<div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (!data.items) return;
const listens = items ?? data.items; const listens = items ?? data.items;

@ -37,11 +37,14 @@ export default function TopAlbums(props: Props) {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) {
return (
<div className="w-[300px]">
<h2>Top Albums</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (isError) {
return <p className="error">Error:{error.message}</p>;
}
if (!data.items) return;
return ( return (
<div> <div>

@ -28,11 +28,14 @@ export default function TopArtists(props: Props) {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) {
return (
<div className="w-[300px]">
<h2>Top Artists</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (isError) {
return <p className="error">Error:{error.message}</p>;
}
if (!data.items) return;
return ( return (
<div> <div>

@ -35,9 +35,13 @@ const TopTracks = (props: Props) => {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} } else if (isError) {
if (isError) { return (
return <p className="error">Error:{error.message}</p>; <div className="w-[300px]">
<h2>Top Tracks</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (!data.items) return; if (!data.items) return;

@ -1,5 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api"; import {
createAlias,
deleteAlias,
getAliases,
setPrimaryAlias,
type Alias,
} from "api/api";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { AsyncButton } from "../../AsyncButton"; import { AsyncButton } from "../../AsyncButton";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -8,24 +14,24 @@ import SetVariousArtists from "./SetVariousArtist";
import SetPrimaryArtist from "./SetPrimaryArtist"; import SetPrimaryArtist from "./SetPrimaryArtist";
interface Props { interface Props {
type: string type: string;
id: number id: number;
open: boolean open: boolean;
setOpen: Function setOpen: Function;
} }
export default function EditModal({ open, setOpen, type, id }: Props) { export default function EditModal({ open, setOpen, type, id }: Props) {
const [input, setInput] = useState('') const [input, setInput] = useState("");
const [loading, setLoading ] = useState(false) const [loading, setLoading] = useState(false);
const [err, setError ] = useState<string>() const [err, setError] = useState<string>();
const [displayData, setDisplayData] = useState<Alias[]>([]) const [displayData, setDisplayData] = useState<Alias[]>([]);
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: [
'aliases', "aliases",
{ {
type: type, type: type,
id: id id: id,
}, },
], ],
queryFn: ({ queryKey }) => { queryFn: ({ queryKey }) => {
@ -36,71 +42,67 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setDisplayData(data) setDisplayData(data);
} }
}, [data]) }, [data]);
if (isError) { if (isError) {
return ( return <p className="error">Error: {error.message}</p>;
<p className="error">Error: {error.message}</p>
)
} }
if (isPending) { if (isPending) {
return ( return <p>Loading...</p>;
<p>Loading...</p>
)
} }
const handleSetPrimary = (alias: string) => { const handleSetPrimary = (alias: string) => {
setError(undefined) setError(undefined);
setLoading(true) setLoading(true);
setPrimaryAlias(type, id, alias) setPrimaryAlias(type, id, alias).then((r) => {
.then(r => {
if (r.ok) { if (r.ok) {
window.location.reload() window.location.reload();
} else { } else {
r.json().then((r) => setError(r.error)) r.json().then((r) => setError(r.error));
}
})
setLoading(false)
} }
});
setLoading(false);
};
const handleNewAlias = () => { const handleNewAlias = () => {
setError(undefined) setError(undefined);
if (input === "") { if (input === "") {
setError("alias must be provided") setError("alias must be provided");
return return;
} }
setLoading(true) setLoading(true);
createAlias(type, id, input) createAlias(type, id, input).then((r) => {
.then(r => {
if (r.ok) { if (r.ok) {
setDisplayData([...displayData, {alias: input, source: "Manual", is_primary: false, id: id}]) setDisplayData([
...displayData,
{ alias: input, source: "Manual", is_primary: false, id: id },
]);
} else { } else {
r.json().then((r) => setError(r.error)) r.json().then((r) => setError(r.error));
}
})
setLoading(false)
} }
});
setLoading(false);
};
const handleDeleteAlias = (alias: string) => { const handleDeleteAlias = (alias: string) => {
setError(undefined) setError(undefined);
setLoading(true) setLoading(true);
deleteAlias(type, id, alias) deleteAlias(type, id, alias).then((r) => {
.then(r => {
if (r.ok) { if (r.ok) {
setDisplayData(displayData.filter((v) => v.alias != alias)) setDisplayData(displayData.filter((v) => v.alias != alias));
} else { } else {
r.json().then((r) => setError(r.error)) r.json().then((r) => setError(r.error));
}
})
setLoading(false)
} }
});
setLoading(false);
};
const handleClose = () => { const handleClose = () => {
setOpen(false) setOpen(false);
setInput('') setInput("");
} };
return ( return (
<Modal maxW={1000} isOpen={open} onClose={handleClose}> <Modal maxW={1000} isOpen={open} onClose={handleClose}>
@ -110,9 +112,24 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{displayData.map((v) => ( {displayData.map((v) => (
<div className="flex gap-2"> <div className="flex gap-2">
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>{v.alias} (source: {v.source})</div> <div className="bg p-3 rounded-md flex-grow" key={v.alias}>
<AsyncButton loading={loading} onClick={() => handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary</AsyncButton> {v.alias} (source: {v.source})
<AsyncButton loading={loading} onClick={() => handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}><Trash size={16} /></AsyncButton> </div>
<AsyncButton
loading={loading}
onClick={() => handleSetPrimary(v.alias)}
disabled={v.is_primary}
>
Set Primary
</AsyncButton>
<AsyncButton
loading={loading}
onClick={() => handleDeleteAlias(v.alias)}
confirm
disabled={v.is_primary}
>
<Trash size={16} />
</AsyncButton>
</div> </div>
))} ))}
<div className="flex gap-2 w-3/5"> <div className="flex gap-2 w-3/5">
@ -123,18 +140,23 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
/> />
<AsyncButton loading={loading} onClick={handleNewAlias}>Submit</AsyncButton> <AsyncButton loading={loading} onClick={handleNewAlias}>
Submit
</AsyncButton>
</div> </div>
{err && <p className="error">{err}</p>} {err && <p className="error">{err}</p>}
</div> </div>
</div> </div>
{ type.toLowerCase() === "album" && {type.toLowerCase() === "album" && (
<> <>
<SetVariousArtists id={id} /> <SetVariousArtists id={id} />
<SetPrimaryArtist id={id} type="album" /> <SetPrimaryArtist id={id} type="album" />
</> </>
} )}
{type.toLowerCase() === "track" && (
<SetPrimaryArtist id={id} type="track" />
)}
</div> </div>
</Modal> </Modal>
) );
} }

@ -5,39 +5,48 @@ import SearchResults from "../SearchResults";
import { AsyncButton } from "../AsyncButton"; import { AsyncButton } from "../AsyncButton";
interface Props { interface Props {
type: string type: string;
id: number id: number;
musicbrainzId?: string musicbrainzId?: string;
open: boolean open: boolean;
setOpen: Function setOpen: Function;
} }
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) { export default function ImageReplaceModal({
const [query, setQuery] = useState(''); musicbrainzId,
const [loading, setLoading] = useState(false) type,
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true) id,
open,
setOpen,
}: Props) {
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true);
const doImageReplace = (url: string) => { const doImageReplace = (url: string) => {
setLoading(true) setLoading(true);
const formData = new FormData setError("");
formData.set(`${type.toLowerCase()}_id`, id.toString()) const formData = new FormData();
formData.set("image_url", url) formData.set(`${type.toLowerCase()}_id`, id.toString());
formData.set("image_url", url);
replaceImage(formData) replaceImage(formData)
.then((r) => { .then((r) => {
if (r.ok) { if (r.status >= 200 && r.status < 300) {
window.location.reload() window.location.reload();
} else { } else {
console.log(r) r.json().then((r) => setError(r.error));
setLoading(false) setLoading(false);
} }
}) })
.catch((err) => console.log(err)) .catch((err) => setError(err));
} };
const closeModal = () => { const closeModal = () => {
setOpen(false) setOpen(false);
setQuery('') setQuery("");
} setError("");
};
return ( return (
<Modal isOpen={open} onClose={closeModal}> <Modal isOpen={open} onClose={closeModal}>
@ -47,23 +56,34 @@ export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOp
type="text" type="text"
autoFocus autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal // i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Image URL`} placeholder={`Enter image URL, or drag-and-drop a local file`}
className="w-full mx-auto fg bg rounded p-2" className="w-full mx-auto fg bg rounded p-2"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/> />
{ query != "" ? {query != "" ? (
<div className="flex gap-2 mt-4"> <div className="flex gap-2 mt-4">
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton> <AsyncButton
</div> : loading={loading}
''} onClick={() => doImageReplace(query)}
{ type === "Album" && musicbrainzId ? >
Submit
</AsyncButton>
</div>
) : (
""
)}
{type === "Album" && musicbrainzId ? (
<> <>
<h3 className="mt-5">Suggested Image (Click to Apply)</h3> <h3 className="mt-5">Suggested Image (Click to Apply)</h3>
<button <button
className="mt-4" className="mt-4"
disabled={loading} disabled={loading}
onClick={() => doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)} onClick={() =>
doImageReplace(
`https://coverartarchive.org/release/${musicbrainzId}/front`
)
}
> >
<div className={`relative`}> <div className={`relative`}>
{suggestedImgLoading && ( {suggestedImgLoading && (
@ -78,13 +98,18 @@ export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOp
src={`https://coverartarchive.org/release/${musicbrainzId}/front`} src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
onLoad={() => setSuggestedImgLoading(false)} onLoad={() => setSuggestedImgLoading(false)}
onError={() => setSuggestedImgLoading(false)} onError={() => setSuggestedImgLoading(false)}
className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} /> className={`block w-[130px] h-auto ${
suggestedImgLoading ? "opacity-0" : "opacity-100"
} transition-opacity duration-300`}
/>
</div> </div>
</button> </button>
</> </>
: '' ) : (
} ""
)}
<p className="error">{error}</p>
</div> </div>
</Modal> </Modal>
) );
} }

@ -19,12 +19,13 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Album() { export default function Album() {
const album = useLoaderData() as Album; const album = useLoaderData() as Album;
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
console.log(album) console.log(album);
return ( return (
<MediaLayout type="Album" <MediaLayout
type="Album"
title={album.title} title={album.title}
img={album.image} img={album.image}
id={album.id} id={album.id}
@ -32,20 +33,35 @@ export default function Album() {
imgItemId={album.id} imgItemId={album.id}
mergeFunc={mergeAlbums} mergeFunc={mergeAlbums}
mergeCleanerFunc={(r, id) => { mergeCleanerFunc={(r, id) => {
r.artists = [] r.artists = [];
r.tracks = [] r.tracks = [];
for (let i = 0; i < r.albums.length; i++) { for (let i = 0; i < r.albums.length; i++) {
if (r.albums[i].id === id) { if (r.albums[i].id === id) {
delete r.albums[i] delete r.albums[i];
} }
} }
return r return r;
}} }}
subContent={<div className="flex flex-col gap-2 items-start"> subContent={
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>} <div className="flex flex-col gap-2 items-start">
{<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>} {album.listen_count && (
{<p title={new Date(album.first_listen * 1000).toLocaleString()}>Listening since {new Date(album.first_listen * 1000).toLocaleDateString()}</p>} <p>
</div>} {album.listen_count} play{album.listen_count > 1 ? "s" : ""}
</p>
)}
{
<p title={Math.floor(album.time_listened / 60 / 60) + " hours"}>
{timeListenedString(album.time_listened)}
</p>
}
{
<p title={new Date(album.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(album.first_listen * 1000).toLocaleDateString()}
</p>
}
</div>
}
> >
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />

@ -20,17 +20,18 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Artist() { export default function Artist() {
const artist = useLoaderData() as Artist; const artist = useLoaderData() as Artist;
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
// remove canonical name from alias list // remove canonical name from alias list
console.log(artist.aliases) console.log(artist.aliases);
let index = artist.aliases.indexOf(artist.name); let index = artist.aliases.indexOf(artist.name);
if (index !== -1) { if (index !== -1) {
artist.aliases.splice(index, 1); artist.aliases.splice(index, 1);
} }
return ( return (
<MediaLayout type="Artist" <MediaLayout
type="Artist"
title={artist.name} title={artist.name}
img={artist.image} img={artist.image}
id={artist.id} id={artist.id}
@ -38,20 +39,35 @@ export default function Artist() {
imgItemId={artist.id} imgItemId={artist.id}
mergeFunc={mergeArtists} mergeFunc={mergeArtists}
mergeCleanerFunc={(r, id) => { mergeCleanerFunc={(r, id) => {
r.albums = [] r.albums = [];
r.tracks = [] r.tracks = [];
for (let i = 0; i < r.artists.length; i++) { for (let i = 0; i < r.artists.length; i++) {
if (r.artists[i].id === id) { if (r.artists[i].id === id) {
delete r.artists[i] delete r.artists[i];
} }
} }
return r return r;
}} }}
subContent={<div className="flex flex-col gap-2 items-start"> subContent={
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>} <div className="flex flex-col gap-2 items-start">
{<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>} {artist.listen_count && (
{<p title={new Date(artist.first_listen * 1000).toLocaleString()}>Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}</p>} <p>
</div>} {artist.listen_count} play{artist.listen_count > 1 ? "s" : ""}
</p>
)}
{
<p title={Math.floor(artist.time_listened / 60 / 60) + " hours"}>
{timeListenedString(artist.time_listened)}
</p>
}
{
<p title={new Date(artist.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(artist.first_listen * 1000).toLocaleDateString()}
</p>
}
</div>
}
> >
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />

@ -13,20 +13,23 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
throw new Response("Failed to load track", { status: res.status }); throw new Response("Failed to load track", { status: res.status });
} }
const track: Track = await res.json(); const track: Track = await res.json();
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`) res = await fetch(`/apis/web/v1/album?id=${track.album_id}`);
if (!res.ok) { if (!res.ok) {
throw new Response("Failed to load album for track", { status: res.status }) throw new Response("Failed to load album for track", {
status: res.status,
});
} }
const album: Album = await res.json() const album: Album = await res.json();
return { track: track, album: album }; return { track: track, album: album };
} }
export default function Track() { export default function Track() {
const { track, album } = useLoaderData(); const { track, album } = useLoaderData();
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
return ( return (
<MediaLayout type="Track" <MediaLayout
type="Track"
title={track.title} title={track.title}
img={track.image} img={track.image}
id={track.id} id={track.id}
@ -34,21 +37,36 @@ export default function Track() {
imgItemId={track.album_id} imgItemId={track.album_id}
mergeFunc={mergeTracks} mergeFunc={mergeTracks}
mergeCleanerFunc={(r, id) => { mergeCleanerFunc={(r, id) => {
r.albums = [] r.albums = [];
r.artists = [] r.artists = [];
for (let i = 0; i < r.tracks.length; i++) { for (let i = 0; i < r.tracks.length; i++) {
if (r.tracks[i].id === id) { if (r.tracks[i].id === id) {
delete r.tracks[i] delete r.tracks[i];
} }
} }
return r return r;
}} }}
subContent={<div className="flex flex-col gap-2 items-start"> subContent={
<div className="flex flex-col gap-2 items-start">
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link> <Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>} {track.listen_count && (
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>} <p>
{<p title={new Date(track.first_listen * 1000).toLocaleString()}>Listening since {new Date(track.first_listen * 1000).toLocaleDateString()}</p>} {track.listen_count} play{track.listen_count > 1 ? "s" : ""}
</div>} </p>
)}
{
<p title={Math.floor(track.time_listened / 60 / 60) + " hours"}>
{timeListenedString(track.time_listened)}
</p>
}
{
<p title={new Date(track.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(track.first_listen * 1000).toLocaleDateString()}
</p>
}
</div>
}
> >
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />
@ -58,5 +76,5 @@ export default function Track() {
<ActivityGrid trackId={track.id} configurable /> <ActivityGrid trackId={track.id} configurable />
</div> </div>
</MediaLayout> </MediaLayout>
) );
} }

@ -1,55 +1,57 @@
import Timeframe from "~/types/timeframe" import Timeframe from "~/types/timeframe";
const timeframeToInterval = (timeframe: Timeframe): string => { const timeframeToInterval = (timeframe: Timeframe): string => {
switch (timeframe) { switch (timeframe) {
case Timeframe.Day: case Timeframe.Day:
return "1 day" return "1 day";
case Timeframe.Week: case Timeframe.Week:
return "1 week" return "1 week";
case Timeframe.Month: case Timeframe.Month:
return "1 month" return "1 month";
case Timeframe.Year: case Timeframe.Year:
return "1 year" return "1 year";
case Timeframe.AllTime: case Timeframe.AllTime:
return "99 years" return "99 years";
}
} }
};
function timeSince(date: Date) { function timeSince(date: Date) {
const now = new Date(); const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
const intervals = [ const intervals = [
{ label: 'year', seconds: 31536000 }, { label: "year", seconds: 31536000 },
{ label: 'month', seconds: 2592000 }, { label: "month", seconds: 2592000 },
{ label: 'week', seconds: 604800 }, { label: "week", seconds: 604800 },
{ label: 'day', seconds: 86400 }, { label: "day", seconds: 86400 },
{ label: 'hour', seconds: 3600 }, { label: "hour", seconds: 3600 },
{ label: 'minute', seconds: 60 }, { label: "minute", seconds: 60 },
{ label: 'second', seconds: 1 }, { label: "second", seconds: 1 },
]; ];
for (const interval of intervals) { for (const interval of intervals) {
const count = Math.floor(seconds / interval.seconds); const count = Math.floor(seconds / interval.seconds);
if (count >= 1) { if (count >= 1) {
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`; return `${count} ${interval.label}${count !== 1 ? "s" : ""} ago`;
} }
} }
return 'just now'; return "just now";
} }
export { timeSince } export { timeSince };
type hsl = { type hsl = {
h: number, h: number;
s: number, s: number;
l: number, l: number;
} };
const hexToHSL = (hex: string): hsl => { const hexToHSL = (hex: string): hsl => {
let r = 0, g = 0, b = 0; let r = 0,
hex = hex.replace('#', ''); g = 0,
b = 0;
hex = hex.replace("#", "");
if (hex.length === 3) { if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16); r = parseInt(hex[0] + hex[0], 16);
@ -65,16 +67,25 @@ const hexToHSL = (hex: string): hsl => {
g /= 255; g /= 255;
b /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b); const max = Math.max(r, g, b),
let h = 0, s = 0, l = (max + min) / 2; min = Math.min(r, g, b);
let h = 0,
s = 0,
l = (max + min) / 2;
if (max !== min) { if (max !== min) {
const d = max - min; const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) { switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)); break; case r:
case g: h = ((b - r) / d + 2); break; h = (g - b) / d + (g < b ? 6 : 0);
case b: h = ((r - g) / d + 4); break; break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
} }
h /= 6; h /= 6;
} }
@ -82,21 +93,16 @@ const hexToHSL = (hex: string): hsl => {
return { return {
h: Math.round(h * 360), h: Math.round(h * 360),
s: Math.round(s * 100), s: Math.round(s * 100),
l: Math.round(l * 100) l: Math.round(l * 100),
}; };
}; };
const timeListenedString = (seconds: number) => { const timeListenedString = (seconds: number) => {
if (!seconds) return "" if (!seconds) return "";
if (seconds > (120 * 60) - 1) { let minutes = Math.floor(seconds / 60);
let hours = Math.floor(seconds / 60 / 60) return `${minutes} minutes listened`;
return `${hours} hours listened` };
} else {
let minutes = Math.floor(seconds / 60)
return `${minutes} minutes listened`
}
}
export {hexToHSL, timeListenedString} export { hexToHSL, timeListenedString };
export type {hsl} export type { hsl };

Loading…
Cancel
Save