Author SHA1 Message Date
lucast 14b7fbf014 Animated mobile player dismissal 2026-10-06 21:34:08 +02:00
lucast 81b1581ed7 Updated mobile player layout 2026-10-06 21:23:13 +02:00
lucast 812884de1a Added PWA support 2026-10-06 20:52:55 +02:00
lucast 27d92c55e5 Improved mobile sign in 2026-10-06 04:17:48 +02:00
lucast cb6fa29e4b Improved mobile detail pages 2026-10-06 04:14:39 +02:00
lucast 8f6f9ef78e Improved mobile library pages 2026-10-06 04:13:02 +02:00
lucast ce0bf50223 Improved mobile search experience 2026-10-06 04:08:50 +02:00
lucast 1fce02ad84 Improved mobile artist carousels 2026-10-06 04:00:14 +02:00
lucast f202f07585 Centered mobile fullscreen player 2026-10-06 03:47:17 +02:00
lucast ae52b1754b Adjusted mobile player layout 2026-10-06 03:39:08 +02:00
lucast 4c883c60a4 Added mobile navigation 2026-10-06 03:26:15 +02:00
lucast 4ab58c8400 Added repeat modes 2026-10-06 03:10:44 +02:00
lucast b483ad84e4 Add album table search and pagination 2026-10-06 03:03:42 +02:00
lucast 900b2b8abf Add artist table search and pagination 2026-10-06 03:03:41 +02:00
lucast 12031cb481 Add genre table search and pagination 2026-10-06 03:03:39 +02:00
lucast 00b482e62a Fade between table pages 2026-10-06 03:02:55 +02:00
lucast 70ef737f19 Paginate songs admin table 2026-10-06 03:02:49 +02:00
lucast 1ffe483e70 Use themed search clear button 2026-10-06 03:00:22 +02:00
lucast 53aa702758 Enable songs table search 2026-10-06 03:00:20 +02:00
lucast 148c5b267d Handle loading table data 2026-10-06 03:00:17 +02:00
lucast 24d4f2e577 Added table search 2026-10-06 02:52:56 +02:00
lucast d9e1cb1c6f Added table pagination 2026-10-06 02:52:26 +02:00
lucast 440b6ef817 Align H3 handler helper imports 2026-10-06 02:50:04 +02:00
lucast c70b840520 Guard player frequency samples 2026-10-06 02:47:37 +02:00
lucast 4d4c0f0e3a Handle empty search query arrays 2026-10-06 02:47:23 +02:00
lucast 7d42c6edb1 Handle unset synced lyrics height 2026-10-06 02:47:04 +02:00
lucast 95f6199cea Render admin field keys as labels 2026-10-06 02:46:42 +02:00
lucast d76ecf6063 Type text input events 2026-10-06 02:46:25 +02:00
lucast 379acf400c Handle optional API data in pages 2026-10-06 02:46:01 +02:00
lucast a6485e5c73 Guard palette array indexing 2026-10-06 02:45:08 +02:00
lucast 8f1cc8d4a7 Avoid typed route expansion in API fetch 2026-10-06 02:44:31 +02:00
lucast dec7f46365 Imported file helpers into Vue templates 2026-10-06 02:43:27 +02:00
lucast 58338d0e8d Merge dynamic background 2026-10-06 02:34:50 +02:00
lucast b190d1671c Optimize fullscreen rendering 2026-10-06 02:33:07 +02:00
lucast b696da1854 Move fullscreen wash to WebGPU 2026-10-06 02:11:56 +02:00
lucast ebd6fedd89 Cap fullscreen pulse brightness 2026-10-06 01:56:46 +02:00
lucast d07221f600 Adapt fullscreen beat response 2026-10-05 22:05:08 +02:00
lucast 27c289483b Tune fullscreen beat response 2026-10-05 21:48:48 +02:00
lucast 1d6422f422 Reduce banding in fullscreen gradient 2026-10-05 21:19:13 +02:00
lucast ffdb159d57 Randomize fullscreen gradient motion 2026-10-05 21:14:46 +02:00
lucast 3001267393 Use isolation style fullscreen gradient 2026-10-05 21:11:05 +02:00
lucast 29b6418e2c Refine fullscreen album color wash 2026-10-05 20:59:41 +02:00
lucast a1e993facb Add album color fullscreen background 2026-10-05 20:44:47 +02:00
lucast 7a80ac8f3f First try 2026-10-05 19:01:45 +02:00
lucast 7ae68592a2 Bundle Vue in server build 2026-10-05 18:38:02 +02:00
lucast 84658e4499 Added saved artists page 2026-10-05 18:28:47 +02:00
lucast 8df84bce86 Added saved albums page 2026-10-05 18:22:01 +02:00
lucast af07bf2249 Added library track menus 2026-10-05 18:20:34 +02:00
lucast 0526e83a9b Added saved library API 2026-10-05 17:54:29 +02:00
lucast 36d05ab04f Set up Golar typechecking 2026-10-05 17:52:52 +02:00
lucast 1eef02ccfa Upgraded Nuxt 2026-10-05 17:35:43 +02:00
lucast e908ae27ad Added song loading state 2026-10-05 03:46:47 +02:00
lucast 869ca7d728 Added track navigation menu items 2026-10-05 02:49:24 +02:00
lucast 51114d65c8 Handle unsynced lyrics marked synced 2026-10-05 02:22:06 +02:00
lucast fb99058492 Allow bearer auth for admin API 2026-10-05 02:01:40 +02:00
lucast 142e34babe Return session token 2026-10-05 01:54:37 +02:00
lucast 45ae19149f Refresh lyrics position on open 2026-10-05 01:10:53 +02:00
lucast bdfa76f993 Reset title when queue ends 2026-10-05 00:26:34 +02:00
lucast 30541eab8d Cache album slider data 2026-10-04 22:01:34 +02:00
lucast 3393c133fd Update readme 2026-10-04 21:52:07 +02:00
lucast e89463591c Use musicSession cookie for auth 2026-10-02 00:20:38 +02:00
lucast e2c47e6d0f Gate debug guide and disclaimer by cookie 2026-09-30 05:18:25 +02:00
lucast bfaa4acb25 Align fullscreen lyrics across line wraps 2026-09-30 05:18:08 +02:00
lucast 4caa9135ee Fixed fullscreen lyrics alignment 2026-09-30 04:32:38 +02:00
lucast cc0623ef4a Add page titles 2026-09-30 01:50:01 +02:00
lucast e2e2fc7755 Enable progressive audio playback 2026-09-30 01:30:30 +02:00
lucast e02cb83235 Fixed model registration 2026-09-30 01:22:49 +02:00
lucast 492a54dd0d Upload processed audio tracks to S3 2026-09-29 17:03:19 +02:00
lucast 0d09198e57 Store artist and album artwork in S3 2026-09-29 17:03:08 +02:00
lucast c2dcec2c31 Add S3 storage configuration and client 2026-09-29 17:02:57 +02:00
lucast 39cb48a656 Remove legacy static media route 2026-09-29 16:22:53 +02:00
lucast 069ef37825 Build media URLs from public bucket config 2026-09-29 16:22:22 +02:00
lucast 19e84d774c Add bucket backed media reads 2026-09-29 16:22:22 +02:00
lucast 8cd0d5ca89 Update API schema and deployment guidance 2026-09-29 15:57:39 +02:00
lucast 0cf29e0e06 Harden Nitro runtime and health handling 2026-09-29 15:57:31 +02:00
lucast f377424d3a Normalize protected read API responses 2026-09-29 15:57:25 +02:00
lucast a2b8dec813 Normalize admin API auth responses 2026-09-29 15:57:14 +02:00
lucast 31e6c0c5d2 Harden cookie authentication 2026-09-29 15:57:07 +02:00
lucast a0a62c1c06 Removed obsolete backend dependencies 2026-09-29 15:32:08 +02:00
lucast 47db7abd6a Switched frontend API calls to Nuxt 2026-09-29 13:33:48 +02:00
lucast 4621a81384 Added API schema and docs routes 2026-09-29 01:54:51 +02:00
lucast bca41b5e6f Migrated admin and media APIs to Nitro 2026-09-29 01:22:29 +02:00
lucast ee5809a489 Migrated read APIs to Nitro 2026-09-29 00:34:50 +02:00
lucast eb4e9f0700 Migrated auth to Nitro 2026-09-29 00:23:30 +02:00
lucast 27feb1ce95 Smooth lyrics fade 2026-09-26 22:27:15 +02:00
lucast e50648e35f Moved lyrics a bit down 2026-09-26 22:19:22 +02:00
lucast b5f7aa1372 Add media session controls 2026-09-26 22:18:33 +02:00
lucast 5e3f3ef6a2 Fix single track artist fields 2026-09-26 00:21:59 +02:00
lucast 661b9341d7 Add backend dependencies 2026-09-26 00:14:00 +02:00
lucast 3402178932 Add singles from admin 2026-09-26 00:04:55 +02:00
lucast 3255a97339 Fixed artist search 2026-09-26 00:00:53 +02:00
lucast fdf2aeb675 Handle songs without lyrics in player 2026-09-25 23:00:27 +02:00
lucast 8ee6164218 Allow clearing song lyrics 2026-09-25 23:00:18 +02:00
lucast a6e8bb51c5 Split search result rows 2026-09-25 22:51:11 +02:00
lucast 9cbf96564f Exit fullscreen when song ends 2026-09-25 22:50:44 +02:00
lucast 23f5709c68 Fixed synced lyrics manual scrolling 2026-09-25 21:53:34 +02:00
lucast 30c7972647 Extended synced lyrics fade 2026-09-25 21:51:24 +02:00
lucast 16e309c346 Added fullscreen volume control 2026-09-25 21:45:06 +02:00
lucast 1d2bec7317 Align search results with track rows 2026-09-25 21:26:58 +02:00
lucast 6b49c7c4d5 Show queue order while dragging 2026-09-25 20:20:12 +02:00
lucast 13081c4771 Wire track queue actions 2026-09-25 20:03:10 +02:00
lucast d6f8bb8588 Added player queue drawer 2026-09-25 19:59:57 +02:00
lucast 64646f778e Added play only this action 2026-09-25 19:48:16 +02:00
lucast 4196db1759 Fixed dropdown clipping 2026-09-25 19:45:55 +02:00
lucast d0886cd9a3 Clarified API schema location 2026-09-25 19:41:44 +02:00
lucast ec69c85102 Applied browser fullscreen 2026-08-22 20:58:39 +02:00
lucast e7bab2a95c Updated AGENTS.md to include /schema for API 2026-08-22 19:44:41 +02:00
lucast cf5ef8d429 Fixed initial load bug 2026-08-22 19:34:58 +02:00
lucast 1eb52f1c94 Updated odd and hover effect for TrackRow 2026-08-22 00:37:25 +02:00
lucast 3aaa3eb42e Added /artist/[id]/tracks 2026-08-22 00:36:08 +02:00
lucast f7adfeac22 Updated AGENTS.md for new guideline 2026-08-22 00:35:22 +02:00
lucast 74ba579919 Fixed waitForPromise not being set to false 2026-08-22 00:19:45 +02:00
lucast 1a62aa192e Added localstorage for volume 2026-08-22 00:13:51 +02:00
lucast e6657a6572 Trying OpenAI Codex 2026-08-22 00:12:30 +02:00
lucast a46f289865 Fixed lyrics 2026-07-20 15:11:46 +02:00
lucast b778ea9eda Small cleanup 2026-07-20 15:04:34 +02:00
lucast db86529219 Fixed tracks on artist page 2026-06-02 10:34:15 +02:00
lucast 603620a418 Made track display be in middle if no lyrics are found 2026-04-05 14:33:16 +02:00
lucast 2b01ed688c Made useAuth safer 2026-04-05 13:57:25 +02:00
lucast 686be7ca8a Merge branch 'feat/player-fullscreen' 2026-04-05 12:10:13 +02:00
lucast c868329d8b Merge branch 'feat/artist-page' 2026-04-05 12:02:53 +02:00
lucast 7cbc087049 Added album slider 2026-04-05 12:02:22 +02:00
lucast 5c9b82265d Test data cleanup 2026-04-05 11:57:21 +02:00
lucast 307f467f94 Added track title to slider 2026-04-05 11:55:45 +02:00
lucast cb481ad983 Added track slider for artist page 2026-04-05 11:55:17 +02:00
lucast 59af94de8c Small refractoring 2026-04-05 11:06:47 +02:00
lucast c5cd36f5e5 Small fix for error thrown when song doesn't have lyrics 2026-03-08 12:07:52 +01:00
lucast e8d2173f17 Created tracks page for artists 2026-03-08 12:06:17 +01:00
lucast 427437bb3b Moved artist page 2026-03-08 12:06:05 +01:00
lucast fceddf641a Added track image row component 2026-03-08 12:05:53 +01:00
lucast af3b9669dc Fixed player hiding the bottom of the page and elements behind the player being visible 2026-03-08 11:30:17 +01:00
lucast 4eb7c6d5a9 Added groups to dropdown menu 2026-03-08 11:24:46 +01:00
lucast aa93320215 Add cursor pointer style to dropdown icon 2026-03-08 11:12:42 +01:00
lucast 6c1dda9622 Fixed small bug with checkbox not having initial value 2026-03-07 19:49:45 +01:00
lucast b48db8bb37 Merge branch 'feat/player-fullscreen' 2026-03-07 19:49:18 +01:00
lucast d5482a9ea4 Added non synced text 2026-03-07 19:49:06 +01:00
lucast ab22cd6eb9 Implemented scrolling 2026-03-07 19:37:59 +01:00
lucast b6e854b1dd Add onMounted hook to refresh session token if needed 2026-03-07 19:28:59 +01:00
lucast fd27fee6c7 Updated synced lyrics system to be more smooth 2026-03-07 15:42:34 +01:00
lucast 22d6150290 Got synced lyrics working 2026-03-07 15:32:20 +01:00
lucast ddef57b802 Merge branch 'master' into feat/player-fullscreen 2026-03-07 14:45:58 +01:00
lucast ae79952ddc Update fullscreen player to show/hide close buttons 2026-02-27 14:53:31 +01:00
lucast 76c57c1ba0 Made cursor hide when no movement is detected 2026-02-27 14:51:45 +01:00
lucast 4475b95aed Implement mouse movement detection for fullscreen player and update layout structure 2026-02-27 14:48:53 +01:00
lucast 2561bd0e62 Add TrackDisplay component for fullscreen player with controls and styling 2026-02-27 14:48:45 +01:00
lucast ed53c41a87 Add monochrome slider support with styling adjustments 2026-02-27 14:48:27 +01:00
lucast 78c4a6cb8e Added fullscreen composable and background for fullscreen 2026-02-25 17:21:31 +01:00
139 changed files with 16894 additions and 3356 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Copy this file to .env for local development. Use real, private values locally.
NUXT_MONGODB_URI=mongodb://127.0.0.1:27017/music-v2
NUXT_PUBLIC_MEDIA_BASE_URL=https://s3-seaweedfs.lucasskt.dk/music
NUXT_S3_ENDPOINT=https://s3-seaweedfs.lucasskt.dk
NUXT_S3_BUCKET=music
NUXT_S3_REGION=us-east-1
NUXT_S3_ACCESS_KEY_ID=
NUXT_S3_SECRET_ACCESS_KEY=
NUXT_TOKEN_EXPIRE=1h
+56
View File
@@ -0,0 +1,56 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is a Nuxt 4 app. Application code lives under `app/`:
- `app/pages/` route pages
- `app/components/` feature components
- `app/ui/` shared UI primitives
- `app/composables/` reusable Vue logic
- `app/utils/` helpers and type utilities
- `app/styles/` global SCSS and variables
- `app/plugins/` client-side plugins
Static files belong in `public/`. Keep page-specific logic close to the page, and prefer shared code in `composables/`, `ui/`, or `utils/` when it is reused.
## Build, Test, and Development Commands
- `npm install` installs dependencies and runs Nuxt prepare through `postinstall`.
- `npm run dev` starts the local dev server on `http://localhost:3000`.
- `npm run build` creates a production build.
- `npm run preview` serves the production build locally.
- `npm run generate` builds a static output when needed.
There is no dedicated automated test script in `package.json`, so verify changes manually in the browser after building or running the dev server.
## Coding Style & Naming Conventions
Prettier is configured with tabs, `tabWidth = 4`, and no semicolons. Follow the existing Vue/Nuxt style in the repo:
- Use `PascalCase` for Vue components, such as `Player.vue` or `TrackDisplay.vue`.
- Use `camelCase` for functions, composables, and variables.
- Keep filenames descriptive and route-driven in `app/pages/` (for example `artist/[id]/index.vue`).
Prefer the current patterns in the codebase over introducing new abstractions.
When making changes that force an element's width or height, always ask the user for confirmation before doing so. This applies to any element, not just images. Do not force a width or height without explicit user approval.
## Testing Guidelines
No test framework is currently configured. For changes that affect rendering, routing, or API access, validate the affected pages manually and check browser console output. If you add tests later, colocate them near the feature or under a dedicated `tests/` directory.
## Commit & Pull Request Guidelines
Recent commits are short and direct, often written as brief past-tense summaries such as `Fixed lyrics` or `Small cleanup`. Keep commit messages similarly concise and action-oriented.
Pull requests should include:
- a short description of the change
- any related issue or context
- screenshots or screen recordings for UI work
- notes about environment variables or backend expectations when relevant
## Security & Configuration Tips
The frontend and backend are served by this Nuxt application; API requests are same-origin and `VITE_PUBLIC_BACKEND` is no longer used. Private runtime settings are `NUXT_MONGODB_URI`, `NUXT_UPLOAD_DIR`, and optional `NUXT_TOKEN_EXPIRE`; see `.env.example` and `README.md`. Production requires the Node Nitro server, persistent writable upload storage, and `ffmpeg`/`ffprobe` on `PATH`. Do not commit secrets or machine-specific values.
+16 -62
View File
@@ -1,75 +1,29 @@
# Nuxt Minimal Starter # Music v2
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. Yet another music platform.
## Setup This is a rewrite of my [first version](https://github.com/Sniperhund/Music), everything is contained in this repo instead of v1 (v1 had a separate frontend + backend)<br />
I got tired and irritated by trying to add new features to v1, so here we are nearly 5 months in development.
Make sure to install dependencies: It's a Nuxt 4 full stack application, hook up a S3 bucket and MongoDB service and you're basically good to go (check .env.example)
```bash This project is mostly a inspiration of Apple Music (my go-to platform) with my personal design choices and the "I can do it" mindset.
# npm
npm install
# pnpm ### Debug view
pnpm install
# yarn There's a debug view (now only for lyric alignemt), just set `DEBUG_VIEW` to `1`. It will likely be expanded.
yarn install
# bun ## Local setup
Install dependencies and create a local environment file:
```sh
bun install bun install
cp -n .env.example .env
``` ```
## Development Server Run the app at <http://localhost:3000>:
Start the development server on `http://localhost:3000`: ```sh
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev bun run dev
``` ```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+42
View File
@@ -1,11 +1,53 @@
<script setup lang="ts">
const { currentSong, isPlaying } = usePlayer()
const debugView = useCookie("DEBUG_VIEW")
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
useHead({
meta: [
{ name: "theme-color", content: "#09090b" },
{ name: "viewport", content: "width=device-width, initial-scale=1, viewport-fit=cover" },
{ name: "apple-mobile-web-app-capable", content: "yes" },
{ name: "apple-mobile-web-app-status-bar-style", content: "black-translucent" },
],
link: [{ rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png" }],
})
useHead(
computed(() => ({
titleTemplate:
currentSong.value && isPlaying.value
? `${currentSong.value.name} - Now Playing - Music`
: (titleChunk) => `${titleChunk || "Home"} - Music`,
})),
)
</script>
<template> <template>
<NuxtPwaManifest />
<ToastContainer /> <ToastContainer />
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
<Player /> <Player />
</NuxtLayout> </NuxtLayout>
<div v-if="debugViewEnabled" class="debug-view-disclaimer">
Debug view: everything may not align correctly on different screens.
It's made for 1920x1080
</div>
</template> </template>
<style lang="scss"> <style lang="scss">
@use "styles/global.scss"; @use "styles/global.scss";
.debug-view-disclaimer {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 200;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 0.875rem;
}
</style> </style>
+32 -5
View File
@@ -4,6 +4,7 @@ import { type Option } from "~/ui/SearchSelect.vue"
interface BaseField<T> { interface BaseField<T> {
key: keyof T key: keyof T
label?: string label?: string
visibleWhen?: (value: any) => boolean
} }
interface TextField<T> extends BaseField<T> { interface TextField<T> extends BaseField<T> {
@@ -46,6 +47,8 @@ interface ModalFormProps<T> {
fields: Field<T>[] fields: Field<T>[]
title?: string title?: string
onSave: (value: any) => Promise<boolean> onSave: (value: any) => Promise<boolean>
loading?: boolean
loadingText?: string
} }
const props = defineProps<ModalFormProps<any>>() const props = defineProps<ModalFormProps<any>>()
@@ -114,7 +117,12 @@ watch(
<p v-if="props.title" class="title">{{ props.title }}</p> <p v-if="props.title" class="title">{{ props.title }}</p>
<form class="form" @submit.prevent="save"> <form class="form" @submit.prevent="save">
<template v-for="field in props.fields"> <template
v-for="field in props.fields.filter(
(field) =>
!field.visibleWhen || field.visibleWhen(localItem),
)"
>
<SearchSelect <SearchSelect
v-if=" v-if="
field.type == 'search-select' || field.type == 'search-select' ||
@@ -122,20 +130,21 @@ watch(
" "
v-model:value="localItem[field.key]" v-model:value="localItem[field.key]"
:fetch-options="field.fetchOptions" :fetch-options="field.fetchOptions"
:label="field.label || field.key" :label="field.label || String(field.key)"
:predefined="props.item?.[field.key]" :predefined="props.item?.[field.key]"
:multiple="field.type == 'search-select-array'" :multiple="field.type == 'search-select-array'"
/> />
<Checkbox <Checkbox
v-else-if="field.type == 'checkbox'" v-else-if="field.type == 'checkbox'"
:label="field.label || field.key" :label="field.label || String(field.key)"
:model-value="localItem[field.key]"
@change="(v) => (localItem[field.key] = v)" @change="(v) => (localItem[field.key] = v)"
/> />
<Input <Input
v-else v-else
:type="field.type" :type="field.type"
:key="field.key" :key="field.key"
:label="field.label || field.key" :label="field.label || String(field.key)"
v-model:value="localItem[field.key]" v-model:value="localItem[field.key]"
:textarea="field.type == 'textarea'" :textarea="field.type == 'textarea'"
:rows="field.type == 'textarea' ? field.rows : 0" :rows="field.type == 'textarea' ? field.rows : 0"
@@ -143,7 +152,10 @@ watch(
/> />
</template> </template>
<Button type="submit" full center-text>Save</Button> <Button type="submit" full center-text :disabled="props.loading">
<span v-if="props.loading" class="spinner" aria-hidden="true" />
{{ props.loading ? props.loadingText || "Saving…" : "Save" }}
</Button>
</form> </form>
</Modal> </Modal>
</template> </template>
@@ -162,4 +174,19 @@ watch(
flex-direction: column; flex-direction: column;
gap: 0.8rem; gap: 0.8rem;
} }
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>
+14 -1
View File
@@ -4,13 +4,14 @@ import type { Album } from "@/utils/types"
type AlbumProps = Omit<Omit<Album, "genre">, "artists"> & { type AlbumProps = Omit<Omit<Album, "genre">, "artists"> & {
artists: Array<Omit<Artist, "file">> artists: Array<Omit<Artist, "file">>
genre?: Genre genre?: Genre
carousel?: boolean
} }
const props = defineProps<AlbumProps>() const props = defineProps<AlbumProps>()
</script> </script>
<template> <template>
<article class="card"> <article class="card" :class="{ carousel: props.carousel }">
<NuxtLink :to="`/album/${props._id}`"> <NuxtLink :to="`/album/${props._id}`">
<NuxtImg <NuxtImg
:src="props.file" :src="props.file"
@@ -48,4 +49,16 @@ const props = defineProps<AlbumProps>()
} }
} }
} }
@media (max-width: 767px) {
.card.carousel {
min-width: 0;
max-width: none;
flex: 0 0 calc(45.455% - 9.091px);
& > a > img {
width: 100%;
}
}
}
</style> </style>
+31 -18
View File
@@ -1,28 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
const { data, pending, refresh } = useAsyncData("random-albums", async () => { import { GET_FILE } from "@/utils/file"
const { data: genres } = await cfetch("/genres/random")
const cacheKey = "album-slider-data"
const data = ref<{ genre: Genre; albums: Album[] }[]>([])
onMounted(async () => {
const cached = sessionStorage.getItem(cacheKey)
if (cached) {
try {
data.value = JSON.parse(cached)
return
} catch {
sessionStorage.removeItem(cacheKey)
}
}
const genres = await cfetch<Genre[]>("/genres/random")
const results = await Promise.allSettled( const results = await Promise.allSettled(
genres.map(async (genre: Genre) => { (genres ?? []).map(async (genre) => ({
const res = await cfetch(`/genres/albums/${genre._id}`) genre,
albums: await cfetch<Album[]>(`/genres/albums/${genre._id}`),
return { })),
genre,
albums: res.data,
}
}),
) )
return results data.value = results
.filter( .filter(
( (
r, result,
): r is PromiseFulfilledResult<{ ): result is PromiseFulfilledResult<{
genre: any genre: Genre
albums: any[] albums: Album[]
}> => r.status === "fulfilled", }> => result.status === "fulfilled",
) )
.map((r) => r.value) .map((result) => result.value)
sessionStorage.setItem(cacheKey, JSON.stringify(data.value))
}) })
</script> </script>
@@ -34,9 +46,10 @@ const { data, pending, refresh } = useAsyncData("random-albums", async () => {
v-for="album in item.albums" v-for="album in item.albums"
:key="album._id" :key="album._id"
:name="album.name" :name="album.name"
:file="`${BACKEND_SERVE}${album.file}`" :file="GET_FILE(album.file)"
:artists="album.artists" :artists="album.artists"
:_id="album._id" :_id="album._id"
carousel
/> />
</Slider> </Slider>
</template> </template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
import type { Artist } from "@/utils/types"
const props = defineProps<{ artist: Artist }>()
</script>
<template>
<article class="card">
<NuxtLink :to="`/artist/${props.artist._id}`">
<NuxtImg
:src="GET_FILE(props.artist.file)"
:alt="props.artist.name"
placeholder
/>
<p>{{ props.artist.name }}</p>
</NuxtLink>
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
@use "@/styles/variables" as *;
.card {
@include fontSize(14px);
display: flex;
flex-direction: column;
justify-content: start;
& > a {
& img {
border-radius: $border-radius-standard;
}
& p {
margin-top: 0.3rem;
}
}
}
</style>
+370
View File
@@ -0,0 +1,370 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const { fullscreen, close: closeFullscreen } = useFullscreen()
const song = useSong()
const mouseMovedRecently = ref(true)
const mobileView = ref(false)
const showMobileLyrics = ref(false)
const queueOpen = useState<boolean>("queueOpen", () => false)
const dragOffset = ref(0)
const isDragging = ref(false)
let dragPointerId: number | null = null
let dragStartX = 0
let dragStartY = 0
let dragStartedAt = 0
let clickSuppressionTimer: number | undefined
let closeAnimationTimer: number | undefined
function suppressClickThrough(event: MouseEvent) {
event.preventDefault()
event.stopImmediatePropagation()
if (clickSuppressionTimer !== undefined)
window.clearTimeout(clickSuppressionTimer)
clickSuppressionTimer = undefined
document.removeEventListener("click", suppressClickThrough, true)
}
function startDrag(event: PointerEvent) {
if (!mobileView.value || !event.isPrimary) return
if (event.pointerType === "mouse" && event.button !== 0) return
if (event.clientY > window.innerHeight * 0.16) return
const target = event.target
if (
target instanceof Element &&
target.closest(
"button, a, input, [role='slider'], .slider, .btns, .volume-slider, .mobile-tabs",
)
)
return
dragPointerId = event.pointerId
dragStartX = event.clientX
dragStartY = event.clientY
dragStartedAt = performance.now()
const element = event.currentTarget as HTMLElement
element.setPointerCapture(event.pointerId)
}
function moveDrag(event: PointerEvent) {
if (dragPointerId !== event.pointerId) return
const deltaX = event.clientX - dragStartX
const deltaY = event.clientY - dragStartY
if (Math.abs(deltaX) > Math.abs(deltaY)) return
if (deltaY <= 0) {
dragOffset.value = 0
return
}
event.preventDefault()
isDragging.value = true
dragOffset.value = deltaY
}
function endDrag(event: PointerEvent) {
if (dragPointerId !== event.pointerId) return
const deltaY = Math.max(0, event.clientY - dragStartY)
const deltaX = Math.abs(event.clientX - dragStartX)
const elapsed = performance.now() - dragStartedAt
const closeThreshold = Math.max(96, window.innerHeight * 0.16)
const shouldClose =
deltaX <= deltaY &&
(deltaY >= closeThreshold || (deltaY >= 56 && elapsed < 220))
dragPointerId = null
if (shouldClose) {
document.addEventListener("click", suppressClickThrough, true)
clickSuppressionTimer = window.setTimeout(() => {
document.removeEventListener("click", suppressClickThrough, true)
clickSuppressionTimer = undefined
}, 400)
isDragging.value = false
dragOffset.value = window.innerHeight
if (closeAnimationTimer !== undefined)
window.clearTimeout(closeAnimationTimer)
closeAnimationTimer = window.setTimeout(finishCloseAnimation, 400)
return
}
isDragging.value = false
dragOffset.value = 0
}
function finishCloseAnimation() {
if (closeAnimationTimer !== undefined)
window.clearTimeout(closeAnimationTimer)
closeAnimationTimer = undefined
closeFullscreen()
dragOffset.value = 0
}
function onFullscreenTransitionEnd(event: TransitionEvent) {
if (
closeAnimationTimer !== undefined &&
event.target === event.currentTarget &&
event.propertyName === "transform"
)
finishCloseAnimation()
}
function cancelDrag(event: PointerEvent) {
if (dragPointerId !== event.pointerId) return
dragPointerId = null
isDragging.value = false
dragOffset.value = 0
}
onMounted(() => {
let timeoutId: NodeJS.Timeout
const mobileQuery = window.matchMedia("(max-width: 767px)")
const updateMobileView = () => (mobileView.value = mobileQuery.matches)
updateMobileView()
mobileQuery.addEventListener("change", updateMobileView)
const handleMouseMove = () => {
mouseMovedRecently.value = true
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
mouseMovedRecently.value = false
}, 2000)
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape" && fullscreen.value) {
closeFullscreen()
}
}
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("keydown", handleKeydown)
onUnmounted(() => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("keydown", handleKeydown)
mobileQuery.removeEventListener("change", updateMobileView)
document.removeEventListener("click", suppressClickThrough, true)
if (clickSuppressionTimer !== undefined)
window.clearTimeout(clickSuppressionTimer)
if (closeAnimationTimer !== undefined)
window.clearTimeout(closeAnimationTimer)
})
})
const nuxtApp = useNuxtApp()
nuxtApp.hook("page:finish", () => {
closeFullscreen()
})
</script>
<template>
<section
class="fullscreen"
:class="{ showCursor: mouseMovedRecently, dragging: isDragging }"
:style="{ '--drag-offset': `${dragOffset}px` }"
@transitionend="onFullscreenTransitionEnd"
v-show="fullscreen"
>
<template v-if="song">
<PlayerFullscreenBackgroundWash
:src="GET_FILE(song.album.file)"
:active="fullscreen"
/>
</template>
<section
class="screen-container"
v-if="song"
@pointerdown="startDrag"
@pointermove="moveDrag"
@pointerup="endDrag"
@pointercancel="cancelDrag"
>
<Icon
v-if="!mobileView"
name="lucide:x"
class="close-btn"
:class="{ show: mouseMovedRecently }"
@click="closeFullscreen()"
/>
<div
class="content-container"
:class="{ lyrics: song.lyrics?.text }"
>
<PlayerFullscreenTrackDisplay
:show-buttons="mouseMovedRecently"
/>
<nav
v-if="mobileView"
class="mobile-tabs"
aria-label="Player views"
>
<button
:class="{ selected: showMobileLyrics }"
aria-label="Toggle lyrics"
@click="showMobileLyrics = !showMobileLyrics"
>
<Icon name="lucide:message-square-quote" />
</button>
<button aria-label="Open queue" @click="queueOpen = true">
<Icon name="lucide:list" />
</button>
</nav>
<PlayerFullscreenLyricsDisplay
v-if="
!mobileView || (showMobileLyrics && song.lyrics?.text)
"
:animation-duration="0.2"
:offset="mobileView ? 100 : 250"
:show-scroll-bar="mouseMovedRecently"
/>
</div>
</section>
</section>
</template>
<style lang="scss" scoped>
.fullscreen {
position: fixed;
inset: 0;
z-index: 150;
overflow: hidden;
cursor: none;
&.showCursor {
cursor: auto;
}
}
.screen-container {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.34);
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
cursor: pointer;
font-size: 24px;
transition: opacity 0.2s ease;
opacity: 0;
&.show {
opacity: 1;
}
}
.mobile-tabs {
display: none;
}
.content-container {
/* TODO: A lot of magic numbers... should be variables */
max-width: 1280px;
width: 100%;
height: 100%;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr;
gap: 110px;
align-items: center;
& > * {
max-height: 675px;
margin: 0 auto;
}
&.lyrics {
grid-template-columns: 500px 1fr;
& > * {
margin: initial;
}
& > :deep(.synced-lyrics-container) {
max-height: none;
}
}
}
@media (max-width: 767px) {
.screen-container {
touch-action: none;
}
.fullscreen {
transition: transform 280ms ease-out;
transform: translate3d(0, var(--drag-offset, 0px), 0);
&.dragging {
transition: none;
}
}
.close-btn {
top: calc(0.75rem + env(safe-area-inset-top));
right: 0.75rem;
z-index: 2;
padding: 0.6rem;
opacity: 1;
font-size: 1.4rem;
&.show {
opacity: 1;
}
}
.mobile-tabs {
display: flex;
justify-content: center;
gap: 3rem;
margin: 0 auto;
color: rgba(255, 255, 255, 0.72);
button {
border: 0;
padding: 0.5rem;
color: inherit;
background: transparent;
font-size: 1.5rem;
}
.selected {
color: white;
}
}
.content-container {
max-width: none;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: space-between;
gap: 1.5rem;
overflow: hidden;
padding: max(11vh, calc(2.75rem + env(safe-area-inset-top))) 1.5rem
calc(env(safe-area-inset-bottom));
height: 100vh;
& > * {
max-height: none;
margin: 0 auto;
}
&.lyrics {
grid-template-columns: 1fr;
}
}
}
</style>
@@ -0,0 +1,218 @@
<script setup lang="ts">
import {
extractPalette,
type AlbumPalette,
} from "./palette"
import { createCpuRenderer } from "./renderers/cpu"
import { createWebGpuRenderer } from "./renderers/webgpu"
import type { BackgroundFrame, BackgroundRenderer } from "./renderers/types"
import { shapeBeatPulse } from "~/utils/beat"
const props = defineProps<{ src: string; active: boolean }>()
const ANIMATION_SPEED = 0.3
const FRAME_RATE = 12
const FRAME_INTERVAL = 1000 / FRAME_RATE
const { beatEnergy } = usePlayer()
const cpuCanvas = ref<HTMLCanvasElement | null>(null)
const webGpuCanvas = ref<HTMLCanvasElement | null>(null)
const palette = ref<AlbumPalette | null>(null)
const backend = ref<"cpu" | "webgpu">("cpu")
let frameTimer: number | null = null
let startedAt = 0
let random: [number, number] = [0, 0]
let angleJitter = 0
let flowParams: [number, number, number, number] = [5, 25, 0.75, -0.08]
let cpuRenderer: BackgroundRenderer | null = null
let webGpuRenderer: BackgroundRenderer | null = null
let activeRenderer: BackgroundRenderer | null = null
let disposed = false
const fallback: AlbumPalette = { dominant: [30, 35, 55], accent: [105, 65, 45] }
function loadArtwork() {
const image = new Image()
image.crossOrigin = "anonymous"
image.onload = () => {
try {
palette.value = extractPalette(image)
} catch {
palette.value = fallback
}
}
image.onerror = () => {
palette.value = fallback
}
image.src = props.src
}
function randomizeMotion() {
random = [Math.random() * Math.PI * 2, Math.random() * Math.PI * 2]
angleJitter = (Math.random() - 0.5) * 0.3
flowParams = [
4.5 + Math.random(),
22 + Math.random() * 7,
(0.65 + Math.random() * 0.2) * (Math.random() < 0.5 ? -1 : 1),
((-5 + (Math.random() - 0.5) * 12) * Math.PI) / 180,
]
}
function useCpuFallback() {
if (!cpuRenderer) return
activeRenderer = cpuRenderer
backend.value = "cpu"
}
async function initializeRenderers() {
const cpuElement = cpuCanvas.value
const gpuElement = webGpuCanvas.value
if (!cpuElement || !gpuElement) return
cpuRenderer = createCpuRenderer(cpuElement)
activeRenderer = cpuRenderer
if (!cpuRenderer) {
console.warn("[background renderer] 2D canvas is unavailable")
return
}
let rendererLost = false
const renderer = await createWebGpuRenderer(gpuElement, () => {
rendererLost = true
webGpuRenderer = null
console.warn("[background renderer] WebGPU device lost; using CPU")
useCpuFallback()
})
if (!renderer) return
if (disposed || rendererLost) {
renderer.dispose()
return
}
webGpuRenderer = renderer
activeRenderer = renderer
backend.value = "webgpu"
console.info("[background renderer] using WebGPU")
}
function makeFrame(now: number, canvas: HTMLCanvasElement): BackgroundFrame {
if (!startedAt) startedAt = now
const width = 224
const height = Math.max(
72,
Math.round(
(width * canvas.clientHeight) / Math.max(1, canvas.clientWidth),
),
)
return {
width,
height,
time: ((now - startedAt) / 1000) * ANIMATION_SPEED,
beat: shapeBeatPulse(beatEnergy.value),
random,
angleJitter,
flowParams,
palette: palette.value ?? fallback,
}
}
function updateCanvasScale(canvas: HTMLCanvasElement, beat: number) {
const transform = `scale(${1.08 + beat * 0.003})`
if (canvas.style.transform !== transform) canvas.style.transform = transform
}
function draw(now: number) {
if (!props.active || disposed) return
const canvas = backend.value === "webgpu" ? webGpuCanvas.value : cpuCanvas.value
if (canvas && activeRenderer) {
const renderFrame = makeFrame(now, canvas)
updateCanvasScale(canvas, renderFrame.beat)
try {
activeRenderer.render(renderFrame)
} catch (error) {
if (backend.value === "webgpu") {
console.warn(
"[background renderer] WebGPU render failed; using CPU",
error,
)
webGpuRenderer?.dispose()
webGpuRenderer = null
useCpuFallback()
if (cpuCanvas.value)
updateCanvasScale(cpuCanvas.value, renderFrame.beat)
try {
cpuRenderer?.render(renderFrame)
} catch (fallbackError) {
console.warn("[background renderer] CPU fallback failed", fallbackError)
}
} else {
console.warn("[background renderer] CPU render failed", error)
}
}
}
frameTimer = window.setTimeout(() => {
frameTimer = null
draw(performance.now())
}, FRAME_INTERVAL)
}
function startAnimation() {
if (frameTimer !== null) window.clearTimeout(frameTimer)
frameTimer = null
startedAt = 0
randomizeMotion()
draw(performance.now())
}
watch(() => props.src, loadArtwork)
watch(
() => props.active,
(active) => {
if (active) startAnimation()
else if (frameTimer !== null) {
window.clearTimeout(frameTimer)
frameTimer = null
}
},
)
onMounted(() => {
loadArtwork()
void initializeRenderers()
if (props.active) startAnimation()
})
onBeforeUnmount(() => {
disposed = true
if (frameTimer !== null) window.clearTimeout(frameTimer)
webGpuRenderer?.dispose()
cpuRenderer?.dispose()
})
</script>
<template>
<canvas
ref="webGpuCanvas"
class="background-wash"
:class="{ 'background-wash--active': backend === 'webgpu' }"
aria-hidden="true"
/>
<canvas
ref="cpuCanvas"
class="background-wash"
:class="{ 'background-wash--active': backend === 'cpu' }"
aria-hidden="true"
/>
</template>
<style scoped>
.background-wash {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
filter: blur(6px) saturate(1.08);
transform: scale(1.08);
visibility: hidden;
}
.background-wash--active {
visibility: visible;
}
</style>
@@ -0,0 +1,161 @@
type RGB = [number, number, number]
type Lab = [number, number, number]
export interface AlbumPalette {
dominant: RGB
accent: RGB
}
export function toOkLab([red, green, blue]: RGB): Lab {
const linear = (value: number) => {
const channel = value / 255
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4
}
const r = linear(red)
const g = linear(green)
const b = linear(blue)
const l = Math.cbrt(
0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b,
)
const m = Math.cbrt(
0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b,
)
const s = Math.cbrt(
0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b,
)
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
]
}
export function fromOkLab([lightness, a, b]: Lab): RGB {
const lRoot = lightness + 0.3963377774 * a + 0.2158037573 * b
const mRoot = lightness - 0.1055613458 * a - 0.0638541728 * b
const sRoot = lightness - 0.0894841775 * a - 1.291485548 * b
const l = lRoot ** 3
const m = mRoot ** 3
const s = sRoot ** 3
const encode = (value: number) => {
const channel =
value <= 0.0031308
? 12.92 * value
: 1.055 * Math.max(value, 0) ** (1 / 2.4) - 0.055
return Math.max(0, Math.min(1, channel)) * 255
}
return [
encode(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
),
encode(
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
),
encode(
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
),
]
}
/** Sample a small CPU histogram and return the most common album colors. */
export function extractPalette(image: HTMLImageElement, size = 48): AlbumPalette {
const canvas = document.createElement("canvas")
canvas.width = size
canvas.height = size
const context = canvas.getContext("2d", { willReadFrequently: true })
if (!context) {
return { dominant: [30, 35, 55], accent: [105, 65, 45] }
}
context.drawImage(image, 0, 0, size, size)
const pixels = context.getImageData(0, 0, size, size).data
const bins = new Map<number, { count: number; r: number; g: number; b: number }>()
for (let i = 0; i < pixels.length; i += 4) {
const red = pixels[i]!
const green = pixels[i + 1]!
const blue = pixels[i + 2]!
if (pixels[i + 3]! < 96) continue
const r = red >> 3
const g = green >> 3
const b = blue >> 3
const key = (r << 10) | (g << 5) | b
const bin = bins.get(key) ?? { count: 0, r: 0, g: 0, b: 0 }
bin.count++
bin.r += red
bin.g += green
bin.b += blue
bins.set(key, bin)
}
const entries = [...bins.values()].map((bin) => ({
count: bin.count,
color: [bin.r / bin.count, bin.g / bin.count, bin.b / bin.count] as RGB,
}))
if (!entries.length) {
return { dominant: [30, 35, 55], accent: [105, 65, 45] }
}
const distance = (a: RGB, b: RGB) => {
const first = toOkLab(a)
const second = toOkLab(b)
return (first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2 + (first[2] - second[2]) ** 2
}
const centers: RGB[] = [entries.reduce((best, entry) => entry.count > best.count ? entry : best).color]
while (centers.length < 4) {
let candidate = entries[0]!
let bestScore = -1
for (const entry of entries) {
const nearestDistance = Math.min(...centers.map((center) => distance(entry.color, center)))
const score = nearestDistance * Math.sqrt(entry.count)
if (score > bestScore) {
bestScore = score
candidate = entry
}
}
centers.push(candidate.color)
}
let clusterSupport = centers.map(() => 0)
for (let iteration = 0; iteration < 8; iteration++) {
const clusters = centers.map(() => ({ count: 0, red: 0, green: 0, blue: 0 }))
for (const entry of entries) {
let nearest = 0
let nearestDistance = Infinity
for (let i = 0; i < centers.length; i++) {
const nextDistance = distance(entry.color, centers[i]!)
if (nextDistance < nearestDistance) {
nearest = i
nearestDistance = nextDistance
}
}
const cluster = clusters[nearest]!
cluster.count += entry.count
cluster.red += entry.color[0] * entry.count
cluster.green += entry.color[1] * entry.count
cluster.blue += entry.color[2] * entry.count
}
for (let i = 0; i < clusters.length; i++) {
const cluster = clusters[i]!
if (cluster.count) centers[i] = [cluster.red / cluster.count, cluster.green / cluster.count, cluster.blue / cluster.count]
}
clusterSupport = clusters.map((cluster) => cluster.count)
}
const dominantIndex = clusterSupport.reduce(
(best, count, index) => count > clusterSupport[best]! ? index : best,
0,
)
const dominant = centers[dominantIndex]!
const distinctCenters = centers.filter((center) => distance(center, dominant) > 0.002)
const candidates = distinctCenters.length ? distinctCenters : centers
const accent = candidates.reduce((best, color) => {
const index = centers.indexOf(color)
const [_, a, b] = toOkLab(color)
const chroma = Math.hypot(a, b)
const score = chroma * Math.sqrt(clusterSupport[index] || 1)
const [__, bestA, bestB] = toOkLab(best)
const bestScore = Math.hypot(bestA, bestB) * Math.sqrt(clusterSupport[centers.indexOf(best)] || 1)
return score > bestScore ? color : best
})
return { dominant, accent }
}
@@ -0,0 +1,116 @@
import { fromOkLab, toOkLab } from "../palette"
import type { BackgroundFrame, BackgroundRenderer } from "./types"
function gradientHash(x: number, y: number): [number, number] {
const hash = (a: number, b: number, xScale: number, yScale: number) => {
const value = Math.sin(a * xScale + b * yScale) * 43758.5453
return (value - Math.floor(value)) * 2 - 1
}
return [hash(x, y, 127.1, 311.7), hash(x, y, 269.5, 183.3)]
}
function gradientNoise(x: number, y: number) {
const cellX = Math.floor(x)
const cellY = Math.floor(y)
const offsetX = x - cellX
const offsetY = y - cellY
const easeX = offsetX * offsetX * (3 - 2 * offsetX)
const easeY = offsetY * offsetY * (3 - 2 * offsetY)
const dot = (cx: number, cy: number, ox: number, oy: number) => {
const [gx, gy] = gradientHash(cx, cy)
return gx * ox + gy * oy
}
const lower =
dot(cellX, cellY, offsetX, offsetY) * (1 - easeX) +
dot(cellX + 1, cellY, offsetX - 1, offsetY) * easeX
const upper =
dot(cellX, cellY + 1, offsetX, offsetY - 1) * (1 - easeX) +
dot(cellX + 1, cellY + 1, offsetX - 1, offsetY - 1) * easeX
return 0.5 + 0.5 * (lower * (1 - easeY) + upper * easeY)
}
function smoothstep(edge0: number, edge1: number, value: number) {
const amount = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
return amount * amount * (3 - 2 * amount)
}
function ditherNoise(x: number, y: number, channel: number) {
const value = Math.sin(
(x + channel * 17) * 127.1 + (y + channel * 59) * 311.7,
) * 43758.5453
return value - Math.floor(value) - 0.5
}
function flowPoint(
x: number,
y: number,
frame: BackgroundFrame,
): [number, number] {
const { random, angleJitter, flowParams, time } = frame
const degree = gradientNoise(
time * 0.1 + random[0] * 0.07,
x * y + random[1] * 0.07,
)
const angle = (((degree - 0.5) * 720 + 180) * Math.PI) / 180 + angleJitter
const sine = Math.sin(angle)
const cosine = Math.cos(angle)
let flowX = x * cosine - y * sine
let flowY = x * sine + y * cosine
const speed = time * flowParams[2]
flowX += Math.sin(flowY * flowParams[0] + speed) / flowParams[1]
flowY +=
Math.sin(flowX * flowParams[0] * 1.5 + speed) / (flowParams[1] * 0.5)
const tiltSine = Math.sin(flowParams[3])
const tiltCosine = Math.cos(flowParams[3])
return [
flowX * tiltCosine - flowY * tiltSine,
flowX * tiltSine + flowY * tiltCosine,
]
}
export function createCpuRenderer(
canvas: HTMLCanvasElement,
): BackgroundRenderer | null {
const context = canvas.getContext("2d")
if (!context) return null
return {
render(frame) {
if (canvas.width !== frame.width || canvas.height !== frame.height) {
canvas.width = frame.width
canvas.height = frame.height
}
const image = context.createImageData(frame.width, frame.height)
const dominant = toOkLab(frame.palette.dominant)
const accent = toOkLab(frame.palette.accent)
const maxPaletteLightness = Math.max(dominant[0], accent[0])
for (let y = 0; y < frame.height; y++) {
for (let x = 0; x < frame.width; x++) {
const px = x / (frame.width - 1)
const py = y / (frame.height - 1)
const point = flowPoint(px - 0.5, py - 0.5, frame)
const blend = smoothstep(-0.34, 0.16, point[0])
const transition = 1 - Math.abs(blend * 2 - 1)
const lab: [number, number, number] = [
dominant[0] * (1 - blend) + accent[0] * blend,
dominant[1] * (1 - blend) + accent[1] * blend,
dominant[2] * (1 - blend) + accent[2] * blend,
]
lab[0] = Math.min(maxPaletteLightness, lab[0] + frame.beat * 0.01)
lab[1] *= 1 + frame.beat * 0.02
lab[2] *= 1 + frame.beat * 0.02
const color = fromOkLab(lab)
const offset = (y * frame.width + x) * 4
image.data[offset] = color[0] + ditherNoise(x, y, 0) * transition * 3
image.data[offset + 1] =
color[1] + ditherNoise(x, y, 1) * transition * 3
image.data[offset + 2] =
color[2] + ditherNoise(x, y, 2) * transition * 3
image.data[offset + 3] = 255
}
}
context.putImageData(image, 0, 0)
},
dispose() {},
}
}
@@ -0,0 +1,133 @@
struct Params {
resolutionTimeBeat: vec4f,
randomAndJitter: vec4f,
flowParams: vec4f,
dominant: vec4f,
accent: vec4f,
}
@group(0) @binding(0) var<uniform> params: Params;
fn hash(a: f32, b: f32, xScale: f32, yScale: f32) -> f32 {
let value = sin(a * xScale + b * yScale) * 43758.5453;
return fract(value) * 2.0 - 1.0;
}
fn gradientHash(cell: vec2f) -> vec2f {
return vec2f(
hash(cell.x, cell.y, 127.1, 311.7),
hash(cell.x, cell.y, 269.5, 183.3),
);
}
fn gradientDot(cell: vec2f, offset: vec2f) -> f32 {
return dot(gradientHash(cell), offset);
}
fn gradientNoise(point: vec2f) -> f32 {
let cell = floor(point);
let offset = fract(point);
let ease = offset * offset * (3.0 - 2.0 * offset);
let lower = mix(
gradientDot(cell, offset),
gradientDot(cell + vec2f(1.0, 0.0), offset - vec2f(1.0, 0.0)),
ease.x,
);
let upper = mix(
gradientDot(cell + vec2f(0.0, 1.0), offset - vec2f(0.0, 1.0)),
gradientDot(cell + vec2f(1.0, 1.0), offset - vec2f(1.0, 1.0)),
ease.x,
);
return 0.5 + 0.5 * mix(lower, upper, ease.y);
}
fn flowPoint(point: vec2f, time: f32) -> vec2f {
let degree = gradientNoise(vec2f(
time * 0.1 + params.randomAndJitter.x * 0.07,
point.x * point.y + params.randomAndJitter.y * 0.07,
));
let angle = ((degree - 0.5) * 720.0 + 180.0) * 0.017453292519943295
+ params.randomAndJitter.z;
let sine = sin(angle);
let cosine = cos(angle);
var flow = vec2f(
point.x * cosine - point.y * sine,
point.x * sine + point.y * cosine,
);
let speed = time * params.flowParams.z;
flow.x += sin(flow.y * params.flowParams.x + speed) / params.flowParams.y;
flow.y += sin(flow.x * params.flowParams.x * 1.5 + speed)
/ (params.flowParams.y * 0.5);
let tiltSine = sin(params.flowParams.w);
let tiltCosine = cos(params.flowParams.w);
return vec2f(
flow.x * tiltCosine - flow.y * tiltSine,
flow.x * tiltSine + flow.y * tiltCosine,
);
}
fn linearToSrgb(value: f32) -> f32 {
if (value <= 0.0031308) {
return 12.92 * value;
}
return 1.055 * pow(max(value, 0.0), 1.0 / 2.4) - 0.055;
}
fn fromOkLab(lab: vec3f) -> vec3f {
let lRoot = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z;
let mRoot = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z;
let sRoot = lab.x - 0.0894841775 * lab.y - 1.291485548 * lab.z;
let l = lRoot * lRoot * lRoot;
let m = mRoot * mRoot * mRoot;
let s = sRoot * sRoot * sRoot;
let red = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
let green = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
let blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
return clamp(vec3f(
linearToSrgb(red),
linearToSrgb(green),
linearToSrgb(blue),
), vec3f(0.0), vec3f(1.0));
}
fn ditherNoise(x: f32, y: f32, channel: f32) -> f32 {
let value = sin(
(x + channel * 17.0) * 127.1 + (y + channel * 59.0) * 311.7,
) * 43758.5453;
return fract(value) - 0.5;
}
@vertex
fn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
var positions = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
return vec4f(positions[index], 0.0, 1.0);
}
@fragment
fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f {
let dimensions = params.resolutionTimeBeat.xy;
let time = params.resolutionTimeBeat.z;
let beat = params.resolutionTimeBeat.w;
let uv = position.xy / dimensions;
let point = flowPoint(uv - vec2f(0.5), time);
let blend = smoothstep(-0.34, 0.16, point.x);
let transition = 1.0 - abs(blend * 2.0 - 1.0);
var lab = mix(params.dominant.xyz, params.accent.xyz, blend);
lab.x = min(params.dominant.w, lab.x + beat * 0.01);
lab.y = lab.y * (1.0 + beat * 0.02);
lab.z = lab.z * (1.0 + beat * 0.02);
let color = fromOkLab(lab);
let pixel = floor(position.xy);
let noise = vec3f(
ditherNoise(pixel.x, pixel.y, 0.0),
ditherNoise(pixel.x, pixel.y, 1.0),
ditherNoise(pixel.x, pixel.y, 2.0),
);
let dithered = clamp(color + noise * transition * (3.0 / 255.0),
vec3f(0.0), vec3f(1.0));
return vec4f(dithered, 1.0);
}
@@ -0,0 +1,17 @@
import type { AlbumPalette } from "../palette"
export interface BackgroundFrame {
width: number
height: number
time: number
beat: number
random: [number, number]
angleJitter: number
flowParams: [number, number, number, number]
palette: AlbumPalette
}
export interface BackgroundRenderer {
render(frame: BackgroundFrame): void
dispose(): void
}
@@ -0,0 +1,188 @@
import { toOkLab } from "../palette"
import shader from "./shader.wgsl?raw"
import type { BackgroundFrame, BackgroundRenderer } from "./types"
const uniformUsage = 0x0040 | 0x0008
interface GpuBuffer {
destroy(): void
}
interface GpuPass {
setPipeline(pipeline: unknown): void
setBindGroup(index: number, bindGroup: unknown): void
draw(vertexCount: number, instanceCount?: number): void
end(): void
}
interface GpuDevice {
queue: {
writeBuffer(buffer: GpuBuffer, offset: number, data: Float32Array): void
submit(commands: unknown[]): void
}
lost: Promise<{ message: string }>
createBuffer(descriptor: { size: number; usage: number }): GpuBuffer
createShaderModule(descriptor: { code: string }): {
getCompilationInfo(): Promise<{
messages: Array<{ type: string; message: string }>
}>
}
createRenderPipeline(descriptor: unknown): {
getBindGroupLayout(index: number): unknown
}
createBindGroup(descriptor: unknown): unknown
createCommandEncoder(): {
beginRenderPass(descriptor: unknown): GpuPass
finish(): unknown
}
destroy(): void
}
interface GpuAdapter {
requestDevice(): Promise<GpuDevice>
}
interface GpuApi {
requestAdapter(options?: {
powerPreference?: "low-power" | "high-performance"
}): Promise<GpuAdapter | null>
getPreferredCanvasFormat(): string
}
interface GpuCanvasContext {
configure(options: {
device: GpuDevice
format: string
alphaMode: "opaque"
}): void
getCurrentTexture(): { createView(): unknown }
unconfigure(): void
}
export async function createWebGpuRenderer(
canvas: HTMLCanvasElement,
onDeviceLost: () => void,
): Promise<BackgroundRenderer | null> {
let device: GpuDevice | null = null
let context: GpuCanvasContext | null = null
let uniformBuffer: GpuBuffer | null = null
try {
const gpu = (navigator as unknown as { gpu?: GpuApi }).gpu
if (!gpu) {
console.info("[background renderer] WebGPU API unavailable; using CPU")
return null
}
const adapter = await gpu.requestAdapter({
powerPreference: "high-performance",
})
if (!adapter) {
console.info("[background renderer] no WebGPU adapter; using CPU")
return null
}
device = await adapter.requestDevice()
const rawContext = (canvas.getContext as (kind: string) => unknown).call(
canvas,
"webgpu",
)
if (!rawContext) {
device.destroy()
return null
}
context = rawContext as GpuCanvasContext
const format = gpu.getPreferredCanvasFormat()
context.configure({ device, format, alphaMode: "opaque" })
const shaderModule = device.createShaderModule({ code: shader })
const compilation = await shaderModule.getCompilationInfo()
const shaderErrors = compilation.messages.filter(
(message) => message.type === "error",
)
if (shaderErrors.length) {
throw new Error(shaderErrors.map((message) => message.message).join("\n"))
}
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: shaderModule, entryPoint: "vertexMain" },
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{ format }],
},
primitive: { topology: "triangle-list" },
})
uniformBuffer = device.createBuffer({ size: 80, usage: uniformUsage })
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
})
const activeDevice = device
const activeContext = context
const activeBuffer = uniformBuffer
const uniforms = new Float32Array(20)
let lastPalette: BackgroundFrame["palette"] | null = null
let dominant: [number, number, number] = [0, 0, 0]
let accent: [number, number, number] = [0, 0, 0]
let maxPaletteLightness = 0
let disposed = false
void device.lost.then(() => {
if (!disposed) onDeviceLost()
})
return {
render(frame: BackgroundFrame) {
if (canvas.width !== frame.width || canvas.height !== frame.height) {
canvas.width = frame.width
canvas.height = frame.height
}
if (lastPalette !== frame.palette) {
dominant = toOkLab(frame.palette.dominant)
accent = toOkLab(frame.palette.accent)
maxPaletteLightness = Math.max(dominant[0], accent[0])
lastPalette = frame.palette
}
uniforms[0] = frame.width
uniforms[1] = frame.height
uniforms[2] = frame.time
uniforms[3] = frame.beat
uniforms[4] = frame.random[0]
uniforms[5] = frame.random[1]
uniforms[6] = frame.angleJitter
uniforms[7] = 0
uniforms.set(frame.flowParams, 8)
uniforms.set(dominant, 12)
uniforms[15] = maxPaletteLightness
uniforms.set(accent, 16)
uniforms[19] = 0
activeDevice.queue.writeBuffer(activeBuffer, 0, uniforms)
const encoder = activeDevice.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: activeContext.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, bindGroup)
pass.draw(3, 1)
pass.end()
activeDevice.queue.submit([encoder.finish()])
},
dispose() {
disposed = true
activeContext.unconfigure()
activeBuffer.destroy()
activeDevice.destroy()
},
}
} catch (error) {
console.warn("[background renderer] WebGPU unavailable; using CPU", error)
uniformBuffer?.destroy()
context?.unconfigure()
device?.destroy()
return null
}
}
@@ -0,0 +1,351 @@
<script setup lang="ts">
const { animationDuration, offset, showScrollBar } = defineProps<{
animationDuration: number
offset: number
showScrollBar: boolean
}>()
const song = useSong()
const { fullscreen } = useFullscreen()
const { secondsPlayed, refreshPosition } = usePlayer()
const debugView = useCookie("DEBUG_VIEW")
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
const parsedLyrics = computed(() => {
if (!song.value?.lyrics?.synced) return []
return parseLyrics(song.value.lyrics.text)
})
const hasParsedLyrics = computed(() => parsedLyrics.value.length > 0)
const findLyricIndex = (time: number) => {
if (!hasParsedLyrics.value) return -1
const index = parsedLyrics.value.findIndex(
(lyric, i) =>
lyric.time <= time &&
(!parsedLyrics.value![i + 1] ||
parsedLyrics.value![i + 1]!.time > time),
)
return index
}
const activeIndex = computed(() => {
return findLyricIndex(secondsPlayed.value)
})
const scrollActiveIndex = computed(() => {
if (!hasParsedLyrics.value) return -1
const time = secondsPlayed.value + animationDuration
let index = findLyricIndex(time)
if (index === -1 && time < parsedLyrics.value[0]!.time) index = 0
if (index >= parsedLyrics.value.length - 1)
index = parsedLyrics.value.length - 2
return index
})
const height = ref<number>()
const top = ref(0)
const syncedHeight = ref<number>()
const mobileView = ref(false)
const layoutVersion = ref(0)
const lyricOffsets = ref<number[]>([])
const topFadeExtension = 75
const updateLyricsLayout = () => {
const trackDisplay = document.querySelector(".track-display")
if (trackDisplay) {
height.value = trackDisplay.clientHeight
top.value = trackDisplay.getBoundingClientRect().top
syncedHeight.value = mobileView.value
? Math.max(
180,
window.innerHeight -
trackDisplay.getBoundingClientRect().bottom -
100,
)
: window.innerHeight - top.value - 100
}
const lines = containerRef.value?.children
if (!lines) {
lyricOffsets.value = []
return
}
let accumulatedHeight = 0
lyricOffsets.value = Array.from(lines, (line) => {
const offset = accumulatedHeight
accumulatedHeight += line.getBoundingClientRect().height
return offset
})
}
watch(
fullscreen,
async () => {
if (fullscreen.value) refreshPosition()
await nextTick()
updateLyricsLayout()
if (fullscreen.value) {
requestAnimationFrame(() => {
updateLyricsLayout()
layoutVersion.value++
})
}
},
{ immediate: true },
)
onMounted(() => {
const mobileQuery = window.matchMedia("(max-width: 767px)")
const updateMobileView = () => {
mobileView.value = mobileQuery.matches
updateLyricsLayout()
}
updateMobileView()
window.addEventListener("resize", updateLyricsLayout)
mobileQuery.addEventListener("change", updateMobileView)
onUnmounted(() => {
window.removeEventListener("resize", updateLyricsLayout)
mobileQuery.removeEventListener("change", updateMobileView)
})
})
const containerRef = useTemplateRef("container-ref")
const outerRef = useTemplateRef("outer-ref")
const transformY = computed(() => {
layoutVersion.value
const offset = lyricOffsets.value[scrollActiveIndex.value]
return offset === undefined ? 0 : -offset
})
watch(
parsedLyrics,
async () => {
await nextTick()
updateLyricsLayout()
layoutVersion.value++
},
{ flush: "post" },
)
const manualOffset = ref(0)
const isScrolling = ref(false)
let scrollingTimeout: any = null
const onWheel = (event: WheelEvent) => {
if (!containerRef.value || !outerRef.value) return
if (!isScrolling.value) {
manualOffset.value = finalTransform.value
}
isScrolling.value = true
clearTimeout(scrollingTimeout)
const delta = event.deltaY
manualOffset.value -= delta
const maxTranslate = finalTransform.value + offset
const lowerViewportLimit = Math.max(
outerRef.value.offsetHeight,
window.innerHeight - 300,
)
const minTranslate = -containerRef.value.scrollHeight + lowerViewportLimit
manualOffset.value = Math.min(
maxTranslate,
Math.max(minTranslate, manualOffset.value),
)
scrollingTimeout = setTimeout(() => (isScrolling.value = false), 3000)
}
const onLyricClick = (time: number) => {
clearTimeout(scrollingTimeout)
isScrolling.value = false
secondsPlayed.value = time
}
const finalTransform = computed(() => {
if (isScrolling.value) return manualOffset.value
return transformY.value + offset + topFadeExtension
})
</script>
<template>
<div
v-if="debugViewEnabled"
:style="[
{
position: 'absolute',
top: '455px',
width: '100px',
height: '1px',
backgroundColor: 'red',
right: '900px',
},
{
transform: 'translateY(20px)',
},
]"
/>
<div
ref="outer-ref"
v-if="song && song.lyrics && song.lyrics.synced && hasParsedLyrics"
class="synced-lyrics-container"
:style="{
height: `${(syncedHeight ?? 0) + topFadeExtension}px`,
marginTop: mobileView ? '0px' : `${top - topFadeExtension}px`,
}"
@wheel.prevent="onWheel"
>
<div
ref="container-ref"
class="synced-lyrics"
:style="{
transform: `translateY(${finalTransform}px)`,
transition: `transform ${animationDuration}s ease`,
}"
>
<p
v-for="(lyric, i) in parsedLyrics"
:key="lyric.time"
:class="{
active: i == activeIndex,
past: i < activeIndex && !isScrolling,
}"
@click="onLyricClick(lyric.time)"
>
{{ lyric.text }}
</p>
</div>
</div>
<div
v-else-if="song?.lyrics?.text"
class="lyrics"
:style="{
height: `${height}px`,
}"
:class="{ show: showScrollBar }"
>
<p>{{ song.lyrics.text }}</p>
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
@use "@/styles/variables" as *;
.synced-lyrics-container {
position: relative;
overflow: hidden;
align-self: start;
mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 15%,
#000 calc(100% - 200px),
transparent 100%
);
}
.synced-lyrics {
position: absolute;
scrollbar-width: none;
will-change: transform;
top: 0;
& > p {
@include fontSize(40px);
font-weight: 600;
color: white;
opacity: 0.4;
margin: 0;
padding: 10px 0;
transition:
opacity 0.1s ease,
filter 0.1s ease;
filter: blur(1.5px);
&.active {
opacity: 1;
filter: initial;
}
&:hover {
cursor: pointer;
opacity: 1;
filter: initial;
}
&.past {
opacity: 0;
filter: blur(1.5px);
transition: opacity 0.4s ease;
&:hover {
cursor: default;
opacity: 0;
filter: blur(1.5px);
}
}
}
}
.lyrics {
overflow: hidden;
& > p {
height: 100%;
overflow-y: scroll;
white-space: pre-wrap;
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 5px;
}
}
&.show > p::-webkit-scrollbar-thumb {
background-color: $color-secondary-background;
}
}
@media (max-width: 767px) {
.synced-lyrics-container {
align-self: stretch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 12%,
#000 calc(100% - 70px),
transparent 100%
);
}
.synced-lyrics > p {
@include fontSize(30px);
padding: 0.4rem 0;
}
.lyrics {
height: auto !important;
}
}
</style>
@@ -0,0 +1,251 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const { showButtons } = defineProps<{ showButtons: boolean }>()
const song = useSong()
const {
isPlaying,
play,
pause,
next,
prev,
shuffle,
repeat,
repeatOnce,
cycleRepeat,
secondsPlayed,
duration,
volume,
} = usePlayer()
const secondsPlayedFormatted = computed(() =>
new Date(secondsPlayed.value * 1000).toISOString().slice(14, 19),
)
const durationFormatted = computed(() =>
new Date((duration.value - secondsPlayed.value) * 1000)
.toISOString()
.slice(14, 19),
)
</script>
<template>
<article class="track-display" v-if="song">
<NuxtImg
class="cover"
:src="GET_FILE(song.album.file)"
:alt="song.name"
width="512"
height="512"
/>
<div class="track-row">
<div class="track">
<p class="name">{{ song.name }}</p>
<ArtistName :artists="song.artists" />
</div>
<div class="track-actions">
<button aria-label="More track options">
<Icon name="lucide:ellipsis" />
</button>
</div>
</div>
<div class="slider">
<RangeSlider
:model-value="secondsPlayed"
@update:model-value="(v) => (secondsPlayed = v ?? 0)"
:max="duration"
dont-update-immediately
monochrome
/>
<div class="time">
<p>{{ secondsPlayedFormatted }}</p>
<p>{{ durationFormatted }}</p>
</div>
</div>
<div class="btns" :class="{ hidden: !showButtons }">
<Icon name="lucide:shuffle" @click="shuffle()" />
<Icon name="lucide:skip-back" @click="prev()" />
<Icon name="lucide:pause" v-if="isPlaying" @click="pause()" />
<Icon name="lucide:play" v-else @click="play()" />
<Icon name="lucide:skip-forward" @click="next()" />
<Icon
:name="repeatOnce ? 'lucide:repeat-1' : 'lucide:repeat'"
:class="{ repeating: repeat || repeatOnce }"
@click="cycleRepeat()"
/>
</div>
<div class="volume-slider" :class="{ hidden: !showButtons }">
<Icon name="lucide:volume-2" />
<RangeSlider
:model-value="volume"
@update:model-value="
(v) => {
if (v !== undefined) volume = v
}
"
:max="1"
:min="0"
:step="0.01"
monochrome
/>
</div>
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
@use "@/styles/variables" as *;
.track-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 500px;
}
.cover {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: $border-radius-standard;
}
.track {
@include fontSize(16px);
width: 100%;
line-height: 1.3;
.name {
@include fontSize(17px);
font-weight: bold;
}
}
.track-row,
.track-actions {
display: flex;
align-items: center;
}
.track-row {
width: 100%;
justify-content: space-between;
gap: 0.75rem;
}
.track-actions {
display: none;
gap: 0.75rem;
button {
border: 0;
padding: 0;
color: inherit;
background: transparent;
font-size: 1.8rem;
}
}
.slider {
display: flex;
flex-direction: column;
width: 100%;
gap: 4px;
.time {
width: 100%;
display: flex;
justify-content: space-between;
opacity: 0.5;
& p {
@include fontSize(15px);
}
}
}
.btns {
cursor: pointer;
font-size: 30px;
display: flex;
justify-content: center;
width: 100%;
gap: 1rem;
transition: opacity 0.2s ease-in-out;
&.hidden {
opacity: 0;
pointer-events: none;
}
& > * {
transition: color 0.2s ease;
}
.repeating {
color: $color-accent;
}
}
.volume-slider {
align-self: stretch;
margin: 0.5rem 1rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
opacity: 1;
transition: opacity 0.2s ease-in-out;
:deep(.slider) {
flex: 1;
}
&.hidden {
opacity: 0;
pointer-events: none;
}
}
@media (max-width: 767px) {
.track-display {
display: contents;
}
.track-actions {
display: flex;
gap: 0.6rem;
}
.btns {
justify-content: space-between;
gap: 0.75rem;
font-size: 2.5rem;
opacity: 1 !important;
pointer-events: auto !important;
& > :first-child,
& > :last-child {
display: none;
}
& > * {
padding: 0.5rem;
}
}
.volume-slider {
margin-inline: 0;
opacity: 1 !important;
pointer-events: auto !important;
}
}
</style>
+361
View File
@@ -0,0 +1,361 @@
<script setup lang="ts">
const open = defineModel<boolean>("open", { default: false })
const { queue, playAlbumAtIndex } = usePlayer()
interface QueueRow {
key: number
track: Track
}
const queueRows = ref<QueueRow[]>([])
const draggedKey = ref<number | null>(null)
const overKey = ref<number | null>(null)
let nextRowKey = 0
const syncRows = (tracks: Track[]) => {
const previousRows = [...queueRows.value]
queueRows.value = tracks.map((track) => {
const previousIndex = previousRows.findIndex((row) => row.track === track)
if (previousIndex !== -1) {
const [row] = previousRows.splice(previousIndex, 1)
return row!
}
return { key: nextRowKey++, track }
})
}
watch(queue, syncRows, { immediate: true, deep: true })
const close = () => (open.value = false)
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") close()
}
watch(open, (isOpen) => {
if (isOpen) document.addEventListener("keydown", handleKeydown)
else document.removeEventListener("keydown", handleKeydown)
})
onBeforeUnmount(() => document.removeEventListener("keydown", handleKeydown))
const startDrag = (event: DragEvent, index: number) => {
const row = queueRows.value[index]
if (!row) return
draggedKey.value = row.key
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("text/plain", String(row.key))
}
}
const moveTo = (event: DragEvent, key: number) => {
event.preventDefault()
const from = queueRows.value.findIndex((row) => row.key === draggedKey.value)
const to = queueRows.value.findIndex((row) => row.key === key)
if (from === -1 || to === -1 || from === to) return
const [row] = queueRows.value.splice(from, 1)
queueRows.value.splice(to, 0, row!)
overKey.value = key
}
const finishDrag = () => {
if (draggedKey.value === null) return
queue.value = queueRows.value.map((row) => row.track)
draggedKey.value = null
overKey.value = null
}
const cancelDrag = () => {
if (draggedKey.value === null) return
syncRows(queue.value)
draggedKey.value = null
overKey.value = null
}
const playAt = (index: number) =>
playAlbumAtIndex(
queueRows.value.map((row) => row.track),
index,
)
const moveRow = (index: number, direction: -1 | 1) => {
const destination = index + direction
if (destination < 0 || destination >= queueRows.value.length) return
const [row] = queueRows.value.splice(index, 1)
queueRows.value.splice(destination, 0, row!)
queue.value = queueRows.value.map((item) => item.track)
}
</script>
<template>
<Teleport to="body">
<Transition name="queue">
<div v-if="open" class="queue-root" @click.self="close">
<div class="backdrop" @click="close"></div>
<aside
class="drawer"
role="dialog"
aria-modal="true"
aria-labelledby="queue-heading"
>
<header class="header">
<h2 id="queue-heading">Up Next</h2>
<button class="close" aria-label="Close queue" @click="close">
<Icon name="lucide:x" />
</button>
</header>
<div v-if="queueRows.length === 0" class="empty">
<p>No upcoming songs.</p>
</div>
<ol v-else class="tracks">
<li
v-for="(row, index) in queueRows"
:key="row.key"
:class="{
dragging: draggedKey === row.key,
over: overKey === row.key,
}"
draggable="true"
@dragstart="startDrag($event, index)"
@dragenter="moveTo($event, row.key)"
@dragover.prevent
@dragleave="overKey = null"
@drop="finishDrag"
@dragend="cancelDrag"
>
<div class="queue-row">
<TrackRow
:track="row.track"
:index="index"
show-image
queue-mode
class="queue-track"
@play-album-at-index="playAt(index)"
/>
<div
class="queue-order-actions"
role="group"
:aria-label="`Reorder ${row.track.name}`"
>
<button
type="button"
:aria-label="`Move ${row.track.name} up in queue`"
:disabled="index === 0"
@click="moveRow(index, -1)"
>
<Icon name="lucide:chevron-up" aria-hidden="true" />
</button>
<button
type="button"
:aria-label="`Move ${row.track.name} down in queue`"
:disabled="index === queueRows.length - 1"
@click="moveRow(index, 1)"
>
<Icon name="lucide:chevron-down" aria-hidden="true" />
</button>
</div>
</div>
</li>
</ol>
</aside>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
.queue-root {
position: fixed;
inset: 0;
z-index: 30;
}
.backdrop {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 55%);
}
.drawer {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
display: flex;
flex-direction: column;
inline-size: min(28rem, 92vw);
max-inline-size: 100vw;
background: $color-background;
border-inline-start: 1px solid $color-border;
box-shadow: 0 0 2rem rgb(0 0 0 / 30%);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid $color-border;
h2 {
margin: 0;
font-size: 1.1rem;
}
}
.close,
.play {
border: 0;
color: inherit;
background: transparent;
cursor: pointer;
}
.close {
display: grid;
place-items: center;
font-size: 1.25rem;
}
.tracks {
margin: 0;
padding: 0.5rem;
list-style: none;
overflow-y: auto;
}
.tracks li {
list-style: none;
border-radius: $border-radius-standard;
&.over :deep(.track) {
background: rgb(255 255 255 / 8%);
}
&.dragging :deep(.track) {
opacity: 0.45;
}
}
.queue-row {
position: relative;
}
.queue-order-actions {
display: none;
}
.empty {
flex: 1;
display: grid;
place-items: center;
padding: 2rem;
color: rgb(255 255 255 / 55%);
}
.queue-enter-active,
.queue-leave-active {
transition: opacity 0.2s ease;
.drawer {
transition: transform 0.2s ease;
}
}
.queue-enter-from,
.queue-leave-to {
opacity: 0;
.drawer {
transform: translateX(100%);
}
}
@media (max-width: 767px) {
.queue-root {
z-index: 120;
}
.drawer {
inset-block: 18dvh 0;
inset-inline: 0;
inline-size: 100%;
border-inline-start: 0;
border-top: 1px solid $color-border;
border-radius: 1rem 1rem 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
.header {
padding: 0.85rem 1rem;
}
.close {
padding: 0.75rem;
}
.tracks {
min-height: 0;
flex: 1;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.queue-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.queue-track {
flex: 1;
min-width: 0;
grid-template-columns: 40px minmax(0, 1fr);
padding-inline: 0.5rem;
}
.queue-row :deep(.queue-track > :nth-last-child(-n + 2)) {
display: none;
}
.queue-order-actions {
display: flex;
flex-direction: column;
button {
display: grid;
place-items: center;
border: 0;
padding: 0.875rem;
color: inherit;
background: transparent;
font-size: 1rem;
}
button:disabled {
color: rgb(255 255 255 / 30%);
cursor: default;
}
}
.queue-enter-active,
.queue-leave-active {
.drawer {
transition: transform 0.2s ease;
}
}
.queue-enter-from,
.queue-leave-to {
.drawer {
transform: translateY(100%);
}
}
}
</style>
+150 -23
View File
@@ -1,4 +1,6 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { GET_FILE } from "@/utils/file"
const widthFixerRef = useTemplateRef("width-fixer-ref") const widthFixerRef = useTemplateRef("width-fixer-ref")
const playerRef = useTemplateRef("player-ref") const playerRef = useTemplateRef("player-ref")
@@ -25,6 +27,8 @@ const {
prev, prev,
shuffle, shuffle,
repeat, repeat,
repeatOnce,
cycleRepeat,
secondsPlayed, secondsPlayed,
duration, duration,
volume, volume,
@@ -38,12 +42,14 @@ const durationFormatted = computed(() =>
new Date(duration.value * 1000).toISOString().slice(14, 19), new Date(duration.value * 1000).toISOString().slice(14, 19),
) )
const toast = useToast() const { open: openFullscreen } = useFullscreen()
const queueOpen = useState<boolean>("queueOpen", () => false)
</script> </script>
<template> <template>
<div id="width-fixer" ref="width-fixer-ref"></div> <div id="width-fixer" ref="width-fixer-ref"></div>
<article class="player" ref="player-ref" :class="{ active: song }"> <article class="player" ref="player-ref" :class="{ active: song }">
<Icon name="lucide:repeat-1" class="icon-preload" aria-hidden="true" />
<template v-if="song"> <template v-if="song">
<div class="track"> <div class="track">
<NuxtImg <NuxtImg
@@ -60,20 +66,50 @@ const toast = useToast()
</div> </div>
<div class="controls"> <div class="controls">
<div class="btns"> <div class="btns">
<Icon name="lucide:shuffle" @click="shuffle()" /> <button
<Icon name="lucide:skip-back" @click="prev()" /> class="icon-button"
<Icon aria-label="Shuffle queue"
name="lucide:pause" @click="shuffle()"
v-if="isPlaying" >
@click="pause()" <Icon name="lucide:shuffle" aria-hidden="true" />
/> </button>
<Icon name="lucide:play" v-else @click="play()" /> <button
<Icon name="lucide:skip-forward" @click="next()" /> class="icon-button"
<Icon aria-label="Previous track"
name="lucide:repeat" @click="prev()"
:class="{ repeating: repeat }" >
@click="repeat = !repeat" <Icon name="lucide:skip-back" aria-hidden="true" />
/> </button>
<button
class="icon-button"
:aria-label="isPlaying ? 'Pause' : 'Play'"
@click="isPlaying ? pause() : play()"
>
<Icon
:name="isPlaying ? 'lucide:pause' : 'lucide:play'"
aria-hidden="true"
/>
</button>
<button class="icon-button" aria-label="Next track" @click="next()">
<Icon name="lucide:skip-forward" aria-hidden="true" />
</button>
<button
class="icon-button"
:class="{ repeating: repeat || repeatOnce }"
:aria-label="
repeatOnce
? 'Repeat current track'
: repeat
? 'Repeat queue'
: 'Repeat off'
"
@click="cycleRepeat()"
>
<Icon
:name="repeatOnce ? 'lucide:repeat-1' : 'lucide:repeat'"
aria-hidden="true"
/>
</button>
</div> </div>
<div class="slider"> <div class="slider">
<p>{{ secondsPlayedFormatted }}</p> <p>{{ secondsPlayedFormatted }}</p>
@@ -102,23 +138,35 @@ const toast = useToast()
:step="0.01" :step="0.01"
/> />
</div> </div>
<Icon <button
name="lucide:list" class="icon-button"
@click="toast.show('Not yet implemented')" aria-label="Open queue"
/> @click="queueOpen = true"
<Icon >
name="lucide:expand" <Icon name="lucide:list" aria-hidden="true" />
@click="toast.show('Not yet implemented')" </button>
/> <button
class="icon-button"
aria-label="Open fullscreen player"
@click="openFullscreen()"
>
<Icon name="lucide:expand" aria-hidden="true" />
</button>
</div> </div>
</template> </template>
</article> </article>
<PlayerQueue v-model:open="queueOpen" />
<PlayerFullscreen />
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/variables" as *; @use "@/styles/variables" as *;
@use "@/styles/util" as *; @use "@/styles/util" as *;
.icon-preload {
display: none;
}
#width-fixer { #width-fixer {
width: 100%; width: 100%;
} }
@@ -132,6 +180,8 @@ const toast = useToast()
border-radius: $border-radius-lg; border-radius: $border-radius-lg;
border: 1px solid $color-border; border: 1px solid $color-border;
background-color: $color-background;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 1rem; gap: 1rem;
@@ -150,6 +200,17 @@ const toast = useToast()
cursor: pointer; cursor: pointer;
transition: color 0.2s ease; transition: color 0.2s ease;
} }
.icon-button {
display: grid;
place-items: center;
border: 0;
padding: 0;
color: inherit;
background: transparent;
cursor: pointer;
transition: color 0.2s ease;
}
} }
.track, .track,
@@ -230,4 +291,70 @@ const toast = useToast()
} }
} }
} }
@media (max-width: 767px) {
#width-fixer {
width: calc(100% + 1rem);
margin-inline: -0.5rem;
}
.player {
bottom: calc(4.5rem + env(safe-area-inset-bottom));
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25rem;
padding: 0.5rem 0.5rem;
margin-inline: -0.5rem;
border-radius: $border-radius-lg;
background-color: rgba(26, 32, 44, 0.96);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.track {
min-width: 0;
gap: 0.6rem;
.details {
min-width: 0;
}
img {
flex: none;
}
}
.controls {
width: auto;
align-items: center;
.slider,
.btns > :not(:nth-child(3)) {
display: none;
}
.btns {
width: auto;
padding: 0;
font-size: 1.5rem;
}
}
.misc-btns {
width: auto;
gap: 0.1rem;
.volume {
display: none;
}
.icon-button {
padding: 0.625rem;
font-size: 1.35rem;
}
}
.controls .icon-button {
padding: 0.625rem;
}
}
</style> </style>
+201
View File
@@ -0,0 +1,201 @@
<script setup lang="ts">
interface SearchResult {
type: "track" | "album" | "artist"
_id: string
name: string
file?: string
cover?: string
album?: { _id?: string; name?: string; file?: string; cover?: string }
artists?: Artist[]
}
const props = defineProps<{ result: SearchResult; index: number }>()
const emit = defineEmits<{ (e: "play"): void }>()
const artworkSrc = computed(() => {
const file = props.result.type === "track"
? props.result.album?.file ?? props.result.album?.cover
: props.result.file ?? props.result.cover
return file ? GET_FILE(file) : undefined
})
const entryType = computed(() =>
props.result.type === "track"
? "Song"
: props.result.type === "album"
? "Album"
: "Artist",
)
</script>
<template>
<article class="search-row" :class="{ odd: props.index % 2 === 1 }">
<div
class="artwork"
:class="{ playable: props.result.type === 'track' }"
:role="props.result.type === 'track' ? 'button' : undefined"
:tabindex="props.result.type === 'track' ? 0 : undefined"
:aria-label="props.result.type === 'track' ? `Play ${props.result.name}` : undefined"
@click="props.result.type === 'track' && emit('play')"
@keydown.enter.prevent="props.result.type === 'track' && emit('play')"
@keydown.space.prevent="props.result.type === 'track' && emit('play')"
>
<NuxtImg
v-if="artworkSrc"
:src="artworkSrc"
width="40"
height="40"
placeholder
/>
<Icon
v-if="props.result.type === 'track'"
name="lucide:play"
class="play-icon"
/>
</div>
<div class="details">
<p v-if="props.result.type === 'track'">{{ props.result.name }}</p>
<NuxtLink
v-else
:to="`/${props.result.type}/${props.result._id}`"
>
{{ props.result.name }}
</NuxtLink>
<span class="entry-type">{{ entryType }}</span>
</div>
<ArtistName
v-if="props.result.type === 'album' && props.result.artists?.length"
:artists="props.result.artists"
class="artists"
/>
<span v-else />
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "sass:color";
.search-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
padding: 0.7rem 1rem;
border-radius: $border-radius-standard;
background-color: $color-background;
line-height: 1.2;
&.odd {
background-color: color.adjust($color-background, $lightness: 3%);
}
&:hover {
background-color: color.adjust($color-background, $lightness: 5%);
}
}
.artwork {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: $border-radius-standard;
cursor: pointer;
border: 0;
padding: 0;
color: inherit;
background: transparent;
img {
max-width: 100%;
max-height: 100%;
border-radius: inherit;
}
&:hover .play-icon {
opacity: 1;
}
}
.play-icon {
position: absolute;
opacity: 0;
font-size: 1.4rem;
transition: opacity 0.15s ease;
}
.details {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
p,
a {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
a {
color: inherit;
text-decoration: none;
}
}
.entry-type {
font-size: 0.8em;
opacity: 0.65;
}
.artists {
min-width: 0;
}
@media (max-width: 767px) {
.search-row {
grid-template-columns: 40px minmax(0, 1fr);
grid-template-areas:
"art details"
"art artists";
column-gap: 0.75rem;
row-gap: 0.25rem;
padding-inline: 0.75rem;
}
.artwork {
grid-area: art;
&.playable .play-icon {
opacity: 1;
padding: 0.5rem;
border-radius: 50%;
background: rgb(0 0 0 / 55%);
}
}
.details {
grid-area: details;
min-width: 0;
}
.artists {
grid-area: artists;
overflow-wrap: anywhere;
:deep(.artist-name) {
min-width: 0;
}
:deep(.artist-name a) {
overflow-wrap: anywhere;
}
}
.search-row > span:last-child {
display: none;
}
}
</style>
+38 -6
View File
@@ -1,22 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
interface SliderProps { interface SliderProps {
title?: string title?: string
scrollWidth?: () => number
sliderClass?: string
} }
const props = defineProps<SliderProps>() const props = defineProps<SliderProps>()
const slider = ref<HTMLDivElement>() const slider = useTemplateRef<HTMLDivElement>("slider")
const scrollLeft = (num: number) => { const scrollLeft = (num: number) => {
if (slider.value) slider.value.scrollLeft += num if (!slider.value) return
const maxScroll = slider.value.scrollWidth - slider.value.clientWidth
slider.value.scrollLeft = Math.max(
0,
Math.min(slider.value?.scrollLeft + num, maxScroll),
)
} }
const left = () => { const left = () => {
if (slider.value) scrollLeft(-slider.value.offsetWidth) if (slider.value)
scrollLeft(
props.scrollWidth
? -props.scrollWidth()
: -slider.value.offsetWidth,
)
} }
const right = () => { const right = () => {
if (slider.value) scrollLeft(slider.value.offsetWidth) if (slider.value)
scrollLeft(
props.scrollWidth ? props.scrollWidth() : slider.value.offsetWidth,
)
} }
const doesScroll = ref<boolean>(false) const doesScroll = ref<boolean>(false)
@@ -48,7 +65,9 @@ onMounted(() => {
class="left" class="left"
name="material-symbols:chevron-left-rounded" name="material-symbols:chevron-left-rounded"
/> />
<section class="slider" ref="slider"><slot /></section> <section class="slider" :class="[props.sliderClass]" ref="slider">
<slot />
</section>
<Icon <Icon
v-if="doesScroll" v-if="doesScroll"
@click="right" @click="right"
@@ -85,7 +104,7 @@ onMounted(() => {
& > * { & > * {
flex: 0 0 auto; flex: 0 0 auto;
scroll-snap-align: center; scroll-snap-align: start;
} }
} }
@@ -107,4 +126,17 @@ onMounted(() => {
.right { .right {
right: -48px; right: -48px;
} }
@media (max-width: 767px) {
.left,
.right {
display: none;
}
.slider {
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scroll-padding-inline: 0.25rem;
}
}
</style> </style>
+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
interface TrackRowProps {
track: Track
}
const { track } = defineProps<TrackRowProps>()
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
const route = useRoute()
const hovering = ref(false)
const toast = useToast()
const { isSaved, toggleSaved } = useTrackLibrary(() => track._id)
const dropdownMenuItems = computed<DropdownMenuItem[][]>(() => [
[
{
label: "Play only this",
icon: "lucide:play",
onSelect() {},
},
{
label: "Play next",
icon: "lucide:list-start",
onSelect() {
toast.show("Playing next")
},
},
{
label: "Add to queue",
icon: "lucide:list-end",
onSelect() {
toast.show("Added to queue")
},
},
],
[
{
label:
isSaved.value === null
? "Checking Library…"
: isSaved.value
? "Remove from Library"
: "Save to Library",
icon: isSaved.value ? "lucide:bookmark-minus" : "lucide:bookmark-plus",
async onSelect() {
await toggleSaved()
},
},
{
label: "Add to playlist",
icon: "lucide:list-plus",
onSelect() {
toast.show("Playlists are not implemented")
},
},
],
[
{
label: "Go to artist",
icon: "lucide:user-round",
onSelect() {
const artist = track.artists[0]
if (artist) navigateTo(`/artist/${artist._id}`)
},
},
{
label: "Go to album",
icon: "lucide:disc-3",
onSelect() {
navigateTo(`/album/${track.album._id}`)
},
},
],
])
const visibleDropdownMenuItems = computed(() =>
dropdownMenuItems.value.map((group) =>
group.filter(
(item) =>
!(item.label === "Go to album" && route.path.startsWith("/album/")),
),
),
)
</script>
<template>
<article
class="track"
@mouseover="hovering = true"
@mouseleave="hovering = false"
>
<div
class="index"
:class="{ hovering }"
@click="emit('playAlbumAtIndex')"
>
<Icon name="lucide:play" />
<NuxtImg
:src="GET_FILE(track.album.file)"
width="50"
height="50"
placeholder
/>
</div>
<div>
<p>{{ track.name }}</p>
<ArtistName :artists="track.artists" class="artists" />
</div>
<DropdownMenu
:items="visibleDropdownMenuItems"
:label="`Options for ${track.name}`"
>
<Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu>
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util" as *;
@use "sass:color";
.track {
display: grid;
grid-template-columns: 40px 1fr 30px;
gap: 0.5rem;
align-items: center;
padding: 0.6rem 1rem;
border-radius: $border-radius-standard;
background-color: $color-background;
line-height: 1;
width: 100%;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.index {
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
position: relative;
cursor: pointer;
img {
border-radius: $border-radius-standard;
}
span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
}
img,
span {
transition: opacity 0.15s ease;
}
&.hovering {
img {
opacity: 0.4;
}
span {
opacity: 1;
}
}
}
.artists {
@include fontSize(14px);
}
.dropdown-icon {
font-size: 20px;
cursor: pointer;
}
</style>
+336 -49
View File
@@ -2,73 +2,227 @@
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue" import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
interface TrackRowProps { interface TrackRowProps {
track: Track track?: Track
entity?: {
type: "album" | "artist"
_id: string
name: string
file?: string
cover?: string
artists?: Artist[]
}
index: number index: number
extendedInfo?: boolean extendedInfo?: boolean
showImage?: boolean
queueMode?: boolean
touchFriendly?: boolean
libraryList?: boolean
} }
const { track, index, extendedInfo } = defineProps<TrackRowProps>() const props = defineProps<TrackRowProps>()
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>() const emit = defineEmits<{
(e: "playAlbumAtIndex"): void
(e: "removeFromLibrary", track: Track): void
}>()
const route = useRoute()
const hovering = ref(false) const hovering = ref(false)
const { playAlbum, addToFrontOfQueue, addToQueue } = usePlayer()
const toast = useToast() const toast = useToast()
const { isSaved, toggleSaved } = useTrackLibrary(() =>
const dropdownMenuItems: DropdownMenuItem[] = [ props.queueMode ? undefined : props.track?._id,
{
label: "Play only this",
icon: "lucide:play",
onSelect() {},
},
{
label: "Play next",
icon: "lucide:list-start",
onSelect() {
toast.show("Playing next")
},
},
{
label: "Add to queue",
icon: "lucide:list-end",
onSelect() {
toast.show("Added to queue")
},
},
]
const durationFormatted = computed(() =>
new Date(track.durationInSeconds * 1000).toISOString().slice(14, 19),
) )
const dropdownMenuItems = computed<DropdownMenuItem[][]>(() => [
[
{
label: "Play only this",
icon: "lucide:play",
onSelect() {
if (props.track) playAlbum([props.track])
},
},
{
label: "Play next",
icon: "lucide:list-start",
onSelect() {
if (!props.track) return
addToFrontOfQueue(props.track)
toast.show("Playing next")
},
},
{
label: "Add to queue",
icon: "lucide:list-end",
onSelect() {
if (!props.track) return
addToQueue(props.track)
toast.show("Added to queue")
},
},
],
[
{
label:
isSaved.value === null
? "Checking Library…"
: isSaved.value
? "Remove from Library"
: "Save to Library",
icon: isSaved.value ? "lucide:bookmark-minus" : "lucide:bookmark-plus",
async onSelect() {
const removed = await toggleSaved()
if (removed && props.track) emit("removeFromLibrary", props.track)
},
},
{
label: "Add to playlist",
icon: "lucide:list-plus",
onSelect() {
toast.show("Playlists are not implemented")
},
},
],
[
{
label: "Go to artist",
icon: "lucide:user-round",
onSelect() {
const artist = props.track?.artists[0]
if (artist) navigateTo(`/artist/${artist._id}`)
},
},
{
label: "Go to album",
icon: "lucide:disc-3",
onSelect() {
if (props.track) navigateTo(`/album/${props.track.album._id}`)
},
},
],
])
const visibleDropdownMenuItems = computed(() =>
dropdownMenuItems.value.map((group) =>
group.filter(
(item) => !(item.label === "Go to album" && route.path.startsWith("/album/")),
),
),
)
const durationFormatted = computed(() => {
if (!props.track) return ""
return new Date(props.track.durationInSeconds * 1000)
.toISOString()
.slice(14, 19)
})
const artworkSrc = computed(() => {
if (props.track) return GET_FILE(props.track.album.file)
const file = props.entity?.file ?? props.entity?.cover
return file ? GET_FILE(file) : undefined
})
</script> </script>
<template> <template>
<article <article
class="track" class="track"
:class="{ extended: extendedInfo, odd: index % 2 == 1 }" :class="{
@mouseover="hovering = true" extended: props.extendedInfo,
odd: props.index % 2 == 1,
image: props.showImage,
queue: props.queueMode,
'library-list': props.libraryList,
}"
@mouseenter="hovering = true"
@mouseleave="hovering = false" @mouseleave="hovering = false"
> >
<div class="index"> <div class="index" :class="{ hovering }">
<Icon <template v-if="props.showImage">
name="lucide:play" <Icon
@click="emit('playAlbumAtIndex')" v-if="props.track"
v-if="hovering" name="lucide:play"
/> class="play-icon"
<p v-else>{{ index + 1 }}</p> :class="{ 'touch-friendly': props.touchFriendly }"
:role="props.touchFriendly ? 'button' : undefined"
:tabindex="props.touchFriendly ? 0 : undefined"
:aria-label="
props.touchFriendly
? `Play ${props.track.name}`
: undefined
"
@click="emit('playAlbumAtIndex')"
@keydown.enter.prevent="
props.touchFriendly && emit('playAlbumAtIndex')
"
@keydown.space.prevent="
props.touchFriendly && emit('playAlbumAtIndex')
"
/>
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
<NuxtImg
v-if="artworkSrc"
:src="artworkSrc"
width="40"
height="40"
placeholder
/>
</template>
<template v-else>
<Icon
v-if="hovering"
name="lucide:play"
class="play-icon inline"
@click="emit('playAlbumAtIndex')"
/>
<p v-else>{{ props.index + 1 }}</p>
</template>
</div> </div>
<p>{{ track.name }}</p> <div v-if="props.track && props.libraryList" class="library-info">
<p class="library-title">{{ props.track.name }}</p>
<ArtistName :artists="props.track.artists" class="artists" />
<NuxtLink :to="`/album/${props.track.album._id}`" class="library-album">
{{ props.track.album.name }}
</NuxtLink>
</div>
<p v-else-if="props.track">{{ props.track.name }}</p>
<NuxtLink
v-else-if="props.entity"
:to="`/${props.entity.type}/${props.entity._id}`"
>
{{ props.entity.name }}
</NuxtLink>
<template v-if="extendedInfo"> <template v-if="props.track && props.extendedInfo && !props.libraryList">
<ArtistName :artists="track.artists" class="artists" /> <ArtistName :artists="props.track.artists" class="artists" />
<NuxtLink :to="`/album/${track.album._id}`"> <NuxtLink :to="`/album/${props.track.album._id}`">
<p>{{ track.album.name }}</p> <p>{{ props.track.album.name }}</p>
</NuxtLink> </NuxtLink>
</template> </template>
<template v-else-if="props.entity">
<ArtistName
v-if="props.entity.type === 'album' && props.entity.artists?.length"
:artists="props.entity.artists"
class="artists"
/>
<span v-else />
<p>{{ props.entity.type }}</p>
</template>
<p>{{ durationFormatted }}</p> <p v-if="props.track">{{ durationFormatted }}</p>
<DropdownMenu :items="dropdownMenuItems"> <Icon
v-if="props.queueMode"
name="lucide:grip-vertical"
class="queue-handle"
aria-hidden="true"
/>
<DropdownMenu
v-else-if="props.track"
:items="visibleDropdownMenuItems"
:label="`Options for ${props.track.name}`"
>
<Icon name="lucide:ellipsis" class="dropdown-icon" /> <Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu> </DropdownMenu>
</article> </article>
@@ -81,7 +235,7 @@ const durationFormatted = computed(() =>
.track { .track {
display: grid; display: grid;
grid-template-columns: 30px 1fr 50px 30px; grid-template-columns: 40px 1fr 50px 30px;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
@@ -90,18 +244,45 @@ const durationFormatted = computed(() =>
line-height: 1; line-height: 1;
&.odd { &.odd {
background-color: color.adjust($color-background, $lightness: 3%);
}
&:hover {
background-color: color.adjust($color-background, $lightness: 5%); background-color: color.adjust($color-background, $lightness: 5%);
} }
&.extended { &.extended {
grid-template-columns: 30px 2fr 1fr 1fr 50px 30px; grid-template-columns: 40px 2fr 1fr 1fr 50px 30px;
} }
p { &.image {
grid-template-columns: 40px 1fr 50px 30px;
padding: 0.45rem 1rem;
.index {
height: 40px;
}
}
&.queue {
cursor: grab;
}
&.library-list {
min-width: 0;
}
p,
> a {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
> a {
color: inherit;
text-decoration: none;
}
} }
.index { .index {
@@ -110,15 +291,121 @@ const durationFormatted = computed(() =>
align-items: center; align-items: center;
font-size: 20px; font-size: 20px;
height: 20px; height: 20px;
position: relative;
cursor: pointer; cursor: pointer;
img {
border-radius: $border-radius-standard;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
z-index: 1;
font-size: 24px;
&:focus-visible {
opacity: 1;
}
}
.play-icon.inline {
position: static;
transform: none;
opacity: 1;
}
img,
.play-icon {
transition: opacity 0.15s ease;
}
&.hovering {
img {
opacity: 0.4;
}
.play-icon {
opacity: 1;
}
}
}
@media (max-width: 767px) {
.track.library-list {
grid-template-columns: 28px minmax(0, 1fr) auto 40px;
gap: 0.5rem;
padding-inline: 0.5rem;
.index {
grid-area: 1 / 1 / 3 / 2;
}
.library-info {
grid-area: 1 / 2 / 3 / 3;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
line-height: 1.25;
}
.library-title,
.library-album,
.artists {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-title {
font-weight: 600;
}
.artists,
.library-album {
font-size: 0.8em;
opacity: 0.75;
}
> p {
grid-area: 1 / 3 / 3 / 4;
font-variant-numeric: tabular-nums;
}
:deep(.dropdown) {
grid-area: 1 / 4 / 3 / 5;
justify-self: end;
}
}
.play-icon.touch-friendly {
opacity: 1;
padding: 0.5rem;
border-radius: 50%;
background: rgb(0 0 0 / 55%);
}
} }
.artists { .artists {
opacity: 1; opacity: 1;
} }
.library-info {
display: contents;
}
.dropdown-icon { .dropdown-icon {
font-size: 20px; font-size: 20px;
cursor: pointer;
}
.queue-handle {
opacity: 0.5;
cursor: grab;
} }
</style> </style>
+15 -16
View File
@@ -6,33 +6,32 @@ export function useApiFetch<DataT = any, ErrorT = any>(
options?: UseFetchOptions<DataT, ErrorT>, options?: UseFetchOptions<DataT, ErrorT>,
authorize: boolean = true, authorize: boolean = true,
): ReturnType<typeof useFetch<DataT, ErrorT>> { ): ReturnType<typeof useFetch<DataT, ErrorT>> {
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND const { refreshSession } = useAuth()
if (!baseUrl) throw new Error("BACKEND URL not set")
const { sessionToken, refreshSessionToken } = useAuth()
const defaults: UseFetchOptions<DataT, ErrorT> = { const defaults: UseFetchOptions<DataT, ErrorT> = {
baseURL: baseUrl, // The migrated API lives in this Nuxt app under server/api.
baseURL: "/api",
retry: 1, retry: 1,
retryDelay: 0, retryDelay: 0,
retryStatusCodes: [401], retryStatusCodes: [401],
headers: headers: {},
authorize && sessionToken.value
? { Authorization: `Bearer ${sessionToken.value}` }
: {},
onResponseError: async ({ request, response, options }) => { onResponseError: async ({ request, response, options }) => {
if (response.status == 401 && authorize) { if (response.status == 401 && authorize) {
const newToken = await refreshSessionToken() const refreshed = await refreshSession()
if (!newToken) return if (!refreshed.ok) return
options.headers = { if (import.meta.server && refreshed.sessionCookie) {
...(options.headers as unknown as Record<string, string>), const headers = new Headers(options.headers as HeadersInit)
// @ts-ignore const cookies = (headers.get("cookie") ?? "")
Authorization: `Bearer ${newToken}`, .split(";")
.map((cookie) => cookie.trim())
.filter((cookie) => cookie && !cookie.startsWith("musicSession="))
cookies.push(refreshed.sessionCookie)
headers.set("cookie", cookies.join("; "))
options.headers = headers
} }
} }
}, },
+75 -115
View File
@@ -1,142 +1,102 @@
import axios, { AxiosError } from "axios" import { appendResponseHeader } from "h3"
let refreshPromise: Promise<any> | null = null type SessionRefresh = { ok: boolean; sessionCookie?: string }
let waitForPromise: boolean = false const refreshPromises = new WeakMap<object, Promise<SessionRefresh>>()
const setCookie = (
name: string,
value: string,
options: {
maxAge?: number
path?: string
sameSite?: "lax" | "strict" | "none"
secure?: boolean
} = {},
) => {
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
if (options.maxAge !== undefined) {
cookie += `; Max-Age=${options.maxAge}`
}
cookie += `; Path=${options.path ?? "/"}`
cookie += `; SameSite=${options.sameSite ?? "Lax"}`
if (options.secure) cookie += "; Secure"
document.cookie = cookie
}
export const useAuth = () => { export const useAuth = () => {
const refreshToken = useCookie<string | null>("refreshToken", { const nuxtApp = useNuxtApp()
sameSite: "lax", const authenticated = useState("auth:authenticated", () => false)
})
const sessionToken = useCookie<string | null>("sessionToken", { const signin = async (email: string, password: string, remember = false) => {
sameSite: "lax",
maxAge: 60 * 60, // 60 minutes
})
const signin = async (
email: string,
password: string,
remember: boolean = false,
) => {
try { try {
const res = await cfetch( await $fetch("/api/auth/signin", {
"/auth/signin", method: "POST",
body: { email, password, remember: Boolean(remember) },
})
if (!(await refreshSession()).ok) throw new Error("Unable to create session")
await navigateTo("/")
} catch (error: any) {
throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign in")
}
}
const signup = async (name: string, email: string, password: string) => {
try {
await $fetch("/api/auth/register", {
method: "POST",
body: { name, email, password },
})
if (!(await refreshSession()).ok) throw new Error("Unable to create session")
await navigateTo("/")
} catch (error: any) {
throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign up")
}
}
const signout = async () => {
try {
await $fetch("/api/auth/signout", { method: "POST" })
} finally {
authenticated.value = false
}
}
const refreshSession = async (): Promise<SessionRefresh> => {
const inFlight = refreshPromises.get(nuxtApp)
if (inFlight) return inFlight
const refreshPromise = (async () => {
const response = await $fetch.raw<{ expireAt: string }>(
"/api/auth/session",
{ {
method: "POST", method: "POST",
data: { headers: import.meta.server
email, ? useRequestHeaders(["cookie"])
password, : undefined,
},
}, },
false,
) )
// NOTE: Quite hacky... this should be changed. let sessionCookie: string | undefined
// TODO: Add httpOnly and update the backend to support that. const setCookies = import.meta.server ? response.headers.getSetCookie() : []
if (remember) { for (const cookie of setCookies) {
setCookie("refreshToken", res.data.refreshToken, { if (!cookie.startsWith("musicSession=")) continue
maxAge: 60 * 60 * 24 * 30, // 30 days sessionCookie = cookie.split(";", 1)[0]
})
} else {
setCookie("refreshToken", res.data.refreshToken)
}
//refreshToken.value = res.data.refreshToken
await refreshSessionToken()
await navigateTo("/")
} catch (e) {
if (axios.isAxiosError(e) && e.response?.data?.message) {
throw new Error(e.response.data.message)
} }
throw new Error("Failed to sign in") if (import.meta.server) {
} const event = useRequestEvent()
} if (event) {
for (const cookie of setCookies) {
const signup = async (name: string, email: string, password: string) => {} appendResponseHeader(event, "set-cookie", cookie)
}
const signout = () => {
refreshToken.value = null
sessionToken.value = null
}
const refreshSessionToken = async () => {
if (waitForPromise) {
// Force it to wait up to 50 ms for it to set refreshPromise, hopefully less
for (let i = 0; i < 10; i++) {
if (refreshPromise != null) {
break
} }
await new Promise((r) => setTimeout(r, 5))
} }
}
waitForPromise = true return { ok: true, sessionCookie }
})()
if (refreshPromise != null) { .then((result) => {
waitForPromise = false authenticated.value = true
return refreshPromise return result
}
refreshPromise = cfetch(
"/auth/session",
{
data: {
refreshToken: refreshToken.value,
},
method: "POST",
},
false,
)
.then((res) => {
const token = res.data.sessionToken
sessionToken.value = token
refreshPromise = null
return token
}) })
.catch((e) => { .catch(() => {
refreshPromise = null authenticated.value = false
sessionToken.value = null return { ok: false }
})
return null .finally(() => {
refreshPromises.delete(nuxtApp)
}) })
refreshPromises.set(nuxtApp, refreshPromise)
return refreshPromise return refreshPromise
} }
return { return {
refreshToken, authenticated,
sessionToken,
signin, signin,
signup, signup,
signout, signout,
refreshSessionToken, refreshSession,
} }
} }
+66
View File
@@ -0,0 +1,66 @@
type FullscreenState = ReturnType<typeof useState<boolean>>
type FullscreenControls = {
fullscreen: FullscreenState
open: () => Promise<boolean>
close: () => Promise<boolean>
toggle: () => Promise<boolean>
}
const syncFullscreenState = (fullscreen: FullscreenState) => {
fullscreen.value = Boolean(document.fullscreenElement)
}
let fullscreenListenerInstalled = false
export const useFullscreen = () => {
const fullscreen = useState<boolean>("fullscreen", () => false)
const open = async () => {
fullscreen.value = true
if (!import.meta.client) return true
if (!document.documentElement.requestFullscreen) return true
try {
await document.documentElement.requestFullscreen()
return true
} catch {
// Keep the in-app fullscreen player open when native fullscreen is denied.
return false
}
}
const close = async () => {
if (import.meta.client && document.fullscreenElement) {
try {
await document.exitFullscreen()
} catch {
// Fall through to keep the app state consistent.
}
}
fullscreen.value = false
return true
}
const toggle = async () => {
if (fullscreen.value) return close()
return open()
}
if (import.meta.client && !fullscreenListenerInstalled) {
fullscreenListenerInstalled = true
document.addEventListener("fullscreenchange", () => {
syncFullscreenState(fullscreen)
})
syncFullscreenState(fullscreen)
}
return {
fullscreen,
open,
close,
toggle,
} as FullscreenControls
}
+64
View File
@@ -0,0 +1,64 @@
import { cfetch } from "@/utils/cfetch"
import { toValue, type MaybeRefOrGetter } from "vue"
interface TrackMembership {
contains: boolean
}
export const useTrackLibrary = (trackId: MaybeRefOrGetter<string | undefined>) => {
const toast = useToast()
const isSaved = ref<boolean | null>(null)
const saving = ref(false)
const checkMembership = async () => {
const id = toValue(trackId)
if (!id) return
try {
const result = await cfetch<TrackMembership>("/user/tracks/contains", {
params: { id },
})
isSaved.value = result.contains
} catch (error: any) {
toast.show(
error?.data?.message || "Could not check Library status",
"error",
)
}
}
onMounted(checkMembership)
const toggleSaved = async () => {
const id = toValue(trackId)
if (!id || isSaved.value === null || saving.value) return false
const wasSaved = isSaved.value
saving.value = true
try {
await cfetch("/user/tracks", {
method: wasSaved ? "DELETE" : "PATCH",
params: { id },
})
isSaved.value = !wasSaved
toast.show(
wasSaved ? "Removed from Library" : "Saved to Library",
"success",
)
return wasSaved
} catch (error: any) {
toast.show(
error?.data?.message ||
(wasSaved
? "Could not remove song from Library"
: "Could not save song to Library"),
"error",
)
return false
} finally {
saving.value = false
}
}
return { isSaved, saving, toggleSaved }
}
+113 -1
View File
@@ -1,5 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
const router = useRouter() const router = useRouter()
const route = useRoute()
const activeTab = computed(() => {
if (route.path === "/") return "home"
if (route.path === "/search") return "search"
if (route.path.startsWith("/library/")) return "library"
return ""
})
let timeout: ReturnType<typeof setTimeout> let timeout: ReturnType<typeof setTimeout>
@@ -13,10 +21,11 @@ const onSearchInput = (v: string) => {
} }
const { data: user } = useApiFetch<any>("/user") const { data: user } = useApiFetch<any>("/user")
const song = useSong()
</script> </script>
<template> <template>
<Sidebar> <Sidebar class="default-shell" :class="{ 'has-player': song }">
<template #sidebar> <template #sidebar>
<p class="title">Music</p> <p class="title">Music</p>
@@ -89,12 +98,55 @@ const { data: user } = useApiFetch<any>("/user")
<template #default> <template #default>
<slot /> <slot />
<nav class="mobile-tabs" aria-label="Primary navigation">
<NuxtLink
to="/"
class="tab"
:class="{ active: activeTab === 'home' }"
:aria-current="activeTab === 'home' ? 'page' : undefined"
>
<Icon
name="lucide:house"
class="tab-icon"
aria-hidden="true"
/>
<span>Home</span>
</NuxtLink>
<NuxtLink
to="/library/songs"
class="tab"
:class="{ active: activeTab === 'library' }"
:aria-current="activeTab === 'library' ? 'page' : undefined"
>
<Icon
name="lucide:library"
class="tab-icon"
aria-hidden="true"
/>
<span>Library</span>
</NuxtLink>
<NuxtLink
to="/search"
class="tab"
:class="{ active: activeTab === 'search' }"
:aria-current="activeTab === 'search' ? 'page' : undefined"
>
<Icon
name="lucide:search"
class="tab-icon"
aria-hidden="true"
/>
<span>Search</span>
</NuxtLink>
</nav>
</template> </template>
</Sidebar> </Sidebar>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/util" as *; @use "@/styles/util" as *;
@use "@/styles/variables" as *;
.title { .title {
@include fontSize(24px); @include fontSize(24px);
@@ -114,4 +166,64 @@ const { data: user } = useApiFetch<any>("/user")
margin-bottom: 0.1rem; margin-bottom: 0.1rem;
} }
} }
.mobile-tabs {
display: none;
}
@media (max-width: 767px) {
.default-shell {
display: block;
overflow: hidden;
:deep(> aside) {
display: none;
}
:deep(> main) {
padding: calc(1.25rem + env(safe-area-inset-top)) 1rem
calc(6rem + env(safe-area-inset-bottom));
}
&.has-player :deep(> main) {
padding-bottom: calc(6rem + env(safe-area-inset-bottom) + $player-height);
}
}
.mobile-tabs {
position: fixed;
z-index: 100;
inset: auto 0 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 0.45rem 0.75rem calc(0.45rem + env(safe-area-inset-bottom));
border-top: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(22, 22, 24, 0.96);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.tab {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
border-radius: 0.65rem;
color: rgba(255, 255, 255, 0.62);
text-decoration: none;
font-size: 0.75rem;
font-weight: 600;
-webkit-tap-highlight-color: transparent;
.tab-icon {
font-size: 1.75rem;
}
&.active {
color: white;
}
}
}
</style> </style>
+38 -4
View File
@@ -1,6 +1,40 @@
export default defineNuxtRouteMiddleware((to, from) => { import { appendResponseHeader } from "h3"
const auth = useAuth()
if (!auth.refreshToken.value && !to.fullPath.includes("/auth")) export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo("/auth/signin") if (to.path.startsWith("/auth")) return
const auth = useAuth()
if (import.meta.client && auth.authenticated.value) return
try {
// Forward the incoming SSR credentials to this endpoint; the browser sends
// same-origin cookies automatically during client navigation.
const requestHeaders = import.meta.server
? useRequestHeaders(["cookie", "authorization"])
: undefined
const response = await $fetch.raw<{
authenticated: boolean
}>("/api/auth/session", { headers: requestHeaders })
const session = response._data
// Nuxt forwards incoming request cookies automatically, but internal SSR
// fetch responses do not forward Set-Cookie to the browser automatically.
if (import.meta.server) {
const event = useRequestEvent()
if (event) {
for (const cookie of response.headers.getSetCookie()) {
appendResponseHeader(event, "set-cookie", cookie)
}
}
}
if (session?.authenticated) {
auth.authenticated.value = true
return
}
} catch {
// Treat unavailable auth state as unauthenticated.
}
return navigateTo("/auth/signin")
}) })
+1
View File
@@ -2,6 +2,7 @@
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
}) })
useHead({ title: "Admin" })
</script> </script>
<template> <template>
+5 -5
View File
@@ -14,7 +14,6 @@ const tableRows: Row[] = [
name: "file", name: "file",
displayName: "Cover", displayName: "Cover",
type: "image", type: "image",
prefix: `${BACKEND_SERVE}`,
}, },
{ {
name: "name", name: "name",
@@ -33,7 +32,7 @@ import { type Option } from "~/ui/SearchSelect.vue"
const { data: artistData } = useApiFetch<Artist[]>("/all/artists") const { data: artistData } = useApiFetch<Artist[]>("/all/artists")
const artistFetchOptions = async (q?: string): Promise<Option[]> => { const artistFetchOptions = async (q?: string): Promise<Option[]> => {
return artistData.value.map<Option>((g) => ({ return (artistData.value ?? []).map<Option>((g) => ({
label: g.name, label: g.name,
value: g._id, value: g._id,
})) }))
@@ -41,7 +40,7 @@ const artistFetchOptions = async (q?: string): Promise<Option[]> => {
const { data: genreData } = useApiFetch<Genre[]>("/all/genres") const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
const genreFetchOptions = async (q?: string): Promise<Option[]> => { const genreFetchOptions = async (q?: string): Promise<Option[]> => {
return genreData.value.map<Option>((g) => ({ return (genreData.value ?? []).map<Option>((g) => ({
label: g.name, label: g.name,
value: g._id, value: g._id,
})) }))
@@ -114,7 +113,7 @@ const show = (id?: string) => {
return return
} }
const curItem: any = data.value.filter((i) => i._id == id)[0] const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return if (!curItem) return
@@ -138,12 +137,13 @@ const show = (id?: string) => {
showModal.value = true showModal.value = true
} }
useHead({ title: "Admin - Albums" })
</script> </script>
<template> <template>
<h1 class="title">Manage albums</h1> <h1 class="title">Manage albums</h1>
<Table :rows="tableRows" :data="data" class="table"> <Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }"> <template #action="{ item, index }">
<div class="action"> <div class="action">
<Button @click="show(item._id)">Edit</Button> <Button @click="show(item._id)">Edit</Button>
+3 -3
View File
@@ -14,7 +14,6 @@ const tableRows: Row[] = [
name: "file", name: "file",
displayName: "Cover", displayName: "Cover",
type: "image", type: "image",
prefix: `${BACKEND_SERVE}`,
}, },
{ {
name: "name", name: "name",
@@ -80,7 +79,7 @@ const show = (id?: string) => {
return return
} }
const curItem: any = data.value.filter((i) => i._id == id)[0] const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return if (!curItem) return
@@ -88,12 +87,13 @@ const show = (id?: string) => {
showModal.value = true showModal.value = true
} }
useHead({ title: "Admin - Artists" })
</script> </script>
<template> <template>
<h1 class="title">Manage artists</h1> <h1 class="title">Manage artists</h1>
<Table :rows="tableRows" :data="data" class="table"> <Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }"> <template #action="{ item, index }">
<div class="action"> <div class="action">
<Button @click="show(item._id)">Edit</Button> <Button @click="show(item._id)">Edit</Button>
+3 -2
View File
@@ -71,7 +71,7 @@ const show = (id?: string) => {
return return
} }
const curItem: any = data.value.filter((i) => i._id == id)[0] const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return if (!curItem) return
@@ -79,12 +79,13 @@ const show = (id?: string) => {
showModal.value = true showModal.value = true
} }
useHead({ title: "Admin - Genres" })
</script> </script>
<template> <template>
<h1 class="title">Manage genres</h1> <h1 class="title">Manage genres</h1>
<Table :rows="tableRows" :data="data" class="table"> <Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }"> <template #action="{ item, index }">
<div class="action"> <div class="action">
<Button @click="show(item._id)">Edit</Button> <Button @click="show(item._id)">Edit</Button>
+2 -1
View File
@@ -1,3 +1,4 @@
<script setup lang="ts"></script> <script setup lang="ts">useHead({ title: "Admin" })
</script>
<template></template> <template></template>
+89 -8
View File
@@ -14,8 +14,6 @@ const tableRows: Row[] = [
name: "fileDir", name: "fileDir",
displayName: "Audio File", displayName: "Audio File",
type: "audio", type: "audio",
prefix: `${BACKEND_SERVE}`,
suffix: "/high.m4a",
}, },
{ {
name: "name", name: "name",
@@ -34,7 +32,7 @@ import { type Option } from "~/ui/SearchSelect.vue"
const { data: artistData } = useApiFetch<Artist[]>("/all/artists") const { data: artistData } = useApiFetch<Artist[]>("/all/artists")
const artistFetchOptions = async (q?: string): Promise<Option[]> => { const artistFetchOptions = async (q?: string): Promise<Option[]> => {
return artistData.value.map<Option>((g) => ({ return (artistData.value ?? []).map<Option>((g) => ({
label: g.name, label: g.name,
value: g._id, value: g._id,
})) }))
@@ -42,12 +40,20 @@ const artistFetchOptions = async (q?: string): Promise<Option[]> => {
const { data: albumData } = useApiFetch<Album[]>("/all/albums") const { data: albumData } = useApiFetch<Album[]>("/all/albums")
const albumFetchOptions = async (q?: string): Promise<Option[]> => { const albumFetchOptions = async (q?: string): Promise<Option[]> => {
return albumData.value.map<Option>((g) => ({ return (albumData.value ?? []).map<Option>((g) => ({
label: g.name, label: g.name,
value: g._id, value: g._id,
})) }))
} }
const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
const genreFetchOptions = async (): Promise<Option[]> => {
return (genreData.value ?? []).map<Option>((genre) => ({
label: genre.name,
value: genre._id,
}))
}
const modalFields: Field[] = [ const modalFields: Field[] = [
{ key: "name", label: "Name" }, { key: "name", label: "Name" },
{ key: "file", label: "Audio file", type: "file" }, { key: "file", label: "Audio file", type: "file" },
@@ -57,18 +63,50 @@ const modalFields: Field[] = [
type: "search-select-array", type: "search-select-array",
fetchOptions: artistFetchOptions, fetchOptions: artistFetchOptions,
}, },
{
key: "createSingle",
label: "Create a single album automatically",
type: "checkbox",
visibleWhen: (value) => !value._id,
},
{ {
key: "album", key: "album",
label: "Album", label: "Album",
type: "search-select", type: "search-select",
fetchOptions: albumFetchOptions, fetchOptions: albumFetchOptions,
visibleWhen: (value) => !value.createSingle,
},
{
key: "cover",
label: "Single cover",
type: "file",
visibleWhen: (value) => !value._id && value.createSingle,
},
{
key: "genre",
label: "Genre",
type: "search-select",
fetchOptions: genreFetchOptions,
visibleWhen: (value) => !value._id && value.createSingle,
}, },
{ key: "lyrics.text", label: "Lyrics", type: "textarea", rows: 6 }, { key: "lyrics.text", label: "Lyrics", type: "textarea", rows: 6 },
{ key: "lyrics.synced", label: "Synced lyrics", type: "checkbox" }, { key: "lyrics.synced", label: "Synced lyrics", type: "checkbox" },
] ]
const serializeLyrics = (lyrics: Track["lyrics"] | undefined) => {
if (!lyrics?.text?.trim()) return JSON.stringify(null)
return JSON.stringify(lyrics)
}
const getArtistIds = (artists: Array<string | Option> = []) =>
artists.map((artist) => (typeof artist === "string" ? artist : artist.value))
const getOptionId = (option: string | Option) =>
typeof option === "string" ? option : option.value
const showModal = ref<boolean>(false) const showModal = ref<boolean>(false)
const item = ref<Track | undefined>(undefined) const item = ref<Track | undefined>(undefined)
const addingSong = ref(false)
const save = async (value: any) => { const save = async (value: any) => {
if (value._id) { if (value._id) {
@@ -77,7 +115,7 @@ const save = async (value: any) => {
method: "PATCH", method: "PATCH",
data: { data: {
...value, ...value,
lyrics: JSON.stringify(value.lyrics), lyrics: serializeLyrics(value.lyrics),
}, },
params: { id: value._id }, params: { id: value._id },
forceFormData: true, forceFormData: true,
@@ -90,12 +128,50 @@ const save = async (value: any) => {
return false return false
} }
} else { } else {
if (!value.name || !value.file || !value.artists?.length) {
toast.show(
"Name, audio file, and at least one artist are required",
"error",
)
return false
}
if (value.createSingle && (!value.cover || !value.genre)) {
toast.show(
"A cover image and genre are required for a single",
"error",
)
return false
}
if (!value.createSingle && !value.album) {
toast.show("Choose an album or create a single album", "error")
return false
}
addingSong.value = true
await nextTick()
try { try {
if (value.createSingle) {
const albumResponse = await cfetch<{ _id: string }>("/admin/album", {
method: "POST",
data: {
name: `${value.name} - Single`,
artists: getArtistIds(value.artists),
genre: getOptionId(value.genre),
file: value.cover,
},
})
value.album = albumResponse._id
}
await cfetch("/admin/track", { await cfetch("/admin/track", {
method: "POST", method: "POST",
data: { data: {
...value, ...value,
lyrics: JSON.stringify(value.lyrics), album: value.album?.value ?? value.album,
artists: getArtistIds(value.artists),
lyrics: serializeLyrics(value.lyrics),
}, },
}) })
@@ -104,6 +180,8 @@ const save = async (value: any) => {
toast.show("Failed to add song", "error") toast.show("Failed to add song", "error")
return false return false
} finally {
addingSong.value = false
} }
} }
@@ -123,7 +201,7 @@ const show = (id?: string) => {
return return
} }
const curItem: any = data.value.filter((i) => i._id == id)[0] const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return if (!curItem) return
@@ -145,12 +223,13 @@ const show = (id?: string) => {
showModal.value = true showModal.value = true
} }
useHead({ title: "Admin - Songs" })
</script> </script>
<template> <template>
<h1 class="title">Manage songs</h1> <h1 class="title">Manage songs</h1>
<Table :rows="tableRows" :data="data" class="table"> <Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }"> <template #action="{ item, index }">
<div class="action"> <div class="action">
<Button @click="show(item._id)">Edit</Button> <Button @click="show(item._id)">Edit</Button>
@@ -166,6 +245,8 @@ const show = (id?: string) => {
:fields="modalFields" :fields="modalFields"
:item="item" :item="item"
:title="item ? 'Edit song' : 'Add new song'" :title="item ? 'Edit song' : 'Add new song'"
:loading="addingSong"
:loading-text="'Adding song…'"
/> />
</template> </template>
+73 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const route = useRoute() const route = useRoute()
const id = computed(() => route.params.id) const id = computed(() => route.params.id)
@@ -7,18 +9,22 @@ const { data: albumData } = await useApiFetch<Album>(`/albums/${id.value}`)
const { data: tracksData } = await useApiFetch<Track[]>( const { data: tracksData } = await useApiFetch<Track[]>(
`/albums/${id.value}/tracks`, `/albums/${id.value}/tracks`,
) )
const tracks = computed(() => tracksData.value ?? [])
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer() const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Album` : "Album") })
</script> </script>
<template> <template>
<section class="info" v-if="albumData"> <section class="info" v-if="albumData">
<NuxtImg <div class="album-art">
:src="GET_FILE(albumData.file)" <NuxtImg
width="300" :src="GET_FILE(albumData.file)"
height="300" width="300"
placeholder height="300"
/> placeholder
/>
</div>
<div class="details"> <div class="details">
<h1>{{ albumData.name }}</h1> <h1>{{ albumData.name }}</h1>
@@ -27,13 +33,15 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
<div class="buttons"> <div class="buttons">
<Button <Button
icon-name="lucide:play" icon-name="lucide:play"
@click="() => playAlbum(tracksData)" :aria-label="`Play ${albumData.name}`"
>Play</Button @click="() => playAlbum(tracks)"
><span class="button-label">Play</span></Button
> >
<Button <Button
icon-name="lucide:shuffle" icon-name="lucide:shuffle"
@click="() => playShuffledAlbum(tracksData)" :aria-label="`Shuffle ${albumData.name}`"
>Shuffle</Button @click="() => playShuffledAlbum(tracks)"
><span class="button-label">Shuffle</span></Button
> >
</div> </div>
</div> </div>
@@ -41,11 +49,11 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
<section class="tracks"> <section class="tracks">
<TrackRow <TrackRow
v-for="(track, i) in tracksData" v-for="(track, i) in tracks"
:key="track._id" :key="track._id"
:track="track" :track="track"
:index="i" :index="i"
@play-album-at-index="() => playAlbumAtIndex(tracksData, i)" @play-album-at-index="() => playAlbumAtIndex(tracks, i)"
/> />
</section> </section>
</template> </template>
@@ -93,4 +101,57 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
@media (max-width: 767px) {
.info {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
margin-bottom: 1rem;
.album-art {
padding: 0.5rem;
img {
display: block;
width: 100%;
height: auto;
}
}
}
.info .details {
align-items: center;
padding-top: 0;
text-align: center;
h1 {
margin: 0;
@include util.fontSize(26px);
line-height: 1.2;
}
.artist {
@include util.fontSize(16px);
}
.buttons {
margin-top: 0.375rem;
gap: 0.75rem;
}
}
.details .buttons :deep(.button) {
padding: 0.75rem;
border-radius: 50%;
}
.details .buttons :deep(.icon-wrapper) {
font-size: 1.5rem;
}
.details .buttons :deep(.button-label) {
display: none;
}
}
</style> </style>
-1
View File
@@ -1 +0,0 @@
<template></template>
+220
View File
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const route = useRoute()
const id = computed(() => route.params.id)
const { data: artistData } = await useApiFetch<Album>(`/artists/${id.value}`)
const { data: albumsData } = await useApiFetch<Album[]>(
`/artists/${id.value}/albums`,
)
const { data: tracksData } = await useApiFetch<Track[]>(
`/artists/${id.value}/tracks`,
)
const tracks = computed(() => tracksData.value ?? [])
const chunkedTracks = computed(() => {
const SIZE = 3
const chunks = []
for (let i = 0; i < tracks.value.length; i += SIZE) {
chunks.push(tracks.value.slice(i, i + SIZE))
}
return chunks
})
const slider = useTemplateRef("slider")
const scrollWidth = () => {
if (!slider.value) return 0
const el = Array.isArray(slider.value) ? slider.value[0] : slider.value
if (!el) return 0
return el.offsetWidth
}
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - Artist` : "Artist") })
</script>
<template>
<section class="info" v-if="artistData">
<div class="artist-art">
<NuxtImg
class="artist-image"
:src="GET_FILE(artistData.file)"
width="300"
height="300"
placeholder
/>
</div>
<div class="details">
<h1>{{ artistData.name }}</h1>
<div class="buttons">
<Button
icon-name="lucide:play"
:aria-label="`Play ${artistData.name}`"
@click="() => playAlbum(tracks)"
>
<span class="button-label">Play</span>
</Button>
<Button
icon-name="lucide:shuffle"
:aria-label="`Shuffle ${artistData.name}`"
@click="() => playShuffledAlbum(tracks)"
>
<span class="button-label">Shuffle</span>
</Button>
</div>
</div>
</section>
<Slider slider-class="slider" :scroll-width="scrollWidth" title="Tracks">
<section
class="tracks"
:class="[`tracks-${chunkedTracks[0]?.length ?? 1}`]"
v-for="(chunk, pI) in chunkedTracks"
:key="`chunk-${pI}`"
ref="slider"
>
<TrackRow
v-for="(track, i) in chunk"
:key="`track-${track._id}`"
:track="track"
:index="i + pI * 3"
@play-album-at-index="
() => playAlbumAtIndex(tracks, i + pI * 3)
"
/>
</section>
</Slider>
<Slider title="Albums">
<AlbumCard
v-for="album in albumsData"
:key="album._id"
:name="album.name"
:file="GET_FILE(album.file)"
:artists="album.artists"
:_id="album._id"
carousel
/>
</Slider>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util";
.info {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
& img {
border-radius: $border-radius-standard;
}
.details {
padding-top: 3.5rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
& h1 {
@include util.fontSize(32px);
font-weight: 700;
line-height: 2.4rem;
}
.artist {
@include util.fontSize(18px);
}
.buttons {
margin-top: auto;
display: flex;
gap: 1rem;
}
}
}
$gap: 12px;
:deep(.slider) {
gap: $gap;
margin-bottom: 1rem;
}
.tracks {
display: grid;
gap: 0.4rem;
flex: 0 0 calc((100% - 3 * $gap) / 4);
@for $i from 1 through 3 {
&-#{$i} {
grid-template-rows: repeat(#{$i}, 1fr);
}
}
}
@media (max-width: 767px) {
.info {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
.artist-art {
padding: 0.5rem;
}
.artist-image {
width: 100%;
height: auto;
}
.info .details {
align-items: center;
padding-top: 0;
text-align: center;
h1 {
margin-top: 0;
margin-bottom: 0;
}
.buttons {
margin-top: 0.375rem;
gap: 0.75rem;
}
}
.details .buttons :deep(.button) {
padding: 0.75rem;
border-radius: 50%;
}
.details .buttons :deep(.icon-wrapper) {
font-size: 1.5rem;
}
.details .buttons :deep(.button-label) {
display: none;
}
.tracks {
flex-basis: 100%;
min-width: 0;
}
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id)
const { data: tracksData } = await useApiFetch<Track[]>(
`/artists/${id.value}/tracks`,
)
const tracks = computed(() => tracksData.value ?? [])
const { playAlbumAtIndex } = usePlayer()
useHead({ title: "Artist Songs" })
</script>
<template>
<section class="tracks">
<h1 class="title">Songs</h1>
<TrackRow
v-for="(track, i) in tracks"
:key="track._id"
:track="track"
:index="i"
show-image
touch-friendly
@play-album-at-index="() => playAlbumAtIndex(tracks, i)"
/>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.tracks {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.title {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1.5rem;
}
@media (max-width: 767px) {
.title {
@include fontSize(26px);
margin-bottom: 1rem;
}
.tracks :deep(.track.image) {
grid-template-columns: 40px minmax(0, 1fr) auto auto;
gap: 0.5rem;
padding-inline: 0.5rem;
}
}
</style>
+8
View File
@@ -2,6 +2,7 @@
definePageMeta({ definePageMeta({
layout: false, layout: false,
}) })
useHead({ title: "Sign In" })
</script> </script>
<template> <template>
@@ -29,4 +30,11 @@ definePageMeta({
width: 40%; width: 40%;
max-width: 450px; max-width: 450px;
} }
@media (max-width: 600px) {
.card {
box-sizing: border-box;
width: calc(100% - 2rem);
}
}
</style> </style>
+13 -1
View File
@@ -10,11 +10,12 @@ const submit = async (e: any) => {
} catch {} } catch {}
try { try {
await auth.signin(data.email, data.password, data.remember) await auth.signin(data.email, data.password, data.remember === "on")
} catch (e: string | any) { } catch (e: string | any) {
toast.show(e, "error") toast.show(e, "error")
} }
} }
useHead({ title: "Sign In" })
</script> </script>
<template> <template>
@@ -57,4 +58,15 @@ h1 {
flex-direction: column; flex-direction: column;
gap: 0.8rem; gap: 0.8rem;
} }
@media (max-width: 480px) {
h1 {
@include fontSize(22px);
margin-bottom: 0.65em;
}
.form {
gap: 0.75rem;
}
}
</style> </style>
+1 -8
View File
@@ -1,12 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const player = usePlayer() useHead({ title: "Home" })
const { data } = useApiFetch("/all/tracks")
const play = () => {
player.addToQueue(data.value[0])
player.play()
}
</script> </script>
<template> <template>
+86 -1
View File
@@ -1 +1,86 @@
<template></template> <script setup lang="ts">
import { GET_FILE } from "@/utils/file"
import type { Album } from "@/utils/types"
const {
data: albumsData,
status,
error,
refresh,
} = await useApiFetch<Album[]>("/user/albums")
const albums = computed(() => albumsData.value ?? [])
useHead({ title: "Albums" })
</script>
<template>
<section class="albums-page">
<h1>Albums</h1>
<p v-if="status === 'pending'" class="message" role="status">
Loading your albums…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your albums could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="albums.length === 0" class="message">
No saved albums yet. Save a song to add its album here.
</p>
<div v-else class="album-list">
<AlbumCard
v-for="album in albums"
:key="album._id"
:name="album.name"
:file="GET_FILE(album.file)"
:artists="album.artists"
:_id="album._id"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.albums-page {
padding-bottom: 2rem;
h1 {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1rem;
}
}
.album-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
@media (max-width: 767px) {
.album-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.album-list :deep(.card) {
min-width: 0;
max-width: none;
}
.album-list :deep(.card img) {
display: block;
width: 100%;
height: auto;
}
}
</style>
+81 -1
View File
@@ -1 +1,81 @@
<template></template> <script setup lang="ts">
import type { Artist } from "@/utils/types"
const {
data: artistsData,
status,
error,
refresh,
} = await useApiFetch<Artist[]>("/user/artists")
const artists = computed(() => artistsData.value ?? [])
useHead({ title: "Artists" })
</script>
<template>
<section class="artists-page">
<h1>Artists</h1>
<p v-if="status === 'pending'" class="message" role="status">
Loading your artists…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your artists could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="artists.length === 0" class="message">
No artists in your saved songs yet.
</p>
<div v-else class="artist-list">
<ArtistCard
v-for="artist in artists"
:key="artist._id"
:artist="artist"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.artists-page {
padding-bottom: 2rem;
h1 {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1rem;
}
}
.artist-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
@media (max-width: 767px) {
.artist-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.artist-list :deep(.card) {
min-width: 0;
}
.artist-list :deep(.card img) {
display: block;
width: 100%;
height: auto;
}
}
</style>
+108 -1
View File
@@ -1 +1,108 @@
<template></template> <script setup lang="ts">
import type { Track } from "@/utils/types"
const toast = useToast()
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
const {
data: tracksData,
status,
error,
refresh,
} = await useApiFetch<Track[]>("/user/tracks")
const tracks = computed(() => tracksData.value ?? [])
const playTracks = () => {
if (tracks.value.length) void playAlbum(tracks.value)
}
const shuffleTracks = () => {
if (tracks.value.length) void playShuffledAlbum(tracks.value)
}
const removeFromLibrary = (track: Track) => {
tracksData.value = tracks.value.filter(
(savedTrack) => savedTrack._id !== track._id,
)
}
useHead({ title: "Songs" })
</script>
<template>
<section class="songs-page">
<header class="page-header">
<h1>Songs</h1>
<div v-if="tracks.length" class="page-actions">
<Button icon-name="lucide:play" @click="playTracks">Play</Button>
<Button icon-name="lucide:shuffle" @click="shuffleTracks">Shuffle</Button>
</div>
</header>
<p v-if="status === 'pending'" class="message" role="status">
Loading your songs…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your songs could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="tracks.length === 0" class="message">
You haven’t saved any songs yet.
</p>
<div v-else class="track-list">
<TrackRow
v-for="(track, index) in tracks"
:key="track._id"
:track="track"
:index="index"
:extended-info="true"
library-list
@play-album-at-index="() => playAlbumAtIndex(tracks, index)"
@remove-from-library="removeFromLibrary"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.songs-page {
padding-bottom: 2rem;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
h1 {
@include fontSize(32px);
font-weight: 700;
}
}
.page-actions {
display: flex;
gap: 0.75rem;
}
.track-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
@media (max-width: 359px) {
.page-header {
flex-wrap: wrap;
justify-content: flex-start;
}
}
</style>
+217 -3
View File
@@ -1,7 +1,221 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute() import type { Track } from "@/utils/types"
watchEffect(() => console.log(route.query.q)) interface SearchResult {
type: "track" | "album" | "artist"
_id: string
name: string
file?: string
cover?: string
album?: { _id?: string; name?: string; file?: string; cover?: string }
artists?: Artist[]
}
const route = useRoute()
const router = useRouter()
const { currentSong, playAlbum } = usePlayer()
const query = computed(() => {
const value = route.query.q
const firstValue = Array.isArray(value) ? value[0] : value
return (firstValue ?? "").trim()
})
const searchInput = ref(query.value)
const debouncedQuery = ref(query.value)
const querySettled = computed(() => query.value === debouncedQuery.value)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
watch(query, (value) => {
searchInput.value = value
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedQuery.value = value
debounceTimeout = undefined
}, 300)
})
const updateSearchRoute = (value: string) => {
const trimmed = value.trim()
if (trimmed)
router.replace(`/search?q=${encodeURIComponent(trimmed)}`)
else router.replace("/search")
}
onBeforeUnmount(() => clearTimeout(debounceTimeout))
const { data, status, error } = await useApiFetch<SearchResult[]>("/search", {
query: { q: debouncedQuery, limit: 9 },
watch: [debouncedQuery],
})
const results = computed<SearchResult[]>(() => {
const payload: any = data.value
if (Array.isArray(payload)) return payload
if (Array.isArray(payload?.results)) return payload.results
if (Array.isArray(payload?.data)) return payload.data
if (!payload || typeof payload !== "object") return []
return (["tracks", "albums", "artists"] as const).flatMap((kind) =>
Array.isArray(payload[kind])
? payload[kind].map((result: SearchResult) => ({
...result,
type: kind.slice(0, -1) as SearchResult["type"],
}))
: [],
)
})
const recentTracks = ref<Track[]>([])
const recentStorageKey = "music-v2-recently-played"
onMounted(() => {
try {
recentTracks.value = JSON.parse(
window.localStorage.getItem(recentStorageKey) ?? "[]",
) as Track[]
} catch {
recentTracks.value = []
}
})
watch(currentSong, (track) => {
if (!track || !import.meta.client) return
const updated = [
track,
...recentTracks.value.filter((item) => item._id !== track._id),
].slice(0, 9)
recentTracks.value = updated
window.localStorage.setItem(recentStorageKey, JSON.stringify(updated))
})
const playResult = (result: SearchResult | Track) => {
if ("album" in result && result.album) playAlbum([result as Track])
}
useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search") })
</script> </script>
<template></template> <template>
<main class="search-page">
<h1>Search</h1>
<Input
class="mobile-search"
v-model:value="searchInput"
full
type="search"
name="mobile-search-query"
label="Search the library"
placeholder="Songs, albums, artists"
icon-name="lucide:search"
@input="updateSearchRoute"
/>
<p
v-if="query && (!querySettled || status === 'pending')"
class="message"
role="status"
>
Searching…
</p>
<p v-else-if="query && querySettled && error" class="message" role="alert">
Search could not be loaded. Please try again.
</p>
<p
v-else-if="query && querySettled && status === 'success' && results.length === 0"
class="message"
>
No results for “{{ query }}”.
</p>
<template v-if="!query || (status === 'success' && results.length === 0)">
<h2>Recently Played</h2>
<p v-if="recentTracks.length === 0" class="message">
Your recently played tracks will appear here.
</p>
</template>
<section
v-if="query && querySettled && status === 'success' && results.length"
class="results"
aria-label="Search results"
>
<template
v-for="(result, index) in results"
:key="`${result.type}-${result._id}`"
>
<SearchRow
:result="result"
:index="index"
@play="playResult(result)"
/>
</template>
</section>
<section
v-else-if="
recentTracks.length &&
(!query || (querySettled && status === 'success'))
"
class="results"
aria-label="Recently played tracks"
>
<TrackRow
v-for="(track, index) in recentTracks"
:key="track._id"
:track="track"
:index="index"
show-image
touch-friendly
@play-album-at-index="playResult(track)"
/>
</section>
</main>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.search-page {
padding-bottom: 2rem;
h1 {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1rem;
}
h2 {
@include fontSize(20px);
font-weight: 600;
margin: 1.5rem 0 0.8rem;
}
}
.mobile-search {
display: none;
}
.results {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.message {
@include fontSize(14px);
opacity: 0.65;
margin: 0.75rem 0;
}
@media (max-width: 767px) {
.mobile-search {
display: block;
margin-bottom: 1rem;
}
.results :deep(.track.image) {
grid-template-columns: 40px minmax(0, 1fr) auto auto;
gap: 0.5rem;
padding-inline: 0.5rem;
}
}
</style>
+378 -33
View File
@@ -1,13 +1,68 @@
import { Howl } from "howler" import { Howl, Howler } from "howler"
import { shapeBeatPulse } from "~/utils/beat"
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
const mediaBaseUrl = useRuntimeConfig().public.mediaBaseUrl
const { close: closeFullscreen } = useFullscreen()
if (import.meta.client) {
const howlerInternal = Howler as unknown as {
_obtainHtml5Audio: () => HTMLAudioElement
_musicV2CorsEnabled?: boolean
}
if (!howlerInternal._musicV2CorsEnabled) {
const obtainHtml5Audio = howlerInternal._obtainHtml5Audio
howlerInternal._obtainHtml5Audio = function () {
const audio = obtainHtml5Audio.call(Howler)
audio.crossOrigin = "anonymous"
return audio
}
howlerInternal._musicV2CorsEnabled = true
}
}
const VOLUME_STORAGE_KEY = "music-v2-player-volume"
const DEFAULT_VOLUME = 0.5
const DEBUG_BEAT_ANALYSIS = false
const sound = ref<Howl | null>(null) const sound = ref<Howl | null>(null)
const queue = ref<Track[]>([]) const queue = ref<Track[]>([])
const prevQueue = ref<Track[]>([]) const prevQueue = ref<Track[]>([])
const currentQueue = ref<Track[]>([]) const currentQueue = ref<Track[]>([])
const currentSong = ref<Track | null>(null) const currentSong = ref<Track | null>(null)
const repeat = ref(false) const repeat = ref(false)
const repeatOnce = ref(false)
const isPlaying = ref(false) const isPlaying = ref(false)
const beatEnergy = ref(0)
const storedVolume = ref(DEFAULT_VOLUME)
type HowlWithMediaNode = Howl & {
_sounds?: Array<{ _node?: HTMLMediaElement }>
}
let analysisContext: AudioContext | null = null
let analysisSource: MediaStreamAudioSourceNode | null = null
let analysisNode: AnalyserNode | null = null
let analysisFrame = 0
let isAnalyzing = false
const clampVolume = (value: number) => Math.min(1, Math.max(0, value))
const readStoredVolume = () => {
if (!import.meta.client) return DEFAULT_VOLUME
const rawVolume = window.localStorage.getItem(VOLUME_STORAGE_KEY)
if (rawVolume === null) return DEFAULT_VOLUME
const parsedVolume = Number(rawVolume)
if (Number.isNaN(parsedVolume)) return DEFAULT_VOLUME
return clampVolume(parsedVolume)
}
if (import.meta.client) {
storedVolume.value = readStoredVolume()
watch(storedVolume, (value) => {
window.localStorage.setItem(VOLUME_STORAGE_KEY, String(value))
})
}
const tick = ref(0) const tick = ref(0)
@@ -20,59 +75,282 @@ export default defineNuxtPlugin((nuxtApp) => {
sound.value?.seek(value) sound.value?.seek(value)
}, },
}) })
const refreshPosition = () => {
tick.value++
}
const volume = computed<number>({ const volume = computed<number>({
get() { get() {
tick.value tick.value
return sound.value?.volume() ?? 1 return storedVolume.value
}, },
set(value) { set(value) {
sound.value?.volume(value) const nextVolume = clampVolume(value)
storedVolume.value = nextVolume
sound.value?.volume(nextVolume)
}, },
}) })
const duration = ref(0) const duration = ref(0)
let lastPositionUpdate = 0
let trackingTimer: number | null = null
let mediaSessionSongId: string | null = null
const aniFrame = ref<number | null>(null) const stopBeatAnalysis = (reason = "stopped") => {
if (isAnalyzing && DEBUG_BEAT_ANALYSIS)
console.info(`[beat analyzer] stopped: ${reason}`)
isAnalyzing = false
if (analysisFrame) cancelAnimationFrame(analysisFrame)
analysisFrame = 0
analysisSource?.disconnect()
analysisSource = null
analysisNode = null
beatEnergy.value = 0
}
// Helpers const startBeatAnalysis = (howl: Howl) => {
const updateMediaSession = async () => { stopBeatAnalysis("restarted")
if (!import.meta.client) return const mediaElement = (howl as HowlWithMediaNode)._sounds?.[0]?._node
if (!navigator.mediaSession) { if (!mediaElement) {
console.warn("Media Session API is not supported") if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed: Howler media element unavailable",
)
return return
} }
if (!currentSong.value) return const elementWithCapture = mediaElement as HTMLMediaElement & {
captureStream?: () => MediaStream
mozCaptureStream?: () => MediaStream
webkitCaptureStream?: () => MediaStream
}
const capture =
elementWithCapture.captureStream ??
elementWithCapture.mozCaptureStream ??
elementWithCapture.webkitCaptureStream
if (!capture) {
if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed: media capture unsupported",
)
return
}
navigator.mediaSession.metadata = new MediaMetadata({ try {
title: currentSong.value.name, const stream = capture.call(mediaElement)
artist: currentSong.value.artists[0]?.name, if (stream.getAudioTracks().length === 0) {
album: currentSong.value.album.name, if (DEBUG_BEAT_ANALYSIS)
artwork: [ console.warn(
{ "[beat analyzer] failed: capture has no audio tracks",
src: GET_FILE(currentSong.value.album.file), )
sizes: "512x512", return
}, }
], analysisContext ??= new AudioContext()
}) if (analysisContext.state === "suspended")
void analysisContext.resume().catch(() => {})
analysisNode = analysisContext.createAnalyser()
analysisNode.fftSize = 2048
analysisNode.smoothingTimeConstant = 0.05
analysisSource = analysisContext.createMediaStreamSource(stream)
analysisSource.connect(analysisNode)
navigator.mediaSession.setPositionState({ const samples = new Uint8Array(analysisNode.frequencyBinCount)
duration: duration.value, let previousBass: number | null = null
position: secondsPlayed.value, let riseAverage = 0.01
}) let riseVariance = 0.0004
let pulse = 0
let lastBeatLogAt = 0
let lastLevelLogAt = 0
const sample = () => {
if (!analysisNode) return
analysisNode.getByteFrequencyData(samples)
const sampleRate = analysisContext!.sampleRate
const firstBin = Math.max(
1,
Math.floor((45 * 2048) / sampleRate),
)
const lastBin = Math.min(
samples.length - 1,
Math.ceil((130 * 2048) / sampleRate),
)
let bass = 0
for (let bin = firstBin; bin <= lastBin; bin++)
bass += samples[bin] ?? 0
bass /= Math.max(1, lastBin - firstBin + 1) * 255
if (previousBass === null) previousBass = bass
const rise = Math.max(0, bass - previousBass)
previousBass = previousBass * 0.985 + bass * 0.015
const riseDeviation = Math.sqrt(riseVariance)
const strongThumpThreshold = Math.min(
0.1,
Math.max(0.045, riseAverage + riseDeviation * 2),
)
const attack = Math.max(0, rise - strongThumpThreshold)
const thumpCooldown = Math.min(
500,
Math.max(300, 300 + riseDeviation * 4000),
)
const now = performance.now()
const isStrongThump =
rise >= strongThumpThreshold &&
now - lastBeatLogAt > thumpCooldown
const riseDelta = rise - riseAverage
riseAverage += riseDelta * 0.01
riseVariance += (riseDelta * riseDelta - riseVariance) * 0.01
if (isStrongThump) {
const thumpStrength = Math.min(0.38, 0.1 + attack * 2.5)
pulse = Math.max(pulse, thumpStrength)
if (DEBUG_BEAT_ANALYSIS) {
console.log(
"[fullscreen beat]",
`detected=${thumpStrength.toFixed(2)}`,
`visual=${shapeBeatPulse(thumpStrength).toFixed(2)}`,
)
}
} else pulse *= 0.94
beatEnergy.value = Math.min(1, pulse)
if (DEBUG_BEAT_ANALYSIS && now - lastLevelLogAt > 1000) {
lastLevelLogAt = now
console.info(
"[beat level]",
`bass=${bass.toFixed(3)}`,
`attack=${attack.toFixed(3)}`,
`gate=${strongThumpThreshold.toFixed(3)}`,
`cooldown=${Math.round(thumpCooldown)}`,
`pulse=${beatEnergy.value.toFixed(2)}`,
)
}
if (isStrongThump) lastBeatLogAt = now
analysisFrame = requestAnimationFrame(sample)
}
analysisFrame = requestAnimationFrame(sample)
isAnalyzing = true
if (DEBUG_BEAT_ANALYSIS)
console.info("[beat analyzer] analysis started")
} catch (error) {
stopBeatAnalysis()
if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed while setting up audio capture",
error,
)
}
}
// Helpers
const updateMediaSession = () => {
if (!import.meta.client) return
const mediaSession = navigator.mediaSession
if (!mediaSession) return
if (!currentSong.value) {
mediaSession.metadata = null
mediaSession.playbackState = "none"
mediaSessionSongId = null
return
}
if (mediaSessionSongId !== currentSong.value._id) {
mediaSession.metadata = new MediaMetadata({
title: currentSong.value.name,
artist: currentSong.value.artists[0]?.name,
album: currentSong.value.album.name,
artwork: [
{
src: GET_FILE(
currentSong.value.album.file,
mediaBaseUrl,
),
sizes: "512x512",
},
],
})
mediaSessionSongId = currentSong.value._id
}
if (duration.value > 0) {
try {
mediaSession.setPositionState({
duration: duration.value,
position: Math.min(secondsPlayed.value, duration.value),
})
} catch {
// Position state can be unavailable while media metadata is loading.
}
}
}
const registerMediaSessionActions = () => {
if (!import.meta.client || !navigator.mediaSession) return
const actions: MediaSessionAction[] = [
"play",
"pause",
"nexttrack",
"previoustrack",
"seekto",
"seekbackward",
"seekforward",
]
for (const action of actions) {
try {
navigator.mediaSession.setActionHandler(action, (event) => {
switch (action) {
case "play":
void play()
break
case "pause":
pause()
break
case "nexttrack":
void next()
break
case "previoustrack":
void prev()
break
case "seekto":
if (event.seekTime !== undefined)
secondsPlayed.value = event.seekTime
updateMediaSession()
break
case "seekbackward":
secondsPlayed.value = Math.max(
0,
secondsPlayed.value - (event.seekOffset ?? 10),
)
updateMediaSession()
break
case "seekforward":
secondsPlayed.value = Math.min(
duration.value,
secondsPlayed.value + (event.seekOffset ?? 10),
)
updateMediaSession()
}
})
} catch {
// Browsers may support Media Session without supporting every action.
}
}
} }
const startTracking = () => { const startTracking = () => {
if (trackingTimer !== null) window.clearTimeout(trackingTimer)
const update = () => { const update = () => {
tick.value++ tick.value++
aniFrame.value = requestAnimationFrame(update) const now = Date.now()
if (now - lastPositionUpdate >= 1000) {
lastPositionUpdate = now
updateMediaSession()
}
trackingTimer = window.setTimeout(update, 100)
} }
update() update()
} }
const stopTracking = () => { const stopTracking = () => {
if (aniFrame.value) cancelAnimationFrame(aniFrame.value) if (trackingTimer !== null) window.clearTimeout(trackingTimer)
aniFrame.value = null trackingTimer = null
} }
const createHowl = async (song: Track) => { const createHowl = async (song: Track) => {
@@ -80,19 +358,29 @@ export default defineNuxtPlugin((nuxtApp) => {
if (!song) throw new Error("Song not provided") if (!song) throw new Error("Song not provided")
const newSound = new Howl({ const newSound = new Howl({
src: [GET_AUDIO_FILE(song.fileDir)], src: [GET_AUDIO_FILE(song.fileDir, mediaBaseUrl)],
html5: false, html5: true,
autoplay: false, autoplay: false,
volume: 0.5, volume: storedVolume.value,
onload: () => { onload: () => {
duration.value = sound.value?.duration() || 0 duration.value = sound.value?.duration() || 0
volume.value = sound.value?.volume() || 1 sound.value?.volume(storedVolume.value)
updateMediaSession()
}, },
onend: () => { onend: () => {
next() stopBeatAnalysis("track ended")
void closeFullscreen()
if (repeatOnce.value && sound.value === newSound) {
repeatOnce.value = false
newSound.seek(0)
newSound.play()
} else {
void next()
}
stopTracking() stopTracking()
}, },
onstop: () => { onstop: () => {
stopBeatAnalysis("playback stopped")
isPlaying.value = false isPlaying.value = false
stopTracking() stopTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
@@ -100,11 +388,13 @@ export default defineNuxtPlugin((nuxtApp) => {
}, },
onplay: () => { onplay: () => {
isPlaying.value = true isPlaying.value = true
startBeatAnalysis(newSound)
startTracking() startTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
navigator.mediaSession.playbackState = "playing" navigator.mediaSession.playbackState = "playing"
}, },
onpause: () => { onpause: () => {
stopBeatAnalysis("paused")
isPlaying.value = false isPlaying.value = false
stopTracking() stopTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
@@ -138,6 +428,17 @@ export default defineNuxtPlugin((nuxtApp) => {
updateMediaSession() updateMediaSession()
} }
const cycleRepeat = () => {
if (repeatOnce.value) {
repeatOnce.value = false
repeat.value = true
} else if (repeat.value) {
repeat.value = false
} else {
repeatOnce.value = true
}
}
const next = async (autoplay: boolean = true) => { const next = async (autoplay: boolean = true) => {
if (currentSong.value) { if (currentSong.value) {
prevQueue.value.unshift(currentSong.value) prevQueue.value.unshift(currentSong.value)
@@ -195,6 +496,46 @@ export default defineNuxtPlugin((nuxtApp) => {
currentSong.value = null currentSong.value = null
sound.value?.stop() sound.value?.stop()
sound.value = null sound.value = null
updateMediaSession()
}
if (import.meta.client) {
registerMediaSessionActions()
window.addEventListener("keydown", (event) => {
if (
event.defaultPrevented ||
event.repeat ||
event.ctrlKey ||
event.metaKey ||
event.altKey
)
return
const target = event.target
if (
target instanceof HTMLElement &&
target.closest(
"input, textarea, select, button, [contenteditable='true'], [role='slider']",
)
)
return
switch (event.code) {
case "Space":
event.preventDefault()
if (isPlaying.value) pause()
else void play()
break
case "ArrowRight":
event.preventDefault()
void next()
break
case "ArrowLeft":
event.preventDefault()
void prev()
}
})
} }
const shuffle = () => { const shuffle = () => {
@@ -245,8 +586,11 @@ export default defineNuxtPlugin((nuxtApp) => {
provide: { provide: {
player: { player: {
isPlaying, isPlaying,
beatEnergy,
currentSong, currentSong,
repeat, repeat,
repeatOnce,
cycleRepeat,
play, play,
pause, pause,
next, next,
@@ -260,6 +604,7 @@ export default defineNuxtPlugin((nuxtApp) => {
playAlbumAtIndex, playAlbumAtIndex,
getDuration, getDuration,
secondsPlayed, secondsPlayed,
refreshPosition,
duration, duration,
volume, volume,
queue, queue,
+3 -1
View File
@@ -1,5 +1,5 @@
$color-accent: rgb(255, 69, 58); $color-accent: rgb(255, 69, 58);
$color-background: oklch(14.1% 0.005 285.823); // #1a202c $color-background: oklch(14.1% 0.005 285.823); // #09090b
$color-secondary-background: oklch(26.862% 0.00003 271.152); $color-secondary-background: oklch(26.862% 0.00003 271.152);
$color-text: rgba(255, 255, 255, 0.92); $color-text: rgba(255, 255, 255, 0.92);
$color-placeholder-text: rgba(255, 255, 255, 0.44); $color-placeholder-text: rgba(255, 255, 255, 0.44);
@@ -7,7 +7,9 @@ $color-border: rgba(255, 255, 255, 0.16);
$color-button: rgba(255, 255, 255, 0.08); $color-button: rgba(255, 255, 255, 0.08);
$color-button-hover: rgba(255, 255, 255, 0.16); $color-button-hover: rgba(255, 255, 255, 0.16);
$color-attention: #fc8181; $color-attention: #fc8181;
$color-slider-monochrome: white;
$border-radius-sm: 0.15rem;
$border-radius-standard: 0.3rem; $border-radius-standard: 0.3rem;
$border-radius-lg: 0.6rem; $border-radius-lg: 0.6rem;
+6
View File
@@ -1,11 +1,17 @@
@use "reset" as *; @use "reset" as *;
@use "util" as *; @use "util" as *;
@use "variables" as *;
html { html {
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
color: white; color: white;
background-color: $color-background;
}
body {
background-color: $color-background;
} }
main > h1 { main > h1 {
+2
View File
@@ -8,6 +8,7 @@ interface ButtonProps {
full?: boolean full?: boolean
iconName?: string iconName?: string
centerText?: boolean centerText?: boolean
disabled?: boolean
} }
const props = withDefaults(defineProps<ButtonProps>(), { const props = withDefaults(defineProps<ButtonProps>(), {
@@ -22,6 +23,7 @@ const props = withDefaults(defineProps<ButtonProps>(), {
props.type == 'link' && props.to != undefined ? NuxtLink : 'button' props.type == 'link' && props.to != undefined ? NuxtLink : 'button'
" "
:to="props.to" :to="props.to"
:disabled="props.disabled"
:class="[ :class="[
`button type-${props.variant}`, `button type-${props.variant}`,
{ {
+172 -54
View File
@@ -6,24 +6,56 @@ export interface DropdownMenuItem {
} }
interface DropdownMenuProps { interface DropdownMenuProps {
items: DropdownMenuItem[] items: DropdownMenuItem[][]
label?: string
} }
const props = defineProps<DropdownMenuProps>() const props = defineProps<DropdownMenuProps>()
const open = ref(false) const open = ref(false)
const mounted = ref(false)
const placement = ref<"bottom" | "top">("bottom") const placement = ref<"bottom" | "top">("bottom")
const root = useTemplateRef("root") const root = useTemplateRef("root")
const triggerRef = useTemplateRef<HTMLButtonElement>("trigger-ref")
const menuRef = useTemplateRef<HTMLDivElement>("menu-ref")
const menuPosition = ref({ top: 0, left: 0 })
const menuItems = () =>
Array.from(
menuRef.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
)
const focusMenuItem = (index: number) => {
const items = menuItems()
items[index]?.focus()
}
const close = (restoreFocus = false) => {
open.value = false
if (restoreFocus) nextTick(() => triggerRef.value?.focus())
}
const openMenu = async (last = false) => {
open.value = true
await nextTick()
updatePlacement()
const items = menuItems()
focusMenuItem(last ? items.length - 1 : 0)
}
const toggle = async () => { const toggle = async () => {
open.value = !open.value if (open.value) close(true)
else await openMenu()
}
if (open.value) {
await nextTick() const handleTriggerKeydown = (event: KeyboardEvent) => {
updatePlacement() if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
} event.preventDefault()
event.stopPropagation()
if (!open.value) void openMenu(event.key === "ArrowUp")
else focusMenuItem(event.key === "ArrowUp" ? menuItems().length - 1 : 0)
} }
const close = () => (open.value = false)
const updatePlacement = () => { const updatePlacement = () => {
const el = root.value const el = root.value
@@ -37,25 +69,43 @@ const updatePlacement = () => {
// TODO: Calculate height instead // TODO: Calculate height instead
const menuHeight = 220 const menuHeight = 220
console.log( const showAbove = spaceBelow < menuHeight && spaceAbove > spaceBelow
spaceBelow, placement.value = showAbove ? "top" : "bottom"
menuHeight, menuPosition.value = {
spaceAbove, top: showAbove ? rect.top : rect.bottom,
spaceBelow < menuHeight && spaceAbove > spaceBelow, left: rect.right,
spaceBelow < menuHeight && spaceAbove > spaceBelow ? "top" : "bottom", }
)
placement.value =
spaceBelow < menuHeight && spaceAbove > spaceBelow ? "top" : "bottom"
} }
const handleOutsideClick = (e: MouseEvent) => { const handleOutsideClick = (e: MouseEvent) => {
if (!root.value) return const target = e.target as Node
if (!root.value.contains(e.target as Node)) close() if (root.value?.contains(target) || menuRef.value?.contains(target)) return
close()
} }
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key == "Escape") close() if (!open.value) return
const items = menuItems()
const activeIndex = items.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === "Escape") {
e.preventDefault()
close(true)
} else if (e.key === "ArrowDown") {
e.preventDefault()
focusMenuItem((activeIndex + 1 + items.length) % items.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
focusMenuItem((activeIndex - 1 + items.length) % items.length)
} else if (e.key === "Home") {
e.preventDefault()
focusMenuItem(0)
} else if (e.key === "End") {
e.preventDefault()
focusMenuItem(items.length - 1)
} else if (e.key === "Tab") {
close()
}
} }
const handleReposition = () => { const handleReposition = () => {
@@ -67,9 +117,10 @@ onMounted(() => {
document.addEventListener("keydown", handleKey) document.addEventListener("keydown", handleKey)
window.addEventListener("resize", handleReposition) window.addEventListener("resize", handleReposition)
window.addEventListener("scroll", handleReposition, true) window.addEventListener("scroll", handleReposition, true)
mounted.value = true
}) })
onBeforeMount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", handleOutsideClick) document.removeEventListener("click", handleOutsideClick)
document.removeEventListener("keydown", handleKey) document.removeEventListener("keydown", handleKey)
window.removeEventListener("resize", handleReposition) window.removeEventListener("resize", handleReposition)
@@ -79,27 +130,56 @@ onBeforeMount(() => {
<template> <template>
<div ref="root" class="dropdown"> <div ref="root" class="dropdown">
<div @click="toggle" class="trigger"> <button
ref="trigger-ref"
type="button"
class="trigger"
:aria-label="props.label ?? 'Open options'"
aria-haspopup="menu"
:aria-expanded="open"
@click="toggle"
@keydown="handleTriggerKeydown"
>
<slot /> <slot />
</div> </button>
<Transition name="dropdown"> <Transition name="dropdown">
<div v-show="open" class="menu" :class="placement"> <Teleport v-if="mounted" to="body">
<button <div
v-for="(item, i) in items" ref="menu-ref"
:key="i" v-show="open"
class="item" class="dropdown-menu"
@click=" role="menu"
() => { :class="placement"
item.onSelect?.() :style="{
close() top: `${menuPosition.top}px`,
} left: `${menuPosition.left}px`,
" }"
> >
<Icon :name="item.icon" class="icon" /> <div
<span>{{ item.label }}</span> v-for="(group, i) in items"
</button> :key="`group-${i}`"
</div> class="group"
role="group"
>
<button
v-for="(item, i) in group"
:key="`item-${i}`"
class="item"
role="menuitem"
@click="
() => {
item.onSelect?.()
close()
}
"
>
<Icon :name="item.icon" class="icon" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
</Teleport>
</Transition> </Transition>
</div> </div>
</template> </template>
@@ -110,33 +190,67 @@ onBeforeMount(() => {
.dropdown { .dropdown {
position: relative; position: relative;
display: inline-block; display: inline-block;
}
.menu { .trigger {
position: absolute; display: inline-flex;
align-items: center;
border: 0;
padding: 0;
color: inherit;
background: transparent;
cursor: pointer;
}
right: 0; @media (max-width: 767px) {
.trigger {
padding: 0.75rem;
}
}
min-width: 180px; .dropdown-menu {
padding: 0.5rem; position: fixed;
z-index: 1000;
background-color: $color-secondary-background; padding: 0.5rem;
border-radius: $border-radius-standard; min-width: 180px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
background-color: $color-secondary-background;
border-radius: $border-radius-standard;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
transform: translateX(-100%);
&.bottom {
transform: translate(-100%, 0.5rem);
transform-origin: top right;
}
&.top {
transform: translate(-100%, calc(-100% - 0.5rem));
transform-origin: bottom right;
}
.group {
width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 4px;
z-index: 1000; padding: 0.4rem 0;
border-bottom: 1px solid $color-border;
&.bottom { &:first-child {
top: calc(100% + 0.5rem); padding-top: 0;
transform-origin: top right;
} }
&.top { &:last-child {
bottom: calc(100% + 0.5rem); border-bottom: none;
transform-origin: bottom right; padding-bottom: 0;
} }
} }
@@ -153,6 +267,10 @@ onBeforeMount(() => {
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
span {
white-space: nowrap;
}
} }
.item:hover { .item:hover {
+45 -3
View File
@@ -41,6 +41,15 @@ const onFileChange = (e: Event) => {
value.value = result value.value = result
emit("file", result) emit("file", result)
} }
const onTextInput = (e: Event) => {
emit("input", (e.currentTarget as HTMLInputElement | HTMLTextAreaElement).value)
}
const clearSearch = () => {
value.value = ""
emit("input", "")
}
</script> </script>
<template> <template>
@@ -68,7 +77,7 @@ const onFileChange = (e: Event) => {
v-if="props.type != 'file' && props.type != 'textarea'" v-if="props.type != 'file' && props.type != 'textarea'"
:placeholder="props.placeholder" :placeholder="props.placeholder"
v-model="value" v-model="value"
@input="(e) => emit('input', e.target?.value)" @input="onTextInput"
@focus="emit('focus')" @focus="emit('focus')"
@blur="emit('blur')" @blur="emit('blur')"
:type="props.type" :type="props.type"
@@ -77,12 +86,11 @@ const onFileChange = (e: Event) => {
:required="props.required" :required="props.required"
:autocomplete="props.autocomplete" :autocomplete="props.autocomplete"
/> />
<textarea <textarea
v-else-if="props.textarea" v-else-if="props.textarea"
:placeholder="props.placeholder" :placeholder="props.placeholder"
v-model="value" v-model="value"
@input="(e) => emit('input', e.target?.value)" @input="onTextInput"
@focus="emit('focus')" @focus="emit('focus')"
@blur="emit('blur')" @blur="emit('blur')"
:name="props.name" :name="props.name"
@@ -102,6 +110,16 @@ const onFileChange = (e: Event) => {
:id="props.name" :id="props.name"
:required="props.required" :required="props.required"
/> />
<button
v-if="props.type == 'search' && typeof value == 'string' && value"
type="button"
class="clear-search"
aria-label="Clear search"
@mousedown.prevent
@click="clearSearch"
>
<Icon name="lucide:x" />
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -160,6 +178,30 @@ const onFileChange = (e: Event) => {
& input::placeholder { & input::placeholder {
color: $color-placeholder-text; color: $color-placeholder-text;
} }
& input[type="search"] {
color-scheme: dark;
&::-webkit-search-cancel-button {
display: none;
}
}
.clear-search {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: $border-radius-standard;
line-height: 1;
color: $color-placeholder-text;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: $color-text;
}
}
} }
.input, .input,
+11 -1
View File
@@ -4,6 +4,7 @@ interface SliderProps {
max?: number max?: number
step?: number step?: number
dontUpdateImmediately?: boolean dontUpdateImmediately?: boolean
monochrome?: boolean
} }
const props = withDefaults(defineProps<SliderProps>(), { const props = withDefaults(defineProps<SliderProps>(), {
@@ -11,6 +12,7 @@ const props = withDefaults(defineProps<SliderProps>(), {
max: 100, max: 100,
step: 1, step: 1,
dontUpdateImmediately: false, dontUpdateImmediately: false,
monochrome: false,
}) })
const value = defineModel<number>() const value = defineModel<number>()
@@ -67,7 +69,11 @@ const onMouseDown = (event: MouseEvent) => {
<template> <template>
<div ref="slider-ref" class="slider" @mousedown="onMouseDown"> <div ref="slider-ref" class="slider" @mousedown="onMouseDown">
<div class="track"> <div class="track">
<div class="fill" :style="{ width: percentage + '%' }" /> <div
class="fill"
:class="{ monochrome }"
:style="{ width: percentage + '%' }"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -98,4 +104,8 @@ const onMouseDown = (event: MouseEvent) => {
background: $color-accent; background: $color-accent;
border-radius: 999px; border-radius: 999px;
} }
.slider .monochrome {
background-color: $color-slider-monochrome;
}
</style> </style>
+13 -4
View File
@@ -21,6 +21,17 @@ const show = ref<boolean>(false)
const isMultiple = computed(() => !!props.multiple) const isMultiple = computed(() => !!props.multiple)
const loadOptions = async (q = "") => {
const allOptions = await props.fetchOptions(q)
const normalizedQuery = q.trim().toLocaleLowerCase()
options.value = allOptions
.filter((option) =>
option.label.toLocaleLowerCase().includes(normalizedQuery),
)
.slice(0, 10)
}
watchEffect(() => { watchEffect(() => {
if (!props.predefined) return if (!props.predefined) return
@@ -47,9 +58,7 @@ watchEffect(() => {
nextTick(() => (options.value = [])) nextTick(() => (options.value = []))
}) })
watch(query, async (q) => { watch(query, loadOptions)
options.value = await (await props.fetchOptions(q)).slice(0, 10)
})
const isSelected = (opt: Option) => { const isSelected = (opt: Option) => {
if (isMultiple.value && Array.isArray(value.value)) { if (isMultiple.value && Array.isArray(value.value)) {
@@ -78,7 +87,7 @@ const select = (item: Option) => {
const onFocus = async () => { const onFocus = async () => {
if (isMultiple.value) query.value = "" if (isMultiple.value) query.value = ""
show.value = true show.value = true
options.value = await (await props.fetchOptions()).slice(0, 10) await loadOptions(isMultiple.value ? "" : query.value)
} }
const onFocusOut = () => { const onFocusOut = () => {
+1 -1
View File
@@ -38,7 +38,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 2rem 3rem; padding: 2rem 3rem calc(2rem + $player-height);
} }
} }
</style> </style>
+150 -12
View File
@@ -1,28 +1,84 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"
import { GET_AUDIO_FILE, GET_FILE } from "@/utils/file"
defineOptions({ inheritAttrs: false })
export type Row = { export type Row = {
name: string name: string
class?: string class?: string
width?: string width?: string
type?: "image" | "audio" type?: "image" | "audio"
prefix?: string
suffix?: string
displayName?: string displayName?: string
} }
type TableProps = { type TableProps = {
rows: Row[] rows: Row[]
data: any data: any
pagination?: boolean
pageSize?: number
search?: boolean
} }
const { rows, data } = defineProps<TableProps>() const props = withDefaults(defineProps<TableProps>(), {
pagination: false,
pageSize: 10,
search: false,
})
const currentPage = ref(1)
const query = ref("")
const data = computed(() => (Array.isArray(props.data) ? props.data : []))
const pageSize = computed(() => Math.max(1, Math.floor(props.pageSize)))
const filteredData = computed(() => {
const normalizedQuery = query.value.trim().toLocaleLowerCase()
const indexedData = data.value.map((item: any, index: number) => ({
item,
index,
}))
if (!props.search || !normalizedQuery) return indexedData
return indexedData.filter(({ item }: { item: any }) =>
props.rows.some((row) =>
String(item[row.name] ?? "")
.toLocaleLowerCase()
.includes(normalizedQuery),
),
)
})
const pageCount = computed(() =>
Math.max(1, Math.ceil(filteredData.value.length / pageSize.value)),
)
const pagedData = computed(() => {
if (!props.pagination) return filteredData.value
const start = (currentPage.value - 1) * pageSize.value
return filteredData.value.slice(start, start + pageSize.value)
})
watch(query, () => {
currentPage.value = 1
})
watch(pageCount, (count) => {
if (currentPage.value > count) currentPage.value = count
})
</script> </script>
<template> <template>
<table class="table"> <Input
v-if="props.search"
v-model:value="query"
type="search"
placeholder="Search table"
class="table-search"
/>
<table v-bind="$attrs" class="table">
<thead> <thead>
<tr> <tr>
<th <th
v-for="row in rows" v-for="row in props.rows"
:key="row.name" :key="row.name"
:class="row.class" :class="row.class"
:style="{ width: row.width }" :style="{ width: row.width }"
@@ -31,25 +87,65 @@ const { rows, data } = defineProps<TableProps>()
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody :key="currentPage">
<tr v-for="(item, i) in data" :key="i"> <tr
<td v-for="(row, j) in rows" :key="j"> v-for="entry in pagedData"
<slot :name="row.name" :item="item" :index="i"> :key="entry.index"
>
<td v-for="(row, j) in props.rows" :key="j">
<slot
:name="row.name"
:item="entry.item"
:index="entry.index"
>
<nuxt-img <nuxt-img
v-if="row.type == 'image'" v-if="row.type == 'image'"
:src="`${row.prefix}${item[row.name]}`" :src="GET_FILE(entry.item[row.name])"
/> />
<audio <audio
v-else-if="row.type == 'audio'" v-else-if="row.type == 'audio'"
controls controls
:src="`${row.prefix}${item[row.name]}${row.suffix}`" :src="GET_AUDIO_FILE(entry.item[row.name])"
/> />
<p v-else>{{ item[row.name] }}</p> <p v-else>{{ entry.item[row.name] }}</p>
</slot> </slot>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<nav
v-if="props.pagination && pageCount > 1"
class="pagination"
aria-label="Table pagination"
>
<button
type="button"
:disabled="currentPage === 1"
aria-label="Previous page"
@click="currentPage--"
>
Previous
</button>
<button
v-for="page in pageCount"
:key="page"
type="button"
:aria-label="`Page ${page}`"
:aria-current="currentPage === page ? 'page' : undefined"
:class="{ active: currentPage === page }"
@click="currentPage = page"
>
{{ page }}
</button>
<button
type="button"
:disabled="currentPage === pageCount"
aria-label="Next page"
@click="currentPage++"
>
Next
</button>
</nav>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -85,4 +181,46 @@ const { rows, data } = defineProps<TableProps>()
padding: 5px 0; padding: 5px 0;
} }
} }
.table-search {
margin-bottom: 0.75rem;
}
.table tbody {
animation: table-page-fade 0.18s ease;
}
@keyframes table-page-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
& button {
padding: 0.4rem 0.65rem;
border-radius: $border-radius-standard;
background-color: $color-button;
cursor: pointer;
}
& button:hover:not(:disabled),
& button.active {
background-color: $color-button-hover;
}
& button:disabled {
cursor: default;
opacity: 0.5;
}
}
</style> </style>
+2
View File
@@ -27,6 +27,7 @@ const song = useSong()
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 100; z-index: 100;
pointer-events: none;
padding: 2rem; padding: 2rem;
@@ -50,6 +51,7 @@ const song = useSong()
min-width: 200px; min-width: 200px;
cursor: pointer; cursor: pointer;
pointer-events: auto;
transition: transition:
transform 0.2s ease, transform 0.2s ease,
+9
View File
@@ -0,0 +1,9 @@
export function shapeBeatPulse(value: number) {
const strength = Math.max(0, Math.min(0.4, value))
if (strength <= 0.2) return strength * 0.4
if (strength <= 0.35) {
const progress = (strength - 0.2) / 0.15
return 0.08 + (1 - (1 - progress) ** 3) * 0.42
}
return 0.5 + ((strength - 0.35) / 0.05) * 0.05
}
+59 -52
View File
@@ -1,76 +1,83 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios" import { $fetch } from "ofetch"
import defu from "defu" import type { FetchOptions } from "ofetch"
const hasFile = (value: any): boolean => { type RequestConfig = Omit<FetchOptions, "body" | "query"> & {
data?: unknown
params?: Record<string, unknown>
forceFormData?: boolean
}
const hasFile = (value: unknown): boolean => {
if (!value || typeof value !== "object") return false if (!value || typeof value !== "object") return false
const isFile = (item: unknown) =>
typeof File !== "undefined" && item instanceof File
const isBlob = (item: unknown) =>
typeof Blob !== "undefined" && item instanceof Blob
return Object.values(value).some( return Object.values(value).some(
(v) => (item) =>
v instanceof File || isFile(item) ||
v instanceof Blob || isBlob(item) ||
(Array.isArray(v) && v.some((i) => i instanceof File)), (Array.isArray(item) && item.some((entry) => isFile(entry) || isBlob(entry))),
) )
} }
type RequestConfig = { const toApiUrl = (url: string) => {
forceFormData?: boolean if (url === "/api" || url.startsWith("/api/")) return url
} & AxiosRequestConfig return `/api${url.startsWith("/") ? url : `/${url}`}`
}
export const cfetch = async ( export const cfetch = async <DataT = any>(
url: string, url: string,
options: RequestConfig = {}, options: RequestConfig = {},
authorize: boolean = true, authorize = true,
): Promise<AxiosResponse> => { ): Promise<DataT> => {
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND const { data, params, forceFormData, ...fetchOptions } = options
const headers = new Headers(fetchOptions.headers as HeadersInit | undefined)
if (!baseUrl) throw new Error("BACKEND URL not set") const requestOptions: FetchOptions = {
...fetchOptions,
const { sessionToken, refreshSessionToken } = useAuth() query: params ?? (fetchOptions as FetchOptions).query,
body: data ?? (fetchOptions as FetchOptions).body,
const defaults: AxiosRequestConfig = { headers,
baseURL: baseUrl,
} }
if (authorize) { if (
let token = sessionToken.value requestOptions.body &&
(typeof requestOptions.body === "object" || forceFormData) &&
if (token) defaults.headers = { Authorization: `Bearer ${token}` } (hasFile(requestOptions.body) || forceFormData)
else ) {
defaults.headers = {
Authorization: `Bearer ${await refreshSessionToken()}`,
}
}
const config: RequestConfig = defu(options, defaults)
if (config.data && (hasFile(config.data) || config.forceFormData)) {
const form = new FormData() const form = new FormData()
for (const [key, val] of Object.entries(config.data)) { for (const [key, value] of Object.entries(requestOptions.body as Record<string, unknown>)) {
if (Array.isArray(val)) { if (Array.isArray(value)) {
val.forEach((v) => form.append(key, v)) // key as-is, no [] value.forEach((entry) => form.append(key, entry as string | Blob))
} else if (val !== undefined && val !== null) { } else if (value !== undefined && value !== null) {
form.append(key, val as any) form.append(key, value as string | Blob)
} }
} }
config.data = form requestOptions.body = form
if (!config.headers) config.headers = {} headers.delete("Content-Type")
delete config.headers["Content-Type"]
} }
let response
try { try {
response = await axios(url, config) return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT
} catch (error: any) { } catch (error: any) {
if (error.response?.status == 401) { if (!authorize || error?.response?.status !== 401) throw error
if (!config.headers) config.headers = {}
config.headers.Authorization = `Bearer ${await refreshSessionToken()}`
response = await axios(url, config) const refreshed = await useAuth().refreshSession()
} else throw error if (!refreshed.ok) throw error
if (import.meta.server && refreshed.sessionCookie) {
const cookies = (headers.get("cookie") ?? "")
.split(";")
.map((cookie) => cookie.trim())
.filter((cookie) => cookie && !cookie.startsWith("musicSession="))
cookies.push(refreshed.sessionCookie)
headers.set("cookie", cookies.join("; "))
}
return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT
} }
if (!response) throw new Error("Something went wrong. Please try again")
return response
} }
+5 -4
View File
@@ -1,6 +1,7 @@
export const BACKEND_SERVE = `${import.meta.env.VITE_PUBLIC_BACKEND}/static/` export const GET_FILE = (file: string, baseUrl = useRuntimeConfig().public.mediaBaseUrl) => {
return `${String(baseUrl).replace(/\/+$/, "")}/${file.replace(/^\/+/, "")}`
}
export const GET_FILE = (file: string) => `${BACKEND_SERVE}${file}` export const GET_AUDIO_FILE = (file: string, baseUrl?: string) => {
export const GET_AUDIO_FILE = (file: string) => { return GET_FILE(`${file}/high.m4a`, baseUrl)
return `${GET_FILE(file)}/high.m4a`
} }
+27
View File
@@ -0,0 +1,27 @@
export const parseLyrics = (lrc: string) => {
const regex = /^\[(\d{2}:\d{2}(.\d{2,3})?)\](.*)/
const lines = lrc.split("\n")
const output: { time: number; text: any }[] = []
const parseTime = (time: any) => {
const minsec = time.split(":")
const min = parseInt(minsec[0]) * 60
const sec = parseFloat(minsec[1])
return min + sec
}
lines.forEach((line: any) => {
const match = line.match(regex)
if (match == null) return
const time = match[1]
const text = match[3]
output.push({
time: parseTime(time),
text: text.trim(),
})
})
return output
}
+1 -1
View File
@@ -24,7 +24,7 @@ export interface Track extends Base {
lyrics: { lyrics: {
synced: boolean synced: boolean
text: string text: string
} } | null
album: Album album: Album
artists: Artist[] artists: Artist[]
durationInSeconds: number durationInSeconds: number
+1909 -412
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "golar/unstable"
import "@golar/vue"
export default defineConfig({
typecheck: {
include: ["app/**/*.{ts,tsx,vue}", "server/**/*.ts", "nuxt.config.ts"],
},
})
+65 -4
View File
@@ -3,8 +3,72 @@ import { fileURLToPath } from "node:url"
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2025-07-15", compatibilityDate: "2025-07-15",
nitro: {
preset: "node-server",
externals: {
inline: ["vue"],
},
},
devtools: { enabled: true }, devtools: { enabled: true },
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"], runtimeConfig: {
// Private server configuration. Matching NUXT_* variables override these
// defaults at runtime in the built Nitro server. Legacy variable names
// are resolved by server utilities at runtime, never during this build.
mongodbUri: "",
tokenExpire: "",
s3Endpoint: "",
s3Bucket: "",
s3Region: "us-east-1",
s3AccessKeyId: "",
s3SecretAccessKey: "",
public: {
mediaBaseUrl: "",
},
},
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image", "@vite-pwa/nuxt"],
pwa: {
strategies: "injectManifest",
srcDir: "../public",
filename: "sw.js",
registerType: "autoUpdate",
registerWebManifestInRouteRules: true,
injectManifest: {
injectionPoint: undefined,
},
devOptions: {
enabled: false,
},
manifest: {
id: "/",
name: "Music",
short_name: "Music",
description: "Your personal music library and player.",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#09090b",
theme_color: "#09090b",
categories: ["music", "entertainment"],
icons: [
{
src: "/icons/pwa-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icons/pwa-512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/icons/pwa-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
},
components: [ components: [
{ {
path: "~/ui", path: "~/ui",
@@ -15,7 +79,4 @@ export default defineNuxtConfig({
pathPrefix: true, pathPrefix: true,
}, },
], ],
image: {
domains: ["api.music.lucasskt.dk"],
},
}) })
+6890 -2400
View File
File diff suppressed because it is too large. Load diff
+36 -27
View File
@@ -1,29 +1,38 @@
{ {
"name": "music-v2", "name": "music-v2",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build && bun scripts/copy-nitro-public-assets.ts",
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
}, "typecheck": "nuxt typecheck"
"dependencies": { },
"@nuxt/fonts": "0.11.4", "dependencies": {
"@nuxt/icon": "^2.1.0", "@aws-sdk/client-s3": "^3.1142.0",
"@nuxt/image": "^2.0.0", "@nuxt/fonts": "0.11.4",
"@types/howler": "^2.2.12", "@nuxt/icon": "^2.1.0",
"axios": "^1.13.2", "@nuxt/image": "^2.0.0",
"defu": "^6.1.4", "@types/howler": "^2.2.12",
"howler": "^2.2.4", "argon2": "^0.45.1",
"nuxt": "^4.2.0", "defu": "^6.1.4",
"typescript": "^5.9.3", "fluent-ffmpeg": "^2.1.3",
"vue": "^3.5.22", "fuse.js": "^7.5.0",
"vue-router": "^4.6.3" "howler": "^2.2.4",
}, "mongoose": "^9.10.2",
"devDependencies": { "nuxt": "^4.5.2",
"@types/node": "^24.9.1", "parse-duration": "^2.1.9",
"sass-embedded": "^1.93.2" "typescript": "^5.9.3",
} "vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@golar/vue": "^0.1.10",
"@types/node": "^24.9.1",
"@vite-pwa/nuxt": "^1.1.1",
"golar": "^0.1.10",
"sass-embedded": "^1.93.2"
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#08090b" />
<path
d="M96 352 174 176l78 176h44l70-138 64 138"
fill="none"
stroke="#00db81"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="34"
/>
</svg>

After

Width:  |  Height:  |  Size: 281 B

+30
View File
@@ -0,0 +1,30 @@
{
"id": "/",
"name": "Music",
"short_name": "Music",
"description": "Your personal music library and player.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#09090b",
"categories": ["music", "entertainment"],
"icons": [
{
"src": "/icons/pwa-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/pwa-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/pwa-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#09090b" />
<title>Offline · Music</title>
<style>
:root {
color-scheme: dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #09090b;
color: white;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
text-align: center;
}
main {
max-width: 26rem;
padding: 2rem;
}
img {
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
}
p {
color: #b8b8bd;
line-height: 1.5;
}
button {
margin-top: 1rem;
padding: 0.75rem 1.25rem;
border: 0;
border-radius: 999px;
background: #00db81;
color: #09090b;
font: inherit;
font-weight: 700;
}
</style>
</head>
<body>
<main>
<img src="/icons/pwa-192.png" alt="" />
<h1>You’re offline</h1>
<p>Reconnect to browse your library and play music.</p>
<button type="button" onclick="location.reload()">Try again</button>
</main>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
const SHELL_CACHE = "music-pwa-shell-v1"
const ASSET_CACHE = "music-pwa-assets-v1"
const OFFLINE_PAGE = "/offline.html"
const SHELL_ASSETS = [
OFFLINE_PAGE,
"/icons/pwa-192.png",
"/icons/pwa-512.png",
"/icons/pwa-maskable-512.png",
"/icons/apple-touch-icon.png",
]
const SHELL_ASSET_PATHS = new Set(SHELL_ASSETS)
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => cache.addAll(SHELL_ASSETS))
.then(() => self.skipWaiting()),
)
})
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then(async (keys) => {
await Promise.all(
keys
.filter(
(key) =>
key.startsWith("music-pwa-") &&
key !== SHELL_CACHE &&
key !== ASSET_CACHE,
)
.map((key) => caches.delete(key)),
)
await self.clients.claim()
}),
)
})
self.addEventListener("fetch", (event) => {
const request = event.request
if (request.method !== "GET") return
const url = new URL(request.url)
if (url.origin !== self.location.origin) return
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(async () => {
const offlinePage = await caches.match(OFFLINE_PAGE)
return offlinePage ?? Response.error()
}),
)
return
}
if (SHELL_ASSET_PATHS.has(url.pathname)) {
event.respondWith(
caches.match(request).then((cached) => cached ?? fetch(request)),
)
return
}
if (url.pathname.startsWith("/_nuxt/")) {
event.respondWith(
caches.open(ASSET_CACHE).then(async (cache) => {
const cached = await cache.match(request)
if (cached) return cached
const response = await fetch(request)
if (response.ok) {
await cache.put(request, response.clone())
const entries = await cache.keys()
if (entries.length > 100) await cache.delete(entries[0])
}
return response
}),
)
}
})
+13
View File
@@ -0,0 +1,13 @@
import { cp, mkdir } from "node:fs/promises"
import { resolve } from "node:path"
const publicOutput = resolve(".output/public")
const nitroAssetDirectory = resolve(".output/server/chunks/public")
const nuxtAssetsDirectory = resolve(publicOutput, "_nuxt")
await mkdir(nitroAssetDirectory, { recursive: true })
await cp(publicOutput, nitroAssetDirectory, {
recursive: true,
force: true,
filter: (path) => path !== nuxtAssetsDirectory,
})
+34
View File
@@ -0,0 +1,34 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObject, tryDeleteObjectPrefix } from "../../utils/object-storage"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const force = query.force === "true" || query.force === true
const album = await Album.findById(parsedId.value)
if (!album) {
setResponseStatus(event, 404)
return { message: "Album not found" }
}
const dependents = await Track.find({ album: album._id }).select("_id name").exec()
if (force) {
const tracks = await Track.find({ album: album._id }).select("_id fileDir").exec()
await Promise.all(tracks.map((track) => Track.findByIdAndDelete(track._id)))
await Promise.all(tracks.map((track) => tryDeleteObjectPrefix(event, `${track.fileDir}/`)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Album has one or more dependents", dependentType: "Track", dependents }
}
await Album.findByIdAndDelete(parsedId.value)
await tryDeleteObject(event, album.file)
return {}
})
+56
View File
@@ -0,0 +1,56 @@
import { getQuery, setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, splitIds } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Album } from "../../models/album"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const genre = adminFormText(parts, "genre")
const file = adminFormFile(parts, "file")
if (artists && !artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
if (genre && !mongoose.isValidObjectId(genre)) return validationResponse(event, "Invalid id")
if (file && !file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const album = await Album.findById(parsedId.value)
if (!album) {
setResponseStatus(event, 404)
return {}
}
if (file) {
const filePath = `album/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
const previousPath = album.file
album.file = filePath
if (name) album.name = name
if (artists) album.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (genre) album.genre = new mongoose.Types.ObjectId(genre)
try {
await album.save()
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
await tryDeleteObject(event, previousPath)
return album
}
if (name) album.name = name
if (artists) album.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (genre) album.genre = new mongoose.Types.ObjectId(genre)
await album.save()
return album
})
+44
View File
@@ -0,0 +1,44 @@
import { setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, splitIds } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Album } from "../../models/album"
import mongoose from "mongoose"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const genre = adminFormText(parts, "genre")
const file = adminFormFile(parts, "file")
if (name === undefined || !artists || !genre || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!mongoose.isValidObjectId(genre)) {
setResponseStatus(event, 400)
return { message: "Invalid id" }
}
if (!artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
if (!file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const filePath = `album/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
let album
try {
album = await Album.create({ name, artists, file: filePath, genre })
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
setResponseStatus(event, 201)
return album
})
+50
View File
@@ -0,0 +1,50 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Artist } from "../../models/artist"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObject, tryDeleteObjectPrefix } from "../../utils/object-storage"
async function findDependents(model: typeof Track | typeof Album, field: string, value: unknown) {
return model.find({ [field]: value }).select("_id name").exec()
}
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const force = query.force === "true" || query.force === true
const artist = await Artist.findById(parsedId.value)
if (!artist) {
setResponseStatus(event, 404)
return { message: "Artist not found" }
}
let dependents = await findDependents(Track, "artists", artist._id)
if (force) {
const tracks = await Track.find({ artists: artist._id }).select("_id fileDir").exec()
await Promise.all(tracks.map((track) => Track.findByIdAndDelete(track._id)))
await Promise.all(tracks.map((track) => tryDeleteObjectPrefix(event, `${track.fileDir}/`)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Artist has one or more dependents", dependentType: "Track", dependents }
}
dependents = await findDependents(Album, "artists", artist._id)
if (force) {
const albums = await Album.find({ artists: artist._id }).select("_id file").exec()
await Promise.all(albums.map((album) => Album.findByIdAndDelete(album._id)))
await Promise.all(albums.map((album) => tryDeleteObject(event, album.file)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Artist has one or more dependents", dependentType: "Artist", dependents }
}
await Artist.findByIdAndDelete(parsedId.value)
await tryDeleteObject(event, artist.file)
return {}
})
+44
View File
@@ -0,0 +1,44 @@
import { getQuery, setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormFile, safeFileExtension } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Artist } from "../../models/artist"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const file = adminFormFile(parts, "file")
if (file && !file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const artist = await Artist.findById(parsedId.value)
if (!artist) {
setResponseStatus(event, 404)
return {}
}
if (file) {
const filePath = `artists/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
const previousPath = artist.file
artist.file = filePath
if (name) artist.name = name
try {
await artist.save()
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
await tryDeleteObject(event, previousPath)
return artist
}
if (name) artist.name = name
await artist.save()
return artist
})
+32
View File
@@ -0,0 +1,32 @@
import { setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormFile, safeFileExtension } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Artist } from "../../models/artist"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const file = adminFormFile(parts, "file")
if (name === undefined || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const filePath = `artists/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
let artist
try {
artist = await Artist.create({ name, file: filePath })
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
setResponseStatus(event, 201)
return artist
})
+42
View File
@@ -0,0 +1,42 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) {
setResponseStatus(event, 400)
return { message: parsedId.error }
}
if (query.force !== "true" && query.force !== "false") {
setResponseStatus(event, 400)
return { message: query.force === undefined ? "Required" : "Expected boolean, received string" }
}
const force = query.force === "true"
const genre = await Genre.findById(parsedId.value)
if (!genre) {
setResponseStatus(event, 404)
return { message: "Genre not found" }
}
const dependents = await Album.find({ genre: genre._id }).select("_id name")
if (force) {
await Promise.all(dependents.map((dependent) => Album.findByIdAndDelete(dependent._id)))
} else if (dependents.length) {
setResponseStatus(event, 409)
return {
message: "Genre has one or more dependents",
dependentType: "Album",
dependents,
}
}
await Genre.findByIdAndDelete(parsedId.value)
return {}
})
+34
View File
@@ -0,0 +1,34 @@
import { getQuery, readBody, setResponseStatus } from "h3"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) {
setResponseStatus(event, 400)
return { message: parsedId.error }
}
const body = await readBody<unknown>(event)
if (!body || typeof body !== "object" || Array.isArray(body)) {
setResponseStatus(event, 400)
const received = body === null ? "null" : Array.isArray(body) ? "array" : typeof body
return { message: `Expected object, received ${received}` }
}
const record = body as Record<string, unknown>
if (record.name !== undefined && typeof record.name !== "string") {
setResponseStatus(event, 400)
return { message: `Expected string, received ${record.name === null ? "null" : typeof record.name}` }
}
const genre = await Genre.findByIdAndUpdate(parsedId.value, record.name === undefined ? {} : { name: record.name }, { new: true })
if (!genre) {
setResponseStatus(event, 404)
return { message: "Genre not found" }
}
return genre
})
+31
View File
@@ -0,0 +1,31 @@
import { readBody, setResponseStatus } from "h3"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const body = await readBody<unknown>(event)
if (!body || typeof body !== "object" || Array.isArray(body)) {
setResponseStatus(event, 400)
const received = body === null ? "null" : Array.isArray(body) ? "array" : typeof body
return { message: `Expected object, received ${received}` }
}
const name = (body as { name?: unknown }).name
if (typeof name !== "string") {
setResponseStatus(event, 400)
return { message: name === undefined ? "Required" : `Expected string, received ${name === null ? "null" : typeof name}` }
}
try {
const genre = await Genre.create({ name })
setResponseStatus(event, 201)
return genre
} catch (error) {
setResponseStatus(event, 400)
if (typeof error === "object" && error !== null && "code" in error && error.code === 11000) {
return { message: "Genre already exists" }
}
return { message: "Unknown error" }
}
})
+19
View File
@@ -0,0 +1,19 @@
import { getQuery, setResponseStatus } from "h3"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObjectPrefix } from "../../utils/object-storage"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const track = await Track.findByIdAndDelete(parsedId.value)
if (!track) {
setResponseStatus(event, 404)
return {}
}
await tryDeleteObjectPrefix(event, `${track.fileDir}/`)
return {}
})
+68
View File
@@ -0,0 +1,68 @@
import mongoose from "mongoose"
import { getQuery, setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, parseOptionalJson, splitIds } from "../../utils/admin-form"
import { prepareAudioUpload } from "../../utils/audio-files"
import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Track } from "../../models/track"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const album = adminFormText(parts, "album")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const file = adminFormFile(parts, "file")
const lyrics = adminFormText(parts, "lyrics")
if (file && !file.type.includes("audio/")) {
setResponseStatus(event, 400)
return { message: "Only audio files are accepted" }
}
if (album && !mongoose.isValidObjectId(album)) return validationResponse(event, "Invalid id")
if (artists && !artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
const parsedLyrics = lyrics ? parseOptionalJson(lyrics) : undefined
if (parsedLyrics && !parsedLyrics.ok) throw new Error("Invalid lyrics JSON")
const track = await Track.findById(parsedId.value)
if (!track) {
setResponseStatus(event, 404)
return {}
}
let replacementDirectory: string | undefined
let previousDirectory: string | undefined
if (file) {
replacementDirectory = `tracks/${crypto.randomUUID()}`
const extension = safeFileExtension(file.name)
const processed = await prepareAudioUpload(file.data, extension)
await putObjects(event, [
{ key: `${replacementDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type },
...processed.renditions.map((rendition) => ({
key: `${replacementDirectory}/${rendition.name}`,
body: rendition.body,
contentType: rendition.contentType,
})),
])
previousDirectory = track.fileDir
track.fileDir = replacementDirectory
track.durationInSeconds = Math.round(processed.duration)
}
if (name) track.name = name
if (album) track.album = new mongoose.Types.ObjectId(album)
if (artists) track.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (parsedLyrics?.ok && parsedLyrics.value !== undefined) track.lyrics = parsedLyrics.value as any
try {
await track.save()
} catch (error) {
if (replacementDirectory) await tryDeleteObjectPrefix(event, `${replacementDirectory}/`)
throw error
}
if (previousDirectory) await tryDeleteObjectPrefix(event, `${previousDirectory}/`)
return track
})
+73
View File
@@ -0,0 +1,73 @@
import { setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, parseOptionalJson, splitIds } from "../../utils/admin-form"
import { prepareAudioUpload } from "../../utils/audio-files"
import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Track } from "../../models/track"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const album = adminFormText(parts, "album")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const file = adminFormFile(parts, "file")
const lyricsInput = adminFormText(parts, "lyrics")
if (name === undefined || album === undefined || !artists || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!mongoose.isValidObjectId(album)) {
setResponseStatus(event, 400)
return { message: "Invalid id" }
}
if (!artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
const parsedLyrics = parseOptionalJson(lyricsInput)
if (!parsedLyrics.ok) {
setResponseStatus(event, 400)
return { message: "Lyrics must be a JSON object" }
}
if (!file.type.includes("audio/")) {
setResponseStatus(event, 400)
return { message: "Only audio files are accepted" }
}
const fileDirectory = `tracks/${crypto.randomUUID()}`
try {
const extension = safeFileExtension(file.name)
const processed = await prepareAudioUpload(file.data, extension)
await putObjects(event, [
{ key: `${fileDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type },
...processed.renditions.map((rendition) => ({
key: `${fileDirectory}/${rendition.name}`,
body: rendition.body,
contentType: rendition.contentType,
})),
])
const track = new Track({
name,
album: new mongoose.Types.ObjectId(album),
artists: artists.map((id) => new mongoose.Types.ObjectId(id)),
fileDir: fileDirectory,
durationInSeconds: Math.round(processed.duration),
lyrics: parsedLyrics.value || undefined,
})
try {
await track.save()
} catch (error) {
await tryDeleteObjectPrefix(event, `${fileDirectory}/`)
throw error
}
setResponseStatus(event, 201)
return track
} catch (error) {
console.error(error)
setResponseStatus(event, 500)
return undefined
}
})
+16
View File
@@ -0,0 +1,16 @@
import { getRouterParam } from "h3"
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
return Album.findOne({ _id: parsedId.value })
.populate("artists")
.populate("genre")
.exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Track } from "../../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Track.find({ album: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate("artists")
.populate({ path: "album", select: "-artists -genre" })
.exec()
})
+16
View File
@@ -0,0 +1,16 @@
import { getQuery } from "h3"
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoIds, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
return Album.find({ _id: { $in: parsedIds.value } })
.populate("artists")
.populate("genre")
.exec()
})
+8
View File
@@ -0,0 +1,8 @@
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Album.find({}).populate("artists").populate("genre")
})
+8
View File
@@ -0,0 +1,8 @@
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Artist.find({})
})
+8
View File
@@ -0,0 +1,8 @@
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Genre.find({})
})
+14
View File
@@ -0,0 +1,14 @@
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Track.find({})
.populate({
path: "album",
select: "-artists",
populate: { path: "genre" },
})
.populate("artists")
})
+13
View File
@@ -0,0 +1,13 @@
import { getRouterParam } from "h3"
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
return Artist.findOne({ _id: parsedId.value }).exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Album } from "../../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Album.find({ artists: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate("genre")
.populate("artists")
.exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Track } from "../../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Track.find({ artists: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate({ path: "album", select: "-artists" })
.populate("artists")
.exec()
})
+13
View File
@@ -0,0 +1,13 @@
import { getQuery } from "h3"
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoIds, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
return Artist.find({ _id: { $in: parsedIds.value } }).exec()
})
+56
View File
@@ -0,0 +1,56 @@
import { readBody, setCookie, setResponseStatus } from "h3"
import argon2 from "argon2"
import { User } from "../../models/user"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
REFRESH_COOKIE,
} from "../../utils/auth"
import { getAuthValidationMessage } from "../../utils/auth-validation"
export default defineAuthenticatedEventHandler(async (event) => {
assertCookieRequestOrigin(event)
const body = await readBody(event)
const validationMessage = getAuthValidationMessage(body, [
{ name: "name" },
{ name: "email", email: true },
{ name: "password", minimumLength: 8 },
])
if (validationMessage) {
setResponseStatus(event, 400)
return { message: validationMessage }
}
const email = body.email.toLowerCase()
if (await User.findOne({ email })) {
setResponseStatus(event, 400)
return { message: "The email is already in use" }
}
try {
const user = await User.create({
name: body.name,
email,
passwordHash: await argon2.hash(body.password),
})
const refreshToken = await User.findById(user._id).select("+refreshToken").then((doc) => doc?.refreshToken)
if (!refreshToken) throw new Error("Unable to create refresh token")
setCookie(event, REFRESH_COOKIE, refreshToken, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !import.meta.dev,
})
setResponseStatus(event, 201)
// Refresh credentials are now delivered only through the HttpOnly cookie.
return {}
} catch (error) {
if ((error as { code?: number }).code === 11000) {
setResponseStatus(event, 400)
return { message: "The email is already in use" }
}
throw error
}
})
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more