updates
This commit is contained in:
parent
5c0034aabd
commit
9ad8d255de
70 changed files with 3498 additions and 2727 deletions
|
@ -1,7 +0,0 @@
|
|||
.logo.vite:hover {
|
||||
filter: drop-shadow(0 0 2em #747bff);
|
||||
}
|
||||
|
||||
.logo.preact:hover {
|
||||
filter: drop-shadow(0 0 2em #673ab8);
|
||||
}
|
46
src/App.tsx
46
src/App.tsx
|
@ -1,23 +1,41 @@
|
|||
import { isWindows } from "@tauri-apps/api/helpers/os-check";
|
||||
import type { RouteSectionProps } from "@solidjs/router";
|
||||
|
||||
import Sidebar from "./components/sidebar";
|
||||
|
||||
import Player from "./components/player";
|
||||
import { UserProvider } from "./contexts/user";
|
||||
import { useBindings } from "./hooks/use-bindings";
|
||||
import { cn } from "./lib/utils";
|
||||
import { sizes } from "./lib/store";
|
||||
|
||||
import { dragging } from "./store";
|
||||
import TitleBar from "./components/title-bar";
|
||||
import { store } from "./contexts/player";
|
||||
|
||||
export default function App({ children }: RouteSectionProps) {
|
||||
useBindings();
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div
|
||||
className={cn("select-none", {
|
||||
"cursor-col-resize": dragging.value.isDragging,
|
||||
})}
|
||||
>
|
||||
{isWindows() && <TitleBar />}
|
||||
<UserProvider>
|
||||
<div class="grid h-[100dvh] grid-rows-[1fr,auto]">
|
||||
<div
|
||||
class={cn(
|
||||
"grid h-full min-h-full grid-cols-[auto,1fr,auto] gap-2 bg-primary/90 p-2 pb-0 backdrop-blur",
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
|
||||
<div className="grid grid-cols-[max-content,1fr]">
|
||||
<Sidebar />
|
||||
<main class="space overflow-y-auto bg-secondary">{children}</main>
|
||||
|
||||
{store.queueOpen && (
|
||||
<div
|
||||
class="space bg-secondary"
|
||||
style={{ width: `${sizes.queue}px` }}
|
||||
>
|
||||
<h1 class="headline">Queue</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Player />
|
||||
</div>
|
||||
</div>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296"><path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"/><path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"/><path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"/><path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"/></svg>
|
Before Width: | Height: | Size: 1.5 KiB |
229
src/components/player.tsx
Normal file
229
src/components/player.tsx
Normal file
|
@ -0,0 +1,229 @@
|
|||
import { As } from "@kobalte/core";
|
||||
import { ComponentProps, Match, Switch } from "solid-js";
|
||||
|
||||
import * as Icons from "@/icons";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
import { setStore, store } from "@/contexts/player";
|
||||
|
||||
import player from "@/lib/player";
|
||||
import { formatDuration } from "@/lib/duration";
|
||||
import { parseDuration } from "@/lib/time";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function VolumeIcon() {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={store.muted}>
|
||||
<Icons.VolumeOffStroke class="size-6" />
|
||||
</Match>
|
||||
<Match when={store.volume <= 0}>
|
||||
<Icons.VolumeMute02Stroke class="size-6" />
|
||||
</Match>
|
||||
<Match when={store.volume <= 0.33}>
|
||||
<Icons.VolumeMute01Stroke class="size-6" />
|
||||
</Match>
|
||||
<Match when={store.volume <= 0.66}>
|
||||
<Icons.VolumeLowStroke class="size-6" />
|
||||
</Match>
|
||||
<Match when={store.volume <= 1}>
|
||||
<Icons.VolumeHighStroke class="size-6" />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayPauseIcon() {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={store.playing}>
|
||||
<Icons.PauseStroke class="size-8 text-primary" />
|
||||
</Match>
|
||||
<Match when={!store.playing}>
|
||||
<Icons.PlayStroke class="size-8 text-primary" />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeatIcon() {
|
||||
return (
|
||||
<Switch fallback={<Icons.RepeatStroke class="size-4 text-secondary" />}>
|
||||
<Match when={store.repeat === "all"}>
|
||||
<Icons.RepeatStroke class="size-4 text-primary" />
|
||||
</Match>
|
||||
<Match when={store.repeat === "one"}>
|
||||
<Icons.RepeatOne01Stroke class="size-4 text-primary" />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Player() {
|
||||
return (
|
||||
<div class="space grid h-24 grid-cols-3 gap-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="aspect-square h-full rounded-md border bg-secondary" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<h4>{store.track.title}</h4>
|
||||
<span class="text-sm text-secondary">
|
||||
{store.track.artists.map((artist, i) => (
|
||||
<>
|
||||
<A class="hover:underline" href={`/artists/${artist.id}`}>
|
||||
{artist.name}
|
||||
</A>
|
||||
{i < store.track.artists.length - 1 && ", "}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-center gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
tooltip="Shuffle"
|
||||
onClick={() =>
|
||||
setStore((store) => ({ ...store, shuffle: !store.shuffle }))
|
||||
}
|
||||
class={cn({
|
||||
"bg-tertiary text-primary": store.shuffle,
|
||||
})}
|
||||
>
|
||||
<Icons.ShuffleStroke class="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button tooltip="Previous">
|
||||
<Icons.PreviousStroke class="size-6" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
tooltip={store.playing ? "Pause" : "Play"}
|
||||
onClick={() => player.playPause()}
|
||||
>
|
||||
<PlayPauseIcon />
|
||||
</Button>
|
||||
|
||||
<Button tooltip="Next">
|
||||
<Icons.NextStroke class="size-6" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
tooltip="Repeat"
|
||||
onClick={() =>
|
||||
setStore((store) => ({
|
||||
...store,
|
||||
repeat:
|
||||
store.repeat === "none"
|
||||
? "all"
|
||||
: store.repeat === "all"
|
||||
? "one"
|
||||
: "none",
|
||||
}))
|
||||
}
|
||||
class={cn({
|
||||
"bg-tertiary text-primary":
|
||||
store.repeat === "all" || store.repeat === "one",
|
||||
})}
|
||||
>
|
||||
<RepeatIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center gap-4 text-sm">
|
||||
<span class="w-16">
|
||||
{formatDuration(parseDuration(store.progress))}
|
||||
</span>
|
||||
<div
|
||||
class="h-1.5 w-full cursor-pointer overflow-hidden rounded-md bg-tertiary"
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const progress = x / width;
|
||||
|
||||
player.seek(progress * store.duration);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="h-full w-0 rounded-md bg-accent"
|
||||
style={{ width: `${(store.progress / store.duration) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span class="w-16 text-right">
|
||||
{formatDuration(parseDuration(store.duration))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<Button tooltip="Sound output">
|
||||
<Icons.RadioStroke class="size-6" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
tooltip="Queue"
|
||||
onClick={() => {
|
||||
setStore((store) => ({ ...store, queueOpen: !store.queueOpen }));
|
||||
}}
|
||||
>
|
||||
<Icons.Queue02Stroke class="size-6" />
|
||||
</Button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
tooltip="Volume"
|
||||
onClick={() =>
|
||||
setStore((store) => ({ ...store, muted: !store.muted }))
|
||||
}
|
||||
>
|
||||
<VolumeIcon />
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
class="block h-full max-w-24 accent-accent"
|
||||
value={store.muted ? 0 : store.volume}
|
||||
onInput={(event) => {
|
||||
setStore((store) => ({
|
||||
...store,
|
||||
volume: Number(event.currentTarget.value),
|
||||
muted: false,
|
||||
}));
|
||||
}}
|
||||
min={0}
|
||||
step={0.01}
|
||||
max={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps extends ComponentProps<"button"> {
|
||||
tooltip?: string;
|
||||
children: any;
|
||||
|
||||
onClick?: () => void;
|
||||
}
|
||||
function Button(props: ButtonProps) {
|
||||
return (
|
||||
<Tooltip placement="top">
|
||||
<TooltipTrigger asChild>
|
||||
<As
|
||||
component="button"
|
||||
class={cn(
|
||||
"flex size-8 cursor-pointer items-center justify-center rounded-md text-secondary transition-colors hover:scale-105 hover:text-primary",
|
||||
props.class,
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</As>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{props.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
import { useCallback, useEffect } from "preact/hooks";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { dragging } from "@/store";
|
||||
|
||||
const { isDragging } = dragging.value;
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<aside
|
||||
className="relative flex h-[100dvh] w-full bg-secondary px-2 py-8"
|
||||
style={{ width: dragging.value.width }}
|
||||
>
|
||||
<div className=""></div>
|
||||
<nav className="">
|
||||
<a href="#">Lol</a>
|
||||
</nav>
|
||||
<Divider />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
function onMouseDown() {
|
||||
dragging.value = {
|
||||
...dragging.value,
|
||||
isDragging: true,
|
||||
};
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
dragging.value = {
|
||||
...dragging.value,
|
||||
isDragging: false,
|
||||
};
|
||||
}
|
||||
|
||||
const handler = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (dragging.value.isDragging) {
|
||||
const width = Math.min(Math.max(200, event.clientX), 320);
|
||||
dragging.value = {
|
||||
...dragging.value,
|
||||
width,
|
||||
};
|
||||
localStorage.setItem("sidebar-width", width.toString());
|
||||
}
|
||||
},
|
||||
[dragging.value.isDragging],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mousemove", handler);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handler);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group absolute -right-2 bottom-0 top-0 px-2 hover:cursor-col-resize",
|
||||
{},
|
||||
)}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
>
|
||||
<div className="h-full w-[1px] bg-border transition-colors group-hover:scale-x-[2] group-hover:bg-accent/50"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
112
src/components/sidebar/index.tsx
Normal file
112
src/components/sidebar/index.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
import { As } from "@kobalte/core";
|
||||
import { A } from "@solidjs/router";
|
||||
import { createSignal, useContext } from "solid-js";
|
||||
|
||||
import * as Icons from "@/icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import Playlists from "./playlists";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserContext } from "@/contexts/user";
|
||||
import { sizes } from "@/lib/store";
|
||||
|
||||
const links = [
|
||||
{
|
||||
href: "/",
|
||||
name: "Library",
|
||||
icon: Icons.FolderLibraryStroke,
|
||||
},
|
||||
{
|
||||
href: "/search",
|
||||
name: "Search",
|
||||
icon: Icons.FileSearchStroke,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<aside
|
||||
class="relative grid h-full w-full grid-rows-[max-content,1fr] flex-col gap-2 text-secondary"
|
||||
style={{ width: `${sizes.sidebar}px` }}
|
||||
>
|
||||
<div class="space flex items-center justify-between bg-secondary py-4">
|
||||
<A
|
||||
href={`/users/${user.id}`}
|
||||
class="flex h-full items-center gap-4 rounded-md px-3 transition-colors hover:bg-tertiary"
|
||||
>
|
||||
<div class="flex size-8 items-center justify-center rounded-full bg-tertiary text-sm font-bold text-secondary">
|
||||
{user.initials}
|
||||
</div>
|
||||
<span class="text-primary">{user.name}</span>
|
||||
</A>
|
||||
|
||||
<DropdownMenu placement="bottom-end" onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger class="transition-colors hover:text-primary">
|
||||
<Icons.ArrowDown01Stroke
|
||||
class={cn("size-6 transition-transform", {
|
||||
"rotate-180 transform-gpu": open(),
|
||||
})}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<As component={A} href="/users/asd">
|
||||
Profile
|
||||
</As>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<As component={A} href="/settings">
|
||||
Settings
|
||||
</As>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
class="text-red-400"
|
||||
onClick={() => alert("Would logout")}
|
||||
>
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div class="space h-100 flex flex-col gap-8 bg-secondary">
|
||||
<nav>
|
||||
{links.map((link) => (
|
||||
<A
|
||||
href={link.href}
|
||||
activeClass="bg-tertiary text-accent"
|
||||
class="mb-2 flex h-10 items-center gap-4 rounded-md px-4 transition-colors hover:bg-tertiary"
|
||||
end
|
||||
>
|
||||
<link.icon class="size-5" />
|
||||
<span class="text-primary">{link.name}</span>
|
||||
</A>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<nav>
|
||||
<header class="mb-2 flex items-center justify-between">
|
||||
<h4 class="text-sm font-light uppercase tracking-widest text-secondary">
|
||||
Playlists
|
||||
</h4>
|
||||
<button>
|
||||
<Icons.PlusSignStroke size={20} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Playlists />
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
34
src/components/sidebar/playlists.tsx
Normal file
34
src/components/sidebar/playlists.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { A } from "@solidjs/router";
|
||||
import { For, Show, createResource } from "solid-js";
|
||||
|
||||
import * as Icons from "@/icons";
|
||||
import { getPlaylists } from "@/lib/playlists";
|
||||
|
||||
export default function Playlists() {
|
||||
const [playlists] = createResource(getPlaylists);
|
||||
|
||||
return (
|
||||
<Show when={!playlists.loading} fallback={<p>Loading...</p>}>
|
||||
<For each={playlists.latest}>
|
||||
{(playlist) => {
|
||||
const Icon =
|
||||
playlist.playlist_type === "folder"
|
||||
? Icons.Folder01Stroke
|
||||
: Icons.Playlist03Stroke;
|
||||
|
||||
return (
|
||||
<A
|
||||
href={`/${playlist.playlist_type}s/${playlist.id}`}
|
||||
activeClass="bg-tertiary text-accent"
|
||||
class="mb-2 flex h-10 items-center gap-4 rounded-md px-4 transition-colors hover:bg-tertiary"
|
||||
end
|
||||
>
|
||||
<Icon class="size-5" />
|
||||
<span class="text-primary">{playlist.name}</span>
|
||||
</A>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
);
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
import { isWindows } from "@tauri-apps/api/helpers/os-check";
|
||||
import { appWindow } from "@tauri-apps/api/window";
|
||||
|
||||
export default function TitleBar() {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-8 w-full select-none border-b bg-primary"
|
||||
>
|
||||
{isWindows() && <Windows />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Windows() {
|
||||
return (
|
||||
<div className="ml-auto flex">
|
||||
<button
|
||||
className="flex w-12 items-center justify-center transition-colors hover:bg-tertiary"
|
||||
onClick={() => appWindow.minimize()}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 12L4 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="flex w-12 items-center justify-center transition-colors hover:bg-tertiary"
|
||||
onClick={() => appWindow.toggleMaximize()}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.5 12C2.5 7.52166 2.5 5.28249 3.89124 3.89124C5.28249 2.5 7.52166 2.5 12 2.5C16.4783 2.5 18.7175 2.5 20.1088 3.89124C21.5 5.28249 21.5 7.52166 21.5 12C21.5 16.4783 21.5 18.7175 20.1088 20.1088C18.7175 21.5 16.4783 21.5 12 21.5C7.52166 21.5 5.28249 21.5 3.89124 20.1088C2.5 18.7175 2.5 16.4783 2.5 12Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="flex w-12 items-center justify-center transition-colors hover:bg-red-500"
|
||||
onClick={() => appWindow.close()}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19 5L5 19M5 5L19 19"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
184
src/components/ui/dropdown-menu.tsx
Normal file
184
src/components/ui/dropdown-menu.tsx
Normal file
|
@ -0,0 +1,184 @@
|
|||
import type { Component, ComponentProps } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "@kobalte/core"
|
||||
import { TbCheck, TbChevronRight, TbCircle } from "solid-icons/tb"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu: Component<DropdownMenuPrimitive.DropdownMenuRootProps> = (props) => {
|
||||
return <DropdownMenuPrimitive.Root gutter={4} {...props} />
|
||||
}
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuContent: Component<DropdownMenuPrimitive.DropdownMenuContentProps> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
class={cn(
|
||||
"bg-secondary text-primary animate-content-hide data-[expanded]:animate-content-show z-50 min-w-[12rem] origin-[var(--kb-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuItem: Component<DropdownMenuPrimitive.DropdownMenuItemProps> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
class={cn(
|
||||
"cursor-pointer focus:bg-tertiary focus:text-accent-foreground relative flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuShortcut: Component<ComponentProps<"span">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <span class={cn("ml-auto text-xs tracking-widest opacity-60", props.class)} {...rest} />
|
||||
}
|
||||
|
||||
const DropdownMenuLabel: Component<ComponentProps<"div"> & { inset?: boolean }> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class", "inset"])
|
||||
return (
|
||||
<div
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", props.inset && "pl-8", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuSeparator: Component<DropdownMenuPrimitive.DropdownMenuSeparatorProps> = (
|
||||
props
|
||||
) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
class={cn("bg-muted -mx-1 my-1 h-px", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuSubTrigger: Component<DropdownMenuPrimitive.DropdownMenuSubTriggerProps> = (
|
||||
props
|
||||
) => {
|
||||
const [, rest] = splitProps(props, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
class={cn(
|
||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{props.children}
|
||||
<TbChevronRight class="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuSubContent: Component<DropdownMenuPrimitive.DropdownMenuSubContentProps> = (
|
||||
props
|
||||
) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground animate-in z-50 min-w-[8rem] origin-[var(--kb-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuCheckboxItem: Component<DropdownMenuPrimitive.DropdownMenuCheckboxItemProps> = (
|
||||
props
|
||||
) => {
|
||||
const [, rest] = splitProps(props, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<TbCheck class="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuGroupLabel: Component<DropdownMenuPrimitive.DropdownMenuGroupLabelProps> = (
|
||||
props
|
||||
) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.GroupLabel
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuRadioItem: Component<DropdownMenuPrimitive.DropdownMenuRadioItemProps> = (
|
||||
props
|
||||
) => {
|
||||
const [, rest] = splitProps(props, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<TbCircle class="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuGroupLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem
|
||||
}
|
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { splitProps, type Component } from "solid-js"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@kobalte/core"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tooltip: Component<TooltipPrimitive.TooltipRootProps> = (props) => {
|
||||
return <TooltipPrimitive.Root gutter={4} {...props} />
|
||||
}
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent: Component<TooltipPrimitive.TooltipContentProps> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
class={cn(
|
||||
"bg-primary text-primary animate-in fade-in-0 zoom-in-95 z-50 origin-[var(--kb-popover-content-transform-origin)] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent }
|
76
src/contexts/player.tsx
Normal file
76
src/contexts/player.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { createStore } from "solid-js/store";
|
||||
|
||||
import player from "@/lib/player";
|
||||
import type { Track } from "@/types/track";
|
||||
|
||||
interface PlayerStore {
|
||||
playing: boolean;
|
||||
progress: number;
|
||||
duration: number;
|
||||
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
|
||||
track: Track;
|
||||
|
||||
queueOpen: boolean;
|
||||
|
||||
shuffle: boolean;
|
||||
repeat: "none" | "one" | "all";
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<PlayerStore>({
|
||||
playing: false,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
|
||||
muted: false,
|
||||
volume: 0.5,
|
||||
|
||||
track: player.getTrack(),
|
||||
|
||||
queueOpen: false,
|
||||
|
||||
shuffle: false,
|
||||
repeat: "none",
|
||||
});
|
||||
|
||||
const audio = player.getAudio();
|
||||
|
||||
audio.addEventListener("play", () =>
|
||||
setStore((store) => ({ ...store, playing: true })),
|
||||
);
|
||||
|
||||
audio.addEventListener("pause", () =>
|
||||
setStore((store) => ({ ...store, playing: false })),
|
||||
);
|
||||
|
||||
audio.addEventListener("ended", () =>
|
||||
setStore((store) => ({ ...store, playing: false })),
|
||||
);
|
||||
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
setStore((store) => ({
|
||||
...store,
|
||||
progress: Math.floor(audio.currentTime),
|
||||
duration: Math.floor(audio.duration),
|
||||
}));
|
||||
});
|
||||
|
||||
audio.addEventListener("volumechange", () => {
|
||||
setStore((store) => ({
|
||||
...store,
|
||||
muted: audio.muted,
|
||||
volume: audio.volume,
|
||||
}));
|
||||
});
|
||||
|
||||
player.on("track", (track) => {
|
||||
setStore((store) => ({
|
||||
...store,
|
||||
track,
|
||||
progress: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
export { store, setStore };
|
46
src/contexts/user.tsx
Normal file
46
src/contexts/user.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { fetchAPI } from "@/lib/api";
|
||||
import { Show } from "solid-js";
|
||||
import { createContext, createResource, JSX } from "solid-js";
|
||||
|
||||
const DEFAULT_USER = {
|
||||
id: "",
|
||||
name: "Miguel da Mota",
|
||||
email: "miguel@damota.de",
|
||||
initials: "",
|
||||
};
|
||||
|
||||
export const UserContext = createContext({
|
||||
user: DEFAULT_USER,
|
||||
});
|
||||
|
||||
export function UserProvider(props: { children: JSX.Element }) {
|
||||
const [user] = createResource(getMe);
|
||||
|
||||
return (
|
||||
<Show when={!user.loading} fallback={<p>Loading...</p>}>
|
||||
<UserContext.Provider value={{ user: user()! }}>
|
||||
{props.children}
|
||||
</UserContext.Provider>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export function getInitials(name: string) {
|
||||
const [first, ...others] = name.split(" ");
|
||||
|
||||
let initials = first[0];
|
||||
|
||||
if (others.length > 0) {
|
||||
initials += others[0][0];
|
||||
}
|
||||
|
||||
return initials.toUpperCase();
|
||||
}
|
||||
|
||||
async function getMe(): Promise<typeof DEFAULT_USER> {
|
||||
const user = await fetchAPI<typeof DEFAULT_USER>("/me");
|
||||
|
||||
user.initials = getInitials(user.name);
|
||||
|
||||
return user;
|
||||
}
|
16
src/hooks/use-bindings.ts
Normal file
16
src/hooks/use-bindings.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
const bindings = new Map<string, (event: KeyboardEvent) => void>();
|
||||
|
||||
export function useBindings() {
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (bindings.has(event.key)) {
|
||||
event.preventDefault();
|
||||
bindings.get(event.key)?.(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function bind(key: string, callback: (event: KeyboardEvent) => void) {
|
||||
bindings.set(key, callback);
|
||||
}
|
||||
|
||||
bind("a", () => console.log("a was pressed"));
|
56
src/index.css
Normal file
56
src/index.css
Normal file
|
@ -0,0 +1,56 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Rubik";
|
||||
font-weight: 400;
|
||||
src: url("/assets/fonts/Rubik-Regular.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Rubik";
|
||||
font-weight: 700;
|
||||
src: url("/assets/fonts/Rubik-Bold.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Rubik";
|
||||
font-weight: 900;
|
||||
src: url("/assets/fonts/Rubik-Black.woff2");
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Rubik";
|
||||
overscroll-behavior-y: none;
|
||||
|
||||
height: 100%;
|
||||
line-height: normal;
|
||||
min-height: 600px;
|
||||
min-width: 800px;
|
||||
|
||||
@apply bg-primary text-primary relative select-none;
|
||||
}
|
||||
|
||||
.space {
|
||||
@apply rounded-lg p-4;
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply border-0 border-t;
|
||||
}
|
||||
|
||||
.playlist-row {
|
||||
@apply grid grid-cols-[48px,1fr,.5fr,128px,48px] items-center gap-4 rounded-md px-4 py-2 md:grid-cols-[48px,1fr,.5fr,128px,128px,48px];
|
||||
}
|
||||
|
||||
/* Custom classes */
|
||||
.headline {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
25
src/lib/api.ts
Normal file
25
src/lib/api.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
const API_URL = "http://localhost:9000";
|
||||
|
||||
const ACCESS_TOKEN =
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3Mzg0MjY1MDMsInVzZXJfaWQiOiI1a3hERGNvSFpTQkM3dUVZc20zRnZrdEsifQ.Z3xceSQKjDg5nHQel3jGlUDjWiCjDUmhFgJtrirxyew";
|
||||
|
||||
export async function fetchAPI<Body>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
): Promise<Body> {
|
||||
const response = await fetch(`${API_URL}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
return body;
|
||||
}
|
15
src/lib/duration.ts
Normal file
15
src/lib/duration.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import type { ParseDuration } from "./time";
|
||||
|
||||
export function formatDuration({ hours, minutes, seconds }: ParseDuration) {
|
||||
let output = "";
|
||||
|
||||
if (hours) {
|
||||
output += hours.toString().padStart(2, "0") + ":";
|
||||
}
|
||||
|
||||
output += minutes.toString().padStart(2, "0") + ":";
|
||||
|
||||
output += seconds.toString().padStart(2, "0");
|
||||
|
||||
return output;
|
||||
}
|
21
src/lib/player/chunk-cache.ts
Normal file
21
src/lib/player/chunk-cache.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import Chunk from "./chunk";
|
||||
|
||||
class ChunkCache {
|
||||
private cache = new Map<number, ArrayBuffer>();
|
||||
|
||||
get(index: number) {
|
||||
return this.cache.get(index);
|
||||
}
|
||||
|
||||
set(chunk: Chunk) {
|
||||
this.cache.set(chunk.getIndex(), chunk.getBuffer());
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const chunkCache = new ChunkCache();
|
||||
|
||||
export default chunkCache;
|
73
src/lib/player/chunk-queue.ts
Normal file
73
src/lib/player/chunk-queue.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { Player } from ".";
|
||||
|
||||
import Chunk from "./chunk";
|
||||
import chunkCache from "./chunk-cache";
|
||||
|
||||
export default class ChunkQueue {
|
||||
private queue = new Map<number, ArrayBuffer>();
|
||||
|
||||
private index = -1;
|
||||
|
||||
constructor(private readonly player: Player) {
|
||||
this.index = player.currentChunk;
|
||||
}
|
||||
|
||||
next() {
|
||||
const next = this.queue.get(this.index);
|
||||
|
||||
if (!next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delete entry from queue.
|
||||
this.queue.delete(this.index);
|
||||
|
||||
// Increment index.
|
||||
this.index++;
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
enqueue(chunk: Chunk) {
|
||||
this.queue.set(chunk.getIndex(), chunk.getBuffer());
|
||||
|
||||
this.player.emit("queue");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear
|
||||
*/
|
||||
clear() {
|
||||
this.queue.clear();
|
||||
}
|
||||
|
||||
setIndex(index: number) {
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.queue.size;
|
||||
}
|
||||
|
||||
private async getChunk(index: number) {
|
||||
const cached = chunkCache.get(index);
|
||||
|
||||
if (cached) {
|
||||
return new Chunk(cached, index);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
this.player
|
||||
.getPlaybackInfo()
|
||||
.stream_url.replace("{chunk}", String(index))!,
|
||||
);
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
return new Chunk(buffer, index);
|
||||
}
|
||||
|
||||
async load(index: number) {
|
||||
const chunk = await this.getChunk(index);
|
||||
this.enqueue(chunk);
|
||||
}
|
||||
}
|
14
src/lib/player/chunk.ts
Normal file
14
src/lib/player/chunk.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export default class Chunk {
|
||||
constructor(
|
||||
private readonly _buffer: ArrayBuffer,
|
||||
private readonly _index: number,
|
||||
) {}
|
||||
|
||||
getIndex() {
|
||||
return this._index;
|
||||
}
|
||||
|
||||
getBuffer() {
|
||||
return this._buffer;
|
||||
}
|
||||
}
|
319
src/lib/player/index.ts
Normal file
319
src/lib/player/index.ts
Normal file
|
@ -0,0 +1,319 @@
|
|||
import ChunkQueue from "./chunk-queue";
|
||||
import type { Events, EventListeners } from "./types/player";
|
||||
|
||||
import { DEFAULT_PLAYBACK_INFO, PlaybackInfo } from "./types/playback-info";
|
||||
import { DEFAULT_TRACK, type Track } from "@/types/track";
|
||||
import chunkCache from "./chunk-cache";
|
||||
|
||||
const CHUNK_LENGTH = 5,
|
||||
PRELOAD_CHUNKS = 3;
|
||||
|
||||
export class Player {
|
||||
static audio = new Audio();
|
||||
|
||||
private _listeners = new Map<Events, EventListeners[Events][]>();
|
||||
private _initialized = false;
|
||||
|
||||
private track = DEFAULT_TRACK;
|
||||
|
||||
private queue = new ChunkQueue(this);
|
||||
|
||||
private playbackInfo: PlaybackInfo = DEFAULT_PLAYBACK_INFO;
|
||||
// private state
|
||||
|
||||
private sourceBuffer: SourceBuffer | null = null;
|
||||
|
||||
currentChunk = 0;
|
||||
|
||||
constructor() {
|
||||
navigator.mediaSession.setActionHandler("pause", () => this.playPause());
|
||||
navigator.mediaSession.setActionHandler("play", () => this.playPause());
|
||||
|
||||
this.createMediaSource = this.createMediaSource.bind(this);
|
||||
this.sourceOpen = this.sourceOpen.bind(this);
|
||||
|
||||
// this.startStateUpdate = this.startStateUpdate.bind(this);
|
||||
// this.updateState = this.updateState.bind(this);
|
||||
|
||||
this.reset = this.reset.bind(this);
|
||||
this.play = this.play.bind(this);
|
||||
this.playPause = this.playPause.bind(this);
|
||||
this.runQueue = this.runQueue.bind(this);
|
||||
|
||||
this.changeTrack = this.changeTrack.bind(this);
|
||||
this.processNextChunk = this.processNextChunk.bind(this);
|
||||
|
||||
this.registerEvents = this.registerEvents.bind(this);
|
||||
|
||||
this.registerEvents();
|
||||
}
|
||||
|
||||
private registerEvents() {
|
||||
this.on("queue", this.runQueue);
|
||||
}
|
||||
|
||||
getAudio() {
|
||||
return Player.audio;
|
||||
}
|
||||
|
||||
getTrack(): Track {
|
||||
return this.track;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change track.
|
||||
*/
|
||||
async changeTrack(track: Track, progress = 0) {
|
||||
// If track is the same, just play/pause
|
||||
if (this.track.id === track.id && !progress) {
|
||||
this.playPause();
|
||||
return;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
|
||||
Player.audio.pause();
|
||||
|
||||
if (this.track.id !== track.id) {
|
||||
this.queue.clear();
|
||||
}
|
||||
|
||||
this.track = track;
|
||||
await this.fetchPlaybackInfo();
|
||||
|
||||
this.emit("track", track);
|
||||
|
||||
// Update mediaSession
|
||||
if ("mediaSession" in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: track.title,
|
||||
artist: track.artists.map((artist) => artist.name).join(", "),
|
||||
album: "Unknown",
|
||||
// artwork: [
|
||||
// {
|
||||
// src: track.image,
|
||||
// sizes: "1280x1280",
|
||||
// type: "image/jpg",
|
||||
// },
|
||||
// ],
|
||||
});
|
||||
}
|
||||
|
||||
this.queue = new ChunkQueue(this);
|
||||
|
||||
this._progress = progress;
|
||||
this.currentChunk = getChunkFromTime(progress);
|
||||
|
||||
this.play();
|
||||
}
|
||||
private _progress = 0;
|
||||
|
||||
private _checkedSecond = 0;
|
||||
private processNextChunk() {
|
||||
const second = Math.floor(Player.audio.currentTime);
|
||||
|
||||
if (second % CHUNK_LENGTH !== 0 || this._checkedSecond === second) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._checkedSecond = second;
|
||||
|
||||
const nextChunk = this.currentChunk + 1;
|
||||
this.queue.setIndex(nextChunk);
|
||||
|
||||
if (nextChunk > this.playbackInfo.max_chunks) {
|
||||
this.sourceBuffer?.removeEventListener("updateend", this.runQueue);
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue.load(nextChunk);
|
||||
|
||||
this.currentChunk = nextChunk;
|
||||
}
|
||||
|
||||
private async fetchPlaybackInfo() {
|
||||
const { id } = this.track;
|
||||
|
||||
const data = await fetch(`http://localhost:9001/tracks/${id}`).then((res) =>
|
||||
res.json(),
|
||||
);
|
||||
|
||||
this.playbackInfo = data;
|
||||
}
|
||||
|
||||
private createMediaSource() {
|
||||
const mediaSource = new MediaSource();
|
||||
|
||||
const audio = this.getAudio();
|
||||
audio.src = URL.createObjectURL(mediaSource);
|
||||
|
||||
audio.currentTime = this._progress;
|
||||
|
||||
mediaSource.addEventListener("sourceopen", () => {
|
||||
this.sourceOpen(mediaSource);
|
||||
});
|
||||
}
|
||||
|
||||
private async sourceOpen(mediaSource: MediaSource) {
|
||||
const { queue } = this;
|
||||
queue.clear();
|
||||
chunkCache.clear();
|
||||
|
||||
this.sourceBuffer = mediaSource.addSourceBuffer(this.getSourceBufferType());
|
||||
|
||||
--this.currentChunk;
|
||||
|
||||
this.queue.setIndex(0);
|
||||
await this.queue.load(0);
|
||||
|
||||
this.queue.setIndex(++this.currentChunk);
|
||||
|
||||
// Prleoad x chunks.
|
||||
for (let i = 0; i < PRELOAD_CHUNKS; i++) {
|
||||
if (this.currentChunk > this.playbackInfo.max_chunks) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch chunk.
|
||||
this.queue.load(this.currentChunk++);
|
||||
}
|
||||
|
||||
--this.currentChunk;
|
||||
|
||||
this.sourceBuffer.addEventListener("updateend", this.runQueue);
|
||||
|
||||
Player.audio.addEventListener("timeupdate", this.processNextChunk);
|
||||
}
|
||||
|
||||
private getSourceBufferType(): string {
|
||||
const types = ["audio/mp4", 'audio/mp4; codecs="flac"'];
|
||||
|
||||
for (const type of types) {
|
||||
if (MediaSource.isTypeSupported(type)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private runQueue() {
|
||||
const { queue } = this;
|
||||
|
||||
if (queue.size === 0 || this.sourceBuffer?.updating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = queue.next();
|
||||
|
||||
if (!buffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.sourceBuffer?.appendBuffer(buffer);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
private play() {
|
||||
if (!this._initialized) {
|
||||
this.createMediaSource();
|
||||
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
Player.audio.play();
|
||||
}
|
||||
|
||||
playPause() {
|
||||
if (!this.track.id) return;
|
||||
|
||||
const audio = this.getAudio();
|
||||
|
||||
if (audio.paused) {
|
||||
this.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this._initialized = false;
|
||||
|
||||
if (this.sourceBuffer) {
|
||||
this.sourceBuffer.removeEventListener("updateend", this.runQueue);
|
||||
}
|
||||
|
||||
Player.audio.removeEventListener("timeupdate", this.processNextChunk);
|
||||
}
|
||||
|
||||
public getPlaybackInfo() {
|
||||
return this.playbackInfo;
|
||||
}
|
||||
|
||||
/// Controls
|
||||
get volume() {
|
||||
return Player.audio.volume;
|
||||
}
|
||||
|
||||
set volume(value: number) {
|
||||
Player.audio.volume = value;
|
||||
}
|
||||
|
||||
get muted() {
|
||||
return Player.audio.muted;
|
||||
}
|
||||
|
||||
set muted(value: boolean) {
|
||||
Player.audio.muted = value;
|
||||
}
|
||||
|
||||
get currentTime() {
|
||||
return Player.audio.currentTime;
|
||||
}
|
||||
|
||||
isPlaying() {
|
||||
return !Player.audio.paused;
|
||||
}
|
||||
|
||||
seek(progress: number) {
|
||||
this.changeTrack(this.track, progress);
|
||||
}
|
||||
|
||||
/// Events
|
||||
public emit<E extends Events>(event: E, ...args: unknown[]) {
|
||||
const listeners: EventListeners[Events][] =
|
||||
this._listeners.get(event) ?? [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
listeners.forEach((listener) => listener(...args));
|
||||
}
|
||||
|
||||
public on<E extends Events>(event: E, listener: EventListeners[E]) {
|
||||
const eventListeners = this._listeners.get(event) ?? [];
|
||||
|
||||
eventListeners.push(listener);
|
||||
|
||||
this._listeners.set(event, eventListeners);
|
||||
}
|
||||
|
||||
public off<E extends Events>(event: E, listener: EventListeners[E]) {
|
||||
const eventListeners = this._listeners.get(event) ?? [];
|
||||
|
||||
this._listeners.set(
|
||||
event,
|
||||
eventListeners.filter((eventListener) => eventListener !== listener),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const player = new Player();
|
||||
|
||||
export default player;
|
||||
|
||||
function getChunkFromTime(time: number) {
|
||||
return Math.floor(time / CHUNK_LENGTH);
|
||||
}
|
13
src/lib/player/types/playback-info.ts
Normal file
13
src/lib/player/types/playback-info.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export interface PlaybackInfo {
|
||||
id: string;
|
||||
duration: number;
|
||||
max_chunks: number;
|
||||
stream_url: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYBACK_INFO: PlaybackInfo = {
|
||||
id: "",
|
||||
duration: 0,
|
||||
max_chunks: 0,
|
||||
stream_url: "",
|
||||
};
|
26
src/lib/player/types/player.ts
Normal file
26
src/lib/player/types/player.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { Track } from "@/types/track";
|
||||
|
||||
export interface PlayerState {
|
||||
playing: boolean;
|
||||
loading: boolean;
|
||||
|
||||
volume: number;
|
||||
progress: number;
|
||||
track: Track;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export interface EventListeners extends Record<Events, Function> {
|
||||
track: (track: Track) => void;
|
||||
queue: (chunk: number) => void;
|
||||
state: (state: PlayerState) => void;
|
||||
progress: (progress: number) => void;
|
||||
}
|
||||
|
||||
export type Events =
|
||||
| "track"
|
||||
| "queue"
|
||||
| "state"
|
||||
| "progress"
|
||||
| "play"
|
||||
| "pause";
|
|
@ -1,8 +1,46 @@
|
|||
interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
import { Playlist } from "@/types/playlist";
|
||||
import { fetchAPI } from "./api";
|
||||
|
||||
const playlists = new Map<string, Playlist>();
|
||||
|
||||
export async function getPlaylist(playlistId: string): Promise<Playlist> {
|
||||
if (playlists.has(playlistId)) {
|
||||
return playlists.get(playlistId)!;
|
||||
}
|
||||
|
||||
const playlist = await fetchAPI<Playlist>(`/playlists/${playlistId}`);
|
||||
|
||||
playlists.set(playlistId, playlist);
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
export function getPlaylists(): Playlist[] {
|
||||
return [];
|
||||
export async function getPlaylists(): Promise<Playlist[]> {
|
||||
return fetchAPI("/me/playlists");
|
||||
}
|
||||
|
||||
export function parseDuration(seconds: string | number) {
|
||||
const hours = Math.floor(Number(seconds) / 3600);
|
||||
seconds = Number(seconds) % 3600;
|
||||
|
||||
let minutes: string | number = Math.floor(seconds / 60);
|
||||
seconds = seconds % 60;
|
||||
|
||||
let output = "";
|
||||
|
||||
if (hours > 0) {
|
||||
output += `${hours}h `;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
if (minutes < 10 && hours > 0) minutes = `0${minutes}`;
|
||||
|
||||
output += `${minutes}m `;
|
||||
}
|
||||
|
||||
if (seconds < 10) seconds = `0${seconds}`;
|
||||
|
||||
output += `${seconds}s`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
|
6
src/lib/store.ts
Normal file
6
src/lib/store.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { createStore } from "solid-js/store";
|
||||
|
||||
export const [sizes, setSizes] = createStore({
|
||||
sidebar: 320,
|
||||
queue: 320,
|
||||
});
|
23
src/lib/time.ts
Normal file
23
src/lib/time.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import time from "dayjs";
|
||||
|
||||
import duration from "dayjs/plugin/duration";
|
||||
|
||||
time.extend(duration);
|
||||
|
||||
export default time;
|
||||
|
||||
export interface ParseDuration {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export function parseDuration(seconds: number): ParseDuration {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
seconds = seconds % 3600;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
seconds = seconds % 60;
|
||||
|
||||
return { hours, minutes, seconds };
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { ClassValue } from "clsx"
|
||||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
36
src/main.tsx
36
src/main.tsx
|
@ -1,13 +1,35 @@
|
|||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { render } from "preact";
|
||||
/* @refresh reload */
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import { lazy } from "solid-js";
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
import "./styles.css";
|
||||
const Library = lazy(() => import("./routes/library"));
|
||||
const Search = lazy(() => import("./routes/search"));
|
||||
const Artist = lazy(() => import("./routes/artists/[id]"));
|
||||
const Playlist = lazy(() => import("./routes/playlists/[id]"));
|
||||
const User = lazy(() => import("./routes/users/[id]"));
|
||||
const NotFound = lazy(() => import("./routes/not-found"));
|
||||
|
||||
render(<App />, document.getElementById("root")!);
|
||||
const root = document.getElementById("root");
|
||||
|
||||
// Hide splashscreen when loaded.
|
||||
document.addEventListener("DOMContentLoaded", () =>
|
||||
invoke("close_splashscreen"),
|
||||
render(
|
||||
() => (
|
||||
<Router root={App}>
|
||||
<Route path="/" component={Library} />
|
||||
<Route path="/search" component={Search} />
|
||||
|
||||
<Route path="/artists/:artistId" component={Artist} />
|
||||
|
||||
<Route path="/playlists/:playlistId" component={Playlist} />
|
||||
|
||||
<Route path="/users/:userId" component={User} />
|
||||
|
||||
<Route path="*404" component={NotFound} />
|
||||
</Router>
|
||||
),
|
||||
root!,
|
||||
);
|
||||
|
|
16
src/routes/artists/[id].tsx
Normal file
16
src/routes/artists/[id].tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { useParams } from "@solidjs/router";
|
||||
import { createResource } from "solid-js";
|
||||
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import { Artist as ArtistType } from "@/types/artist";
|
||||
|
||||
export default function Artist() {
|
||||
const params = useParams<{ artistId: string }>();
|
||||
const [artist] = createResource(() => params.artistId, getArtist);
|
||||
|
||||
return <p>{artist()?.name}</p>;
|
||||
}
|
||||
|
||||
async function getArtist(artistId: string): Promise<ArtistType> {
|
||||
return fetchAPI(`/artists/${artistId}`);
|
||||
}
|
7
src/routes/library.tsx
Normal file
7
src/routes/library.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default function Library() {
|
||||
return (
|
||||
<div>
|
||||
<h1 class="headline">Library</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
15
src/routes/not-found.tsx
Normal file
15
src/routes/not-found.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
export default function NotFound() {
|
||||
return (
|
||||
<div class="flex size-full flex-col items-center justify-center gap-8">
|
||||
<h1 class="text-3xl font-bold">Page not found</h1>
|
||||
<div>
|
||||
<a
|
||||
href="/"
|
||||
class="rounded-md border bg-primary px-4 py-2 text-secondary transition-colors hover:text-primary"
|
||||
>
|
||||
Back to Library
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
59
src/routes/playlists/[id].tsx
Normal file
59
src/routes/playlists/[id].tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { A, useParams } from "@solidjs/router";
|
||||
import { For, Show, createResource } from "solid-js";
|
||||
|
||||
import { getPlaylist, parseDuration } from "@/lib/playlists";
|
||||
import NotFound from "../not-found";
|
||||
import Track from "./track";
|
||||
|
||||
export default function Playlist() {
|
||||
const params = useParams<{ playlistId: string }>();
|
||||
const [playlist] = createResource(() => params.playlistId, getPlaylist);
|
||||
|
||||
return (
|
||||
<Show when={playlist()} fallback={<NotFound />}>
|
||||
<section class="flex flex-col gap-12">
|
||||
<header class="flex items-center gap-8">
|
||||
<div class="size-56 rounded-xl border bg-tertiary" />
|
||||
<div class="flex flex-col gap-4 text-secondary">
|
||||
<h1 class="text-3xl font-bold text-primary">{playlist()?.name}</h1>
|
||||
<p>
|
||||
Created by{" "}
|
||||
<A
|
||||
href={`/users/${playlist()?.creator.id}`}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{playlist()?.creator.name}
|
||||
</A>
|
||||
</p>
|
||||
<p class="text-xs uppercase tracking-widest">
|
||||
{playlist()?.tracks_count} Track
|
||||
{playlist()?.tracks_count === 1 ? "" : "s"} –{" "}
|
||||
{parseDuration(playlist()?.duration ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div aria-colcount={5}>
|
||||
<div class="playlist-row sticky -top-4 mb-2 border-b bg-secondary text-sm uppercase text-secondary">
|
||||
<span></span>
|
||||
<span>Title</span>
|
||||
<span>Album</span>
|
||||
<span class="hidden text-center md:block">Date added</span>
|
||||
<span class="text-center">Duration</span>
|
||||
</div>
|
||||
<For each={playlist()?.tracks} fallback={<EmptyPlaylist />}>
|
||||
{(track) => <Track track={track} />}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPlaylist() {
|
||||
return (
|
||||
<div class="flex h-12 items-center justify-center text-center">
|
||||
😭 No tracks...
|
||||
</div>
|
||||
);
|
||||
}
|
62
src/routes/playlists/track.tsx
Normal file
62
src/routes/playlists/track.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { A } from "@solidjs/router";
|
||||
import { For, createSignal } from "solid-js";
|
||||
|
||||
import * as Icons from "@/icons";
|
||||
|
||||
import { Track as TrackType } from "@/types/track";
|
||||
import time, { parseDuration } from "@/lib/time";
|
||||
import { cn } from "@/lib/utils";
|
||||
import player from "@/lib/player";
|
||||
|
||||
interface TrackProps {
|
||||
track: TrackType;
|
||||
}
|
||||
|
||||
export default function Track({ track }: TrackProps) {
|
||||
const [liked, setLiked] = createSignal(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="playlist-row mb-2 cursor-pointer text-secondary transition-colors hover:bg-tertiary"
|
||||
onDblClick={() => player.changeTrack(track)}
|
||||
>
|
||||
<div class="size-12 rounded-md border bg-tertiary" />
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base text-primary">{track.title}</span>
|
||||
<span class="text-sm">
|
||||
<For each={track.artists}>
|
||||
{(artist, i) => (
|
||||
<>
|
||||
<A href={`/artists/${artist.id}`} class="hover:underline">
|
||||
{artist.name}
|
||||
</A>
|
||||
{i() < track.artists.length - 1 && ", "}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm">None</span>
|
||||
<span class="hidden text-center text-sm md:block">
|
||||
{Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(
|
||||
new Date(track.added_at),
|
||||
)}
|
||||
</span>
|
||||
<span class="text-center text-sm">
|
||||
{time.duration(parseDuration(track.duration_ms)).format("m:ss")}
|
||||
</span>
|
||||
<div>
|
||||
<button onClick={() => setLiked(!liked())} class="hover:text-accent">
|
||||
<Icons.FavouriteStroke
|
||||
class={cn(
|
||||
"size-6 transition-colors hover:scale-110 hover:fill-accent",
|
||||
{
|
||||
"fill-accent": liked(),
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
3
src/routes/search.tsx
Normal file
3
src/routes/search.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function Search() {
|
||||
return <h1>SEarch</h1>;
|
||||
}
|
7
src/routes/settings.tsx
Normal file
7
src/routes/settings.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default function Settings() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
154
src/routes/users/[id].tsx
Normal file
154
src/routes/users/[id].tsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
import { A, useParams } from "@solidjs/router";
|
||||
import { For, Show, createResource, createSignal, useContext } from "solid-js";
|
||||
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import { Playlist } from "@/types/playlist";
|
||||
import NotFound from "../not-found";
|
||||
import { UserContext } from "@/contexts/user";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function User() {
|
||||
const { user: loggedInUser } = useContext(UserContext);
|
||||
const params = useParams<{ userId: string }>();
|
||||
|
||||
const [user] = createResource(() => params.userId, getUser);
|
||||
const [playlists] = createResource(() => params.userId, getUserPlaylists);
|
||||
|
||||
const isSelf = loggedInUser.id === user()?.id;
|
||||
|
||||
const [followed, setFollowed] = createSignal(false);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!user.loading}
|
||||
fallback={
|
||||
<Show
|
||||
when={user.loading}
|
||||
fallback={<NotFound />}
|
||||
children={<p>Loading...</p>}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<section>
|
||||
<header class="mb-8 flex items-center gap-4 border-b pb-8">
|
||||
<div class="aspect-square size-40 rounded-full bg-tertiary xl:size-48" />
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<h1 class="text-3xl font-bold">{user()?.name}</h1>
|
||||
<p class="text-sm uppercase tracking-widest text-secondary">
|
||||
x Followers - x Following
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
"ml-auto rounded-md border bg-primary px-4 py-2 text-secondary transition-colors hover:bg-secondary hover:text-primary",
|
||||
{
|
||||
"bg-white text-secondary": followed(),
|
||||
},
|
||||
)}
|
||||
onClick={() => setFollowed(!followed())}
|
||||
>
|
||||
{followed() ? "Unfollow" : "Follow"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-8 md:gap-16">
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-bold">Public Playlists</h2>
|
||||
<div class="grid grid-cols-2 gap-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<For each={playlists()}>
|
||||
{(playlist) => (
|
||||
<A
|
||||
href={`/playlists/${playlist.id}`}
|
||||
class="flex flex-col overflow-hidden rounded-lg border bg-primary transition-transform hover:scale-105"
|
||||
>
|
||||
<div class="aspect-square w-full bg-tertiary" />
|
||||
|
||||
<div class="p-2">
|
||||
<h4 class="text-lg font-bold">{playlist.name}</h4>
|
||||
<p class="line-clamp-2 text-sm leading-tight text-secondary">
|
||||
Lorem ipsum dolor sit amet consectetur consectetur
|
||||
consectetur
|
||||
</p>
|
||||
</div>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-bold">Followers</h2>
|
||||
<div class="grid grid-cols-2 gap-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<For each={playlists()}>
|
||||
{(playlist) => (
|
||||
<A
|
||||
href={`/playlists/${playlist.id}`}
|
||||
class="flex flex-col overflow-hidden rounded-lg border bg-primary transition-transform hover:scale-105"
|
||||
>
|
||||
<div class="aspect-square w-full bg-tertiary" />
|
||||
|
||||
<div class="p-2">
|
||||
<h4 class="text-lg font-bold">{playlist.name}</h4>
|
||||
<p class="line-clamp-2 text-sm leading-tight text-secondary">
|
||||
Lorem ipsum dolor sit amet consectetur consectetur
|
||||
consectetur
|
||||
</p>
|
||||
</div>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-bold">Following</h2>
|
||||
<div class="grid grid-cols-2 gap-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<For each={playlists()}>
|
||||
{(playlist) => (
|
||||
<A
|
||||
href={`/playlists/${playlist.id}`}
|
||||
class="flex flex-col overflow-hidden rounded-lg border bg-primary transition-transform hover:scale-105"
|
||||
>
|
||||
<div class="aspect-square w-full bg-tertiary" />
|
||||
|
||||
<div class="p-2">
|
||||
<h4 class="text-lg font-bold">{playlist.name}</h4>
|
||||
<p class="line-clamp-2 text-sm leading-tight text-secondary">
|
||||
Lorem ipsum dolor sit amet consectetur consectetur
|
||||
consectetur
|
||||
</p>
|
||||
</div>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
const users = new Map<string, UserType>();
|
||||
|
||||
async function getUser(userId: string): Promise<UserType> {
|
||||
if (users.has(userId)) {
|
||||
return users.get(userId)!;
|
||||
}
|
||||
|
||||
const user = await fetchAPI<UserType>(`/users/${userId}`);
|
||||
|
||||
users.set(userId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function getUserPlaylists(userId: string): Promise<Playlist[]> {
|
||||
return fetchAPI(`/users/${userId}/playlists`);
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
import { signal } from "@preact/signals";
|
||||
|
||||
export const dragging = signal({
|
||||
isDragging: false,
|
||||
width: Number(localStorage.getItem("sidebar-width") ?? 256),
|
||||
});
|
|
@ -1,35 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Rubik";
|
||||
font-weight: 400;
|
||||
src: url("./assets/fonts/Rubik-Regular.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Rubik";
|
||||
font-weight: 700;
|
||||
src: url("./assets/fonts/Rubik-Bold.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Rubik";
|
||||
font-weight: 900;
|
||||
src: url("./assets/fonts/Rubik-Black.woff2");
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Rubik";
|
||||
background-color: #131317;
|
||||
color: #eeeeff;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply select-none bg-primary text-primary;
|
||||
}
|
4
src/types/artist.ts
Normal file
4
src/types/artist.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
13
src/types/playlist.ts
Normal file
13
src/types/playlist.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Artist } from "./artist";
|
||||
import { Track } from "./track";
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
playlist_type: "playlist" | "folder";
|
||||
parent_id: string | null;
|
||||
creator: Artist;
|
||||
tracks: Track[];
|
||||
tracks_count: number;
|
||||
duration: number;
|
||||
}
|
19
src/types/track.ts
Normal file
19
src/types/track.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Artist } from "./artist";
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
image: string;
|
||||
duration: number;
|
||||
artists: Artist[];
|
||||
|
||||
added_at?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRACK: Track = {
|
||||
id: "",
|
||||
title: "",
|
||||
image: "",
|
||||
duration: 0,
|
||||
artists: [],
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue