Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b7fbf014 | ||
|
|
81b1581ed7 | ||
|
|
812884de1a | ||
|
|
27d92c55e5 | ||
|
|
cb6fa29e4b | ||
|
|
8f6f9ef78e | ||
|
|
ce0bf50223 | ||
|
|
1fce02ad84 | ||
|
|
f202f07585 | ||
|
|
ae52b1754b | ||
|
|
4c883c60a4 | ||
|
|
4ab58c8400 | ||
|
|
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 |
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
|
||||||
@@ -53,4 +53,4 @@ Pull requests should include:
|
|||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
|
|
||||||
The app reads `VITE_PUBLIC_BACKEND` from `.env` and expects remote assets from `api.music.lucasskt.dk`. You can get the API schema from `https://api.music.lucasskt.dk/schema`. Do not commit secrets or machine-specific values.
|
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.
|
|
||||||
+42
@@ -1,11 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { currentSong, isPlaying } = usePlayer()
|
||||||
|
const debugView = useCookie("DEBUG_VIEW")
|
||||||
|
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
meta: [
|
||||||
|
{ name: "theme-color", content: "#09090b" },
|
||||||
|
{ name: "viewport", content: "width=device-width, initial-scale=1, viewport-fit=cover" },
|
||||||
|
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
||||||
|
{ name: "apple-mobile-web-app-status-bar-style", content: "black-translucent" },
|
||||||
|
],
|
||||||
|
link: [{ rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead(
|
||||||
|
computed(() => ({
|
||||||
|
titleTemplate:
|
||||||
|
currentSong.value && isPlaying.value
|
||||||
|
? `${currentSong.value.name} - Now Playing - Music`
|
||||||
|
: (titleChunk) => `${titleChunk || "Home"} - Music`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<NuxtPwaManifest />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
<Player />
|
<Player />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
<div v-if="debugViewEnabled" class="debug-view-disclaimer">
|
||||||
|
Debug view: everything may not align correctly on different screens.
|
||||||
|
It's made for 1920x1080
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use "styles/global.scss";
|
@use "styles/global.scss";
|
||||||
|
|
||||||
|
.debug-view-disclaimer {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -47,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>>()
|
||||||
@@ -128,13 +130,13 @@ 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]"
|
:model-value="localItem[field.key]"
|
||||||
@change="(v) => (localItem[field.key] = v)"
|
@change="(v) => (localItem[field.key] = v)"
|
||||||
/>
|
/>
|
||||||
@@ -142,7 +144,7 @@ watch(
|
|||||||
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"
|
||||||
@@ -150,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>
|
||||||
@@ -169,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>
|
||||||
@@ -4,13 +4,14 @@ import type { Album } from "@/utils/types"
|
|||||||
type AlbumProps = Omit<Omit<Album, "genre">, "artists"> & {
|
type AlbumProps = Omit<Omit<Album, "genre">, "artists"> & {
|
||||||
artists: Array<Omit<Artist, "file">>
|
artists: Array<Omit<Artist, "file">>
|
||||||
genre?: Genre
|
genre?: Genre
|
||||||
|
carousel?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<AlbumProps>()
|
const props = defineProps<AlbumProps>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="card">
|
<article class="card" :class="{ carousel: props.carousel }">
|
||||||
<NuxtLink :to="`/album/${props._id}`">
|
<NuxtLink :to="`/album/${props._id}`">
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
:src="props.file"
|
:src="props.file"
|
||||||
@@ -48,4 +49,16 @@ const props = defineProps<AlbumProps>()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.card.carousel {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
flex: 0 0 calc(45.455% - 9.091px);
|
||||||
|
|
||||||
|
& > a > img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,27 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { data: genresData } = await useApiFetch<Genre[]>("/genres/random")
|
import { GET_FILE } from "@/utils/file"
|
||||||
|
|
||||||
const data = await Promise.allSettled(
|
const cacheKey = "album-slider-data"
|
||||||
(genresData.value ?? []).map(async (genre: Genre) => {
|
const data = ref<{ genre: Genre; albums: Album[] }[]>([])
|
||||||
const res = await cfetch(`/genres/albums/${genre._id}`)
|
|
||||||
|
|
||||||
return {
|
onMounted(async () => {
|
||||||
genre,
|
const cached = sessionStorage.getItem(cacheKey)
|
||||||
albums: res.data,
|
if (cached) {
|
||||||
|
try {
|
||||||
|
data.value = JSON.parse(cached)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(cacheKey)
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
).then((results) =>
|
|
||||||
results
|
const genres = await cfetch<Genre[]>("/genres/random")
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
(genres ?? []).map(async (genre) => ({
|
||||||
|
genre,
|
||||||
|
albums: await cfetch<Album[]>(`/genres/albums/${genre._id}`),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -32,9 +46,10 @@ const data = await Promise.allSettled(
|
|||||||
v-for="album in item.albums"
|
v-for="album in item.albums"
|
||||||
:key="album._id"
|
:key="album._id"
|
||||||
:name="album.name"
|
:name="album.name"
|
||||||
:file="`${BACKEND_SERVE}${album.file}`"
|
:file="GET_FILE(album.file)"
|
||||||
:artists="album.artists"
|
:artists="album.artists"
|
||||||
:_id="album._id"
|
:_id="album._id"
|
||||||
|
carousel
|
||||||
/>
|
/>
|
||||||
</Slider>
|
</Slider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,11 +1,128 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { GET_FILE } from "@/utils/file"
|
||||||
|
|
||||||
const { fullscreen, close: closeFullscreen } = useFullscreen()
|
const { fullscreen, close: closeFullscreen } = useFullscreen()
|
||||||
const song = useSong()
|
const song = useSong()
|
||||||
|
|
||||||
const mouseMovedRecently = ref(true)
|
const mouseMovedRecently = ref(true)
|
||||||
|
const mobileView = ref(false)
|
||||||
|
const showMobileLyrics = ref(false)
|
||||||
|
const queueOpen = useState<boolean>("queueOpen", () => false)
|
||||||
|
const dragOffset = ref(0)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
let dragPointerId: number | null = null
|
||||||
|
let dragStartX = 0
|
||||||
|
let dragStartY = 0
|
||||||
|
let dragStartedAt = 0
|
||||||
|
let clickSuppressionTimer: number | undefined
|
||||||
|
let closeAnimationTimer: number | undefined
|
||||||
|
|
||||||
|
function suppressClickThrough(event: MouseEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
if (clickSuppressionTimer !== undefined)
|
||||||
|
window.clearTimeout(clickSuppressionTimer)
|
||||||
|
clickSuppressionTimer = undefined
|
||||||
|
document.removeEventListener("click", suppressClickThrough, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(event: PointerEvent) {
|
||||||
|
if (!mobileView.value || !event.isPrimary) return
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) return
|
||||||
|
if (event.clientY > window.innerHeight * 0.16) return
|
||||||
|
|
||||||
|
const target = event.target
|
||||||
|
if (
|
||||||
|
target instanceof Element &&
|
||||||
|
target.closest(
|
||||||
|
"button, a, input, [role='slider'], .slider, .btns, .volume-slider, .mobile-tabs",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
dragPointerId = event.pointerId
|
||||||
|
dragStartX = event.clientX
|
||||||
|
dragStartY = event.clientY
|
||||||
|
dragStartedAt = performance.now()
|
||||||
|
const element = event.currentTarget as HTMLElement
|
||||||
|
element.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDrag(event: PointerEvent) {
|
||||||
|
if (dragPointerId !== event.pointerId) return
|
||||||
|
|
||||||
|
const deltaX = event.clientX - dragStartX
|
||||||
|
const deltaY = event.clientY - dragStartY
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY)) return
|
||||||
|
if (deltaY <= 0) {
|
||||||
|
dragOffset.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
isDragging.value = true
|
||||||
|
dragOffset.value = deltaY
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(event: PointerEvent) {
|
||||||
|
if (dragPointerId !== event.pointerId) return
|
||||||
|
|
||||||
|
const deltaY = Math.max(0, event.clientY - dragStartY)
|
||||||
|
const deltaX = Math.abs(event.clientX - dragStartX)
|
||||||
|
const elapsed = performance.now() - dragStartedAt
|
||||||
|
const closeThreshold = Math.max(96, window.innerHeight * 0.16)
|
||||||
|
const shouldClose =
|
||||||
|
deltaX <= deltaY &&
|
||||||
|
(deltaY >= closeThreshold || (deltaY >= 56 && elapsed < 220))
|
||||||
|
|
||||||
|
dragPointerId = null
|
||||||
|
if (shouldClose) {
|
||||||
|
document.addEventListener("click", suppressClickThrough, true)
|
||||||
|
clickSuppressionTimer = window.setTimeout(() => {
|
||||||
|
document.removeEventListener("click", suppressClickThrough, true)
|
||||||
|
clickSuppressionTimer = undefined
|
||||||
|
}, 400)
|
||||||
|
isDragging.value = false
|
||||||
|
dragOffset.value = window.innerHeight
|
||||||
|
if (closeAnimationTimer !== undefined)
|
||||||
|
window.clearTimeout(closeAnimationTimer)
|
||||||
|
closeAnimationTimer = window.setTimeout(finishCloseAnimation, 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging.value = false
|
||||||
|
dragOffset.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishCloseAnimation() {
|
||||||
|
if (closeAnimationTimer !== undefined)
|
||||||
|
window.clearTimeout(closeAnimationTimer)
|
||||||
|
closeAnimationTimer = undefined
|
||||||
|
closeFullscreen()
|
||||||
|
dragOffset.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFullscreenTransitionEnd(event: TransitionEvent) {
|
||||||
|
if (
|
||||||
|
closeAnimationTimer !== undefined &&
|
||||||
|
event.target === event.currentTarget &&
|
||||||
|
event.propertyName === "transform"
|
||||||
|
)
|
||||||
|
finishCloseAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDrag(event: PointerEvent) {
|
||||||
|
if (dragPointerId !== event.pointerId) return
|
||||||
|
dragPointerId = null
|
||||||
|
isDragging.value = false
|
||||||
|
dragOffset.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
let timeoutId: NodeJS.Timeout
|
let timeoutId: NodeJS.Timeout
|
||||||
|
const mobileQuery = window.matchMedia("(max-width: 767px)")
|
||||||
|
const updateMobileView = () => (mobileView.value = mobileQuery.matches)
|
||||||
|
updateMobileView()
|
||||||
|
mobileQuery.addEventListener("change", updateMobileView)
|
||||||
|
|
||||||
const handleMouseMove = () => {
|
const handleMouseMove = () => {
|
||||||
mouseMovedRecently.value = true
|
mouseMovedRecently.value = true
|
||||||
@@ -15,11 +132,24 @@ onMounted(() => {
|
|||||||
mouseMovedRecently.value = false
|
mouseMovedRecently.value = false
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && fullscreen.value) {
|
||||||
|
closeFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("mousemove", handleMouseMove)
|
window.addEventListener("mousemove", handleMouseMove)
|
||||||
|
window.addEventListener("keydown", handleKeydown)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener("mousemove", handleMouseMove)
|
window.removeEventListener("mousemove", handleMouseMove)
|
||||||
|
window.removeEventListener("keydown", handleKeydown)
|
||||||
|
mobileQuery.removeEventListener("change", updateMobileView)
|
||||||
|
document.removeEventListener("click", suppressClickThrough, true)
|
||||||
|
if (clickSuppressionTimer !== undefined)
|
||||||
|
window.clearTimeout(clickSuppressionTimer)
|
||||||
|
if (closeAnimationTimer !== undefined)
|
||||||
|
window.clearTimeout(closeAnimationTimer)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -33,32 +163,63 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="fullscreen"
|
class="fullscreen"
|
||||||
:class="{ showCursor: mouseMovedRecently }"
|
:class="{ showCursor: mouseMovedRecently, dragging: isDragging }"
|
||||||
|
:style="{ '--drag-offset': `${dragOffset}px` }"
|
||||||
|
@transitionend="onFullscreenTransitionEnd"
|
||||||
v-show="fullscreen"
|
v-show="fullscreen"
|
||||||
>
|
>
|
||||||
<template v-if="song">
|
<template v-if="song">
|
||||||
<NuxtImg
|
<PlayerFullscreenBackgroundWash
|
||||||
:src="GET_FILE(song.album.file)"
|
:src="GET_FILE(song.album.file)"
|
||||||
:alt="song.name"
|
:active="fullscreen"
|
||||||
width="500"
|
|
||||||
height="500"
|
|
||||||
class="background"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section class="screen-container" v-if="song">
|
<section
|
||||||
|
class="screen-container"
|
||||||
|
v-if="song"
|
||||||
|
@pointerdown="startDrag"
|
||||||
|
@pointermove="moveDrag"
|
||||||
|
@pointerup="endDrag"
|
||||||
|
@pointercancel="cancelDrag"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
v-if="!mobileView"
|
||||||
name="lucide:x"
|
name="lucide:x"
|
||||||
class="close-btn"
|
class="close-btn"
|
||||||
:class="{ show: mouseMovedRecently }"
|
:class="{ show: mouseMovedRecently }"
|
||||||
@click="closeFullscreen()"
|
@click="closeFullscreen()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="content-container" :class="{ lyrics: song.lyrics?.text }">
|
<div
|
||||||
<PlayerFullscreenTrackDisplay :show-buttons="mouseMovedRecently" />
|
class="content-container"
|
||||||
|
:class="{ lyrics: song.lyrics?.text }"
|
||||||
|
>
|
||||||
|
<PlayerFullscreenTrackDisplay
|
||||||
|
:show-buttons="mouseMovedRecently"
|
||||||
|
/>
|
||||||
|
<nav
|
||||||
|
v-if="mobileView"
|
||||||
|
class="mobile-tabs"
|
||||||
|
aria-label="Player views"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:class="{ selected: showMobileLyrics }"
|
||||||
|
aria-label="Toggle lyrics"
|
||||||
|
@click="showMobileLyrics = !showMobileLyrics"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:message-square-quote" />
|
||||||
|
</button>
|
||||||
|
<button aria-label="Open queue" @click="queueOpen = true">
|
||||||
|
<Icon name="lucide:list" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
<PlayerFullscreenLyricsDisplay
|
<PlayerFullscreenLyricsDisplay
|
||||||
|
v-if="
|
||||||
|
!mobileView || (showMobileLyrics && song.lyrics?.text)
|
||||||
|
"
|
||||||
:animation-duration="0.2"
|
:animation-duration="0.2"
|
||||||
:offset="150"
|
:offset="mobileView ? 100 : 250"
|
||||||
:show-scroll-bar="mouseMovedRecently"
|
:show-scroll-bar="mouseMovedRecently"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,12 +230,9 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.fullscreen {
|
.fullscreen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
|
||||||
|
|
||||||
width: 200vw;
|
|
||||||
height: 200vh;
|
|
||||||
z-index: 150;
|
z-index: 150;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
cursor: none;
|
cursor: none;
|
||||||
|
|
||||||
@@ -83,26 +241,11 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.background {
|
|
||||||
position: absolute;
|
|
||||||
top: -25%;
|
|
||||||
left: -25%;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
filter: blur(40px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.screen-container {
|
.screen-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
|
||||||
|
|
||||||
width: 100vw;
|
background-color: rgba(0, 0, 0, 0.34);
|
||||||
height: 100vh;
|
|
||||||
|
|
||||||
background-color: rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
@@ -120,6 +263,10 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.content-container {
|
.content-container {
|
||||||
/* TODO: A lot of magic numbers... should be variables */
|
/* TODO: A lot of magic numbers... should be variables */
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
@@ -150,4 +297,74 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.screen-container {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen {
|
||||||
|
transition: transform 280ms ease-out;
|
||||||
|
transform: translate3d(0, var(--drag-offset, 0px), 0);
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
top: calc(0.75rem + env(safe-area-inset-top));
|
||||||
|
right: 0.75rem;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0.6rem;
|
||||||
|
opacity: 1;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
max-width: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: max(11vh, calc(2.75rem + env(safe-area-inset-top))) 1.5rem
|
||||||
|
calc(env(safe-area-inset-bottom));
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
max-height: none;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.lyrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,39 +7,40 @@ const { animationDuration, offset, showScrollBar } = defineProps<{
|
|||||||
|
|
||||||
const song = useSong()
|
const song = useSong()
|
||||||
const { fullscreen } = useFullscreen()
|
const { fullscreen } = useFullscreen()
|
||||||
const { secondsPlayed } = usePlayer()
|
const { secondsPlayed, refreshPosition } = usePlayer()
|
||||||
|
const debugView = useCookie("DEBUG_VIEW")
|
||||||
|
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
|
||||||
|
|
||||||
const parsedLyrics = computed(() => {
|
const parsedLyrics = computed(() => {
|
||||||
if (!song.value?.lyrics?.synced) return
|
if (!song.value?.lyrics?.synced) return []
|
||||||
|
|
||||||
if (song.value.lyrics.synced) return parseLyrics(song.value.lyrics.text)
|
return parseLyrics(song.value.lyrics.text)
|
||||||
})
|
})
|
||||||
|
const hasParsedLyrics = computed(() => parsedLyrics.value.length > 0)
|
||||||
|
|
||||||
const lyricsRefs = useTemplateRef("lyrics")
|
const findLyricIndex = (time: number) => {
|
||||||
|
if (!hasParsedLyrics.value) return -1
|
||||||
|
|
||||||
const activeIndex = computed(() => {
|
const index = parsedLyrics.value.findIndex(
|
||||||
if (!parsedLyrics.value) return -1
|
|
||||||
|
|
||||||
const time = secondsPlayed.value
|
|
||||||
|
|
||||||
return parsedLyrics.value.findIndex(
|
|
||||||
(lyric, i) =>
|
(lyric, i) =>
|
||||||
lyric.time <= time &&
|
lyric.time <= time &&
|
||||||
(!parsedLyrics.value![i + 1] ||
|
(!parsedLyrics.value![i + 1] ||
|
||||||
parsedLyrics.value![i + 1]!.time > time),
|
parsedLyrics.value![i + 1]!.time > time),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = computed(() => {
|
||||||
|
return findLyricIndex(secondsPlayed.value)
|
||||||
})
|
})
|
||||||
const scrollActiveIndex = computed(() => {
|
const scrollActiveIndex = computed(() => {
|
||||||
if (!parsedLyrics.value) return -1
|
if (!hasParsedLyrics.value) return -1
|
||||||
|
|
||||||
const time = secondsPlayed.value + animationDuration
|
const time = secondsPlayed.value + animationDuration
|
||||||
|
|
||||||
let index = parsedLyrics.value.findIndex(
|
let index = findLyricIndex(time)
|
||||||
(lyric, i) =>
|
if (index === -1 && time < parsedLyrics.value[0]!.time) index = 0
|
||||||
lyric.time <= time &&
|
|
||||||
(!parsedLyrics.value![i + 1] ||
|
|
||||||
parsedLyrics.value![i + 1]!.time > time),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (index >= parsedLyrics.value.length - 1)
|
if (index >= parsedLyrics.value.length - 1)
|
||||||
index = parsedLyrics.value.length - 2
|
index = parsedLyrics.value.length - 2
|
||||||
@@ -49,47 +50,91 @@ const scrollActiveIndex = computed(() => {
|
|||||||
const height = ref<number>()
|
const height = ref<number>()
|
||||||
const top = ref(0)
|
const top = ref(0)
|
||||||
const syncedHeight = ref<number>()
|
const syncedHeight = ref<number>()
|
||||||
|
const mobileView = ref(false)
|
||||||
|
const layoutVersion = ref(0)
|
||||||
|
const lyricOffsets = ref<number[]>([])
|
||||||
|
const topFadeExtension = 75
|
||||||
|
|
||||||
const updateLyricsLayout = () => {
|
const updateLyricsLayout = () => {
|
||||||
const trackDisplay = document.querySelector(".track-display")
|
const trackDisplay = document.querySelector(".track-display")
|
||||||
|
|
||||||
if (!trackDisplay) return
|
if (trackDisplay) {
|
||||||
|
|
||||||
height.value = trackDisplay.clientHeight
|
height.value = trackDisplay.clientHeight
|
||||||
top.value = trackDisplay.getBoundingClientRect().top
|
top.value = trackDisplay.getBoundingClientRect().top
|
||||||
syncedHeight.value = window.innerHeight - top.value - 100
|
syncedHeight.value = mobileView.value
|
||||||
|
? Math.max(
|
||||||
|
180,
|
||||||
|
window.innerHeight -
|
||||||
|
trackDisplay.getBoundingClientRect().bottom -
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
: window.innerHeight - top.value - 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = containerRef.value?.children
|
||||||
|
if (!lines) {
|
||||||
|
lyricOffsets.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let accumulatedHeight = 0
|
||||||
|
lyricOffsets.value = Array.from(lines, (line) => {
|
||||||
|
const offset = accumulatedHeight
|
||||||
|
accumulatedHeight += line.getBoundingClientRect().height
|
||||||
|
return offset
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
fullscreen,
|
fullscreen,
|
||||||
async () => {
|
async () => {
|
||||||
|
if (fullscreen.value) refreshPosition()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
updateLyricsLayout()
|
updateLyricsLayout()
|
||||||
|
if (fullscreen.value) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateLyricsLayout()
|
||||||
|
layoutVersion.value++
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => window.addEventListener("resize", updateLyricsLayout))
|
onMounted(() => {
|
||||||
onUnmounted(() => window.removeEventListener("resize", updateLyricsLayout))
|
const mobileQuery = window.matchMedia("(max-width: 767px)")
|
||||||
|
const updateMobileView = () => {
|
||||||
const transformY = computed(() => {
|
mobileView.value = mobileQuery.matches
|
||||||
if (activeIndex.value < 0) return 0
|
updateLyricsLayout()
|
||||||
|
|
||||||
let accumulatedHeight = 0
|
|
||||||
|
|
||||||
if (!lyricsRefs.value) return 0
|
|
||||||
|
|
||||||
for (let i = 0; i < scrollActiveIndex.value; i++) {
|
|
||||||
const el = lyricsRefs.value[i]
|
|
||||||
if (el) accumulatedHeight += el.getBoundingClientRect().height
|
|
||||||
}
|
}
|
||||||
|
updateMobileView()
|
||||||
return -accumulatedHeight
|
window.addEventListener("resize", updateLyricsLayout)
|
||||||
|
mobileQuery.addEventListener("change", updateMobileView)
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", updateLyricsLayout)
|
||||||
|
mobileQuery.removeEventListener("change", updateMobileView)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const containerRef = useTemplateRef("container-ref")
|
const containerRef = useTemplateRef("container-ref")
|
||||||
const outerRef = useTemplateRef("outer-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 manualOffset = ref(0)
|
||||||
const isScrolling = ref(false)
|
const isScrolling = ref(false)
|
||||||
|
|
||||||
@@ -99,7 +144,7 @@ const onWheel = (event: WheelEvent) => {
|
|||||||
if (!containerRef.value || !outerRef.value) return
|
if (!containerRef.value || !outerRef.value) return
|
||||||
|
|
||||||
if (!isScrolling.value) {
|
if (!isScrolling.value) {
|
||||||
manualOffset.value = transformY.value + offset
|
manualOffset.value = finalTransform.value
|
||||||
}
|
}
|
||||||
|
|
||||||
isScrolling.value = true
|
isScrolling.value = true
|
||||||
@@ -109,13 +154,12 @@ const onWheel = (event: WheelEvent) => {
|
|||||||
|
|
||||||
manualOffset.value -= delta
|
manualOffset.value -= delta
|
||||||
|
|
||||||
const maxTranslate = offset
|
const maxTranslate = finalTransform.value + offset
|
||||||
const lowerViewportLimit = Math.max(
|
const lowerViewportLimit = Math.max(
|
||||||
outerRef.value.offsetHeight,
|
outerRef.value.offsetHeight,
|
||||||
window.innerHeight - 300,
|
window.innerHeight - 300,
|
||||||
)
|
)
|
||||||
const minTranslate =
|
const minTranslate = -containerRef.value.scrollHeight + lowerViewportLimit
|
||||||
-containerRef.value.scrollHeight + lowerViewportLimit
|
|
||||||
|
|
||||||
manualOffset.value = Math.min(
|
manualOffset.value = Math.min(
|
||||||
maxTranslate,
|
maxTranslate,
|
||||||
@@ -134,18 +178,34 @@ const onLyricClick = (time: number) => {
|
|||||||
const finalTransform = computed(() => {
|
const finalTransform = computed(() => {
|
||||||
if (isScrolling.value) return manualOffset.value
|
if (isScrolling.value) return manualOffset.value
|
||||||
|
|
||||||
return transformY.value + offset
|
return transformY.value + offset + topFadeExtension
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="debugViewEnabled"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '455px',
|
||||||
|
width: '100px',
|
||||||
|
height: '1px',
|
||||||
|
backgroundColor: 'red',
|
||||||
|
right: '900px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transform: 'translateY(20px)',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
ref="outer-ref"
|
ref="outer-ref"
|
||||||
v-if="song && song.lyrics && song.lyrics.synced"
|
v-if="song && song.lyrics && song.lyrics.synced && hasParsedLyrics"
|
||||||
class="synced-lyrics-container"
|
class="synced-lyrics-container"
|
||||||
:style="{
|
:style="{
|
||||||
height: `${syncedHeight}px`,
|
height: `${(syncedHeight ?? 0) + topFadeExtension}px`,
|
||||||
marginTop: `${top}px`,
|
marginTop: mobileView ? '0px' : `${top - topFadeExtension}px`,
|
||||||
}"
|
}"
|
||||||
@wheel.prevent="onWheel"
|
@wheel.prevent="onWheel"
|
||||||
>
|
>
|
||||||
@@ -160,8 +220,10 @@ const finalTransform = computed(() => {
|
|||||||
<p
|
<p
|
||||||
v-for="(lyric, i) in parsedLyrics"
|
v-for="(lyric, i) in parsedLyrics"
|
||||||
:key="lyric.time"
|
:key="lyric.time"
|
||||||
:class="{ active: i == scrollActiveIndex }"
|
:class="{
|
||||||
ref="lyrics"
|
active: i == activeIndex,
|
||||||
|
past: i < activeIndex && !isScrolling,
|
||||||
|
}"
|
||||||
@click="onLyricClick(lyric.time)"
|
@click="onLyricClick(lyric.time)"
|
||||||
>
|
>
|
||||||
{{ lyric.text }}
|
{{ lyric.text }}
|
||||||
@@ -210,6 +272,7 @@ const finalTransform = computed(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: white;
|
color: white;
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
margin: 0;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
transition:
|
transition:
|
||||||
opacity 0.1s ease,
|
opacity 0.1s ease,
|
||||||
@@ -226,6 +289,18 @@ const finalTransform = computed(() => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: initial;
|
filter: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.past {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(1.5px);
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(1.5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,4 +326,26 @@ const finalTransform = computed(() => {
|
|||||||
background-color: $color-secondary-background;
|
background-color: $color-secondary-background;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.synced-lyrics-container {
|
||||||
|
align-self: stretch;
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0%,
|
||||||
|
#000 12%,
|
||||||
|
#000 calc(100% - 70px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.synced-lyrics > p {
|
||||||
|
@include fontSize(30px);
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { GET_FILE } from "@/utils/file"
|
||||||
|
|
||||||
const { showButtons } = defineProps<{ showButtons: boolean }>()
|
const { showButtons } = defineProps<{ showButtons: boolean }>()
|
||||||
|
|
||||||
const song = useSong()
|
const song = useSong()
|
||||||
@@ -10,6 +12,8 @@ const {
|
|||||||
prev,
|
prev,
|
||||||
shuffle,
|
shuffle,
|
||||||
repeat,
|
repeat,
|
||||||
|
repeatOnce,
|
||||||
|
cycleRepeat,
|
||||||
secondsPlayed,
|
secondsPlayed,
|
||||||
duration,
|
duration,
|
||||||
volume,
|
volume,
|
||||||
@@ -36,10 +40,17 @@ const durationFormatted = computed(() =>
|
|||||||
height="512"
|
height="512"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="track-row">
|
||||||
<div class="track">
|
<div class="track">
|
||||||
<p class="name">{{ song.name }}</p>
|
<p class="name">{{ song.name }}</p>
|
||||||
<ArtistName :artists="song.artists" />
|
<ArtistName :artists="song.artists" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="track-actions">
|
||||||
|
<button aria-label="More track options">
|
||||||
|
<Icon name="lucide:ellipsis" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="slider">
|
<div class="slider">
|
||||||
<RangeSlider
|
<RangeSlider
|
||||||
@@ -62,9 +73,9 @@ const durationFormatted = computed(() =>
|
|||||||
<Icon name="lucide:play" v-else @click="play()" />
|
<Icon name="lucide:play" v-else @click="play()" />
|
||||||
<Icon name="lucide:skip-forward" @click="next()" />
|
<Icon name="lucide:skip-forward" @click="next()" />
|
||||||
<Icon
|
<Icon
|
||||||
name="lucide:repeat"
|
:name="repeatOnce ? 'lucide:repeat-1' : 'lucide:repeat'"
|
||||||
:class="{ repeating: repeat }"
|
:class="{ repeating: repeat || repeatOnce }"
|
||||||
@click="repeat = !repeat"
|
@click="cycleRepeat()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,6 +129,31 @@ const durationFormatted = computed(() =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.track-row,
|
||||||
|
.track-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-actions {
|
||||||
|
display: none;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.slider {
|
.slider {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -178,4 +214,38 @@ const durationFormatted = computed(() =>
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.track-display {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btns {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
|
||||||
|
& > :first-child,
|
||||||
|
& > :last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
margin-inline: 0;
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -79,6 +79,15 @@ const playAt = (index: number) =>
|
|||||||
queueRows.value.map((row) => row.track),
|
queueRows.value.map((row) => row.track),
|
||||||
index,
|
index,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const moveRow = (index: number, direction: -1 | 1) => {
|
||||||
|
const destination = index + direction
|
||||||
|
if (destination < 0 || destination >= queueRows.value.length) return
|
||||||
|
|
||||||
|
const [row] = queueRows.value.splice(index, 1)
|
||||||
|
queueRows.value.splice(destination, 0, row!)
|
||||||
|
queue.value = queueRows.value.map((item) => item.track)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -118,6 +127,7 @@ const playAt = (index: number) =>
|
|||||||
@drop="finishDrag"
|
@drop="finishDrag"
|
||||||
@dragend="cancelDrag"
|
@dragend="cancelDrag"
|
||||||
>
|
>
|
||||||
|
<div class="queue-row">
|
||||||
<TrackRow
|
<TrackRow
|
||||||
:track="row.track"
|
:track="row.track"
|
||||||
:index="index"
|
:index="index"
|
||||||
@@ -126,6 +136,29 @@ const playAt = (index: number) =>
|
|||||||
class="queue-track"
|
class="queue-track"
|
||||||
@play-album-at-index="playAt(index)"
|
@play-album-at-index="playAt(index)"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
class="queue-order-actions"
|
||||||
|
role="group"
|
||||||
|
:aria-label="`Reorder ${row.track.name}`"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Move ${row.track.name} up in queue`"
|
||||||
|
:disabled="index === 0"
|
||||||
|
@click="moveRow(index, -1)"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:chevron-up" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Move ${row.track.name} down in queue`"
|
||||||
|
:disabled="index === queueRows.length - 1"
|
||||||
|
@click="moveRow(index, 1)"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:chevron-down" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -210,6 +243,14 @@ const playAt = (index: number) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-row {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-order-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -235,4 +276,86 @@ const playAt = (index: number) =>
|
|||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.queue-root {
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
inset-block: 18dvh 0;
|
||||||
|
inset-inline: 0;
|
||||||
|
inline-size: 100%;
|
||||||
|
border-inline-start: 0;
|
||||||
|
border-top: 1px solid $color-border;
|
||||||
|
border-radius: 1rem 1rem 0 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-track {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row :deep(.queue-track > :nth-last-child(-n + 2)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-order-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.875rem;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
color: rgb(255 255 255 / 30%);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-enter-active,
|
||||||
|
.queue-leave-active {
|
||||||
|
.drawer {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-enter-from,
|
||||||
|
.queue-leave-to {
|
||||||
|
.drawer {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+140
-15
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
|
import { GET_FILE } from "@/utils/file"
|
||||||
|
|
||||||
const widthFixerRef = useTemplateRef("width-fixer-ref")
|
const widthFixerRef = useTemplateRef("width-fixer-ref")
|
||||||
const playerRef = useTemplateRef("player-ref")
|
const playerRef = useTemplateRef("player-ref")
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ const {
|
|||||||
prev,
|
prev,
|
||||||
shuffle,
|
shuffle,
|
||||||
repeat,
|
repeat,
|
||||||
|
repeatOnce,
|
||||||
|
cycleRepeat,
|
||||||
secondsPlayed,
|
secondsPlayed,
|
||||||
duration,
|
duration,
|
||||||
volume,
|
volume,
|
||||||
@@ -39,12 +43,13 @@ const durationFormatted = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const { open: openFullscreen } = useFullscreen()
|
const { open: openFullscreen } = useFullscreen()
|
||||||
const queueOpen = ref(false)
|
const queueOpen = useState<boolean>("queueOpen", () => false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="width-fixer" ref="width-fixer-ref"></div>
|
<div id="width-fixer" ref="width-fixer-ref"></div>
|
||||||
<article class="player" ref="player-ref" :class="{ active: song }">
|
<article class="player" ref="player-ref" :class="{ active: song }">
|
||||||
|
<Icon name="lucide:repeat-1" class="icon-preload" aria-hidden="true" />
|
||||||
<template v-if="song">
|
<template v-if="song">
|
||||||
<div class="track">
|
<div class="track">
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
@@ -61,20 +66,50 @@ const queueOpen = ref(false)
|
|||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<Icon name="lucide:shuffle" @click="shuffle()" />
|
<button
|
||||||
<Icon name="lucide:skip-back" @click="prev()" />
|
class="icon-button"
|
||||||
|
aria-label="Shuffle queue"
|
||||||
|
@click="shuffle()"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:shuffle" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
aria-label="Previous track"
|
||||||
|
@click="prev()"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:skip-back" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
:aria-label="isPlaying ? 'Pause' : 'Play'"
|
||||||
|
@click="isPlaying ? pause() : play()"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="lucide:pause"
|
:name="isPlaying ? 'lucide:pause' : 'lucide:play'"
|
||||||
v-if="isPlaying"
|
aria-hidden="true"
|
||||||
@click="pause()"
|
|
||||||
/>
|
/>
|
||||||
<Icon name="lucide:play" v-else @click="play()" />
|
</button>
|
||||||
<Icon name="lucide:skip-forward" @click="next()" />
|
<button class="icon-button" aria-label="Next track" @click="next()">
|
||||||
|
<Icon name="lucide:skip-forward" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
:class="{ repeating: repeat || repeatOnce }"
|
||||||
|
:aria-label="
|
||||||
|
repeatOnce
|
||||||
|
? 'Repeat current track'
|
||||||
|
: repeat
|
||||||
|
? 'Repeat queue'
|
||||||
|
: 'Repeat off'
|
||||||
|
"
|
||||||
|
@click="cycleRepeat()"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="lucide:repeat"
|
:name="repeatOnce ? 'lucide:repeat-1' : 'lucide:repeat'"
|
||||||
:class="{ repeating: repeat }"
|
aria-hidden="true"
|
||||||
@click="repeat = !repeat"
|
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="slider">
|
<div class="slider">
|
||||||
<p>{{ secondsPlayedFormatted }}</p>
|
<p>{{ secondsPlayedFormatted }}</p>
|
||||||
@@ -103,11 +138,20 @@ const queueOpen = ref(false)
|
|||||||
:step="0.01"
|
:step="0.01"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Icon
|
<button
|
||||||
name="lucide:list"
|
class="icon-button"
|
||||||
|
aria-label="Open queue"
|
||||||
@click="queueOpen = true"
|
@click="queueOpen = true"
|
||||||
/>
|
>
|
||||||
<Icon name="lucide:expand" @click="openFullscreen()" />
|
<Icon name="lucide:list" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
aria-label="Open fullscreen player"
|
||||||
|
@click="openFullscreen()"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:expand" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</article>
|
</article>
|
||||||
@@ -119,6 +163,10 @@ const queueOpen = ref(false)
|
|||||||
@use "@/styles/variables" as *;
|
@use "@/styles/variables" as *;
|
||||||
@use "@/styles/util" as *;
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.icon-preload {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
#width-fixer {
|
#width-fixer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -152,6 +200,17 @@ const queueOpen = ref(false)
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.track,
|
.track,
|
||||||
@@ -232,4 +291,70 @@ const queueOpen = ref(false)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#width-fixer {
|
||||||
|
width: calc(100% + 1rem);
|
||||||
|
margin-inline: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player {
|
||||||
|
bottom: calc(4.5rem + env(safe-area-inset-bottom));
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
margin-inline: -0.5rem;
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
background-color: rgba(26, 32, 44, 0.96);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.6rem;
|
||||||
|
|
||||||
|
.details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
width: auto;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.slider,
|
||||||
|
.btns > :not(:nth-child(3)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btns {
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-btns {
|
||||||
|
width: auto;
|
||||||
|
gap: 0.1rem;
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
padding: 0.625rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .icon-button {
|
||||||
|
padding: 0.625rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -30,7 +30,16 @@ const entryType = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="search-row" :class="{ odd: props.index % 2 === 1 }">
|
<article class="search-row" :class="{ odd: props.index % 2 === 1 }">
|
||||||
<div class="artwork" @click="props.result.type === 'track' && emit('play')">
|
<div
|
||||||
|
class="artwork"
|
||||||
|
:class="{ playable: props.result.type === 'track' }"
|
||||||
|
:role="props.result.type === 'track' ? 'button' : undefined"
|
||||||
|
:tabindex="props.result.type === 'track' ? 0 : undefined"
|
||||||
|
:aria-label="props.result.type === 'track' ? `Play ${props.result.name}` : undefined"
|
||||||
|
@click="props.result.type === 'track' && emit('play')"
|
||||||
|
@keydown.enter.prevent="props.result.type === 'track' && emit('play')"
|
||||||
|
@keydown.space.prevent="props.result.type === 'track' && emit('play')"
|
||||||
|
>
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
v-if="artworkSrc"
|
v-if="artworkSrc"
|
||||||
:src="artworkSrc"
|
:src="artworkSrc"
|
||||||
@@ -93,6 +102,10 @@ const entryType = computed(() =>
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: $border-radius-standard;
|
border-radius: $border-radius-standard;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -140,4 +153,49 @@ const entryType = computed(() =>
|
|||||||
.artists {
|
.artists {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-row {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"art details"
|
||||||
|
"art artists";
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork {
|
||||||
|
grid-area: art;
|
||||||
|
|
||||||
|
&.playable .play-icon {
|
||||||
|
opacity: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
grid-area: details;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artists {
|
||||||
|
grid-area: artists;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
|
:deep(.artist-name) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.artist-name a) {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row > span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -126,4 +126,17 @@ onMounted(() => {
|
|||||||
.right {
|
.right {
|
||||||
right: -48px;
|
right: -48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.left,
|
||||||
|
.right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scroll-padding-inline: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { GET_FILE } from "@/utils/file"
|
||||||
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
|
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
|
||||||
|
|
||||||
interface TrackRowProps {
|
interface TrackRowProps {
|
||||||
@@ -7,12 +8,14 @@ interface TrackRowProps {
|
|||||||
|
|
||||||
const { track } = defineProps<TrackRowProps>()
|
const { track } = defineProps<TrackRowProps>()
|
||||||
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
|
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const hovering = ref(false)
|
const hovering = ref(false)
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { isSaved, toggleSaved } = useTrackLibrary(() => track._id)
|
||||||
|
|
||||||
const dropdownMenuItems: DropdownMenuItem[][] = [
|
const dropdownMenuItems = computed<DropdownMenuItem[][]>(() => [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: "Play only this",
|
label: "Play only this",
|
||||||
@@ -34,7 +37,54 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]
|
[
|
||||||
|
{
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -62,7 +112,10 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
|
|||||||
<ArtistName :artists="track.artists" class="artists" />
|
<ArtistName :artists="track.artists" class="artists" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu :items="dropdownMenuItems">
|
<DropdownMenu
|
||||||
|
:items="visibleDropdownMenuItems"
|
||||||
|
:label="`Options for ${track.name}`"
|
||||||
|
>
|
||||||
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -15,16 +15,25 @@ interface TrackRowProps {
|
|||||||
extendedInfo?: boolean
|
extendedInfo?: boolean
|
||||||
showImage?: boolean
|
showImage?: boolean
|
||||||
queueMode?: boolean
|
queueMode?: boolean
|
||||||
|
touchFriendly?: boolean
|
||||||
|
libraryList?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = 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 { playAlbum, addToFrontOfQueue, addToQueue } = usePlayer()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { isSaved, toggleSaved } = useTrackLibrary(() =>
|
||||||
|
props.queueMode ? undefined : props.track?._id,
|
||||||
|
)
|
||||||
|
|
||||||
const dropdownMenuItems: DropdownMenuItem[][] = [
|
const dropdownMenuItems = computed<DropdownMenuItem[][]>(() => [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: "Play only this",
|
label: "Play only this",
|
||||||
@@ -52,7 +61,54 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]
|
[
|
||||||
|
{
|
||||||
|
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(() => {
|
const durationFormatted = computed(() => {
|
||||||
if (!props.track) return ""
|
if (!props.track) return ""
|
||||||
@@ -76,6 +132,7 @@ const artworkSrc = computed(() => {
|
|||||||
odd: props.index % 2 == 1,
|
odd: props.index % 2 == 1,
|
||||||
image: props.showImage,
|
image: props.showImage,
|
||||||
queue: props.queueMode,
|
queue: props.queueMode,
|
||||||
|
'library-list': props.libraryList,
|
||||||
}"
|
}"
|
||||||
@mouseenter="hovering = true"
|
@mouseenter="hovering = true"
|
||||||
@mouseleave="hovering = false"
|
@mouseleave="hovering = false"
|
||||||
@@ -86,7 +143,21 @@ const artworkSrc = computed(() => {
|
|||||||
v-if="props.track"
|
v-if="props.track"
|
||||||
name="lucide:play"
|
name="lucide:play"
|
||||||
class="play-icon"
|
class="play-icon"
|
||||||
|
:class="{ 'touch-friendly': props.touchFriendly }"
|
||||||
|
:role="props.touchFriendly ? 'button' : undefined"
|
||||||
|
:tabindex="props.touchFriendly ? 0 : undefined"
|
||||||
|
:aria-label="
|
||||||
|
props.touchFriendly
|
||||||
|
? `Play ${props.track.name}`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
@click="emit('playAlbumAtIndex')"
|
@click="emit('playAlbumAtIndex')"
|
||||||
|
@keydown.enter.prevent="
|
||||||
|
props.touchFriendly && emit('playAlbumAtIndex')
|
||||||
|
"
|
||||||
|
@keydown.space.prevent="
|
||||||
|
props.touchFriendly && emit('playAlbumAtIndex')
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
|
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
@@ -108,7 +179,14 @@ const artworkSrc = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="props.track">{{ props.track.name }}</p>
|
<div v-if="props.track && props.libraryList" class="library-info">
|
||||||
|
<p class="library-title">{{ props.track.name }}</p>
|
||||||
|
<ArtistName :artists="props.track.artists" class="artists" />
|
||||||
|
<NuxtLink :to="`/album/${props.track.album._id}`" class="library-album">
|
||||||
|
{{ props.track.album.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="props.track">{{ props.track.name }}</p>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-else-if="props.entity"
|
v-else-if="props.entity"
|
||||||
:to="`/${props.entity.type}/${props.entity._id}`"
|
:to="`/${props.entity.type}/${props.entity._id}`"
|
||||||
@@ -116,7 +194,7 @@ const artworkSrc = computed(() => {
|
|||||||
{{ props.entity.name }}
|
{{ props.entity.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<template v-if="props.track && props.extendedInfo">
|
<template v-if="props.track && props.extendedInfo && !props.libraryList">
|
||||||
<ArtistName :artists="props.track.artists" class="artists" />
|
<ArtistName :artists="props.track.artists" class="artists" />
|
||||||
<NuxtLink :to="`/album/${props.track.album._id}`">
|
<NuxtLink :to="`/album/${props.track.album._id}`">
|
||||||
<p>{{ props.track.album.name }}</p>
|
<p>{{ props.track.album.name }}</p>
|
||||||
@@ -140,7 +218,11 @@ const artworkSrc = computed(() => {
|
|||||||
class="queue-handle"
|
class="queue-handle"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<DropdownMenu v-else-if="props.track" :items="dropdownMenuItems">
|
<DropdownMenu
|
||||||
|
v-else-if="props.track"
|
||||||
|
:items="visibleDropdownMenuItems"
|
||||||
|
:label="`Options for ${props.track.name}`"
|
||||||
|
>
|
||||||
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</article>
|
</article>
|
||||||
@@ -186,6 +268,10 @@ const artworkSrc = computed(() => {
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.library-list {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
p,
|
p,
|
||||||
> a {
|
> a {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -220,6 +306,10 @@ const artworkSrc = computed(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-icon.inline {
|
.play-icon.inline {
|
||||||
@@ -244,10 +334,71 @@ const artworkSrc = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.track.library-list {
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto 40px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
|
||||||
|
.index {
|
||||||
|
grid-area: 1 / 1 / 3 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-info {
|
||||||
|
grid-area: 1 / 2 / 3 / 3;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-title,
|
||||||
|
.library-album,
|
||||||
|
.artists {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artists,
|
||||||
|
.library-album {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
> p {
|
||||||
|
grid-area: 1 / 3 / 3 / 4;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown) {
|
||||||
|
grid-area: 1 / 4 / 3 / 5;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon.touch-friendly {
|
||||||
|
opacity: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.artists {
|
.artists {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-info {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-icon {
|
.dropdown-icon {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+74
-123
@@ -1,151 +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",
|
method: "POST",
|
||||||
data: {
|
body: { email, password, remember: Boolean(remember) },
|
||||||
email,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
// NOTE: Quite hacky... this should be changed.
|
|
||||||
// TODO: Add httpOnly and update the backend to support that.
|
|
||||||
if (remember) {
|
|
||||||
setCookie("refreshToken", res.data.refreshToken, {
|
|
||||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
setCookie("refreshToken", res.data.refreshToken)
|
|
||||||
}
|
|
||||||
//refreshToken.value = res.data.refreshToken
|
|
||||||
await refreshSessionToken()
|
|
||||||
|
|
||||||
|
if (!(await refreshSession()).ok) throw new Error("Unable to create session")
|
||||||
await navigateTo("/")
|
await navigateTo("/")
|
||||||
} catch (e) {
|
} catch (error: any) {
|
||||||
if (axios.isAxiosError(e) && e.response?.data?.message) {
|
throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign in")
|
||||||
throw new Error(e.response.data.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Failed to sign in")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const signup = async (name: string, email: string, password: string) => {}
|
const signup = async (name: string, email: string, password: string) => {
|
||||||
|
try {
|
||||||
const signout = () => {
|
await $fetch("/api/auth/register", {
|
||||||
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
|
|
||||||
|
|
||||||
if (refreshPromise != null) {
|
|
||||||
waitForPromise = false
|
|
||||||
return refreshPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshPromise = cfetch(
|
|
||||||
"/auth/session",
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
refreshToken: refreshToken.value,
|
|
||||||
},
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
body: { name, email, password },
|
||||||
false,
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
const token = res.data.sessionToken
|
|
||||||
sessionToken.value = token
|
|
||||||
|
|
||||||
refreshPromise = null
|
|
||||||
|
|
||||||
return token
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
|
||||||
refreshPromise = null
|
|
||||||
sessionToken.value = null
|
|
||||||
|
|
||||||
return null
|
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",
|
||||||
|
headers: import.meta.server
|
||||||
|
? useRequestHeaders(["cookie"])
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
let sessionCookie: string | undefined
|
||||||
|
const setCookies = import.meta.server ? response.headers.getSetCookie() : []
|
||||||
|
for (const cookie of setCookies) {
|
||||||
|
if (!cookie.startsWith("musicSession=")) continue
|
||||||
|
sessionCookie = cookie.split(";", 1)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.server) {
|
||||||
|
const event = useRequestEvent()
|
||||||
|
if (event) {
|
||||||
|
for (const cookie of setCookies) {
|
||||||
|
appendResponseHeader(event, "set-cookie", cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, sessionCookie }
|
||||||
|
})()
|
||||||
|
.then((result) => {
|
||||||
|
authenticated.value = true
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
authenticated.value = false
|
||||||
|
return { ok: false }
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
waitForPromise = false
|
refreshPromises.delete(nuxtApp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
refreshPromises.set(nuxtApp, refreshPromise)
|
||||||
return refreshPromise
|
return refreshPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getCurrentInstance()) {
|
|
||||||
onMounted(() => {
|
|
||||||
if (refreshToken.value && !sessionToken.value) refreshSessionToken()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refreshToken,
|
authenticated,
|
||||||
sessionToken,
|
|
||||||
signin,
|
signin,
|
||||||
signup,
|
signup,
|
||||||
signout,
|
signout,
|
||||||
refreshSessionToken,
|
refreshSession,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ export const useFullscreen = () => {
|
|||||||
await document.documentElement.requestFullscreen()
|
await document.documentElement.requestFullscreen()
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
fullscreen.value = false
|
// Keep the in-app fullscreen player open when native fullscreen is denied.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { cfetch } from "@/utils/cfetch"
|
||||||
|
import { toValue, type MaybeRefOrGetter } from "vue"
|
||||||
|
|
||||||
|
interface TrackMembership {
|
||||||
|
contains: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTrackLibrary = (trackId: MaybeRefOrGetter<string | undefined>) => {
|
||||||
|
const toast = useToast()
|
||||||
|
const isSaved = ref<boolean | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const checkMembership = async () => {
|
||||||
|
const id = toValue(trackId)
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await cfetch<TrackMembership>("/user/tracks/contains", {
|
||||||
|
params: { id },
|
||||||
|
})
|
||||||
|
isSaved.value = result.contains
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.show(
|
||||||
|
error?.data?.message || "Could not check Library status",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(checkMembership)
|
||||||
|
|
||||||
|
const toggleSaved = async () => {
|
||||||
|
const id = toValue(trackId)
|
||||||
|
if (!id || isSaved.value === null || saving.value) return false
|
||||||
|
|
||||||
|
const wasSaved = isSaved.value
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await cfetch("/user/tracks", {
|
||||||
|
method: wasSaved ? "DELETE" : "PATCH",
|
||||||
|
params: { id },
|
||||||
|
})
|
||||||
|
isSaved.value = !wasSaved
|
||||||
|
toast.show(
|
||||||
|
wasSaved ? "Removed from Library" : "Saved to Library",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return wasSaved
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.show(
|
||||||
|
error?.data?.message ||
|
||||||
|
(wasSaved
|
||||||
|
? "Could not remove song from Library"
|
||||||
|
: "Could not save song to Library"),
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isSaved, saving, toggleSaved }
|
||||||
|
}
|
||||||
+113
-1
@@ -1,5 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const activeTab = computed(() => {
|
||||||
|
if (route.path === "/") return "home"
|
||||||
|
if (route.path === "/search") return "search"
|
||||||
|
if (route.path.startsWith("/library/")) return "library"
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
let timeout: ReturnType<typeof setTimeout>
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
@@ -13,10 +21,11 @@ const onSearchInput = (v: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data: user } = useApiFetch<any>("/user")
|
const { data: user } = useApiFetch<any>("/user")
|
||||||
|
const song = useSong()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sidebar>
|
<Sidebar class="default-shell" :class="{ 'has-player': song }">
|
||||||
<template #sidebar>
|
<template #sidebar>
|
||||||
<p class="title">Music</p>
|
<p class="title">Music</p>
|
||||||
|
|
||||||
@@ -89,12 +98,55 @@ const { data: user } = useApiFetch<any>("/user")
|
|||||||
|
|
||||||
<template #default>
|
<template #default>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
|
<nav class="mobile-tabs" aria-label="Primary navigation">
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: activeTab === 'home' }"
|
||||||
|
:aria-current="activeTab === 'home' ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="lucide:house"
|
||||||
|
class="tab-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>Home</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/library/songs"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: activeTab === 'library' }"
|
||||||
|
:aria-current="activeTab === 'library' ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="lucide:library"
|
||||||
|
class="tab-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>Library</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/search"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: activeTab === 'search' }"
|
||||||
|
:aria-current="activeTab === 'search' ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="lucide:search"
|
||||||
|
class="tab-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>Search</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use "@/styles/util" as *;
|
@use "@/styles/util" as *;
|
||||||
|
@use "@/styles/variables" as *;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@include fontSize(24px);
|
@include fontSize(24px);
|
||||||
@@ -114,4 +166,64 @@ const { data: user } = useApiFetch<any>("/user")
|
|||||||
margin-bottom: 0.1rem;
|
margin-bottom: 0.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.default-shell {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:deep(> aside) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(> main) {
|
||||||
|
padding: calc(1.25rem + env(safe-area-inset-top)) 1rem
|
||||||
|
calc(6rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-player :deep(> main) {
|
||||||
|
padding-bottom: calc(6rem + env(safe-area-inset-bottom) + $player-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabs {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
inset: auto 0 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
padding: 0.45rem 0.75rem calc(0.45rem + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(22, 22, 24, 0.96);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,40 @@
|
|||||||
export default defineNuxtRouteMiddleware((to, from) => {
|
import { appendResponseHeader } from "h3"
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
if (to.path.startsWith("/auth")) return
|
||||||
|
|
||||||
const auth = useAuth()
|
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.
|
||||||
|
}
|
||||||
|
|
||||||
if (!auth.refreshToken.value && !to.fullPath.includes("/auth"))
|
|
||||||
return navigateTo("/auth/signin")
|
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>
|
||||||
+32
-21
@@ -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,7 +40,7 @@ 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,
|
||||||
}))
|
}))
|
||||||
@@ -50,7 +48,7 @@ const albumFetchOptions = async (q?: string): Promise<Option[]> => {
|
|||||||
|
|
||||||
const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
|
const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
|
||||||
const genreFetchOptions = async (): Promise<Option[]> => {
|
const genreFetchOptions = async (): Promise<Option[]> => {
|
||||||
return genreData.value.map<Option>((genre) => ({
|
return (genreData.value ?? []).map<Option>((genre) => ({
|
||||||
label: genre.name,
|
label: genre.name,
|
||||||
value: genre._id,
|
value: genre._id,
|
||||||
}))
|
}))
|
||||||
@@ -100,8 +98,15 @@ const serializeLyrics = (lyrics: Track["lyrics"] | undefined) => {
|
|||||||
return JSON.stringify(lyrics)
|
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) {
|
||||||
@@ -123,7 +128,6 @@ const save = async (value: any) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
|
||||||
if (!value.name || !value.file || !value.artists?.length) {
|
if (!value.name || !value.file || !value.artists?.length) {
|
||||||
toast.show(
|
toast.show(
|
||||||
"Name, audio file, and at least one artist are required",
|
"Name, audio file, and at least one artist are required",
|
||||||
@@ -132,8 +136,7 @@ const save = async (value: any) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.createSingle) {
|
if (value.createSingle && (!value.cover || !value.genre)) {
|
||||||
if (!value.cover || !value.genre) {
|
|
||||||
toast.show(
|
toast.show(
|
||||||
"A cover image and genre are required for a single",
|
"A cover image and genre are required for a single",
|
||||||
"error",
|
"error",
|
||||||
@@ -141,22 +144,25 @@ const save = async (value: any) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const albumResponse = await cfetch("/admin/album", {
|
if (!value.createSingle && !value.album) {
|
||||||
|
toast.show("Choose an album or create a single album", "error")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
addingSong.value = true
|
||||||
|
await nextTick()
|
||||||
|
try {
|
||||||
|
if (value.createSingle) {
|
||||||
|
const albumResponse = await cfetch<{ _id: string }>("/admin/album", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: {
|
data: {
|
||||||
name: `${value.name} - Single`,
|
name: `${value.name} - Single`,
|
||||||
artists: value.artists.map((artist: Option) => artist.value),
|
artists: getArtistIds(value.artists),
|
||||||
genres: value.genre.value,
|
genre: getOptionId(value.genre),
|
||||||
file: value.cover,
|
file: value.cover,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
value.album = albumResponse.data._id
|
value.album = albumResponse._id
|
||||||
} else if (!value.album) {
|
|
||||||
toast.show(
|
|
||||||
"Choose an album or create a single album",
|
|
||||||
"error",
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await cfetch("/admin/track", {
|
await cfetch("/admin/track", {
|
||||||
@@ -164,7 +170,7 @@ const save = async (value: any) => {
|
|||||||
data: {
|
data: {
|
||||||
...value,
|
...value,
|
||||||
album: value.album?.value ?? value.album,
|
album: value.album?.value ?? value.album,
|
||||||
artists: value.artists.map((artist: Option) => artist.value),
|
artists: getArtistIds(value.artists),
|
||||||
lyrics: serializeLyrics(value.lyrics),
|
lyrics: serializeLyrics(value.lyrics),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -174,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,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
|
||||||
|
|
||||||
@@ -215,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>
|
||||||
@@ -236,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,18 +9,22 @@ const { data: albumData } = await useApiFetch<Album>(`/albums/${id.value}`)
|
|||||||
const { data: tracksData } = await useApiFetch<Track[]>(
|
const { data: tracksData } = await useApiFetch<Track[]>(
|
||||||
`/albums/${id.value}/tracks`,
|
`/albums/${id.value}/tracks`,
|
||||||
)
|
)
|
||||||
|
const tracks = computed(() => tracksData.value ?? [])
|
||||||
|
|
||||||
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
||||||
|
useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Album` : "Album") })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="info" v-if="albumData">
|
<section class="info" v-if="albumData">
|
||||||
|
<div class="album-art">
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
:src="GET_FILE(albumData.file)"
|
:src="GET_FILE(albumData.file)"
|
||||||
width="300"
|
width="300"
|
||||||
height="300"
|
height="300"
|
||||||
placeholder
|
placeholder
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<h1>{{ albumData.name }}</h1>
|
<h1>{{ albumData.name }}</h1>
|
||||||
@@ -27,13 +33,15 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
|||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<Button
|
<Button
|
||||||
icon-name="lucide:play"
|
icon-name="lucide:play"
|
||||||
@click="() => playAlbum(tracksData)"
|
:aria-label="`Play ${albumData.name}`"
|
||||||
>Play</Button
|
@click="() => playAlbum(tracks)"
|
||||||
|
><span class="button-label">Play</span></Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon-name="lucide:shuffle"
|
icon-name="lucide:shuffle"
|
||||||
@click="() => playShuffledAlbum(tracksData)"
|
:aria-label="`Shuffle ${albumData.name}`"
|
||||||
>Shuffle</Button
|
@click="() => playShuffledAlbum(tracks)"
|
||||||
|
><span class="button-label">Shuffle</span></Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,11 +49,11 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
|||||||
|
|
||||||
<section class="tracks">
|
<section class="tracks">
|
||||||
<TrackRow
|
<TrackRow
|
||||||
v-for="(track, i) in tracksData"
|
v-for="(track, i) in tracks"
|
||||||
:key="track._id"
|
:key="track._id"
|
||||||
:track="track"
|
:track="track"
|
||||||
:index="i"
|
:index="i"
|
||||||
@play-album-at-index="() => playAlbumAtIndex(tracksData, i)"
|
@play-album-at-index="() => playAlbumAtIndex(tracks, i)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -93,4 +101,57 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.album-art {
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info .details {
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
@include util.fontSize(26px);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
@include util.fontSize(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details .buttons :deep(.button) {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details .buttons :deep(.icon-wrapper) {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details .buttons :deep(.button-label) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,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)
|
||||||
|
|
||||||
@@ -11,13 +13,14 @@ const { data: albumsData } = await useApiFetch<Album[]>(
|
|||||||
const { data: tracksData } = await useApiFetch<Track[]>(
|
const { data: tracksData } = await useApiFetch<Track[]>(
|
||||||
`/artists/${id.value}/tracks`,
|
`/artists/${id.value}/tracks`,
|
||||||
)
|
)
|
||||||
|
const tracks = computed(() => tracksData.value ?? [])
|
||||||
|
|
||||||
const chunkedTracks = computed(() => {
|
const chunkedTracks = computed(() => {
|
||||||
const SIZE = 3
|
const SIZE = 3
|
||||||
const chunks = []
|
const chunks = []
|
||||||
|
|
||||||
for (let i = 0; i < tracksData.value.length; i += SIZE) {
|
for (let i = 0; i < tracks.value.length; i += SIZE) {
|
||||||
chunks.push(tracksData.value.slice(i, i + SIZE))
|
chunks.push(tracks.value.slice(i, i + SIZE))
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
@@ -36,16 +39,20 @@ const scrollWidth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
||||||
|
useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - Artist` : "Artist") })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="info" v-if="artistData">
|
<section class="info" v-if="artistData">
|
||||||
|
<div class="artist-art">
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
|
class="artist-image"
|
||||||
:src="GET_FILE(artistData.file)"
|
:src="GET_FILE(artistData.file)"
|
||||||
width="300"
|
width="300"
|
||||||
height="300"
|
height="300"
|
||||||
placeholder
|
placeholder
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<h1>{{ artistData.name }}</h1>
|
<h1>{{ artistData.name }}</h1>
|
||||||
@@ -53,14 +60,18 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
|||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<Button
|
<Button
|
||||||
icon-name="lucide:play"
|
icon-name="lucide:play"
|
||||||
@click="() => playAlbum(tracksData)"
|
:aria-label="`Play ${artistData.name}`"
|
||||||
>Play</Button
|
@click="() => playAlbum(tracks)"
|
||||||
>
|
>
|
||||||
|
<span class="button-label">Play</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
icon-name="lucide:shuffle"
|
icon-name="lucide:shuffle"
|
||||||
@click="() => playShuffledAlbum(tracksData)"
|
:aria-label="`Shuffle ${artistData.name}`"
|
||||||
>Shuffle</Button
|
@click="() => playShuffledAlbum(tracks)"
|
||||||
>
|
>
|
||||||
|
<span class="button-label">Shuffle</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -79,7 +90,7 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
|||||||
:track="track"
|
:track="track"
|
||||||
:index="i + pI * 3"
|
:index="i + pI * 3"
|
||||||
@play-album-at-index="
|
@play-album-at-index="
|
||||||
() => playAlbumAtIndex(tracksData, i + pI * 3)
|
() => playAlbumAtIndex(tracks, i + pI * 3)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -90,9 +101,10 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
|||||||
v-for="album in albumsData"
|
v-for="album in albumsData"
|
||||||
:key="album._id"
|
:key="album._id"
|
||||||
:name="album.name"
|
:name="album.name"
|
||||||
:file="`${BACKEND_SERVE}${album.file}`"
|
:file="GET_FILE(album.file)"
|
||||||
:artists="album.artists"
|
:artists="album.artists"
|
||||||
:_id="album._id"
|
:_id="album._id"
|
||||||
|
carousel
|
||||||
/>
|
/>
|
||||||
</Slider>
|
</Slider>
|
||||||
</template>
|
</template>
|
||||||
@@ -154,4 +166,55 @@ $gap: 12px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-art {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info .details {
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details .buttons :deep(.button) {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details .buttons :deep(.icon-wrapper) {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details .buttons :deep(.button-label) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks {
|
||||||
|
flex-basis: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -5,8 +5,10 @@ const id = computed(() => route.params.id)
|
|||||||
const { data: tracksData } = await useApiFetch<Track[]>(
|
const { data: tracksData } = await useApiFetch<Track[]>(
|
||||||
`/artists/${id.value}/tracks`,
|
`/artists/${id.value}/tracks`,
|
||||||
)
|
)
|
||||||
|
const tracks = computed(() => tracksData.value ?? [])
|
||||||
|
|
||||||
const { playAlbumAtIndex } = usePlayer()
|
const { playAlbumAtIndex } = usePlayer()
|
||||||
|
useHead({ title: "Artist Songs" })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -14,12 +16,13 @@ const { playAlbumAtIndex } = usePlayer()
|
|||||||
<h1 class="title">Songs</h1>
|
<h1 class="title">Songs</h1>
|
||||||
|
|
||||||
<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"
|
||||||
show-image
|
show-image
|
||||||
@play-album-at-index="() => playAlbumAtIndex(tracksData, i)"
|
touch-friendly
|
||||||
|
@play-album-at-index="() => playAlbumAtIndex(tracks, i)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -39,4 +42,17 @@ const { playAlbumAtIndex } = usePlayer()
|
|||||||
|
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title {
|
||||||
|
@include fontSize(26px);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks :deep(.track.image) {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) auto auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: false,
|
layout: false,
|
||||||
})
|
})
|
||||||
|
useHead({ title: "Sign In" })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -29,4 +30,11 @@ definePageMeta({
|
|||||||
width: 40%;
|
width: 40%;
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -10,11 +10,12 @@ const submit = async (e: any) => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.signin(data.email, data.password, data.remember)
|
await auth.signin(data.email, data.password, data.remember === "on")
|
||||||
} catch (e: string | any) {
|
} catch (e: string | any) {
|
||||||
toast.show(e, "error")
|
toast.show(e, "error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useHead({ title: "Sign In" })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -57,4 +58,15 @@ h1 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
h1 {
|
||||||
|
@include fontSize(22px);
|
||||||
|
margin-bottom: 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
useHead({ title: "Home" })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>Home</h1>
|
<h1>Home</h1>
|
||||||
|
|||||||
@@ -1 +1,86 @@
|
|||||||
<template></template>
|
<script setup lang="ts">
|
||||||
|
import { GET_FILE } from "@/utils/file"
|
||||||
|
import type { Album } from "@/utils/types"
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: albumsData,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = await useApiFetch<Album[]>("/user/albums")
|
||||||
|
const albums = computed(() => albumsData.value ?? [])
|
||||||
|
|
||||||
|
useHead({ title: "Albums" })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="albums-page">
|
||||||
|
<h1>Albums</h1>
|
||||||
|
|
||||||
|
<p v-if="status === 'pending'" class="message" role="status">
|
||||||
|
Loading your albums…
|
||||||
|
</p>
|
||||||
|
<div v-else-if="error" class="message" role="alert">
|
||||||
|
<p>Your albums could not be loaded.</p>
|
||||||
|
<Button variant="ghost" @click="refresh">Try again</Button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="albums.length === 0" class="message">
|
||||||
|
No saved albums yet. Save a song to add its album here.
|
||||||
|
</p>
|
||||||
|
<div v-else class="album-list">
|
||||||
|
<AlbumCard
|
||||||
|
v-for="album in albums"
|
||||||
|
:key="album._id"
|
||||||
|
:name="album.name"
|
||||||
|
:file="GET_FILE(album.file)"
|
||||||
|
:artists="album.artists"
|
||||||
|
:_id="album._id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.albums-page {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@include fontSize(32px);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
@include fontSize(14px);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.album-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-list :deep(.card) {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-list :deep(.card img) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1 +1,81 @@
|
|||||||
<template></template>
|
<script setup lang="ts">
|
||||||
|
import type { Artist } from "@/utils/types"
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: artistsData,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = await useApiFetch<Artist[]>("/user/artists")
|
||||||
|
const artists = computed(() => artistsData.value ?? [])
|
||||||
|
|
||||||
|
useHead({ title: "Artists" })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="artists-page">
|
||||||
|
<h1>Artists</h1>
|
||||||
|
|
||||||
|
<p v-if="status === 'pending'" class="message" role="status">
|
||||||
|
Loading your artists…
|
||||||
|
</p>
|
||||||
|
<div v-else-if="error" class="message" role="alert">
|
||||||
|
<p>Your artists could not be loaded.</p>
|
||||||
|
<Button variant="ghost" @click="refresh">Try again</Button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="artists.length === 0" class="message">
|
||||||
|
No artists in your saved songs yet.
|
||||||
|
</p>
|
||||||
|
<div v-else class="artist-list">
|
||||||
|
<ArtistCard
|
||||||
|
v-for="artist in artists"
|
||||||
|
:key="artist._id"
|
||||||
|
:artist="artist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.artists-page {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@include fontSize(32px);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
@include fontSize(14px);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.artist-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-list :deep(.card) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-list :deep(.card img) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+108
-1
@@ -1 +1,108 @@
|
|||||||
<template></template>
|
<script setup lang="ts">
|
||||||
|
import type { Track } from "@/utils/types"
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
||||||
|
const {
|
||||||
|
data: tracksData,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = await useApiFetch<Track[]>("/user/tracks")
|
||||||
|
const tracks = computed(() => tracksData.value ?? [])
|
||||||
|
const playTracks = () => {
|
||||||
|
if (tracks.value.length) void playAlbum(tracks.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffleTracks = () => {
|
||||||
|
if (tracks.value.length) void playShuffledAlbum(tracks.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromLibrary = (track: Track) => {
|
||||||
|
tracksData.value = tracks.value.filter(
|
||||||
|
(savedTrack) => savedTrack._id !== track._id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: "Songs" })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="songs-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Songs</h1>
|
||||||
|
<div v-if="tracks.length" class="page-actions">
|
||||||
|
<Button icon-name="lucide:play" @click="playTracks">Play</Button>
|
||||||
|
<Button icon-name="lucide:shuffle" @click="shuffleTracks">Shuffle</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="status === 'pending'" class="message" role="status">
|
||||||
|
Loading your songs…
|
||||||
|
</p>
|
||||||
|
<div v-else-if="error" class="message" role="alert">
|
||||||
|
<p>Your songs could not be loaded.</p>
|
||||||
|
<Button variant="ghost" @click="refresh">Try again</Button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="tracks.length === 0" class="message">
|
||||||
|
You haven’t saved any songs yet.
|
||||||
|
</p>
|
||||||
|
<div v-else class="track-list">
|
||||||
|
<TrackRow
|
||||||
|
v-for="(track, index) in tracks"
|
||||||
|
:key="track._id"
|
||||||
|
:track="track"
|
||||||
|
:index="index"
|
||||||
|
:extended-info="true"
|
||||||
|
library-list
|
||||||
|
@play-album-at-index="() => playAlbumAtIndex(tracks, index)"
|
||||||
|
@remove-from-library="removeFromLibrary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.songs-page {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@include fontSize(32px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
@include fontSize(14px);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 359px) {
|
||||||
|
.page-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+57
-9
@@ -12,15 +12,20 @@ interface SearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { currentSong, playAlbum } = usePlayer()
|
const { currentSong, playAlbum } = usePlayer()
|
||||||
const query = computed(() => {
|
const query = computed(() => {
|
||||||
const value = route.query.q
|
const value = route.query.q
|
||||||
return (Array.isArray(value) ? value[0] : value ?? "").trim()
|
const firstValue = Array.isArray(value) ? value[0] : value
|
||||||
|
return (firstValue ?? "").trim()
|
||||||
})
|
})
|
||||||
|
const searchInput = ref(query.value)
|
||||||
const debouncedQuery = ref(query.value)
|
const debouncedQuery = ref(query.value)
|
||||||
|
const querySettled = computed(() => query.value === debouncedQuery.value)
|
||||||
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
|
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
watch(query, (value) => {
|
watch(query, (value) => {
|
||||||
|
searchInput.value = value
|
||||||
clearTimeout(debounceTimeout)
|
clearTimeout(debounceTimeout)
|
||||||
debounceTimeout = setTimeout(() => {
|
debounceTimeout = setTimeout(() => {
|
||||||
debouncedQuery.value = value
|
debouncedQuery.value = value
|
||||||
@@ -28,9 +33,14 @@ watch(query, (value) => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
const updateSearchRoute = (value: string) => {
|
||||||
clearTimeout(debounceTimeout)
|
const trimmed = value.trim()
|
||||||
})
|
if (trimmed)
|
||||||
|
router.replace(`/search?q=${encodeURIComponent(trimmed)}`)
|
||||||
|
else router.replace("/search")
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => clearTimeout(debounceTimeout))
|
||||||
|
|
||||||
const { data, status, error } = await useApiFetch<SearchResult[]>("/search", {
|
const { data, status, error } = await useApiFetch<SearchResult[]>("/search", {
|
||||||
query: { q: debouncedQuery, limit: 9 },
|
query: { q: debouncedQuery, limit: 9 },
|
||||||
@@ -80,20 +90,36 @@ watch(currentSong, (track) => {
|
|||||||
const playResult = (result: SearchResult | Track) => {
|
const playResult = (result: SearchResult | Track) => {
|
||||||
if ("album" in result && result.album) playAlbum([result as Track])
|
if ("album" in result && result.album) playAlbum([result as Track])
|
||||||
}
|
}
|
||||||
|
useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search") })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="search-page">
|
<main class="search-page">
|
||||||
<h1>Search</h1>
|
<h1>Search</h1>
|
||||||
|
<Input
|
||||||
|
class="mobile-search"
|
||||||
|
v-model:value="searchInput"
|
||||||
|
full
|
||||||
|
type="search"
|
||||||
|
name="mobile-search-query"
|
||||||
|
label="Search the library"
|
||||||
|
placeholder="Songs, albums, artists"
|
||||||
|
icon-name="lucide:search"
|
||||||
|
@input="updateSearchRoute"
|
||||||
|
/>
|
||||||
|
|
||||||
<p v-if="query && status === 'pending'" class="message" role="status">
|
<p
|
||||||
|
v-if="query && (!querySettled || status === 'pending')"
|
||||||
|
class="message"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
Searching…
|
Searching…
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="query && error" class="message" role="alert">
|
<p v-else-if="query && querySettled && error" class="message" role="alert">
|
||||||
Search could not be loaded. Please try again.
|
Search could not be loaded. Please try again.
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-else-if="query && status === 'success' && results.length === 0"
|
v-else-if="query && querySettled && status === 'success' && results.length === 0"
|
||||||
class="message"
|
class="message"
|
||||||
>
|
>
|
||||||
No results for “{{ query }}”.
|
No results for “{{ query }}”.
|
||||||
@@ -107,7 +133,7 @@ const playResult = (result: SearchResult | Track) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-if="query && results.length"
|
v-if="query && querySettled && status === 'success' && results.length"
|
||||||
class="results"
|
class="results"
|
||||||
aria-label="Search results"
|
aria-label="Search results"
|
||||||
>
|
>
|
||||||
@@ -124,7 +150,10 @@ const playResult = (result: SearchResult | Track) => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-else-if="recentTracks.length && (!query || status === 'success')"
|
v-else-if="
|
||||||
|
recentTracks.length &&
|
||||||
|
(!query || (querySettled && status === 'success'))
|
||||||
|
"
|
||||||
class="results"
|
class="results"
|
||||||
aria-label="Recently played tracks"
|
aria-label="Recently played tracks"
|
||||||
>
|
>
|
||||||
@@ -134,6 +163,7 @@ const playResult = (result: SearchResult | Track) => {
|
|||||||
:track="track"
|
:track="track"
|
||||||
:index="index"
|
:index="index"
|
||||||
show-image
|
show-image
|
||||||
|
touch-friendly
|
||||||
@play-album-at-index="playResult(track)"
|
@play-album-at-index="playResult(track)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -159,6 +189,10 @@ const playResult = (result: SearchResult | Track) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -170,4 +204,18 @@ const playResult = (result: SearchResult | Track) => {
|
|||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-search {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results :deep(.track.image) {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) auto auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+333
-18
@@ -1,9 +1,27 @@
|
|||||||
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()
|
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 VOLUME_STORAGE_KEY = "music-v2-player-volume"
|
||||||
const DEFAULT_VOLUME = 0.5
|
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[]>([])
|
||||||
@@ -11,8 +29,18 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
const currentQueue = ref<Track[]>([])
|
const currentQueue = ref<Track[]>([])
|
||||||
const currentSong = ref<Track | null>(null)
|
const currentSong = ref<Track | null>(null)
|
||||||
const repeat = ref(false)
|
const repeat = ref(false)
|
||||||
|
const repeatOnce = ref(false)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
|
const beatEnergy = ref(0)
|
||||||
const storedVolume = ref(DEFAULT_VOLUME)
|
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 clampVolume = (value: number) => Math.min(1, Math.max(0, value))
|
||||||
|
|
||||||
@@ -47,6 +75,9 @@ 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
|
||||||
@@ -59,49 +90,267 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
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 {
|
||||||
|
const stream = capture.call(mediaElement)
|
||||||
|
if (stream.getAudioTracks().length === 0) {
|
||||||
|
if (DEBUG_BEAT_ANALYSIS)
|
||||||
|
console.warn(
|
||||||
|
"[beat analyzer] failed: capture has no audio tracks",
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
const samples = new Uint8Array(analysisNode.frequencyBinCount)
|
||||||
|
let previousBass: number | null = null
|
||||||
|
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,
|
title: currentSong.value.name,
|
||||||
artist: currentSong.value.artists[0]?.name,
|
artist: currentSong.value.artists[0]?.name,
|
||||||
album: currentSong.value.album.name,
|
album: currentSong.value.album.name,
|
||||||
artwork: [
|
artwork: [
|
||||||
{
|
{
|
||||||
src: GET_FILE(currentSong.value.album.file),
|
src: GET_FILE(
|
||||||
|
currentSong.value.album.file,
|
||||||
|
mediaBaseUrl,
|
||||||
|
),
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
mediaSessionSongId = currentSong.value._id
|
||||||
|
}
|
||||||
|
|
||||||
navigator.mediaSession.setPositionState({
|
if (duration.value > 0) {
|
||||||
|
try {
|
||||||
|
mediaSession.setPositionState({
|
||||||
duration: duration.value,
|
duration: duration.value,
|
||||||
position: secondsPlayed.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) => {
|
||||||
@@ -109,20 +358,29 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
if (!song) throw new Error("Song not provided")
|
if (!song) throw new Error("Song not provided")
|
||||||
|
|
||||||
const newSound = new Howl({
|
const newSound = new Howl({
|
||||||
src: [GET_AUDIO_FILE(song.fileDir)],
|
src: [GET_AUDIO_FILE(song.fileDir, mediaBaseUrl)],
|
||||||
html5: false,
|
html5: true,
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
volume: storedVolume.value,
|
volume: storedVolume.value,
|
||||||
onload: () => {
|
onload: () => {
|
||||||
duration.value = sound.value?.duration() || 0
|
duration.value = sound.value?.duration() || 0
|
||||||
sound.value?.volume(storedVolume.value)
|
sound.value?.volume(storedVolume.value)
|
||||||
|
updateMediaSession()
|
||||||
},
|
},
|
||||||
onend: () => {
|
onend: () => {
|
||||||
|
stopBeatAnalysis("track ended")
|
||||||
void closeFullscreen()
|
void closeFullscreen()
|
||||||
next()
|
if (repeatOnce.value && sound.value === newSound) {
|
||||||
|
repeatOnce.value = false
|
||||||
|
newSound.seek(0)
|
||||||
|
newSound.play()
|
||||||
|
} else {
|
||||||
|
void next()
|
||||||
|
}
|
||||||
stopTracking()
|
stopTracking()
|
||||||
},
|
},
|
||||||
onstop: () => {
|
onstop: () => {
|
||||||
|
stopBeatAnalysis("playback stopped")
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
stopTracking()
|
stopTracking()
|
||||||
if (navigator.mediaSession)
|
if (navigator.mediaSession)
|
||||||
@@ -130,11 +388,13 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
},
|
},
|
||||||
onplay: () => {
|
onplay: () => {
|
||||||
isPlaying.value = true
|
isPlaying.value = true
|
||||||
|
startBeatAnalysis(newSound)
|
||||||
startTracking()
|
startTracking()
|
||||||
if (navigator.mediaSession)
|
if (navigator.mediaSession)
|
||||||
navigator.mediaSession.playbackState = "playing"
|
navigator.mediaSession.playbackState = "playing"
|
||||||
},
|
},
|
||||||
onpause: () => {
|
onpause: () => {
|
||||||
|
stopBeatAnalysis("paused")
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
stopTracking()
|
stopTracking()
|
||||||
if (navigator.mediaSession)
|
if (navigator.mediaSession)
|
||||||
@@ -168,6 +428,17 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
updateMediaSession()
|
updateMediaSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cycleRepeat = () => {
|
||||||
|
if (repeatOnce.value) {
|
||||||
|
repeatOnce.value = false
|
||||||
|
repeat.value = true
|
||||||
|
} else if (repeat.value) {
|
||||||
|
repeat.value = false
|
||||||
|
} else {
|
||||||
|
repeatOnce.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const next = async (autoplay: boolean = true) => {
|
const next = async (autoplay: boolean = true) => {
|
||||||
if (currentSong.value) {
|
if (currentSong.value) {
|
||||||
prevQueue.value.unshift(currentSong.value)
|
prevQueue.value.unshift(currentSong.value)
|
||||||
@@ -225,6 +496,46 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
currentSong.value = null
|
currentSong.value = null
|
||||||
sound.value?.stop()
|
sound.value?.stop()
|
||||||
sound.value = null
|
sound.value = null
|
||||||
|
updateMediaSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
registerMediaSessionActions()
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.repeat ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.altKey
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
const target = event.target
|
||||||
|
if (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
target.closest(
|
||||||
|
"input, textarea, select, button, [contenteditable='true'], [role='slider']",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
switch (event.code) {
|
||||||
|
case "Space":
|
||||||
|
event.preventDefault()
|
||||||
|
if (isPlaying.value) pause()
|
||||||
|
else void play()
|
||||||
|
break
|
||||||
|
case "ArrowRight":
|
||||||
|
event.preventDefault()
|
||||||
|
void next()
|
||||||
|
break
|
||||||
|
case "ArrowLeft":
|
||||||
|
event.preventDefault()
|
||||||
|
void prev()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const shuffle = () => {
|
const shuffle = () => {
|
||||||
@@ -275,8 +586,11 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
provide: {
|
provide: {
|
||||||
player: {
|
player: {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
beatEnergy,
|
||||||
currentSong,
|
currentSong,
|
||||||
repeat,
|
repeat,
|
||||||
|
repeatOnce,
|
||||||
|
cycleRepeat,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
next,
|
next,
|
||||||
@@ -290,6 +604,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
playAlbumAtIndex,
|
playAlbumAtIndex,
|
||||||
getDuration,
|
getDuration,
|
||||||
secondsPlayed,
|
secondsPlayed,
|
||||||
|
refreshPosition,
|
||||||
duration,
|
duration,
|
||||||
volume,
|
volume,
|
||||||
queue,
|
queue,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
$color-accent: rgb(255, 69, 58);
|
$color-accent: rgb(255, 69, 58);
|
||||||
$color-background: oklch(14.1% 0.005 285.823); // #1a202c
|
$color-background: oklch(14.1% 0.005 285.823); // #09090b
|
||||||
$color-secondary-background: oklch(26.862% 0.00003 271.152);
|
$color-secondary-background: oklch(26.862% 0.00003 271.152);
|
||||||
$color-text: rgba(255, 255, 255, 0.92);
|
$color-text: rgba(255, 255, 255, 0.92);
|
||||||
$color-placeholder-text: rgba(255, 255, 255, 0.44);
|
$color-placeholder-text: rgba(255, 255, 255, 0.44);
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
@use "reset" as *;
|
@use "reset" as *;
|
||||||
@use "util" as *;
|
@use "util" as *;
|
||||||
|
@use "variables" as *;
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
|
||||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||||
color: white;
|
color: white;
|
||||||
|
background-color: $color-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: $color-background;
|
||||||
}
|
}
|
||||||
|
|
||||||
main > h1 {
|
main > h1 {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
{
|
{
|
||||||
|
|||||||
+98
-11
@@ -7,24 +7,55 @@ export interface DropdownMenuItem {
|
|||||||
|
|
||||||
interface DropdownMenuProps {
|
interface DropdownMenuProps {
|
||||||
items: DropdownMenuItem[][]
|
items: DropdownMenuItem[][]
|
||||||
|
label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuProps>()
|
const props = defineProps<DropdownMenuProps>()
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
const mounted = ref(false)
|
||||||
const placement = ref<"bottom" | "top">("bottom")
|
const placement = ref<"bottom" | "top">("bottom")
|
||||||
const root = useTemplateRef("root")
|
const root = useTemplateRef("root")
|
||||||
|
const triggerRef = useTemplateRef<HTMLButtonElement>("trigger-ref")
|
||||||
|
const menuRef = useTemplateRef<HTMLDivElement>("menu-ref")
|
||||||
const menuPosition = ref({ top: 0, left: 0 })
|
const menuPosition = ref({ top: 0, left: 0 })
|
||||||
|
|
||||||
const toggle = async () => {
|
const menuItems = () =>
|
||||||
open.value = !open.value
|
Array.from(
|
||||||
|
menuRef.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
if (open.value) {
|
const focusMenuItem = (index: number) => {
|
||||||
|
const items = menuItems()
|
||||||
|
items[index]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = (restoreFocus = false) => {
|
||||||
|
open.value = false
|
||||||
|
if (restoreFocus) nextTick(() => triggerRef.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenu = async (last = false) => {
|
||||||
|
open.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
updatePlacement()
|
updatePlacement()
|
||||||
|
const items = menuItems()
|
||||||
|
focusMenuItem(last ? items.length - 1 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
if (open.value) close(true)
|
||||||
|
else await openMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleTriggerKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!open.value) void openMenu(event.key === "ArrowUp")
|
||||||
|
else focusMenuItem(event.key === "ArrowUp" ? menuItems().length - 1 : 0)
|
||||||
}
|
}
|
||||||
const close = () => (open.value = false)
|
|
||||||
|
|
||||||
const updatePlacement = () => {
|
const updatePlacement = () => {
|
||||||
const el = root.value
|
const el = root.value
|
||||||
@@ -47,12 +78,34 @@ const updatePlacement = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOutsideClick = (e: MouseEvent) => {
|
const handleOutsideClick = (e: MouseEvent) => {
|
||||||
if (!root.value) return
|
const target = e.target as Node
|
||||||
if (!root.value.contains(e.target as Node)) close()
|
if (root.value?.contains(target) || menuRef.value?.contains(target)) return
|
||||||
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key == "Escape") close()
|
if (!open.value) return
|
||||||
|
const items = menuItems()
|
||||||
|
const activeIndex = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
close(true)
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
focusMenuItem((activeIndex + 1 + items.length) % items.length)
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
focusMenuItem((activeIndex - 1 + items.length) % items.length)
|
||||||
|
} else if (e.key === "Home") {
|
||||||
|
e.preventDefault()
|
||||||
|
focusMenuItem(0)
|
||||||
|
} else if (e.key === "End") {
|
||||||
|
e.preventDefault()
|
||||||
|
focusMenuItem(items.length - 1)
|
||||||
|
} else if (e.key === "Tab") {
|
||||||
|
close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReposition = () => {
|
const handleReposition = () => {
|
||||||
@@ -64,9 +117,10 @@ onMounted(() => {
|
|||||||
document.addEventListener("keydown", handleKey)
|
document.addEventListener("keydown", handleKey)
|
||||||
window.addEventListener("resize", handleReposition)
|
window.addEventListener("resize", handleReposition)
|
||||||
window.addEventListener("scroll", handleReposition, true)
|
window.addEventListener("scroll", handleReposition, true)
|
||||||
|
mounted.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener("click", handleOutsideClick)
|
document.removeEventListener("click", handleOutsideClick)
|
||||||
document.removeEventListener("keydown", handleKey)
|
document.removeEventListener("keydown", handleKey)
|
||||||
window.removeEventListener("resize", handleReposition)
|
window.removeEventListener("resize", handleReposition)
|
||||||
@@ -76,15 +130,26 @@ onBeforeMount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="root" class="dropdown">
|
<div ref="root" class="dropdown">
|
||||||
<div @click="toggle" class="trigger">
|
<button
|
||||||
|
ref="trigger-ref"
|
||||||
|
type="button"
|
||||||
|
class="trigger"
|
||||||
|
:aria-label="props.label ?? 'Open options'"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click="toggle"
|
||||||
|
@keydown="handleTriggerKeydown"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<Teleport to="body">
|
<Teleport v-if="mounted" to="body">
|
||||||
<div
|
<div
|
||||||
|
ref="menu-ref"
|
||||||
v-show="open"
|
v-show="open"
|
||||||
class="dropdown-menu"
|
class="dropdown-menu"
|
||||||
|
role="menu"
|
||||||
:class="placement"
|
:class="placement"
|
||||||
:style="{
|
:style="{
|
||||||
top: `${menuPosition.top}px`,
|
top: `${menuPosition.top}px`,
|
||||||
@@ -95,11 +160,13 @@ onBeforeMount(() => {
|
|||||||
v-for="(group, i) in items"
|
v-for="(group, i) in items"
|
||||||
:key="`group-${i}`"
|
:key="`group-${i}`"
|
||||||
class="group"
|
class="group"
|
||||||
|
role="group"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="(item, i) in group"
|
v-for="(item, i) in group"
|
||||||
:key="`item-${i}`"
|
:key="`item-${i}`"
|
||||||
class="item"
|
class="item"
|
||||||
|
role="menuitem"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
item.onSelect?.()
|
item.onSelect?.()
|
||||||
@@ -125,6 +192,22 @@ onBeforeMount(() => {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.trigger {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -184,6 +267,10 @@ onBeforeMount(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover {
|
.item:hover {
|
||||||
|
|||||||
+45
-3
@@ -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,
|
||||||
|
|||||||
+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>
|
||||||
@@ -27,6 +27,7 @@ const song = useSong()
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ const song = useSong()
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
transition:
|
transition:
|
||||||
transform 0.2s ease,
|
transform 0.2s ease,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+57
-52
@@ -1,78 +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 defaults: AxiosRequestConfig = {
|
query: params ?? (fetchOptions as FetchOptions).query,
|
||||||
baseURL: baseUrl,
|
body: data ?? (fetchOptions as FetchOptions).body,
|
||||||
|
headers,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authorize) {
|
if (
|
||||||
const { sessionToken, refreshSessionToken } = useAuth()
|
requestOptions.body &&
|
||||||
|
(typeof requestOptions.body === "object" || forceFormData) &&
|
||||||
let token = sessionToken.value
|
(hasFile(requestOptions.body) || forceFormData)
|
||||||
|
) {
|
||||||
if (token) defaults.headers = { Authorization: `Bearer ${token}` }
|
|
||||||
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
|
||||||
const { sessionToken, refreshSessionToken } = useAuth()
|
|
||||||
|
|
||||||
if (!config.headers) config.headers = {}
|
const refreshed = await useAuth().refreshSession()
|
||||||
config.headers.Authorization = `Bearer ${await refreshSessionToken()}`
|
if (!refreshed.ok) throw error
|
||||||
|
|
||||||
response = await axios(url, config)
|
if (import.meta.server && refreshed.sessionCookie) {
|
||||||
} else throw error
|
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("; "))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response) throw new Error("Something went wrong. Please try again")
|
return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT
|
||||||
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,9 @@
|
|||||||
|
import { defineConfig } from "golar/unstable"
|
||||||
|
|
||||||
|
import "@golar/vue"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
typecheck: {
|
||||||
|
include: ["app/**/*.{ts,tsx,vue}", "server/**/*.ts", "nuxt.config.ts"],
|
||||||
|
},
|
||||||
|
})
|
||||||
+65
-4
@@ -3,8 +3,72 @@ import { fileURLToPath } from "node:url"
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: "2025-07-15",
|
compatibilityDate: "2025-07-15",
|
||||||
|
nitro: {
|
||||||
|
preset: "node-server",
|
||||||
|
externals: {
|
||||||
|
inline: ["vue"],
|
||||||
|
},
|
||||||
|
},
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"],
|
runtimeConfig: {
|
||||||
|
// Private server configuration. Matching NUXT_* variables override these
|
||||||
|
// defaults at runtime in the built Nitro server. Legacy variable names
|
||||||
|
// are resolved by server utilities at runtime, never during this build.
|
||||||
|
mongodbUri: "",
|
||||||
|
tokenExpire: "",
|
||||||
|
s3Endpoint: "",
|
||||||
|
s3Bucket: "",
|
||||||
|
s3Region: "us-east-1",
|
||||||
|
s3AccessKeyId: "",
|
||||||
|
s3SecretAccessKey: "",
|
||||||
|
public: {
|
||||||
|
mediaBaseUrl: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image", "@vite-pwa/nuxt"],
|
||||||
|
pwa: {
|
||||||
|
strategies: "injectManifest",
|
||||||
|
srcDir: "../public",
|
||||||
|
filename: "sw.js",
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
registerWebManifestInRouteRules: true,
|
||||||
|
injectManifest: {
|
||||||
|
injectionPoint: undefined,
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
id: "/",
|
||||||
|
name: "Music",
|
||||||
|
short_name: "Music",
|
||||||
|
description: "Your personal music library and player.",
|
||||||
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#09090b",
|
||||||
|
theme_color: "#09090b",
|
||||||
|
categories: ["music", "entertainment"],
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "/icons/pwa-192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/icons/pwa-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/icons/pwa-maskable-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
path: "~/ui",
|
path: "~/ui",
|
||||||
@@ -15,7 +79,4 @@ export default defineNuxtConfig({
|
|||||||
pathPrefix: true,
|
pathPrefix: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
image: {
|
|
||||||
domains: ["api.music.lucasskt.dk"],
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
Generated
+6885
-2395
File diff suppressed because it is too large.
Load diff
+13
-4
@@ -3,27 +3,36 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build && bun scripts/copy-nitro-public-assets.ts",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare",
|
||||||
|
"typecheck": "nuxt typecheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1142.0",
|
||||||
"@nuxt/fonts": "0.11.4",
|
"@nuxt/fonts": "0.11.4",
|
||||||
"@nuxt/icon": "^2.1.0",
|
"@nuxt/icon": "^2.1.0",
|
||||||
"@nuxt/image": "^2.0.0",
|
"@nuxt/image": "^2.0.0",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
"axios": "^1.13.2",
|
"argon2": "^0.45.1",
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"fuse.js": "^7.5.0",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"nuxt": "^4.2.0",
|
"mongoose": "^9.10.2",
|
||||||
|
"nuxt": "^4.5.2",
|
||||||
|
"parse-duration": "^2.1.9",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@golar/vue": "^0.1.10",
|
||||||
"@types/node": "^24.9.1",
|
"@types/node": "^24.9.1",
|
||||||
|
"@vite-pwa/nuxt": "^1.1.1",
|
||||||
|
"golar": "^0.1.10",
|
||||||
"sass-embedded": "^1.93.2"
|
"sass-embedded": "^1.93.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#08090b" />
|
||||||
|
<path
|
||||||
|
d="M96 352 174 176l78 176h44l70-138 64 138"
|
||||||
|
fill="none"
|
||||||
|
stroke="#00db81"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="34"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "Music",
|
||||||
|
"short_name": "Music",
|
||||||
|
"description": "Your personal music library and player.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#09090b",
|
||||||
|
"theme_color": "#09090b",
|
||||||
|
"categories": ["music", "entertainment"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/pwa-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/pwa-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/pwa-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<title>Offline · Music</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #09090b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 26rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #b8b8bd;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #00db81;
|
||||||
|
color: #09090b;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<img src="/icons/pwa-192.png" alt="" />
|
||||||
|
<h1>You’re offline</h1>
|
||||||
|
<p>Reconnect to browse your library and play music.</p>
|
||||||
|
<button type="button" onclick="location.reload()">Try again</button>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const SHELL_CACHE = "music-pwa-shell-v1"
|
||||||
|
const ASSET_CACHE = "music-pwa-assets-v1"
|
||||||
|
const OFFLINE_PAGE = "/offline.html"
|
||||||
|
const SHELL_ASSETS = [
|
||||||
|
OFFLINE_PAGE,
|
||||||
|
"/icons/pwa-192.png",
|
||||||
|
"/icons/pwa-512.png",
|
||||||
|
"/icons/pwa-maskable-512.png",
|
||||||
|
"/icons/apple-touch-icon.png",
|
||||||
|
]
|
||||||
|
const SHELL_ASSET_PATHS = new Set(SHELL_ASSETS)
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SHELL_CACHE)
|
||||||
|
.then((cache) => cache.addAll(SHELL_ASSETS))
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(async (keys) => {
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter(
|
||||||
|
(key) =>
|
||||||
|
key.startsWith("music-pwa-") &&
|
||||||
|
key !== SHELL_CACHE &&
|
||||||
|
key !== ASSET_CACHE,
|
||||||
|
)
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
)
|
||||||
|
await self.clients.claim()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const request = event.request
|
||||||
|
if (request.method !== "GET") return
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.origin !== self.location.origin) return
|
||||||
|
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(async () => {
|
||||||
|
const offlinePage = await caches.match(OFFLINE_PAGE)
|
||||||
|
return offlinePage ?? Response.error()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SHELL_ASSET_PATHS.has(url.pathname)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => cached ?? fetch(request)),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/_nuxt/")) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(ASSET_CACHE).then(async (cache) => {
|
||||||
|
const cached = await cache.match(request)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const response = await fetch(request)
|
||||||
|
if (response.ok) {
|
||||||
|
await cache.put(request, response.clone())
|
||||||
|
const entries = await cache.keys()
|
||||||
|
if (entries.length > 100) await cache.delete(entries[0])
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cp, mkdir } from "node:fs/promises"
|
||||||
|
import { resolve } from "node:path"
|
||||||
|
|
||||||
|
const publicOutput = resolve(".output/public")
|
||||||
|
const nitroAssetDirectory = resolve(".output/server/chunks/public")
|
||||||
|
const nuxtAssetsDirectory = resolve(publicOutput, "_nuxt")
|
||||||
|
|
||||||
|
await mkdir(nitroAssetDirectory, { recursive: true })
|
||||||
|
await cp(publicOutput, nitroAssetDirectory, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
filter: (path) => path !== nuxtAssetsDirectory,
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user