Compare commits
135
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b483ad84e4 | ||
|
|
900b2b8abf | ||
|
|
12031cb481 | ||
|
|
00b482e62a | ||
|
|
70ef737f19 | ||
|
|
1ffe483e70 | ||
|
|
53aa702758 | ||
|
|
148c5b267d | ||
|
|
24d4f2e577 | ||
|
|
d9e1cb1c6f | ||
|
|
440b6ef817 | ||
|
|
c70b840520 | ||
|
|
4d4c0f0e3a | ||
|
|
7d42c6edb1 | ||
|
|
95f6199cea | ||
|
|
d76ecf6063 | ||
|
|
379acf400c | ||
|
|
a6485e5c73 | ||
|
|
8f1cc8d4a7 | ||
|
|
dec7f46365 | ||
|
|
58338d0e8d | ||
|
|
b190d1671c | ||
|
|
b696da1854 | ||
|
|
ebd6fedd89 | ||
|
|
d07221f600 | ||
|
|
27c289483b | ||
|
|
1d6422f422 | ||
|
|
ffdb159d57 | ||
|
|
3001267393 | ||
|
|
29b6418e2c | ||
|
|
a1e993facb | ||
|
|
7a80ac8f3f | ||
|
|
7ae68592a2 | ||
|
|
84658e4499 | ||
|
|
8df84bce86 | ||
|
|
af07bf2249 | ||
|
|
0526e83a9b | ||
|
|
36d05ab04f | ||
|
|
1eef02ccfa | ||
|
|
e908ae27ad | ||
|
|
869ca7d728 | ||
|
|
51114d65c8 | ||
|
|
fb99058492 | ||
|
|
142e34babe | ||
|
|
45ae19149f | ||
|
|
bdfa76f993 | ||
|
|
30541eab8d | ||
|
|
3393c133fd | ||
|
|
e89463591c | ||
|
|
e2c47e6d0f | ||
|
|
bfaa4acb25 | ||
|
|
4caa9135ee | ||
|
|
cc0623ef4a | ||
|
|
e2e2fc7755 | ||
|
|
e02cb83235 | ||
|
|
492a54dd0d | ||
|
|
0d09198e57 | ||
|
|
c2dcec2c31 | ||
|
|
39cb48a656 | ||
|
|
069ef37825 | ||
|
|
19e84d774c | ||
|
|
8cd0d5ca89 | ||
|
|
0cf29e0e06 | ||
|
|
f377424d3a | ||
|
|
a2b8dec813 | ||
|
|
31e6c0c5d2 | ||
|
|
a0a62c1c06 | ||
|
|
47db7abd6a | ||
|
|
4621a81384 | ||
|
|
bca41b5e6f | ||
|
|
ee5809a489 | ||
|
|
eb4e9f0700 | ||
|
|
27feb1ce95 | ||
|
|
e50648e35f | ||
|
|
b5f7aa1372 | ||
|
|
5e3f3ef6a2 | ||
|
|
661b9341d7 | ||
|
|
3402178932 | ||
|
|
3255a97339 | ||
|
|
fdf2aeb675 | ||
|
|
8ee6164218 | ||
|
|
a6e8bb51c5 | ||
|
|
9cbf96564f | ||
|
|
23f5709c68 | ||
|
|
30c7972647 | ||
|
|
16e309c346 | ||
|
|
1d2bec7317 | ||
|
|
6b49c7c4d5 | ||
|
|
13081c4771 | ||
|
|
d6f8bb8588 | ||
|
|
64646f778e | ||
|
|
4196db1759 | ||
|
|
d0886cd9a3 | ||
|
|
ec69c85102 | ||
|
|
e7bab2a95c | ||
|
|
cf5ef8d429 | ||
|
|
1eb52f1c94 | ||
|
|
3aaa3eb42e | ||
|
|
f7adfeac22 | ||
|
|
74ba579919 | ||
|
|
1a62aa192e | ||
|
|
e6657a6572 | ||
|
|
a46f289865 | ||
|
|
b778ea9eda | ||
|
|
db86529219 | ||
|
|
603620a418 | ||
|
|
2b01ed688c | ||
|
|
686be7ca8a | ||
|
|
c868329d8b | ||
|
|
7cbc087049 | ||
|
|
5c9b82265d | ||
|
|
307f467f94 | ||
|
|
cb481ad983 | ||
|
|
59af94de8c | ||
|
|
c5cd36f5e5 | ||
|
|
e8d2173f17 | ||
|
|
427437bb3b | ||
|
|
fceddf641a | ||
|
|
af3b9669dc | ||
|
|
4eb7c6d5a9 | ||
|
|
aa93320215 | ||
|
|
6c1dda9622 | ||
|
|
b48db8bb37 | ||
|
|
d5482a9ea4 | ||
|
|
ab22cd6eb9 | ||
|
|
b6e854b1dd | ||
|
|
fd27fee6c7 | ||
|
|
22d6150290 | ||
|
|
ddef57b802 | ||
|
|
ae79952ddc | ||
|
|
76c57c1ba0 | ||
|
|
4475b95aed | ||
|
|
2561bd0e62 | ||
|
|
ed53c41a87 | ||
|
|
78c4a6cb8e |
No files matched your search
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
|
||||||
+31
@@ -1,11 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { currentSong, isPlaying } = usePlayer()
|
||||||
|
const debugView = useCookie("DEBUG_VIEW")
|
||||||
|
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
|
||||||
|
|
||||||
|
useHead(
|
||||||
|
computed(() => ({
|
||||||
|
titleTemplate:
|
||||||
|
currentSong.value && isPlaying.value
|
||||||
|
? `${currentSong.value.name} - Now Playing - Music`
|
||||||
|
: (titleChunk) => `${titleChunk || "Home"} - Music`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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>
|
||||||
@@ -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>
|
||||||
@@ -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,7 +46,7 @@ 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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { GET_FILE } from "@/utils/file"
|
||||||
|
|
||||||
|
const { fullscreen, close: closeFullscreen } = useFullscreen()
|
||||||
|
const song = useSong()
|
||||||
|
const mouseMovedRecently = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const nuxtApp = useNuxtApp()
|
||||||
|
|
||||||
|
nuxtApp.hook("page:finish", () => {
|
||||||
|
closeFullscreen()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="fullscreen"
|
||||||
|
:class="{ showCursor: mouseMovedRecently }"
|
||||||
|
v-show="fullscreen"
|
||||||
|
>
|
||||||
|
<template v-if="song">
|
||||||
|
<PlayerFullscreenBackgroundWash
|
||||||
|
:src="GET_FILE(song.album.file)"
|
||||||
|
:active="fullscreen"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<section class="screen-container" v-if="song">
|
||||||
|
<Icon
|
||||||
|
name="lucide:x"
|
||||||
|
class="close-btn"
|
||||||
|
:class="{ show: mouseMovedRecently }"
|
||||||
|
@click="closeFullscreen()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="content-container"
|
||||||
|
:class="{ lyrics: song.lyrics?.text }"
|
||||||
|
>
|
||||||
|
<PlayerFullscreenTrackDisplay
|
||||||
|
:show-buttons="mouseMovedRecently"
|
||||||
|
/>
|
||||||
|
<PlayerFullscreenLyricsDisplay
|
||||||
|
:animation-duration="0.2"
|
||||||
|
:offset="250"
|
||||||
|
:show-scroll-bar="mouseMovedRecently"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 150;
|
||||||
|
|
||||||
|
cursor: none;
|
||||||
|
|
||||||
|
&.showCursor {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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,309 @@
|
|||||||
|
<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 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 = 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(() => window.addEventListener("resize", updateLyricsLayout))
|
||||||
|
onUnmounted(() => window.removeEventListener("resize", updateLyricsLayout))
|
||||||
|
|
||||||
|
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: `${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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<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,
|
||||||
|
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">
|
||||||
|
<p class="name">{{ song.name }}</p>
|
||||||
|
<ArtistName :artists="song.artists" />
|
||||||
|
</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="lucide:repeat"
|
||||||
|
:class="{ repeating: repeat }"
|
||||||
|
@click="repeat = !repeat"
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<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,
|
||||||
|
)
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<TrackRow
|
||||||
|
:track="row.track"
|
||||||
|
:index="index"
|
||||||
|
show-image
|
||||||
|
queue-mode
|
||||||
|
class="queue-track"
|
||||||
|
@play-album-at-index="playAt(index)"
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -38,7 +40,8 @@ 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 = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -104,15 +107,14 @@ const toast = useToast()
|
|||||||
</div>
|
</div>
|
||||||
<Icon
|
<Icon
|
||||||
name="lucide:list"
|
name="lucide:list"
|
||||||
@click="toast.show('Not yet implemented')"
|
@click="queueOpen = true"
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
name="lucide:expand"
|
|
||||||
@click="toast.show('Not yet implemented')"
|
|
||||||
/>
|
/>
|
||||||
|
<Icon name="lucide:expand" @click="openFullscreen()" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</article>
|
</article>
|
||||||
|
<PlayerQueue v-model:open="queueOpen" />
|
||||||
|
<PlayerFullscreen />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -132,6 +134,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;
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<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" @click="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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<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">
|
||||||
|
<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>
|
||||||
+239
-49
@@ -2,73 +2,199 @@
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
}"
|
||||||
|
@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>
|
@click="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>
|
<p v-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">
|
||||||
<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">
|
||||||
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</article>
|
</article>
|
||||||
@@ -81,7 +207,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 +216,41 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,8 +259,43 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.artists {
|
.artists {
|
||||||
@@ -120,5 +304,11 @@ const durationFormatted = computed(() =>
|
|||||||
|
|
||||||
.dropdown-icon {
|
.dropdown-icon {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-handle {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
fullscreen.value = false
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
})
|
})
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
})
|
})
|
||||||
|
useHead({ title: "Admin" })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">useHead({ title: "Admin" })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template></template>
|
<template></template>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +9,10 @@ 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>
|
||||||
@@ -27,12 +31,12 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
|||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<Button
|
<Button
|
||||||
icon-name="lucide:play"
|
icon-name="lucide:play"
|
||||||
@click="() => playAlbum(tracksData)"
|
@click="() => playAlbum(tracks)"
|
||||||
>Play</Button
|
>Play</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon-name="lucide:shuffle"
|
icon-name="lucide:shuffle"
|
||||||
@click="() => playShuffledAlbum(tracksData)"
|
@click="() => playShuffledAlbum(tracks)"
|
||||||
>Shuffle</Button
|
>Shuffle</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,11 +45,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>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<template></template>
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<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">
|
||||||
|
<NuxtImg
|
||||||
|
:src="GET_FILE(artistData.file)"
|
||||||
|
width="300"
|
||||||
|
height="300"
|
||||||
|
placeholder
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="details">
|
||||||
|
<h1>{{ artistData.name }}</h1>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<Button
|
||||||
|
icon-name="lucide:play"
|
||||||
|
@click="() => playAlbum(tracks)"
|
||||||
|
>Play</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon-name="lucide:shuffle"
|
||||||
|
@click="() => playShuffledAlbum(tracks)"
|
||||||
|
>Shuffle</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"
|
||||||
|
/>
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<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
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: false,
|
layout: false,
|
||||||
})
|
})
|
||||||
|
useHead({ title: "Sign In" })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+1
-8
@@ -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>
|
||||||
|
|||||||
@@ -1 +1,67 @@
|
|||||||
<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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1 +1,63 @@
|
|||||||
<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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+100
-1
@@ -1 +1,100 @@
|
|||||||
<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"
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+171
-3
@@ -1,7 +1,175 @@
|
|||||||
<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 { currentSong, playAlbum } = usePlayer()
|
||||||
|
const query = computed(() => {
|
||||||
|
const value = route.query.q
|
||||||
|
const firstValue = Array.isArray(value) ? value[0] : value
|
||||||
|
return (firstValue ?? "").trim()
|
||||||
|
})
|
||||||
|
const debouncedQuery = ref(query.value)
|
||||||
|
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
watch(query, (value) => {
|
||||||
|
clearTimeout(debounceTimeout)
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
debouncedQuery.value = value
|
||||||
|
debounceTimeout = undefined
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<p v-if="query && status === 'pending'" class="message" role="status">
|
||||||
|
Searching…
|
||||||
|
</p>
|
||||||
|
<p v-else-if="query && error" class="message" role="alert">
|
||||||
|
Search could not be loaded. Please try again.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="query && 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 && 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 || 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
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
@include fontSize(14px);
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+357
-32
@@ -1,6 +1,28 @@
|
|||||||
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[]>([])
|
||||||
@@ -8,6 +30,38 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
const currentSong = ref<Track | null>(null)
|
const currentSong = ref<Track | null>(null)
|
||||||
const repeat = ref(false)
|
const repeat = 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 +74,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 +357,23 @@ 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: () => {
|
||||||
|
stopBeatAnalysis("track ended")
|
||||||
|
void closeFullscreen()
|
||||||
next()
|
next()
|
||||||
stopTracking()
|
stopTracking()
|
||||||
},
|
},
|
||||||
onstop: () => {
|
onstop: () => {
|
||||||
|
stopBeatAnalysis("playback stopped")
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
stopTracking()
|
stopTracking()
|
||||||
if (navigator.mediaSession)
|
if (navigator.mediaSession)
|
||||||
@@ -100,11 +381,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)
|
||||||
@@ -195,6 +478,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,6 +568,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
provide: {
|
provide: {
|
||||||
player: {
|
player: {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
beatEnergy,
|
||||||
currentSong,
|
currentSong,
|
||||||
repeat,
|
repeat,
|
||||||
play,
|
play,
|
||||||
@@ -260,6 +584,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
playAlbumAtIndex,
|
playAlbumAtIndex,
|
||||||
getDuration,
|
getDuration,
|
||||||
secondsPlayed,
|
secondsPlayed,
|
||||||
|
refreshPosition,
|
||||||
duration,
|
duration,
|
||||||
volume,
|
volume,
|
||||||
queue,
|
queue,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
{
|
{
|
||||||
|
|||||||
+79
-42
@@ -6,14 +6,16 @@ export interface DropdownMenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DropdownMenuProps {
|
interface DropdownMenuProps {
|
||||||
items: DropdownMenuItem[]
|
items: DropdownMenuItem[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
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 menuPosition = ref({ top: 0, left: 0 })
|
||||||
|
|
||||||
const toggle = async () => {
|
const toggle = async () => {
|
||||||
open.value = !open.value
|
open.value = !open.value
|
||||||
@@ -37,16 +39,12 @@ 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) => {
|
||||||
@@ -67,6 +65,7 @@ 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(() => {
|
onBeforeMount(() => {
|
||||||
@@ -84,22 +83,38 @@ onBeforeMount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
v-show="open"
|
||||||
:key="i"
|
class="dropdown-menu"
|
||||||
class="item"
|
:class="placement"
|
||||||
@click="
|
:style="{
|
||||||
() => {
|
top: `${menuPosition.top}px`,
|
||||||
item.onSelect?.()
|
left: `${menuPosition.left}px`,
|
||||||
close()
|
}"
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<Icon :name="item.icon" class="icon" />
|
<div
|
||||||
<span>{{ item.label }}</span>
|
v-for="(group, i) in items"
|
||||||
</button>
|
:key="`group-${i}`"
|
||||||
</div>
|
class="group"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(item, i) in group"
|
||||||
|
:key="`item-${i}`"
|
||||||
|
class="item"
|
||||||
|
@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 +125,51 @@ onBeforeMount(() => {
|
|||||||
.dropdown {
|
.dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
right: 0;
|
padding: 0.5rem;
|
||||||
|
min-width: 180px;
|
||||||
|
|
||||||
min-width: 180px;
|
background-color: $color-secondary-background;
|
||||||
padding: 0.5rem;
|
border-radius: $border-radius-standard;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
background-color: $color-secondary-background;
|
display: flex;
|
||||||
border-radius: $border-radius-standard;
|
flex-direction: column;
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
|
||||||
|
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 +186,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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`
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
})
|
||||||
+21
-3
@@ -3,7 +3,28 @@ 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 },
|
||||||
|
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"],
|
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"],
|
||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
@@ -15,7 +36,4 @@ export default defineNuxtConfig({
|
|||||||
pathPrefix: true,
|
pathPrefix: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
image: {
|
|
||||||
domains: ["api.music.lucasskt.dk"],
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
Generated
+6890
-2400
File diff suppressed because it is too large.
Load diff
+35
-27
@@ -1,29 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "music-v2",
|
"name": "music-v2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"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",
|
||||||
|
"golar": "^0.1.10",
|
||||||
|
"sass-embedded": "^1.93.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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 {}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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 {}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 {}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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({})
|
||||||
|
})
|
||||||
@@ -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({})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineEventHandler, getCookie, setCookie } from "h3"
|
||||||
|
import { User } from "../../models/user"
|
||||||
|
import { getSessionModel } from "../../models/session"
|
||||||
|
import {
|
||||||
|
findAuthenticatedSession,
|
||||||
|
getSessionTtl,
|
||||||
|
REFRESH_COOKIE,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
} from "../../utils/auth"
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const activeSession = await findAuthenticatedSession(event)
|
||||||
|
if (activeSession) return { authenticated: true }
|
||||||
|
|
||||||
|
const refreshToken = getCookie(event, REFRESH_COOKIE)
|
||||||
|
if (!refreshToken) return { authenticated: false }
|
||||||
|
|
||||||
|
const user = await User.findOne({ refreshToken }).select("+refreshToken +verified")
|
||||||
|
if (!user?.verified) return { authenticated: false }
|
||||||
|
|
||||||
|
const ttl = getSessionTtl(event)
|
||||||
|
const Session = getSessionModel(ttl)
|
||||||
|
const session = await Session.create({ userId: user._id })
|
||||||
|
setCookie(event, SESSION_COOKIE, session.token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: !import.meta.dev,
|
||||||
|
maxAge: Math.floor(ttl / 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
return { authenticated: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { getCookie, readBody, setCookie, setResponseStatus } from "h3"
|
||||||
|
import { User } from "../../models/user"
|
||||||
|
import { getSessionModel } from "../../models/session"
|
||||||
|
import {
|
||||||
|
assertCookieRequestOrigin,
|
||||||
|
defineAuthenticatedEventHandler,
|
||||||
|
getSessionTtl,
|
||||||
|
REFRESH_COOKIE,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
} from "../../utils/auth"
|
||||||
|
import { getAuthValidationMessage } from "../../utils/auth-validation"
|
||||||
|
|
||||||
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event).catch(() => undefined)
|
||||||
|
const validationMessage =
|
||||||
|
body === undefined ? null : getAuthValidationMessage(body, [{ name: "refreshToken" }])
|
||||||
|
if (validationMessage) {
|
||||||
|
setResponseStatus(event, 400)
|
||||||
|
return { message: validationMessage }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyRefreshToken = typeof body?.refreshToken === "string" && body.refreshToken
|
||||||
|
const cookieRefreshToken = getCookie(event, REFRESH_COOKIE)
|
||||||
|
if (!bodyRefreshToken && cookieRefreshToken) assertCookieRequestOrigin(event)
|
||||||
|
const refreshToken = bodyRefreshToken || cookieRefreshToken
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
setResponseStatus(event, 401)
|
||||||
|
return { message: "Invalid refresh token" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ refreshToken }).select("+refreshToken")
|
||||||
|
if (!user) {
|
||||||
|
setResponseStatus(event, 401)
|
||||||
|
return { message: "Invalid refresh token" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = getSessionTtl(event)
|
||||||
|
const Session = getSessionModel(ttl)
|
||||||
|
const session = await Session.create({ userId: user._id })
|
||||||
|
const expireAt = new Date(session.createdAt.getTime() + ttl)
|
||||||
|
|
||||||
|
setCookie(event, SESSION_COOKIE, session.token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: !import.meta.dev,
|
||||||
|
maxAge: Math.floor(ttl / 1000),
|
||||||
|
})
|
||||||
|
setResponseStatus(event, 201)
|
||||||
|
|
||||||
|
return { expireAt, sessionToken: session.token }
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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: "email", email: true },
|
||||||
|
{ name: "password", minimumLength: 8 },
|
||||||
|
])
|
||||||
|
if (validationMessage) {
|
||||||
|
setResponseStatus(event, 400)
|
||||||
|
return { message: validationMessage }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ email: body.email.toLowerCase() }).select(
|
||||||
|
"+refreshToken +passwordHash",
|
||||||
|
)
|
||||||
|
let passwordMatches = false
|
||||||
|
if (user) {
|
||||||
|
try {
|
||||||
|
// Bun.password's default output is PHC Argon2id and argon2 verifies
|
||||||
|
// its encoded algorithm and parameters directly.
|
||||||
|
passwordMatches = await argon2.verify(user.passwordHash, body.password)
|
||||||
|
} catch {
|
||||||
|
// Malformed or unsupported stored hashes must not reveal account state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !passwordMatches) {
|
||||||
|
setResponseStatus(event, 400)
|
||||||
|
return { message: "The email or password is wrong" }
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(event, REFRESH_COOKIE, user.refreshToken, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: !import.meta.dev,
|
||||||
|
...(body.remember ? { maxAge: 60 * 60 * 24 * 30 } : {}),
|
||||||
|
})
|
||||||
|
setResponseStatus(event, 200)
|
||||||
|
// The long-lived refresh token is deliberately omitted from the JSON body.
|
||||||
|
return {}
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { deleteCookie } from "h3"
|
||||||
|
import {
|
||||||
|
assertCookieRequestOrigin,
|
||||||
|
defineAuthenticatedEventHandler,
|
||||||
|
REFRESH_COOKIE,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
} from "../../utils/auth"
|
||||||
|
|
||||||
|
export default defineAuthenticatedEventHandler((event) => {
|
||||||
|
assertCookieRequestOrigin(event)
|
||||||
|
deleteCookie(event, REFRESH_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
|
||||||
|
deleteCookie(event, SESSION_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { getQuery, getRouterParam } from "h3"
|
||||||
|
import mongoose, { type PipelineStage } from "mongoose"
|
||||||
|
import { Album } from "../../../models/album"
|
||||||
|
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
|
||||||
|
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
|
||||||
|
|
||||||
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
|
await requireAuthenticatedUser(event)
|
||||||
|
|
||||||
|
const id = parseMongoId(getRouterParam(event, "id"))
|
||||||
|
if ("error" in id) return validationResponse(event, id.error)
|
||||||
|
|
||||||
|
const { limit: rawLimit } = getQuery(event)
|
||||||
|
const limit = parseNumber(rawLimit, 10, 1)
|
||||||
|
if ("error" in limit) return validationResponse(event, limit.error)
|
||||||
|
|
||||||
|
const pipeline: PipelineStage[] = [
|
||||||
|
{ $match: { genre: new mongoose.Types.ObjectId(id.value) } },
|
||||||
|
{ $sample: { size: limit.value || 10 } },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "artists",
|
||||||
|
localField: "artists",
|
||||||
|
foreignField: "_id",
|
||||||
|
as: "artists",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unset: "genre" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return Album.aggregate(pipeline)
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { getQuery } from "h3"
|
||||||
|
import { Genre } from "../../models/genre"
|
||||||
|
import { parseNumber, validationResponse } from "../../utils/api-validation"
|
||||||
|
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
|
||||||
|
|
||||||
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
|
await requireAuthenticatedUser(event)
|
||||||
|
|
||||||
|
const { limit: rawLimit } = getQuery(event)
|
||||||
|
const limit = parseNumber(rawLimit, 10, 1)
|
||||||
|
if ("error" in limit) return validationResponse(event, limit.error)
|
||||||
|
|
||||||
|
const genres = await Genre.aggregate([{ $sample: { size: limit.value || 10 } }])
|
||||||
|
return genres
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { getQuery, getRouterParam } from "h3"
|
||||||
|
import mongoose, { type PipelineStage } from "mongoose"
|
||||||
|
import { Track } from "../../../models/track"
|
||||||
|
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
|
||||||
|
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
|
||||||
|
|
||||||
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
|
await requireAuthenticatedUser(event)
|
||||||
|
|
||||||
|
const id = parseMongoId(getRouterParam(event, "id"))
|
||||||
|
if ("error" in id) return validationResponse(event, id.error)
|
||||||
|
|
||||||
|
const { limit: rawLimit } = getQuery(event)
|
||||||
|
const limit = parseNumber(rawLimit, 10, 1)
|
||||||
|
if ("error" in limit) return validationResponse(event, limit.error)
|
||||||
|
|
||||||
|
const pipeline: PipelineStage[] = [
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "albums",
|
||||||
|
localField: "album",
|
||||||
|
foreignField: "_id",
|
||||||
|
as: "album",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: "$album" },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "artists",
|
||||||
|
localField: "artists",
|
||||||
|
foreignField: "_id",
|
||||||
|
as: "artists",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $match: { "album.genre": new mongoose.Types.ObjectId(id.value) } },
|
||||||
|
{ $sample: { size: limit.value || 10 } },
|
||||||
|
{ $unset: "album.artists" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return Track.aggregate(pipeline)
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineEventHandler(() => ({ status: "ok" }))
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { getQuery } from "h3"
|
||||||
|
import Fuse from "fuse.js"
|
||||||
|
import { Album } from "../models/album"
|
||||||
|
import { Artist } from "../models/artist"
|
||||||
|
import { Track } from "../models/track"
|
||||||
|
import {
|
||||||
|
parseNumber,
|
||||||
|
parseQueryString,
|
||||||
|
validationResponse,
|
||||||
|
} from "../utils/api-validation"
|
||||||
|
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../utils/auth"
|
||||||
|
|
||||||
|
const searchTypes = ["track", "album", "artist", "default"] as const
|
||||||
|
|
||||||
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
|
await requireAuthenticatedUser(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const q = parseQueryString(query.q)
|
||||||
|
if ("error" in q) return validationResponse(event, q.error)
|
||||||
|
|
||||||
|
let type = "default"
|
||||||
|
if (query.type !== undefined) {
|
||||||
|
const parsedType = parseQueryString(query.type)
|
||||||
|
if ("error" in parsedType) return validationResponse(event, parsedType.error)
|
||||||
|
if (!searchTypes.includes(parsedType.value as (typeof searchTypes)[number])) {
|
||||||
|
return validationResponse(
|
||||||
|
event,
|
||||||
|
`Invalid enum value. Expected 'track' | 'album' | 'artist' | 'default', received '${parsedType.value}'`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type = parsedType.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedLimit = parseNumber(query.limit, 9, 1)
|
||||||
|
if ("error" in parsedLimit) return validationResponse(event, parsedLimit.error)
|
||||||
|
const limit = parsedLimit.value
|
||||||
|
|
||||||
|
let results: { tracks: any[]; albums: any[]; artists: any[] } = {
|
||||||
|
tracks: [],
|
||||||
|
albums: [],
|
||||||
|
artists: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "track" || type === "default") {
|
||||||
|
const rawTracks = await Track.find({})
|
||||||
|
.populate(["album", "artists"])
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
const fuse = new Fuse(rawTracks, {
|
||||||
|
keys: ["name", "lyrics.text"],
|
||||||
|
threshold: 0.3,
|
||||||
|
distance: 100,
|
||||||
|
ignoreLocation: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
results.tracks = fuse.search(q.value).slice(0, limit).map((result) => result.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "album" || type === "default") {
|
||||||
|
const rawAlbums = await Album.find({})
|
||||||
|
.populate(["artists", "genre"])
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
const fuse = new Fuse(rawAlbums, {
|
||||||
|
keys: ["name"],
|
||||||
|
threshold: 0.3,
|
||||||
|
distance: 100,
|
||||||
|
ignoreLocation: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
results.albums = fuse.search(q.value).slice(0, limit).map((result) => result.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "artist" || type === "default") {
|
||||||
|
const rawArtists = await Artist.find({}).lean()
|
||||||
|
|
||||||
|
const fuse = new Fuse(rawArtists, {
|
||||||
|
keys: ["name"],
|
||||||
|
threshold: 0.3,
|
||||||
|
distance: 100,
|
||||||
|
ignoreLocation: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
results.artists = fuse.search(q.value).slice(0, limit).map((result) => result.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the original API's split allocation, including when type selects one category.
|
||||||
|
results = {
|
||||||
|
tracks: results.tracks.slice(0, Math.ceil(limit / 3)),
|
||||||
|
albums: results.albums.slice(0, Math.floor(limit / 3)),
|
||||||
|
artists: results.artists.slice(0, Math.floor(limit / 3)),
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getQuery } from "h3"
|
||||||
|
import { Track } from "../models/track"
|
||||||
|
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 Track.find({ _id: { $in: parsedIds.value } })
|
||||||
|
.populate({
|
||||||
|
path: "album",
|
||||||
|
select: "-artists",
|
||||||
|
populate: { path: "genre" },
|
||||||
|
})
|
||||||
|
.populate("artists")
|
||||||
|
.exec()
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getRouterParam } from "h3"
|
||||||
|
import { Track } from "../../models/track"
|
||||||
|
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 Track.findOne({ _id: parsedId.value })
|
||||||
|
.populate({
|
||||||
|
path: "album",
|
||||||
|
select: "-artists",
|
||||||
|
populate: { path: "genre" },
|
||||||
|
})
|
||||||
|
.populate("artists")
|
||||||
|
.exec()
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { getRouterParam } from "h3"
|
||||||
|
import { Track } from "../../../models/track"
|
||||||
|
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)
|
||||||
|
|
||||||
|
const track = await Track.findOne({ _id: parsedId.value }).select("lyrics").exec()
|
||||||
|
if (!track) return {}
|
||||||
|
|
||||||
|
return track.lyrics
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { setResponseStatus } from "h3"
|
||||||
|
import { User } from "#server/models/user"
|
||||||
|
import { Album } from "#server/models/album"
|
||||||
|
import { Track } from "#server/models/track"
|
||||||
|
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
|
||||||
|
|
||||||
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
|
const authenticatedUser = await requireAuthenticatedUser(event)
|
||||||
|
const user = await User.findById(authenticatedUser._id).select("savedTracks")
|
||||||
|
if (!user) {
|
||||||
|
setResponseStatus(event, 404)
|
||||||
|
return { message: "User not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.savedTracks.length) return []
|
||||||
|
|
||||||
|
const tracks = await Track.find({ _id: { $in: user.savedTracks } }).select("_id album").exec()
|
||||||
|
const albumByTrack = new Map(tracks.map((track: any) => [track._id.toString(), track.album?.toString()]))
|
||||||
|
const albumIds: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const savedTrack of user.savedTracks) {
|
||||||
|
const albumId = albumByTrack.get(savedTrack.toString())
|
||||||
|
if (albumId && !seen.has(albumId)) {
|
||||||
|
seen.add(albumId)
|
||||||
|
albumIds.push(albumId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!albumIds.length) return []
|
||||||
|
|
||||||
|
const albums = await Album.find({ _id: { $in: albumIds } }).populate("artists").populate("genre").exec()
|
||||||
|
const albumsById = new Map(albums.map((album: any) => [album._id.toString(), album]))
|
||||||
|
return albumIds.map((id) => albumsById.get(id)).filter((album) => album != null)
|
||||||
|
})
|
||||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user