This commit is contained in:
Miguel da Mota 2024-02-11 18:12:40 +01:00
parent 5c0034aabd
commit 9ad8d255de
70 changed files with 3498 additions and 2727 deletions

View file

@ -1,7 +0,0 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.preact:hover {
filter: drop-shadow(0 0 2em #673ab8);
}

View file

@ -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.

View file

@ -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
View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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;

View 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
View 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
View 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);
}

View 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: "",
};

View 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";

View file

@ -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
View 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
View 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 };
}

View file

@ -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))
}

View file

@ -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!,
);

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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
View file

@ -0,0 +1,3 @@
export default function Search() {
return <h1>SEarch</h1>;
}

7
src/routes/settings.tsx Normal file
View file

@ -0,0 +1,7 @@
export default function Settings() {
return (
<div>
<h1>Settings</h1>
</div>
);
}

154
src/routes/users/[id].tsx Normal file
View 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;
}

View file

@ -1,6 +0,0 @@
import { signal } from "@preact/signals";
export const dragging = signal({
isDragging: false,
width: Number(localStorage.getItem("sidebar-width") ?? 256),
});

View file

@ -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
View file

@ -0,0 +1,4 @@
export interface Artist {
id: string;
name: string;
}

13
src/types/playlist.ts Normal file
View 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
View 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: [],
};