Author SHA1 Message Date
lucast b483ad84e4 Add album table search and pagination 2026-10-06 03:03:42 +02:00
lucast 900b2b8abf Add artist table search and pagination 2026-10-06 03:03:41 +02:00
lucast 12031cb481 Add genre table search and pagination 2026-10-06 03:03:39 +02:00
lucast 00b482e62a Fade between table pages 2026-10-06 03:02:55 +02:00
lucast 70ef737f19 Paginate songs admin table 2026-10-06 03:02:49 +02:00
lucast 1ffe483e70 Use themed search clear button 2026-10-06 03:00:22 +02:00
lucast 53aa702758 Enable songs table search 2026-10-06 03:00:20 +02:00
lucast 148c5b267d Handle loading table data 2026-10-06 03:00:17 +02:00
lucast 24d4f2e577 Added table search 2026-10-06 02:52:56 +02:00
lucast d9e1cb1c6f Added table pagination 2026-10-06 02:52:26 +02:00
lucast 440b6ef817 Align H3 handler helper imports 2026-10-06 02:50:04 +02:00
lucast c70b840520 Guard player frequency samples 2026-10-06 02:47:37 +02:00
lucast 4d4c0f0e3a Handle empty search query arrays 2026-10-06 02:47:23 +02:00
lucast 7d42c6edb1 Handle unset synced lyrics height 2026-10-06 02:47:04 +02:00
lucast 95f6199cea Render admin field keys as labels 2026-10-06 02:46:42 +02:00
lucast d76ecf6063 Type text input events 2026-10-06 02:46:25 +02:00
lucast 379acf400c Handle optional API data in pages 2026-10-06 02:46:01 +02:00
lucast a6485e5c73 Guard palette array indexing 2026-10-06 02:45:08 +02:00
lucast 8f1cc8d4a7 Avoid typed route expansion in API fetch 2026-10-06 02:44:31 +02:00
lucast dec7f46365 Imported file helpers into Vue templates 2026-10-06 02:43:27 +02:00
lucast 58338d0e8d Merge dynamic background 2026-10-06 02:34:50 +02:00
lucast b190d1671c Optimize fullscreen rendering 2026-10-06 02:33:07 +02:00
lucast b696da1854 Move fullscreen wash to WebGPU 2026-10-06 02:11:56 +02:00
lucast ebd6fedd89 Cap fullscreen pulse brightness 2026-10-06 01:56:46 +02:00
lucast d07221f600 Adapt fullscreen beat response 2026-10-05 22:05:08 +02:00
lucast 27c289483b Tune fullscreen beat response 2026-10-05 21:48:48 +02:00
lucast 1d6422f422 Reduce banding in fullscreen gradient 2026-10-05 21:19:13 +02:00
lucast ffdb159d57 Randomize fullscreen gradient motion 2026-10-05 21:14:46 +02:00
lucast 3001267393 Use isolation style fullscreen gradient 2026-10-05 21:11:05 +02:00
lucast 29b6418e2c Refine fullscreen album color wash 2026-10-05 20:59:41 +02:00
lucast a1e993facb Add album color fullscreen background 2026-10-05 20:44:47 +02:00
lucast 7a80ac8f3f First try 2026-10-05 19:01:45 +02:00
lucast 7ae68592a2 Bundle Vue in server build 2026-10-05 18:38:02 +02:00
lucast 84658e4499 Added saved artists page 2026-10-05 18:28:47 +02:00
lucast 8df84bce86 Added saved albums page 2026-10-05 18:22:01 +02:00
lucast af07bf2249 Added library track menus 2026-10-05 18:20:34 +02:00
lucast 0526e83a9b Added saved library API 2026-10-05 17:54:29 +02:00
lucast 36d05ab04f Set up Golar typechecking 2026-10-05 17:52:52 +02:00
lucast 1eef02ccfa Upgraded Nuxt 2026-10-05 17:35:43 +02:00
lucast e908ae27ad Added song loading state 2026-10-05 03:46:47 +02:00
lucast 869ca7d728 Added track navigation menu items 2026-10-05 02:49:24 +02:00
lucast 51114d65c8 Handle unsynced lyrics marked synced 2026-10-05 02:22:06 +02:00
lucast fb99058492 Allow bearer auth for admin API 2026-10-05 02:01:40 +02:00
lucast 142e34babe Return session token 2026-10-05 01:54:37 +02:00
lucast 45ae19149f Refresh lyrics position on open 2026-10-05 01:10:53 +02:00
lucast bdfa76f993 Reset title when queue ends 2026-10-05 00:26:34 +02:00
lucast 30541eab8d Cache album slider data 2026-10-04 22:01:34 +02:00
lucast 3393c133fd Update readme 2026-10-04 21:52:07 +02:00
lucast e89463591c Use musicSession cookie for auth 2026-10-02 00:20:38 +02:00
lucast e2c47e6d0f Gate debug guide and disclaimer by cookie 2026-09-30 05:18:25 +02:00
lucast bfaa4acb25 Align fullscreen lyrics across line wraps 2026-09-30 05:18:08 +02:00
lucast 4caa9135ee Fixed fullscreen lyrics alignment 2026-09-30 04:32:38 +02:00
lucast cc0623ef4a Add page titles 2026-09-30 01:50:01 +02:00
lucast e2e2fc7755 Enable progressive audio playback 2026-09-30 01:30:30 +02:00
lucast e02cb83235 Fixed model registration 2026-09-30 01:22:49 +02:00
lucast 492a54dd0d Upload processed audio tracks to S3 2026-09-29 17:03:19 +02:00
lucast 0d09198e57 Store artist and album artwork in S3 2026-09-29 17:03:08 +02:00
lucast c2dcec2c31 Add S3 storage configuration and client 2026-09-29 17:02:57 +02:00
lucast 39cb48a656 Remove legacy static media route 2026-09-29 16:22:53 +02:00
lucast 069ef37825 Build media URLs from public bucket config 2026-09-29 16:22:22 +02:00
lucast 19e84d774c Add bucket backed media reads 2026-09-29 16:22:22 +02:00
lucast 8cd0d5ca89 Update API schema and deployment guidance 2026-09-29 15:57:39 +02:00
lucast 0cf29e0e06 Harden Nitro runtime and health handling 2026-09-29 15:57:31 +02:00
lucast f377424d3a Normalize protected read API responses 2026-09-29 15:57:25 +02:00
lucast a2b8dec813 Normalize admin API auth responses 2026-09-29 15:57:14 +02:00
lucast 31e6c0c5d2 Harden cookie authentication 2026-09-29 15:57:07 +02:00
lucast a0a62c1c06 Removed obsolete backend dependencies 2026-09-29 15:32:08 +02:00
lucast 47db7abd6a Switched frontend API calls to Nuxt 2026-09-29 13:33:48 +02:00
lucast 4621a81384 Added API schema and docs routes 2026-09-29 01:54:51 +02:00
lucast bca41b5e6f Migrated admin and media APIs to Nitro 2026-09-29 01:22:29 +02:00
lucast ee5809a489 Migrated read APIs to Nitro 2026-09-29 00:34:50 +02:00
lucast eb4e9f0700 Migrated auth to Nitro 2026-09-29 00:23:30 +02:00
lucast 27feb1ce95 Smooth lyrics fade 2026-09-26 22:27:15 +02:00
lucast e50648e35f Moved lyrics a bit down 2026-09-26 22:19:22 +02:00
lucast b5f7aa1372 Add media session controls 2026-09-26 22:18:33 +02:00
lucast 5e3f3ef6a2 Fix single track artist fields 2026-09-26 00:21:59 +02:00
lucast 661b9341d7 Add backend dependencies 2026-09-26 00:14:00 +02:00
115 changed files with 12920 additions and 3365 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Copy this file to .env for local development. Use real, private values locally.
NUXT_MONGODB_URI=mongodb://127.0.0.1:27017/music-v2
NUXT_PUBLIC_MEDIA_BASE_URL=https://s3-seaweedfs.lucasskt.dk/music
NUXT_S3_ENDPOINT=https://s3-seaweedfs.lucasskt.dk
NUXT_S3_BUCKET=music
NUXT_S3_REGION=us-east-1
NUXT_S3_ACCESS_KEY_ID=
NUXT_S3_SECRET_ACCESS_KEY=
NUXT_TOKEN_EXPIRE=1h
+1 -1
View File
@@ -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.
+16 -62
View File
@@ -1,75 +1,29 @@
# Nuxt Minimal Starter # Music v2
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. Yet another music platform.
## Setup This is a rewrite of my [first version](https://github.com/Sniperhund/Music), everything is contained in this repo instead of v1 (v1 had a separate frontend + backend)<br />
I got tired and irritated by trying to add new features to v1, so here we are nearly 5 months in development.
Make sure to install dependencies: It's a Nuxt 4 full stack application, hook up a S3 bucket and MongoDB service and you're basically good to go (check .env.example)
```bash This project is mostly a inspiration of Apple Music (my go-to platform) with my personal design choices and the "I can do it" mindset.
# npm
npm install
# pnpm ### Debug view
pnpm install
# yarn There's a debug view (now only for lyric alignemt), just set `DEBUG_VIEW` to `1`. It will likely be expanded.
yarn install
# bun ## Local setup
Install dependencies and create a local environment file:
```sh
bun install bun install
cp -n .env.example .env
``` ```
## Development Server Run the app at <http://localhost:3000>:
Start the development server on `http://localhost:3000`: ```sh
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev bun run dev
``` ```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+31
View File
@@ -1,11 +1,42 @@
<script setup lang="ts">
const { currentSong, isPlaying } = usePlayer()
const debugView = useCookie("DEBUG_VIEW")
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
useHead(
computed(() => ({
titleTemplate:
currentSong.value && isPlaying.value
? `${currentSong.value.name} - Now Playing - Music`
: (titleChunk) => `${titleChunk || "Home"} - Music`,
})),
)
</script>
<template> <template>
<ToastContainer /> <ToastContainer />
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
<Player /> <Player />
</NuxtLayout> </NuxtLayout>
<div v-if="debugViewEnabled" class="debug-view-disclaimer">
Debug view: everything may not align correctly on different screens.
It's made for 1920x1080
</div>
</template> </template>
<style lang="scss"> <style lang="scss">
@use "styles/global.scss"; @use "styles/global.scss";
.debug-view-disclaimer {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 200;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 0.875rem;
}
</style> </style>
+24 -4
View File
@@ -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>
+32 -18
View File
@@ -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,7 +46,7 @@ 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"
/> />
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
import type { Artist } from "@/utils/types"
const props = defineProps<{ artist: Artist }>()
</script>
<template>
<article class="card">
<NuxtLink :to="`/artist/${props.artist._id}`">
<NuxtImg
:src="GET_FILE(props.artist.file)"
:alt="props.artist.name"
placeholder
/>
<p>{{ props.artist.name }}</p>
</NuxtLink>
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
@use "@/styles/variables" as *;
.card {
@include fontSize(14px);
display: flex;
flex-direction: column;
justify-content: start;
& > a {
& img {
border-radius: $border-radius-standard;
}
& p {
margin-top: 0.3rem;
}
}
}
</style>
+22 -23
View File
@@ -1,7 +1,8 @@
<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)
onMounted(() => { onMounted(() => {
@@ -15,11 +16,18 @@ 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)
}) })
}) })
@@ -37,12 +45,9 @@ nuxtApp.hook("page:finish", () => {
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>
@@ -54,11 +59,16 @@ nuxtApp.hook("page:finish", () => {
@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"
/>
<PlayerFullscreenLyricsDisplay <PlayerFullscreenLyricsDisplay
:animation-duration="0.2" :animation-duration="0.2"
:offset="150" :offset="250"
:show-scroll-bar="mouseMovedRecently" :show-scroll-bar="mouseMovedRecently"
/> />
</div> </div>
@@ -72,8 +82,8 @@ nuxtApp.hook("page:finish", () => {
top: 0; top: 0;
left: 0; left: 0;
width: 200vw; width: 100vw;
height: 200vh; height: 100vh;
z-index: 150; z-index: 150;
cursor: none; cursor: none;
@@ -83,17 +93,6 @@ 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; top: 0;
@@ -102,7 +101,7 @@ nuxtApp.hook("page:finish", () => {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: rgba(0, 0, 0, 0.45); background-color: rgba(0, 0, 0, 0.34);
} }
.close-btn { .close-btn {
@@ -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,22 +50,45 @@ 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 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
top.value = trackDisplay.getBoundingClientRect().top
syncedHeight.value = window.innerHeight - top.value - 100
}
height.value = trackDisplay.clientHeight const lines = containerRef.value?.children
top.value = trackDisplay.getBoundingClientRect().top if (!lines) {
syncedHeight.value = window.innerHeight - top.value - 100 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 },
) )
@@ -72,24 +96,25 @@ watch(
onMounted(() => window.addEventListener("resize", updateLyricsLayout)) onMounted(() => window.addEventListener("resize", updateLyricsLayout))
onUnmounted(() => window.removeEventListener("resize", updateLyricsLayout)) onUnmounted(() => window.removeEventListener("resize", updateLyricsLayout))
const transformY = computed(() => {
if (activeIndex.value < 0) return 0
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
}
return -accumulatedHeight
})
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 +124,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 +134,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 +158,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: `${top - topFadeExtension}px`,
}" }"
@wheel.prevent="onWheel" @wheel.prevent="onWheel"
> >
@@ -160,8 +200,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 +252,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 +269,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);
}
}
} }
} }
@@ -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()
+2
View File
@@ -1,4 +1,6 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { GET_FILE } from "@/utils/file"
const widthFixerRef = useTemplateRef("width-fixer-ref") const widthFixerRef = useTemplateRef("width-fixer-ref")
const playerRef = useTemplateRef("player-ref") const playerRef = useTemplateRef("player-ref")
+53 -3
View File
@@ -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,7 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
<ArtistName :artists="track.artists" class="artists" /> <ArtistName :artists="track.artists" class="artists" />
</div> </div>
<DropdownMenu :items="dropdownMenuItems"> <DropdownMenu :items="visibleDropdownMenuItems">
<Icon name="lucide:ellipsis" class="dropdown-icon" /> <Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu> </DropdownMenu>
</article> </article>
+58 -4
View File
@@ -18,13 +18,20 @@ interface TrackRowProps {
} }
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 +59,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 ""
@@ -140,7 +194,7 @@ 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">
<Icon name="lucide:ellipsis" class="dropdown-icon" /> <Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu> </DropdownMenu>
</article> </article>
+15 -16
View File
@@ -6,33 +6,32 @@ export function useApiFetch<DataT = any, ErrorT = any>(
options?: UseFetchOptions<DataT, ErrorT>, options?: UseFetchOptions<DataT, ErrorT>,
authorize: boolean = true, authorize: boolean = true,
): ReturnType<typeof useFetch<DataT, ErrorT>> { ): ReturnType<typeof useFetch<DataT, ErrorT>> {
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND const { refreshSession } = useAuth()
if (!baseUrl) throw new Error("BACKEND URL not set")
const { sessionToken, refreshSessionToken } = useAuth()
const defaults: UseFetchOptions<DataT, ErrorT> = { const defaults: UseFetchOptions<DataT, ErrorT> = {
baseURL: baseUrl, // The migrated API lives in this Nuxt app under server/api.
baseURL: "/api",
retry: 1, retry: 1,
retryDelay: 0, retryDelay: 0,
retryStatusCodes: [401], retryStatusCodes: [401],
headers: headers: {},
authorize && sessionToken.value
? { Authorization: `Bearer ${sessionToken.value}` }
: {},
onResponseError: async ({ request, response, options }) => { onResponseError: async ({ request, response, options }) => {
if (response.status == 401 && authorize) { if (response.status == 401 && authorize) {
const newToken = await refreshSessionToken() const refreshed = await refreshSession()
if (!newToken) return if (!refreshed.ok) return
options.headers = { if (import.meta.server && refreshed.sessionCookie) {
...(options.headers as unknown as Record<string, string>), const headers = new Headers(options.headers as HeadersInit)
// @ts-ignore const cookies = (headers.get("cookie") ?? "")
Authorization: `Bearer ${newToken}`, .split(";")
.map((cookie) => cookie.trim())
.filter((cookie) => cookie && !cookie.startsWith("musicSession="))
cookies.push(refreshed.sessionCookie)
headers.set("cookie", cookies.join("; "))
options.headers = headers
} }
} }
}, },
+73 -122
View File
@@ -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",
body: { email, password, remember: Boolean(remember) },
})
if (!(await refreshSession()).ok) throw new Error("Unable to create session")
await navigateTo("/")
} catch (error: any) {
throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign in")
}
}
const signup = async (name: string, email: string, password: string) => {
try {
await $fetch("/api/auth/register", {
method: "POST",
body: { name, email, password },
})
if (!(await refreshSession()).ok) throw new Error("Unable to create session")
await navigateTo("/")
} catch (error: any) {
throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign up")
}
}
const signout = async () => {
try {
await $fetch("/api/auth/signout", { method: "POST" })
} finally {
authenticated.value = false
}
}
const refreshSession = async (): Promise<SessionRefresh> => {
const inFlight = refreshPromises.get(nuxtApp)
if (inFlight) return inFlight
const refreshPromise = (async () => {
const response = await $fetch.raw<{ expireAt: string }>(
"/api/auth/session",
{ {
method: "POST", method: "POST",
data: { headers: import.meta.server
email, ? useRequestHeaders(["cookie"])
password, : undefined,
},
}, },
false,
) )
// NOTE: Quite hacky... this should be changed. let sessionCookie: string | undefined
// TODO: Add httpOnly and update the backend to support that. const setCookies = import.meta.server ? response.headers.getSetCookie() : []
if (remember) { for (const cookie of setCookies) {
setCookie("refreshToken", res.data.refreshToken, { if (!cookie.startsWith("musicSession=")) continue
maxAge: 60 * 60 * 24 * 30, // 30 days sessionCookie = cookie.split(";", 1)[0]
})
} else {
setCookie("refreshToken", res.data.refreshToken)
}
//refreshToken.value = res.data.refreshToken
await refreshSessionToken()
await navigateTo("/")
} catch (e) {
if (axios.isAxiosError(e) && e.response?.data?.message) {
throw new Error(e.response.data.message)
} }
throw new Error("Failed to sign in") if (import.meta.server) {
} const event = useRequestEvent()
} if (event) {
for (const cookie of setCookies) {
const signup = async (name: string, email: string, password: string) => {} appendResponseHeader(event, "set-cookie", cookie)
}
const signout = () => {
refreshToken.value = null
sessionToken.value = null
}
const refreshSessionToken = async () => {
if (waitForPromise) {
// Force it to wait up to 50 ms for it to set refreshPromise, hopefully less
for (let i = 0; i < 10; i++) {
if (refreshPromise != null) {
break
} }
await new Promise((r) => setTimeout(r, 5))
} }
}
waitForPromise = true return { ok: true, sessionCookie }
})()
if (refreshPromise != null) { .then((result) => {
waitForPromise = false authenticated.value = true
return refreshPromise return result
}
refreshPromise = cfetch(
"/auth/session",
{
data: {
refreshToken: refreshToken.value,
},
method: "POST",
},
false,
)
.then((res) => {
const token = res.data.sessionToken
sessionToken.value = token
refreshPromise = null
return token
}) })
.catch((e) => { .catch(() => {
refreshPromise = null authenticated.value = false
sessionToken.value = null return { ok: false }
return null
}) })
.finally(() => { .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,
} }
} }
+64
View File
@@ -0,0 +1,64 @@
import { cfetch } from "@/utils/cfetch"
import { toValue, type MaybeRefOrGetter } from "vue"
interface TrackMembership {
contains: boolean
}
export const useTrackLibrary = (trackId: MaybeRefOrGetter<string | undefined>) => {
const toast = useToast()
const isSaved = ref<boolean | null>(null)
const saving = ref(false)
const checkMembership = async () => {
const id = toValue(trackId)
if (!id) return
try {
const result = await cfetch<TrackMembership>("/user/tracks/contains", {
params: { id },
})
isSaved.value = result.contains
} catch (error: any) {
toast.show(
error?.data?.message || "Could not check Library status",
"error",
)
}
}
onMounted(checkMembership)
const toggleSaved = async () => {
const id = toValue(trackId)
if (!id || isSaved.value === null || saving.value) return false
const wasSaved = isSaved.value
saving.value = true
try {
await cfetch("/user/tracks", {
method: wasSaved ? "DELETE" : "PATCH",
params: { id },
})
isSaved.value = !wasSaved
toast.show(
wasSaved ? "Removed from Library" : "Saved to Library",
"success",
)
return wasSaved
} catch (error: any) {
toast.show(
error?.data?.message ||
(wasSaved
? "Could not remove song from Library"
: "Could not save song to Library"),
"error",
)
return false
} finally {
saving.value = false
}
}
return { isSaved, saving, toggleSaved }
}
+38 -4
View File
@@ -1,6 +1,40 @@
export default defineNuxtRouteMiddleware((to, from) => { import { appendResponseHeader } from "h3"
const auth = useAuth()
if (!auth.refreshToken.value && !to.fullPath.includes("/auth")) export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo("/auth/signin") if (to.path.startsWith("/auth")) return
const auth = useAuth()
if (import.meta.client && auth.authenticated.value) return
try {
// Forward the incoming SSR credentials to this endpoint; the browser sends
// same-origin cookies automatically during client navigation.
const requestHeaders = import.meta.server
? useRequestHeaders(["cookie", "authorization"])
: undefined
const response = await $fetch.raw<{
authenticated: boolean
}>("/api/auth/session", { headers: requestHeaders })
const session = response._data
// Nuxt forwards incoming request cookies automatically, but internal SSR
// fetch responses do not forward Set-Cookie to the browser automatically.
if (import.meta.server) {
const event = useRequestEvent()
if (event) {
for (const cookie of response.headers.getSetCookie()) {
appendResponseHeader(event, "set-cookie", cookie)
}
}
}
if (session?.authenticated) {
auth.authenticated.value = true
return
}
} catch {
// Treat unavailable auth state as unauthenticated.
}
return navigateTo("/auth/signin")
}) })
+1
View File
@@ -2,6 +2,7 @@
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
}) })
useHead({ title: "Admin" })
</script> </script>
<template> <template>
+5 -5
View File
@@ -14,7 +14,6 @@ const tableRows: Row[] = [
name: "file", name: "file",
displayName: "Cover", displayName: "Cover",
type: "image", type: "image",
prefix: `${BACKEND_SERVE}`,
}, },
{ {
name: "name", name: "name",
@@ -33,7 +32,7 @@ import { type Option } from "~/ui/SearchSelect.vue"
const { data: artistData } = useApiFetch<Artist[]>("/all/artists") const { data: artistData } = useApiFetch<Artist[]>("/all/artists")
const artistFetchOptions = async (q?: string): Promise<Option[]> => { const artistFetchOptions = async (q?: string): Promise<Option[]> => {
return artistData.value.map<Option>((g) => ({ return (artistData.value ?? []).map<Option>((g) => ({
label: g.name, label: g.name,
value: g._id, value: g._id,
})) }))
@@ -41,7 +40,7 @@ const artistFetchOptions = async (q?: string): Promise<Option[]> => {
const { data: genreData } = useApiFetch<Genre[]>("/all/genres") const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
const genreFetchOptions = async (q?: string): Promise<Option[]> => { const genreFetchOptions = async (q?: string): Promise<Option[]> => {
return genreData.value.map<Option>((g) => ({ return (genreData.value ?? []).map<Option>((g) => ({
label: g.name, label: g.name,
value: g._id, value: g._id,
})) }))
@@ -114,7 +113,7 @@ const show = (id?: string) => {
return return
} }
const curItem: any = data.value.filter((i) => i._id == id)[0] const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return if (!curItem) return
@@ -138,12 +137,13 @@ const show = (id?: string) => {
showModal.value = true showModal.value = true
} }
useHead({ title: "Admin - Albums" })
</script> </script>
<template> <template>
<h1 class="title">Manage albums</h1> <h1 class="title">Manage albums</h1>
<Table :rows="tableRows" :data="data" class="table"> <Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }"> <template #action="{ item, index }">
<div class="action"> <div class="action">
<Button @click="show(item._id)">Edit</Button> <Button @click="show(item._id)">Edit</Button>
+3 -3
View File
@@ -14,7 +14,6 @@ const tableRows: Row[] = [
name: "file", name: "file",
displayName: "Cover", displayName: "Cover",
type: "image", type: "image",
prefix: `${BACKEND_SERVE}`,
}, },
{ {
name: "name", name: "name",
@@ -80,7 +79,7 @@ const show = (id?: string) => {
return return
} }
const curItem: any = data.value.filter((i) => i._id == id)[0] const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return if (!curItem) return
@@ -88,12 +87,13 @@ const show = (id?: string) => {
showModal.value = true showModal.value = true
} }
useHead({ title: "Admin - Artists" })
</script> </script>
<template> <template>
<h1 class="title">Manage artists</h1> <h1 class="title">Manage artists</h1>
<Table :rows="tableRows" :data="data" class="table"> <Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }"> <template #action="{ item, index }">
<div class="action"> <div class="action">
<Button @click="show(item._id)">Edit</Button> <Button @click="show(item._id)">Edit</Button>
+3 -2
View File
@@ -71,7 +71,7 @@ const show = (id?: string) => {
return return
} }
const curItem: any = data.value.filter((i) => i._id == id)[0] const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return if (!curItem) return
@@ -79,12 +79,13 @@ const show = (id?: string) => {
showModal.value = true showModal.value = true
} }
useHead({ title: "Admin - Genres" })
</script> </script>
<template> <template>
<h1 class="title">Manage genres</h1> <h1 class="title">Manage genres</h1>
<Table :rows="tableRows" :data="data" class="table"> <Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }"> <template #action="{ item, index }">
<div class="action"> <div class="action">
<Button @click="show(item._id)">Edit</Button> <Button @click="show(item._id)">Edit</Button>
+2 -1
View File
@@ -1,3 +1,4 @@
<script setup lang="ts"></script> <script setup lang="ts">useHead({ title: "Admin" })
</script>
<template></template> <template></template>
+45 -34
View File
@@ -14,8 +14,6 @@ const tableRows: Row[] = [
name: "fileDir", name: "fileDir",
displayName: "Audio File", displayName: "Audio File",
type: "audio", type: "audio",
prefix: `${BACKEND_SERVE}`,
suffix: "/high.m4a",
}, },
{ {
name: "name", name: "name",
@@ -34,7 +32,7 @@ import { type Option } from "~/ui/SearchSelect.vue"
const { data: artistData } = useApiFetch<Artist[]>("/all/artists") const { data: artistData } = useApiFetch<Artist[]>("/all/artists")
const artistFetchOptions = async (q?: string): Promise<Option[]> => { const artistFetchOptions = async (q?: string): Promise<Option[]> => {
return artistData.value.map<Option>((g) => ({ return (artistData.value ?? []).map<Option>((g) => ({
label: g.name, label: g.name,
value: g._id, value: g._id,
})) }))
@@ -42,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,40 +128,41 @@ const save = async (value: any) => {
return false return false
} }
} else { } else {
if (!value.name || !value.file || !value.artists?.length) {
toast.show(
"Name, audio file, and at least one artist are required",
"error",
)
return false
}
if (value.createSingle && (!value.cover || !value.genre)) {
toast.show(
"A cover image and genre are required for a single",
"error",
)
return false
}
if (!value.createSingle && !value.album) {
toast.show("Choose an album or create a single album", "error")
return false
}
addingSong.value = true
await nextTick()
try { try {
if (!value.name || !value.file || !value.artists?.length) {
toast.show(
"Name, audio file, and at least one artist are required",
"error",
)
return false
}
if (value.createSingle) { if (value.createSingle) {
if (!value.cover || !value.genre) { const albumResponse = await cfetch<{ _id: string }>("/admin/album", {
toast.show(
"A cover image and genre are required for a single",
"error",
)
return false
}
const albumResponse = await cfetch("/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>
+8 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const route = useRoute() const route = useRoute()
const id = computed(() => route.params.id) const id = computed(() => route.params.id)
@@ -7,8 +9,10 @@ const { data: albumData } = await useApiFetch<Album>(`/albums/${id.value}`)
const { data: tracksData } = await useApiFetch<Track[]>( const { data: tracksData } = await useApiFetch<Track[]>(
`/albums/${id.value}/tracks`, `/albums/${id.value}/tracks`,
) )
const tracks = computed(() => tracksData.value ?? [])
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer() const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Album` : "Album") })
</script> </script>
<template> <template>
@@ -27,12 +31,12 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
<div class="buttons"> <div class="buttons">
<Button <Button
icon-name="lucide:play" icon-name="lucide:play"
@click="() => playAlbum(tracksData)" @click="() => playAlbum(tracks)"
>Play</Button >Play</Button
> >
<Button <Button
icon-name="lucide:shuffle" icon-name="lucide:shuffle"
@click="() => playShuffledAlbum(tracksData)" @click="() => playShuffledAlbum(tracks)"
>Shuffle</Button >Shuffle</Button
> >
</div> </div>
@@ -41,11 +45,11 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
<section class="tracks"> <section class="tracks">
<TrackRow <TrackRow
v-for="(track, i) in tracksData" v-for="(track, i) in tracks"
:key="track._id" :key="track._id"
:track="track" :track="track"
:index="i" :index="i"
@play-album-at-index="() => playAlbumAtIndex(tracksData, i)" @play-album-at-index="() => playAlbumAtIndex(tracks, i)"
/> />
</section> </section>
</template> </template>
+10 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const route = useRoute() const route = useRoute()
const id = computed(() => route.params.id) const id = computed(() => route.params.id)
@@ -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,6 +39,7 @@ 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>
@@ -53,12 +57,12 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
<div class="buttons"> <div class="buttons">
<Button <Button
icon-name="lucide:play" icon-name="lucide:play"
@click="() => playAlbum(tracksData)" @click="() => playAlbum(tracks)"
>Play</Button >Play</Button
> >
<Button <Button
icon-name="lucide:shuffle" icon-name="lucide:shuffle"
@click="() => playShuffledAlbum(tracksData)" @click="() => playShuffledAlbum(tracks)"
>Shuffle</Button >Shuffle</Button
> >
</div> </div>
@@ -79,7 +83,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,7 +94,7 @@ 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"
/> />
+4 -2
View File
@@ -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,12 @@ 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)" @play-album-at-index="() => playAlbumAtIndex(tracks, i)"
/> />
</section> </section>
</template> </template>
+1
View File
@@ -2,6 +2,7 @@
definePageMeta({ definePageMeta({
layout: false, layout: false,
}) })
useHead({ title: "Sign In" })
</script> </script>
<template> <template>
+2 -1
View File
@@ -10,11 +10,12 @@ const submit = async (e: any) => {
} catch {} } catch {}
try { try {
await auth.signin(data.email, data.password, data.remember) await auth.signin(data.email, data.password, data.remember === "on")
} catch (e: string | any) { } catch (e: string | any) {
toast.show(e, "error") toast.show(e, "error")
} }
} }
useHead({ title: "Sign In" })
</script> </script>
<template> <template>
+3 -1
View File
@@ -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>
+67 -1
View File
@@ -1 +1,67 @@
<template></template> <script setup lang="ts">
import { GET_FILE } from "@/utils/file"
import type { Album } from "@/utils/types"
const {
data: albumsData,
status,
error,
refresh,
} = await useApiFetch<Album[]>("/user/albums")
const albums = computed(() => albumsData.value ?? [])
useHead({ title: "Albums" })
</script>
<template>
<section class="albums-page">
<h1>Albums</h1>
<p v-if="status === 'pending'" class="message" role="status">
Loading your albums…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your albums could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="albums.length === 0" class="message">
No saved albums yet. Save a song to add its album here.
</p>
<div v-else class="album-list">
<AlbumCard
v-for="album in albums"
:key="album._id"
:name="album.name"
:file="GET_FILE(album.file)"
:artists="album.artists"
:_id="album._id"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.albums-page {
padding-bottom: 2rem;
h1 {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1rem;
}
}
.album-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
</style>
+63 -1
View File
@@ -1 +1,63 @@
<template></template> <script setup lang="ts">
import type { Artist } from "@/utils/types"
const {
data: artistsData,
status,
error,
refresh,
} = await useApiFetch<Artist[]>("/user/artists")
const artists = computed(() => artistsData.value ?? [])
useHead({ title: "Artists" })
</script>
<template>
<section class="artists-page">
<h1>Artists</h1>
<p v-if="status === 'pending'" class="message" role="status">
Loading your artists…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your artists could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="artists.length === 0" class="message">
No artists in your saved songs yet.
</p>
<div v-else class="artist-list">
<ArtistCard
v-for="artist in artists"
:key="artist._id"
:artist="artist"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.artists-page {
padding-bottom: 2rem;
h1 {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1rem;
}
}
.artist-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
</style>
+100 -1
View File
@@ -1 +1,100 @@
<template></template> <script setup lang="ts">
import type { Track } from "@/utils/types"
const toast = useToast()
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
const {
data: tracksData,
status,
error,
refresh,
} = await useApiFetch<Track[]>("/user/tracks")
const tracks = computed(() => tracksData.value ?? [])
const playTracks = () => {
if (tracks.value.length) void playAlbum(tracks.value)
}
const shuffleTracks = () => {
if (tracks.value.length) void playShuffledAlbum(tracks.value)
}
const removeFromLibrary = (track: Track) => {
tracksData.value = tracks.value.filter(
(savedTrack) => savedTrack._id !== track._id,
)
}
useHead({ title: "Songs" })
</script>
<template>
<section class="songs-page">
<header class="page-header">
<h1>Songs</h1>
<div v-if="tracks.length" class="page-actions">
<Button icon-name="lucide:play" @click="playTracks">Play</Button>
<Button icon-name="lucide:shuffle" @click="shuffleTracks">Shuffle</Button>
</div>
</header>
<p v-if="status === 'pending'" class="message" role="status">
Loading your songs…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your songs could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="tracks.length === 0" class="message">
You haven’t saved any songs yet.
</p>
<div v-else class="track-list">
<TrackRow
v-for="(track, index) in tracks"
:key="track._id"
:track="track"
:index="index"
:extended-info="true"
@play-album-at-index="() => playAlbumAtIndex(tracks, index)"
@remove-from-library="removeFromLibrary"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.songs-page {
padding-bottom: 2rem;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
h1 {
@include fontSize(32px);
font-weight: 700;
}
}
.page-actions {
display: flex;
gap: 0.75rem;
}
.track-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
</style>
+3 -1
View File
@@ -15,7 +15,8 @@ const route = useRoute()
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 debouncedQuery = ref(query.value) const debouncedQuery = ref(query.value)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined let debounceTimeout: ReturnType<typeof setTimeout> | undefined
@@ -80,6 +81,7 @@ 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>
+323 -28
View File
@@ -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[]>([])
@@ -12,7 +30,16 @@ export default defineNuxtPlugin((nuxtApp) => {
const currentSong = ref<Track | null>(null) const currentSong = ref<Track | null>(null)
const repeat = ref(false) const repeat = ref(false)
const isPlaying = ref(false) const isPlaying = ref(false)
const beatEnergy = ref(0)
const storedVolume = ref(DEFAULT_VOLUME) 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 +74,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 +89,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 {
title: currentSong.value.name, const stream = capture.call(mediaElement)
artist: currentSong.value.artists[0]?.name, if (stream.getAudioTracks().length === 0) {
album: currentSong.value.album.name, if (DEBUG_BEAT_ANALYSIS)
artwork: [ console.warn(
{ "[beat analyzer] failed: capture has no audio tracks",
src: GET_FILE(currentSong.value.album.file), )
sizes: "512x512", return
}, }
], analysisContext ??= new AudioContext()
}) if (analysisContext.state === "suspended")
void analysisContext.resume().catch(() => {})
analysisNode = analysisContext.createAnalyser()
analysisNode.fftSize = 2048
analysisNode.smoothingTimeConstant = 0.05
analysisSource = analysisContext.createMediaStreamSource(stream)
analysisSource.connect(analysisNode)
navigator.mediaSession.setPositionState({ const samples = new Uint8Array(analysisNode.frequencyBinCount)
duration: duration.value, let previousBass: number | null = null
position: secondsPlayed.value, let riseAverage = 0.01
}) let riseVariance = 0.0004
let pulse = 0
let lastBeatLogAt = 0
let lastLevelLogAt = 0
const sample = () => {
if (!analysisNode) return
analysisNode.getByteFrequencyData(samples)
const sampleRate = analysisContext!.sampleRate
const firstBin = Math.max(
1,
Math.floor((45 * 2048) / sampleRate),
)
const lastBin = Math.min(
samples.length - 1,
Math.ceil((130 * 2048) / sampleRate),
)
let bass = 0
for (let bin = firstBin; bin <= lastBin; bin++)
bass += samples[bin] ?? 0
bass /= Math.max(1, lastBin - firstBin + 1) * 255
if (previousBass === null) previousBass = bass
const rise = Math.max(0, bass - previousBass)
previousBass = previousBass * 0.985 + bass * 0.015
const riseDeviation = Math.sqrt(riseVariance)
const strongThumpThreshold = Math.min(
0.1,
Math.max(0.045, riseAverage + riseDeviation * 2),
)
const attack = Math.max(0, rise - strongThumpThreshold)
const thumpCooldown = Math.min(
500,
Math.max(300, 300 + riseDeviation * 4000),
)
const now = performance.now()
const isStrongThump =
rise >= strongThumpThreshold &&
now - lastBeatLogAt > thumpCooldown
const riseDelta = rise - riseAverage
riseAverage += riseDelta * 0.01
riseVariance += (riseDelta * riseDelta - riseVariance) * 0.01
if (isStrongThump) {
const thumpStrength = Math.min(0.38, 0.1 + attack * 2.5)
pulse = Math.max(pulse, thumpStrength)
if (DEBUG_BEAT_ANALYSIS) {
console.log(
"[fullscreen beat]",
`detected=${thumpStrength.toFixed(2)}`,
`visual=${shapeBeatPulse(thumpStrength).toFixed(2)}`,
)
}
} else pulse *= 0.94
beatEnergy.value = Math.min(1, pulse)
if (DEBUG_BEAT_ANALYSIS && now - lastLevelLogAt > 1000) {
lastLevelLogAt = now
console.info(
"[beat level]",
`bass=${bass.toFixed(3)}`,
`attack=${attack.toFixed(3)}`,
`gate=${strongThumpThreshold.toFixed(3)}`,
`cooldown=${Math.round(thumpCooldown)}`,
`pulse=${beatEnergy.value.toFixed(2)}`,
)
}
if (isStrongThump) lastBeatLogAt = now
analysisFrame = requestAnimationFrame(sample)
}
analysisFrame = requestAnimationFrame(sample)
isAnalyzing = true
if (DEBUG_BEAT_ANALYSIS)
console.info("[beat analyzer] analysis started")
} catch (error) {
stopBeatAnalysis()
if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed while setting up audio capture",
error,
)
}
}
// Helpers
const updateMediaSession = () => {
if (!import.meta.client) return
const mediaSession = navigator.mediaSession
if (!mediaSession) return
if (!currentSong.value) {
mediaSession.metadata = null
mediaSession.playbackState = "none"
mediaSessionSongId = null
return
}
if (mediaSessionSongId !== currentSong.value._id) {
mediaSession.metadata = new MediaMetadata({
title: currentSong.value.name,
artist: currentSong.value.artists[0]?.name,
album: currentSong.value.album.name,
artwork: [
{
src: GET_FILE(
currentSong.value.album.file,
mediaBaseUrl,
),
sizes: "512x512",
},
],
})
mediaSessionSongId = currentSong.value._id
}
if (duration.value > 0) {
try {
mediaSession.setPositionState({
duration: duration.value,
position: Math.min(secondsPlayed.value, duration.value),
})
} catch {
// Position state can be unavailable while media metadata is loading.
}
}
}
const registerMediaSessionActions = () => {
if (!import.meta.client || !navigator.mediaSession) return
const actions: MediaSessionAction[] = [
"play",
"pause",
"nexttrack",
"previoustrack",
"seekto",
"seekbackward",
"seekforward",
]
for (const action of actions) {
try {
navigator.mediaSession.setActionHandler(action, (event) => {
switch (action) {
case "play":
void play()
break
case "pause":
pause()
break
case "nexttrack":
void next()
break
case "previoustrack":
void prev()
break
case "seekto":
if (event.seekTime !== undefined)
secondsPlayed.value = event.seekTime
updateMediaSession()
break
case "seekbackward":
secondsPlayed.value = Math.max(
0,
secondsPlayed.value - (event.seekOffset ?? 10),
)
updateMediaSession()
break
case "seekforward":
secondsPlayed.value = Math.min(
duration.value,
secondsPlayed.value + (event.seekOffset ?? 10),
)
updateMediaSession()
}
})
} catch {
// Browsers may support Media Session without supporting every action.
}
}
} }
const startTracking = () => { const startTracking = () => {
if (trackingTimer !== null) window.clearTimeout(trackingTimer)
const update = () => { const update = () => {
tick.value++ tick.value++
aniFrame.value = requestAnimationFrame(update) const now = Date.now()
if (now - lastPositionUpdate >= 1000) {
lastPositionUpdate = now
updateMediaSession()
}
trackingTimer = window.setTimeout(update, 100)
} }
update() update()
} }
const stopTracking = () => { const stopTracking = () => {
if (aniFrame.value) cancelAnimationFrame(aniFrame.value) if (trackingTimer !== null) window.clearTimeout(trackingTimer)
aniFrame.value = null trackingTimer = null
} }
const createHowl = async (song: Track) => { const createHowl = async (song: Track) => {
@@ -109,20 +357,23 @@ export default defineNuxtPlugin((nuxtApp) => {
if (!song) throw new Error("Song not provided") if (!song) throw new Error("Song not provided")
const newSound = new Howl({ const newSound = new Howl({
src: [GET_AUDIO_FILE(song.fileDir)], src: [GET_AUDIO_FILE(song.fileDir, mediaBaseUrl)],
html5: false, html5: true,
autoplay: false, autoplay: false,
volume: 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() next()
stopTracking() stopTracking()
}, },
onstop: () => { onstop: () => {
stopBeatAnalysis("playback stopped")
isPlaying.value = false isPlaying.value = false
stopTracking() stopTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
@@ -130,11 +381,13 @@ export default defineNuxtPlugin((nuxtApp) => {
}, },
onplay: () => { onplay: () => {
isPlaying.value = true isPlaying.value = true
startBeatAnalysis(newSound)
startTracking() startTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
navigator.mediaSession.playbackState = "playing" navigator.mediaSession.playbackState = "playing"
}, },
onpause: () => { onpause: () => {
stopBeatAnalysis("paused")
isPlaying.value = false isPlaying.value = false
stopTracking() stopTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
@@ -225,6 +478,46 @@ export default defineNuxtPlugin((nuxtApp) => {
currentSong.value = null currentSong.value = null
sound.value?.stop() sound.value?.stop()
sound.value = null sound.value = null
updateMediaSession()
}
if (import.meta.client) {
registerMediaSessionActions()
window.addEventListener("keydown", (event) => {
if (
event.defaultPrevented ||
event.repeat ||
event.ctrlKey ||
event.metaKey ||
event.altKey
)
return
const target = event.target
if (
target instanceof HTMLElement &&
target.closest(
"input, textarea, select, button, [contenteditable='true'], [role='slider']",
)
)
return
switch (event.code) {
case "Space":
event.preventDefault()
if (isPlaying.value) pause()
else void play()
break
case "ArrowRight":
event.preventDefault()
void next()
break
case "ArrowLeft":
event.preventDefault()
void prev()
}
})
} }
const shuffle = () => { const shuffle = () => {
@@ -275,6 +568,7 @@ export default defineNuxtPlugin((nuxtApp) => {
provide: { provide: {
player: { player: {
isPlaying, isPlaying,
beatEnergy,
currentSong, currentSong,
repeat, repeat,
play, play,
@@ -290,6 +584,7 @@ export default defineNuxtPlugin((nuxtApp) => {
playAlbumAtIndex, playAlbumAtIndex,
getDuration, getDuration,
secondsPlayed, secondsPlayed,
refreshPosition,
duration, duration,
volume, volume,
queue, queue,
+2
View File
@@ -8,6 +8,7 @@ interface ButtonProps {
full?: boolean full?: boolean
iconName?: string iconName?: string
centerText?: boolean centerText?: boolean
disabled?: boolean
} }
const props = withDefaults(defineProps<ButtonProps>(), { const props = withDefaults(defineProps<ButtonProps>(), {
@@ -22,6 +23,7 @@ const props = withDefaults(defineProps<ButtonProps>(), {
props.type == 'link' && props.to != undefined ? NuxtLink : 'button' props.type == 'link' && props.to != undefined ? NuxtLink : 'button'
" "
:to="props.to" :to="props.to"
:disabled="props.disabled"
:class="[ :class="[
`button type-${props.variant}`, `button type-${props.variant}`,
{ {
+44 -38
View File
@@ -12,6 +12,7 @@ interface DropdownMenuProps {
const props = defineProps<DropdownMenuProps>() const props = defineProps<DropdownMenuProps>()
const open = ref(false) const open = ref(false)
const mounted = ref(false)
const placement = ref<"bottom" | "top">("bottom") const placement = ref<"bottom" | "top">("bottom")
const root = useTemplateRef("root") const root = useTemplateRef("root")
const menuPosition = ref({ top: 0, left: 0 }) const menuPosition = ref({ top: 0, left: 0 })
@@ -64,6 +65,7 @@ onMounted(() => {
document.addEventListener("keydown", handleKey) document.addEventListener("keydown", handleKey)
window.addEventListener("resize", handleReposition) window.addEventListener("resize", handleReposition)
window.addEventListener("scroll", handleReposition, true) window.addEventListener("scroll", handleReposition, true)
mounted.value = true
}) })
onBeforeMount(() => { onBeforeMount(() => {
@@ -81,7 +83,7 @@ onBeforeMount(() => {
</div> </div>
<Transition name="dropdown"> <Transition name="dropdown">
<Teleport to="body"> <Teleport v-if="mounted" to="body">
<div <div
v-show="open" v-show="open"
class="dropdown-menu" class="dropdown-menu"
@@ -91,26 +93,26 @@ onBeforeMount(() => {
left: `${menuPosition.left}px`, left: `${menuPosition.left}px`,
}" }"
> >
<div <div
v-for="(group, i) in items" v-for="(group, i) in items"
:key="`group-${i}`" :key="`group-${i}`"
class="group" class="group"
>
<button
v-for="(item, i) in group"
:key="`item-${i}`"
class="item"
@click="
() => {
item.onSelect?.()
close()
}
"
> >
<Icon :name="item.icon" class="icon" /> <button
<span>{{ item.label }}</span> v-for="(item, i) in group"
</button> :key="`item-${i}`"
</div> class="item"
@click="
() => {
item.onSelect?.()
close()
}
"
>
<Icon :name="item.icon" class="icon" />
<span>{{ item.label }}</span>
</button>
</div>
</div> </div>
</Teleport> </Teleport>
</Transition> </Transition>
@@ -126,30 +128,30 @@ onBeforeMount(() => {
} }
.dropdown-menu { .dropdown-menu {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
padding: 0.5rem; padding: 0.5rem;
min-width: 180px; min-width: 180px;
background-color: $color-secondary-background; background-color: $color-secondary-background;
border-radius: $border-radius-standard; border-radius: $border-radius-standard;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transform: translateX(-100%); transform: translateX(-100%);
&.bottom { &.bottom {
transform: translate(-100%, 0.5rem); transform: translate(-100%, 0.5rem);
transform-origin: top right; transform-origin: top right;
} }
&.top { &.top {
transform: translate(-100%, calc(-100% - 0.5rem)); transform: translate(-100%, calc(-100% - 0.5rem));
transform-origin: bottom right; transform-origin: bottom right;
} }
.group { .group {
width: 100%; width: 100%;
@@ -184,6 +186,10 @@ onBeforeMount(() => {
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
span {
white-space: nowrap;
}
} }
.item:hover { .item:hover {
+45 -3
View File
@@ -41,6 +41,15 @@ const onFileChange = (e: Event) => {
value.value = result value.value = result
emit("file", result) emit("file", result)
} }
const onTextInput = (e: Event) => {
emit("input", (e.currentTarget as HTMLInputElement | HTMLTextAreaElement).value)
}
const clearSearch = () => {
value.value = ""
emit("input", "")
}
</script> </script>
<template> <template>
@@ -68,7 +77,7 @@ const onFileChange = (e: Event) => {
v-if="props.type != 'file' && props.type != 'textarea'" v-if="props.type != 'file' && props.type != 'textarea'"
:placeholder="props.placeholder" :placeholder="props.placeholder"
v-model="value" v-model="value"
@input="(e) => emit('input', e.target?.value)" @input="onTextInput"
@focus="emit('focus')" @focus="emit('focus')"
@blur="emit('blur')" @blur="emit('blur')"
:type="props.type" :type="props.type"
@@ -77,12 +86,11 @@ const onFileChange = (e: Event) => {
:required="props.required" :required="props.required"
:autocomplete="props.autocomplete" :autocomplete="props.autocomplete"
/> />
<textarea <textarea
v-else-if="props.textarea" v-else-if="props.textarea"
:placeholder="props.placeholder" :placeholder="props.placeholder"
v-model="value" v-model="value"
@input="(e) => emit('input', e.target?.value)" @input="onTextInput"
@focus="emit('focus')" @focus="emit('focus')"
@blur="emit('blur')" @blur="emit('blur')"
:name="props.name" :name="props.name"
@@ -102,6 +110,16 @@ const onFileChange = (e: Event) => {
:id="props.name" :id="props.name"
:required="props.required" :required="props.required"
/> />
<button
v-if="props.type == 'search' && typeof value == 'string' && value"
type="button"
class="clear-search"
aria-label="Clear search"
@mousedown.prevent
@click="clearSearch"
>
<Icon name="lucide:x" />
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -160,6 +178,30 @@ const onFileChange = (e: Event) => {
& input::placeholder { & input::placeholder {
color: $color-placeholder-text; color: $color-placeholder-text;
} }
& input[type="search"] {
color-scheme: dark;
&::-webkit-search-cancel-button {
display: none;
}
}
.clear-search {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: $border-radius-standard;
line-height: 1;
color: $color-placeholder-text;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: $color-text;
}
}
} }
.input, .input,
+150 -12
View File
@@ -1,28 +1,84 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"
import { GET_AUDIO_FILE, GET_FILE } from "@/utils/file"
defineOptions({ inheritAttrs: false })
export type Row = { export type Row = {
name: string name: string
class?: string class?: string
width?: string width?: string
type?: "image" | "audio" type?: "image" | "audio"
prefix?: string
suffix?: string
displayName?: string displayName?: string
} }
type TableProps = { type TableProps = {
rows: Row[] rows: Row[]
data: any data: any
pagination?: boolean
pageSize?: number
search?: boolean
} }
const { rows, data } = defineProps<TableProps>() const props = withDefaults(defineProps<TableProps>(), {
pagination: false,
pageSize: 10,
search: false,
})
const currentPage = ref(1)
const query = ref("")
const data = computed(() => (Array.isArray(props.data) ? props.data : []))
const pageSize = computed(() => Math.max(1, Math.floor(props.pageSize)))
const filteredData = computed(() => {
const normalizedQuery = query.value.trim().toLocaleLowerCase()
const indexedData = data.value.map((item: any, index: number) => ({
item,
index,
}))
if (!props.search || !normalizedQuery) return indexedData
return indexedData.filter(({ item }: { item: any }) =>
props.rows.some((row) =>
String(item[row.name] ?? "")
.toLocaleLowerCase()
.includes(normalizedQuery),
),
)
})
const pageCount = computed(() =>
Math.max(1, Math.ceil(filteredData.value.length / pageSize.value)),
)
const pagedData = computed(() => {
if (!props.pagination) return filteredData.value
const start = (currentPage.value - 1) * pageSize.value
return filteredData.value.slice(start, start + pageSize.value)
})
watch(query, () => {
currentPage.value = 1
})
watch(pageCount, (count) => {
if (currentPage.value > count) currentPage.value = count
})
</script> </script>
<template> <template>
<table class="table"> <Input
v-if="props.search"
v-model:value="query"
type="search"
placeholder="Search table"
class="table-search"
/>
<table v-bind="$attrs" class="table">
<thead> <thead>
<tr> <tr>
<th <th
v-for="row in rows" v-for="row in props.rows"
:key="row.name" :key="row.name"
:class="row.class" :class="row.class"
:style="{ width: row.width }" :style="{ width: row.width }"
@@ -31,25 +87,65 @@ const { rows, data } = defineProps<TableProps>()
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody :key="currentPage">
<tr v-for="(item, i) in data" :key="i"> <tr
<td v-for="(row, j) in rows" :key="j"> v-for="entry in pagedData"
<slot :name="row.name" :item="item" :index="i"> :key="entry.index"
>
<td v-for="(row, j) in props.rows" :key="j">
<slot
:name="row.name"
:item="entry.item"
:index="entry.index"
>
<nuxt-img <nuxt-img
v-if="row.type == 'image'" v-if="row.type == 'image'"
:src="`${row.prefix}${item[row.name]}`" :src="GET_FILE(entry.item[row.name])"
/> />
<audio <audio
v-else-if="row.type == 'audio'" v-else-if="row.type == 'audio'"
controls controls
:src="`${row.prefix}${item[row.name]}${row.suffix}`" :src="GET_AUDIO_FILE(entry.item[row.name])"
/> />
<p v-else>{{ item[row.name] }}</p> <p v-else>{{ entry.item[row.name] }}</p>
</slot> </slot>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<nav
v-if="props.pagination && pageCount > 1"
class="pagination"
aria-label="Table pagination"
>
<button
type="button"
:disabled="currentPage === 1"
aria-label="Previous page"
@click="currentPage--"
>
Previous
</button>
<button
v-for="page in pageCount"
:key="page"
type="button"
:aria-label="`Page ${page}`"
:aria-current="currentPage === page ? 'page' : undefined"
:class="{ active: currentPage === page }"
@click="currentPage = page"
>
{{ page }}
</button>
<button
type="button"
:disabled="currentPage === pageCount"
aria-label="Next page"
@click="currentPage++"
>
Next
</button>
</nav>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -85,4 +181,46 @@ const { rows, data } = defineProps<TableProps>()
padding: 5px 0; padding: 5px 0;
} }
} }
.table-search {
margin-bottom: 0.75rem;
}
.table tbody {
animation: table-page-fade 0.18s ease;
}
@keyframes table-page-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
& button {
padding: 0.4rem 0.65rem;
border-radius: $border-radius-standard;
background-color: $color-button;
cursor: pointer;
}
& button:hover:not(:disabled),
& button.active {
background-color: $color-button-hover;
}
& button:disabled {
cursor: default;
opacity: 0.5;
}
}
</style> </style>
+9
View File
@@ -0,0 +1,9 @@
export function shapeBeatPulse(value: number) {
const strength = Math.max(0, Math.min(0.4, value))
if (strength <= 0.2) return strength * 0.4
if (strength <= 0.35) {
const progress = (strength - 0.2) / 0.15
return 0.08 + (1 - (1 - progress) ** 3) * 0.42
}
return 0.5 + ((strength - 0.35) / 0.05) * 0.05
}
+58 -53
View File
@@ -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("; "))
}
return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT
} }
if (!response) throw new Error("Something went wrong. Please try again")
return response
} }
+5 -4
View File
@@ -1,6 +1,7 @@
export const BACKEND_SERVE = `${import.meta.env.VITE_PUBLIC_BACKEND}/static/` export const GET_FILE = (file: string, baseUrl = useRuntimeConfig().public.mediaBaseUrl) => {
return `${String(baseUrl).replace(/\/+$/, "")}/${file.replace(/^\/+/, "")}`
}
export const GET_FILE = (file: string) => `${BACKEND_SERVE}${file}` export const GET_AUDIO_FILE = (file: string, baseUrl?: string) => {
export const GET_AUDIO_FILE = (file: string) => { return GET_FILE(`${file}/high.m4a`, baseUrl)
return `${GET_FILE(file)}/high.m4a`
} }
+1347 -431
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "golar/unstable"
import "@golar/vue"
export default defineConfig({
typecheck: {
include: ["app/**/*.{ts,tsx,vue}", "server/**/*.ts", "nuxt.config.ts"],
},
})
+21 -3
View File
@@ -3,7 +3,28 @@ import { fileURLToPath } from "node:url"
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2025-07-15", compatibilityDate: "2025-07-15",
nitro: {
preset: "node-server",
externals: {
inline: ["vue"],
},
},
devtools: { enabled: true }, devtools: { enabled: true },
runtimeConfig: {
// Private server configuration. Matching NUXT_* variables override these
// defaults at runtime in the built Nitro server. Legacy variable names
// are resolved by server utilities at runtime, never during this build.
mongodbUri: "",
tokenExpire: "",
s3Endpoint: "",
s3Bucket: "",
s3Region: "us-east-1",
s3AccessKeyId: "",
s3SecretAccessKey: "",
public: {
mediaBaseUrl: "",
},
},
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"], modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"],
components: [ components: [
{ {
@@ -15,7 +36,4 @@ export default defineNuxtConfig({
pathPrefix: true, pathPrefix: true,
}, },
], ],
image: {
domains: ["api.music.lucasskt.dk"],
},
}) })
+6890 -2400
View File
File diff suppressed because it is too large. Load diff
+35 -27
View File
@@ -1,29 +1,37 @@
{ {
"name": "music-v2", "name": "music-v2",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
}, "typecheck": "nuxt typecheck"
"dependencies": { },
"@nuxt/fonts": "0.11.4", "dependencies": {
"@nuxt/icon": "^2.1.0", "@aws-sdk/client-s3": "^3.1142.0",
"@nuxt/image": "^2.0.0", "@nuxt/fonts": "0.11.4",
"@types/howler": "^2.2.12", "@nuxt/icon": "^2.1.0",
"axios": "^1.13.2", "@nuxt/image": "^2.0.0",
"defu": "^6.1.4", "@types/howler": "^2.2.12",
"howler": "^2.2.4", "argon2": "^0.45.1",
"nuxt": "^4.2.0", "defu": "^6.1.4",
"typescript": "^5.9.3", "fluent-ffmpeg": "^2.1.3",
"vue": "^3.5.22", "fuse.js": "^7.5.0",
"vue-router": "^4.6.3" "howler": "^2.2.4",
}, "mongoose": "^9.10.2",
"devDependencies": { "nuxt": "^4.5.2",
"@types/node": "^24.9.1", "parse-duration": "^2.1.9",
"sass-embedded": "^1.93.2" "typescript": "^5.9.3",
} "vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@golar/vue": "^0.1.10",
"@types/node": "^24.9.1",
"golar": "^0.1.10",
"sass-embedded": "^1.93.2"
}
} }
+34
View File
@@ -0,0 +1,34 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObject, tryDeleteObjectPrefix } from "../../utils/object-storage"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const force = query.force === "true" || query.force === true
const album = await Album.findById(parsedId.value)
if (!album) {
setResponseStatus(event, 404)
return { message: "Album not found" }
}
const dependents = await Track.find({ album: album._id }).select("_id name").exec()
if (force) {
const tracks = await Track.find({ album: album._id }).select("_id fileDir").exec()
await Promise.all(tracks.map((track) => Track.findByIdAndDelete(track._id)))
await Promise.all(tracks.map((track) => tryDeleteObjectPrefix(event, `${track.fileDir}/`)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Album has one or more dependents", dependentType: "Track", dependents }
}
await Album.findByIdAndDelete(parsedId.value)
await tryDeleteObject(event, album.file)
return {}
})
+56
View File
@@ -0,0 +1,56 @@
import { getQuery, setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, splitIds } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Album } from "../../models/album"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const genre = adminFormText(parts, "genre")
const file = adminFormFile(parts, "file")
if (artists && !artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
if (genre && !mongoose.isValidObjectId(genre)) return validationResponse(event, "Invalid id")
if (file && !file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const album = await Album.findById(parsedId.value)
if (!album) {
setResponseStatus(event, 404)
return {}
}
if (file) {
const filePath = `album/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
const previousPath = album.file
album.file = filePath
if (name) album.name = name
if (artists) album.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (genre) album.genre = new mongoose.Types.ObjectId(genre)
try {
await album.save()
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
await tryDeleteObject(event, previousPath)
return album
}
if (name) album.name = name
if (artists) album.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (genre) album.genre = new mongoose.Types.ObjectId(genre)
await album.save()
return album
})
+44
View File
@@ -0,0 +1,44 @@
import { setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, splitIds } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Album } from "../../models/album"
import mongoose from "mongoose"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const genre = adminFormText(parts, "genre")
const file = adminFormFile(parts, "file")
if (name === undefined || !artists || !genre || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!mongoose.isValidObjectId(genre)) {
setResponseStatus(event, 400)
return { message: "Invalid id" }
}
if (!artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
if (!file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const filePath = `album/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
let album
try {
album = await Album.create({ name, artists, file: filePath, genre })
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
setResponseStatus(event, 201)
return album
})
+50
View File
@@ -0,0 +1,50 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Artist } from "../../models/artist"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObject, tryDeleteObjectPrefix } from "../../utils/object-storage"
async function findDependents(model: typeof Track | typeof Album, field: string, value: unknown) {
return model.find({ [field]: value }).select("_id name").exec()
}
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const force = query.force === "true" || query.force === true
const artist = await Artist.findById(parsedId.value)
if (!artist) {
setResponseStatus(event, 404)
return { message: "Artist not found" }
}
let dependents = await findDependents(Track, "artists", artist._id)
if (force) {
const tracks = await Track.find({ artists: artist._id }).select("_id fileDir").exec()
await Promise.all(tracks.map((track) => Track.findByIdAndDelete(track._id)))
await Promise.all(tracks.map((track) => tryDeleteObjectPrefix(event, `${track.fileDir}/`)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Artist has one or more dependents", dependentType: "Track", dependents }
}
dependents = await findDependents(Album, "artists", artist._id)
if (force) {
const albums = await Album.find({ artists: artist._id }).select("_id file").exec()
await Promise.all(albums.map((album) => Album.findByIdAndDelete(album._id)))
await Promise.all(albums.map((album) => tryDeleteObject(event, album.file)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Artist has one or more dependents", dependentType: "Artist", dependents }
}
await Artist.findByIdAndDelete(parsedId.value)
await tryDeleteObject(event, artist.file)
return {}
})
+44
View File
@@ -0,0 +1,44 @@
import { getQuery, setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormFile, safeFileExtension } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Artist } from "../../models/artist"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const file = adminFormFile(parts, "file")
if (file && !file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const artist = await Artist.findById(parsedId.value)
if (!artist) {
setResponseStatus(event, 404)
return {}
}
if (file) {
const filePath = `artists/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
const previousPath = artist.file
artist.file = filePath
if (name) artist.name = name
try {
await artist.save()
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
await tryDeleteObject(event, previousPath)
return artist
}
if (name) artist.name = name
await artist.save()
return artist
})
+32
View File
@@ -0,0 +1,32 @@
import { setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormFile, safeFileExtension } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Artist } from "../../models/artist"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const file = adminFormFile(parts, "file")
if (name === undefined || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const filePath = `artists/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
let artist
try {
artist = await Artist.create({ name, file: filePath })
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
setResponseStatus(event, 201)
return artist
})
+42
View File
@@ -0,0 +1,42 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) {
setResponseStatus(event, 400)
return { message: parsedId.error }
}
if (query.force !== "true" && query.force !== "false") {
setResponseStatus(event, 400)
return { message: query.force === undefined ? "Required" : "Expected boolean, received string" }
}
const force = query.force === "true"
const genre = await Genre.findById(parsedId.value)
if (!genre) {
setResponseStatus(event, 404)
return { message: "Genre not found" }
}
const dependents = await Album.find({ genre: genre._id }).select("_id name")
if (force) {
await Promise.all(dependents.map((dependent) => Album.findByIdAndDelete(dependent._id)))
} else if (dependents.length) {
setResponseStatus(event, 409)
return {
message: "Genre has one or more dependents",
dependentType: "Album",
dependents,
}
}
await Genre.findByIdAndDelete(parsedId.value)
return {}
})
+34
View File
@@ -0,0 +1,34 @@
import { getQuery, readBody, setResponseStatus } from "h3"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) {
setResponseStatus(event, 400)
return { message: parsedId.error }
}
const body = await readBody<unknown>(event)
if (!body || typeof body !== "object" || Array.isArray(body)) {
setResponseStatus(event, 400)
const received = body === null ? "null" : Array.isArray(body) ? "array" : typeof body
return { message: `Expected object, received ${received}` }
}
const record = body as Record<string, unknown>
if (record.name !== undefined && typeof record.name !== "string") {
setResponseStatus(event, 400)
return { message: `Expected string, received ${record.name === null ? "null" : typeof record.name}` }
}
const genre = await Genre.findByIdAndUpdate(parsedId.value, record.name === undefined ? {} : { name: record.name }, { new: true })
if (!genre) {
setResponseStatus(event, 404)
return { message: "Genre not found" }
}
return genre
})
+31
View File
@@ -0,0 +1,31 @@
import { readBody, setResponseStatus } from "h3"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const body = await readBody<unknown>(event)
if (!body || typeof body !== "object" || Array.isArray(body)) {
setResponseStatus(event, 400)
const received = body === null ? "null" : Array.isArray(body) ? "array" : typeof body
return { message: `Expected object, received ${received}` }
}
const name = (body as { name?: unknown }).name
if (typeof name !== "string") {
setResponseStatus(event, 400)
return { message: name === undefined ? "Required" : `Expected string, received ${name === null ? "null" : typeof name}` }
}
try {
const genre = await Genre.create({ name })
setResponseStatus(event, 201)
return genre
} catch (error) {
setResponseStatus(event, 400)
if (typeof error === "object" && error !== null && "code" in error && error.code === 11000) {
return { message: "Genre already exists" }
}
return { message: "Unknown error" }
}
})
+19
View File
@@ -0,0 +1,19 @@
import { getQuery, setResponseStatus } from "h3"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObjectPrefix } from "../../utils/object-storage"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const track = await Track.findByIdAndDelete(parsedId.value)
if (!track) {
setResponseStatus(event, 404)
return {}
}
await tryDeleteObjectPrefix(event, `${track.fileDir}/`)
return {}
})
+68
View File
@@ -0,0 +1,68 @@
import mongoose from "mongoose"
import { getQuery, setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, parseOptionalJson, splitIds } from "../../utils/admin-form"
import { prepareAudioUpload } from "../../utils/audio-files"
import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Track } from "../../models/track"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const album = adminFormText(parts, "album")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const file = adminFormFile(parts, "file")
const lyrics = adminFormText(parts, "lyrics")
if (file && !file.type.includes("audio/")) {
setResponseStatus(event, 400)
return { message: "Only audio files are accepted" }
}
if (album && !mongoose.isValidObjectId(album)) return validationResponse(event, "Invalid id")
if (artists && !artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
const parsedLyrics = lyrics ? parseOptionalJson(lyrics) : undefined
if (parsedLyrics && !parsedLyrics.ok) throw new Error("Invalid lyrics JSON")
const track = await Track.findById(parsedId.value)
if (!track) {
setResponseStatus(event, 404)
return {}
}
let replacementDirectory: string | undefined
let previousDirectory: string | undefined
if (file) {
replacementDirectory = `tracks/${crypto.randomUUID()}`
const extension = safeFileExtension(file.name)
const processed = await prepareAudioUpload(file.data, extension)
await putObjects(event, [
{ key: `${replacementDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type },
...processed.renditions.map((rendition) => ({
key: `${replacementDirectory}/${rendition.name}`,
body: rendition.body,
contentType: rendition.contentType,
})),
])
previousDirectory = track.fileDir
track.fileDir = replacementDirectory
track.durationInSeconds = Math.round(processed.duration)
}
if (name) track.name = name
if (album) track.album = new mongoose.Types.ObjectId(album)
if (artists) track.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (parsedLyrics?.ok && parsedLyrics.value !== undefined) track.lyrics = parsedLyrics.value as any
try {
await track.save()
} catch (error) {
if (replacementDirectory) await tryDeleteObjectPrefix(event, `${replacementDirectory}/`)
throw error
}
if (previousDirectory) await tryDeleteObjectPrefix(event, `${previousDirectory}/`)
return track
})
+73
View File
@@ -0,0 +1,73 @@
import { setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, parseOptionalJson, splitIds } from "../../utils/admin-form"
import { prepareAudioUpload } from "../../utils/audio-files"
import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Track } from "../../models/track"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const album = adminFormText(parts, "album")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const file = adminFormFile(parts, "file")
const lyricsInput = adminFormText(parts, "lyrics")
if (name === undefined || album === undefined || !artists || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!mongoose.isValidObjectId(album)) {
setResponseStatus(event, 400)
return { message: "Invalid id" }
}
if (!artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
const parsedLyrics = parseOptionalJson(lyricsInput)
if (!parsedLyrics.ok) {
setResponseStatus(event, 400)
return { message: "Lyrics must be a JSON object" }
}
if (!file.type.includes("audio/")) {
setResponseStatus(event, 400)
return { message: "Only audio files are accepted" }
}
const fileDirectory = `tracks/${crypto.randomUUID()}`
try {
const extension = safeFileExtension(file.name)
const processed = await prepareAudioUpload(file.data, extension)
await putObjects(event, [
{ key: `${fileDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type },
...processed.renditions.map((rendition) => ({
key: `${fileDirectory}/${rendition.name}`,
body: rendition.body,
contentType: rendition.contentType,
})),
])
const track = new Track({
name,
album: new mongoose.Types.ObjectId(album),
artists: artists.map((id) => new mongoose.Types.ObjectId(id)),
fileDir: fileDirectory,
durationInSeconds: Math.round(processed.duration),
lyrics: parsedLyrics.value || undefined,
})
try {
await track.save()
} catch (error) {
await tryDeleteObjectPrefix(event, `${fileDirectory}/`)
throw error
}
setResponseStatus(event, 201)
return track
} catch (error) {
console.error(error)
setResponseStatus(event, 500)
return undefined
}
})
+16
View File
@@ -0,0 +1,16 @@
import { getRouterParam } from "h3"
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
return Album.findOne({ _id: parsedId.value })
.populate("artists")
.populate("genre")
.exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Track } from "../../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Track.find({ album: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate("artists")
.populate({ path: "album", select: "-artists -genre" })
.exec()
})
+16
View File
@@ -0,0 +1,16 @@
import { getQuery } from "h3"
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoIds, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
return Album.find({ _id: { $in: parsedIds.value } })
.populate("artists")
.populate("genre")
.exec()
})
+8
View File
@@ -0,0 +1,8 @@
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Album.find({}).populate("artists").populate("genre")
})
+8
View File
@@ -0,0 +1,8 @@
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Artist.find({})
})
+8
View File
@@ -0,0 +1,8 @@
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Genre.find({})
})
+14
View File
@@ -0,0 +1,14 @@
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Track.find({})
.populate({
path: "album",
select: "-artists",
populate: { path: "genre" },
})
.populate("artists")
})
+13
View File
@@ -0,0 +1,13 @@
import { getRouterParam } from "h3"
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
return Artist.findOne({ _id: parsedId.value }).exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Album } from "../../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Album.find({ artists: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate("genre")
.populate("artists")
.exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Track } from "../../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Track.find({ artists: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate({ path: "album", select: "-artists" })
.populate("artists")
.exec()
})
+13
View File
@@ -0,0 +1,13 @@
import { getQuery } from "h3"
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoIds, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
return Artist.find({ _id: { $in: parsedIds.value } }).exec()
})
+56
View File
@@ -0,0 +1,56 @@
import { readBody, setCookie, setResponseStatus } from "h3"
import argon2 from "argon2"
import { User } from "../../models/user"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
REFRESH_COOKIE,
} from "../../utils/auth"
import { getAuthValidationMessage } from "../../utils/auth-validation"
export default defineAuthenticatedEventHandler(async (event) => {
assertCookieRequestOrigin(event)
const body = await readBody(event)
const validationMessage = getAuthValidationMessage(body, [
{ name: "name" },
{ name: "email", email: true },
{ name: "password", minimumLength: 8 },
])
if (validationMessage) {
setResponseStatus(event, 400)
return { message: validationMessage }
}
const email = body.email.toLowerCase()
if (await User.findOne({ email })) {
setResponseStatus(event, 400)
return { message: "The email is already in use" }
}
try {
const user = await User.create({
name: body.name,
email,
passwordHash: await argon2.hash(body.password),
})
const refreshToken = await User.findById(user._id).select("+refreshToken").then((doc) => doc?.refreshToken)
if (!refreshToken) throw new Error("Unable to create refresh token")
setCookie(event, REFRESH_COOKIE, refreshToken, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !import.meta.dev,
})
setResponseStatus(event, 201)
// Refresh credentials are now delivered only through the HttpOnly cookie.
return {}
} catch (error) {
if ((error as { code?: number }).code === 11000) {
setResponseStatus(event, 400)
return { message: "The email is already in use" }
}
throw error
}
})
+33
View File
@@ -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 }
})
+53
View File
@@ -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 }
})
+52
View File
@@ -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 {}
})
+14
View File
@@ -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 }
})
+32
View File
@@ -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)
})
+15
View File
@@ -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
})
+41
View File
@@ -0,0 +1,41 @@
import { getQuery, getRouterParam } from "h3"
import mongoose, { type PipelineStage } from "mongoose"
import { Track } from "../../../models/track"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const id = parseMongoId(getRouterParam(event, "id"))
if ("error" in id) return validationResponse(event, id.error)
const { limit: rawLimit } = getQuery(event)
const limit = parseNumber(rawLimit, 10, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const pipeline: PipelineStage[] = [
{
$lookup: {
from: "albums",
localField: "album",
foreignField: "_id",
as: "album",
},
},
{ $unwind: "$album" },
{
$lookup: {
from: "artists",
localField: "artists",
foreignField: "_id",
as: "artists",
},
},
{ $match: { "album.genre": new mongoose.Types.ObjectId(id.value) } },
{ $sample: { size: limit.value || 10 } },
{ $unset: "album.artists" },
]
return Track.aggregate(pipeline)
})
+1
View File
@@ -0,0 +1 @@
export default defineEventHandler(() => ({ status: "ok" }))
+96
View File
@@ -0,0 +1,96 @@
import { getQuery } from "h3"
import Fuse from "fuse.js"
import { Album } from "../models/album"
import { Artist } from "../models/artist"
import { Track } from "../models/track"
import {
parseNumber,
parseQueryString,
validationResponse,
} from "../utils/api-validation"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../utils/auth"
const searchTypes = ["track", "album", "artist", "default"] as const
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const query = getQuery(event)
const q = parseQueryString(query.q)
if ("error" in q) return validationResponse(event, q.error)
let type = "default"
if (query.type !== undefined) {
const parsedType = parseQueryString(query.type)
if ("error" in parsedType) return validationResponse(event, parsedType.error)
if (!searchTypes.includes(parsedType.value as (typeof searchTypes)[number])) {
return validationResponse(
event,
`Invalid enum value. Expected 'track' | 'album' | 'artist' | 'default', received '${parsedType.value}'`,
)
}
type = parsedType.value
}
const parsedLimit = parseNumber(query.limit, 9, 1)
if ("error" in parsedLimit) return validationResponse(event, parsedLimit.error)
const limit = parsedLimit.value
let results: { tracks: any[]; albums: any[]; artists: any[] } = {
tracks: [],
albums: [],
artists: [],
}
if (type === "track" || type === "default") {
const rawTracks = await Track.find({})
.populate(["album", "artists"])
.lean()
const fuse = new Fuse(rawTracks, {
keys: ["name", "lyrics.text"],
threshold: 0.3,
distance: 100,
ignoreLocation: true,
})
results.tracks = fuse.search(q.value).slice(0, limit).map((result) => result.item)
}
if (type === "album" || type === "default") {
const rawAlbums = await Album.find({})
.populate(["artists", "genre"])
.lean()
const fuse = new Fuse(rawAlbums, {
keys: ["name"],
threshold: 0.3,
distance: 100,
ignoreLocation: true,
})
results.albums = fuse.search(q.value).slice(0, limit).map((result) => result.item)
}
if (type === "artist" || type === "default") {
const rawArtists = await Artist.find({}).lean()
const fuse = new Fuse(rawArtists, {
keys: ["name"],
threshold: 0.3,
distance: 100,
ignoreLocation: true,
})
results.artists = fuse.search(q.value).slice(0, limit).map((result) => result.item)
}
// Keep the original API's split allocation, including when type selects one category.
results = {
tracks: results.tracks.slice(0, Math.ceil(limit / 3)),
albums: results.albums.slice(0, Math.floor(limit / 3)),
artists: results.artists.slice(0, Math.floor(limit / 3)),
}
return results
})
+20
View File
@@ -0,0 +1,20 @@
import { getQuery } from "h3"
import { Track } from "../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../utils/auth"
import { parseMongoIds, validationResponse } from "../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
return Track.find({ _id: { $in: parsedIds.value } })
.populate({
path: "album",
select: "-artists",
populate: { path: "genre" },
})
.populate("artists")
.exec()
})
+20
View File
@@ -0,0 +1,20 @@
import { getRouterParam } from "h3"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
return Track.findOne({ _id: parsedId.value })
.populate({
path: "album",
select: "-artists",
populate: { path: "genre" },
})
.populate("artists")
.exec()
})
+16
View File
@@ -0,0 +1,16 @@
import { getRouterParam } from "h3"
import { Track } from "../../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const track = await Track.findOne({ _id: parsedId.value }).select("lyrics").exec()
if (!track) return {}
return track.lyrics
})
+33
View File
@@ -0,0 +1,33 @@
import { setResponseStatus } from "h3"
import { User } from "#server/models/user"
import { Album } from "#server/models/album"
import { Track } from "#server/models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const user = await User.findById(authenticatedUser._id).select("savedTracks")
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
if (!user.savedTracks.length) return []
const tracks = await Track.find({ _id: { $in: user.savedTracks } }).select("_id album").exec()
const albumByTrack = new Map(tracks.map((track: any) => [track._id.toString(), track.album?.toString()]))
const albumIds: string[] = []
const seen = new Set<string>()
for (const savedTrack of user.savedTracks) {
const albumId = albumByTrack.get(savedTrack.toString())
if (albumId && !seen.has(albumId)) {
seen.add(albumId)
albumIds.push(albumId)
}
}
if (!albumIds.length) return []
const albums = await Album.find({ _id: { $in: albumIds } }).populate("artists").populate("genre").exec()
const albumsById = new Map(albums.map((album: any) => [album._id.toString(), album]))
return albumIds.map((id) => albumsById.get(id)).filter((album) => album != null)
})
+41
View File
@@ -0,0 +1,41 @@
import { setResponseStatus } from "h3"
import { Artist } from "#server/models/artist"
import { Track } from "#server/models/track"
import { User } from "#server/models/user"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const user = await User.findById(authenticatedUser._id).select("savedTracks")
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
if (!user.savedTracks.length) return []
const tracks = await Track.find({ _id: { $in: user.savedTracks } })
.select("_id artists")
.exec()
const artistsByTrack = new Map(
tracks.map((track: any) => [
track._id.toString(),
(track.artists ?? []).map((artist: any) => artist.toString()),
]),
)
const artistIds: string[] = []
const seen = new Set<string>()
for (const savedTrack of user.savedTracks) {
for (const artistId of artistsByTrack.get(savedTrack.toString()) ?? []) {
if (!seen.has(artistId)) {
seen.add(artistId)
artistIds.push(artistId)
}
}
}
if (!artistIds.length) return []
const artists = await Artist.find({ _id: { $in: artistIds } }).exec()
const artistsById = new Map(artists.map((artist: any) => [artist._id.toString(), artist]))
return artistIds.map((id) => artistsById.get(id)).filter((artist) => artist != null)
})
+5
View File
@@ -0,0 +1,5 @@
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
return await requireAuthenticatedUser(event)
})
+26
View File
@@ -0,0 +1,26 @@
import { getQuery, setResponseStatus } from "h3"
import { User } from "#server/models/user"
import {
defineAuthenticatedEventHandler,
requireAuthenticatedUser,
} from "#server/utils/auth"
import { parseMongoIds, validationResponse } from "#server/utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids ?? getQuery(event).id)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
const user = await User.findById(authenticatedUser._id)
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
user.savedTracks = user.savedTracks.filter(
(savedTrack: { toString(): string }) =>
!parsedIds.value.includes(savedTrack.toString()),
)
await user.save()
return {}
})
+21
View File
@@ -0,0 +1,21 @@
import { User } from "#server/models/user"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
import { setResponseStatus } from "h3"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const user = await User.findById(authenticatedUser._id).populate({
path: "savedTracks",
populate: [
{ path: "artists" },
{ path: "album", populate: { path: "artists" } },
],
})
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
return user.savedTracks.filter((track: unknown) => track != null)
})
+26
View File
@@ -0,0 +1,26 @@
import { getQuery, setResponseStatus } from "h3"
import mongoose, { type ObjectId } from "mongoose"
import { User } from "#server/models/user"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
import { parseMongoId, validationResponse } from "#server/utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const user = await User.findById(authenticatedUser._id)
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
// Preserve the legacy comparison behavior and empty-object response.
if ((user.savedTracks as ObjectId[]).some((savedTrack) => savedTrack.toString() === parsedId.value)) {
return {}
}
user.savedTracks.push(new mongoose.Types.ObjectId(parsedId.value))
await user.save()
return {}
})
+22
View File
@@ -0,0 +1,22 @@
import { getQuery, setResponseStatus } from "h3"
import { User } from "#server/models/user"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
import { parseMongoId, validationResponse } from "#server/utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const user = await User.findById(authenticatedUser._id).select("savedTracks")
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
return {
contains: user.savedTracks.some(
(savedTrack: { toString(): string }) => savedTrack.toString() === parsedId.value,
),
}
})
+17
View File
@@ -0,0 +1,17 @@
import mongoose, { Schema, type Model } from "mongoose"
import "./artist"
import "./genre"
const albumSchema = new Schema({
name: { type: String, required: true },
artists: {
type: [{ type: Schema.Types.ObjectId, ref: "Artist" }],
required: true,
},
file: { type: String, required: true },
genre: { type: Schema.Types.ObjectId, ref: "Genre", required: true },
})
export const Album =
(mongoose.models.Album as Model<any> | undefined) ??
(mongoose.model<any>("Album", albumSchema) as Model<any>)
+10
View File
@@ -0,0 +1,10 @@
import mongoose, { Schema, type Model } from "mongoose"
const artistSchema = new Schema({
name: { type: String, required: true },
file: { type: String, required: true },
})
export const Artist =
(mongoose.models.Artist as Model<any> | undefined) ??
(mongoose.model<any>("Artist", artistSchema) as Model<any>)
+9
View File
@@ -0,0 +1,9 @@
import mongoose, { Schema, type Model } from "mongoose"
const genreSchema = new Schema({
name: { type: String, required: true, unique: true },
})
export const Genre =
(mongoose.models.Genre as Model<any> | undefined) ??
(mongoose.model<any>("Genre", genreSchema) as Model<any>)
+20
View File
@@ -0,0 +1,20 @@
import { randomUUID } from "node:crypto"
import mongoose, { Schema, type Model } from "mongoose"
import "./user"
export function getSessionModel(sessionTtlMs: number) {
if (mongoose.models.Session) return mongoose.models.Session as Model<any>
const sessionSchema = new Schema({
token: { type: String, default: () => randomUUID(), required: true },
createdAt: {
type: Date,
default: Date.now,
index: { expires: Math.floor(sessionTtlMs / 1000) },
required: true,
},
userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
})
return mongoose.model<any>("Session", sessionSchema)
}
+24
View File
@@ -0,0 +1,24 @@
import mongoose, { Schema, type Model } from "mongoose"
import "./album"
import "./artist"
const trackSchema = new Schema({
name: { type: String, required: true },
album: { type: Schema.Types.ObjectId, ref: "Album", required: true },
artists: {
type: [{ type: Schema.Types.ObjectId, ref: "Artist" }],
required: true,
},
fileDir: { type: String, required: true },
durationInSeconds: { type: Number, required: true },
lyrics: {
type: {
synced: { type: Boolean, required: true },
text: { type: String, required: true },
},
},
})
export const Track =
(mongoose.models.Track as Model<any> | undefined) ??
(mongoose.model<any>("Track", trackSchema) as Model<any>)
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more