Author SHA1 Message Date
lucast 3402178932 Add singles from admin 2026-09-26 00:04:55 +02:00
lucast 3255a97339 Fixed artist search 2026-09-26 00:00:53 +02:00
lucast fdf2aeb675 Handle songs without lyrics in player 2026-09-25 23:00:27 +02:00
lucast 8ee6164218 Allow clearing song lyrics 2026-09-25 23:00:18 +02:00
lucast a6e8bb51c5 Split search result rows 2026-09-25 22:51:11 +02:00
lucast 9cbf96564f Exit fullscreen when song ends 2026-09-25 22:50:44 +02:00
lucast 23f5709c68 Fixed synced lyrics manual scrolling 2026-09-25 21:53:34 +02:00
lucast 30c7972647 Extended synced lyrics fade 2026-09-25 21:51:24 +02:00
lucast 16e309c346 Added fullscreen volume control 2026-09-25 21:45:06 +02:00
lucast 1d2bec7317 Align search results with track rows 2026-09-25 21:26:58 +02:00
lucast 6b49c7c4d5 Show queue order while dragging 2026-09-25 20:20:12 +02:00
lucast 13081c4771 Wire track queue actions 2026-09-25 20:03:10 +02:00
lucast d6f8bb8588 Added player queue drawer 2026-09-25 19:59:57 +02:00
lucast 64646f778e Added play only this action 2026-09-25 19:48:16 +02:00
lucast 4196db1759 Fixed dropdown clipping 2026-09-25 19:45:55 +02:00
lucast d0886cd9a3 Clarified API schema location 2026-09-25 19:41:44 +02:00
lucast ec69c85102 Applied browser fullscreen 2026-08-22 20:58:39 +02:00
lucast e7bab2a95c Updated AGENTS.md to include /schema for API 2026-08-22 19:44:41 +02:00
lucast cf5ef8d429 Fixed initial load bug 2026-08-22 19:34:58 +02:00
lucast 1eb52f1c94 Updated odd and hover effect for TrackRow 2026-08-22 00:37:25 +02:00
lucast 3aaa3eb42e Added /artist/[id]/tracks 2026-08-22 00:36:08 +02:00
lucast f7adfeac22 Updated AGENTS.md for new guideline 2026-08-22 00:35:22 +02:00
lucast 74ba579919 Fixed waitForPromise not being set to false 2026-08-22 00:19:45 +02:00
lucast 1a62aa192e Added localstorage for volume 2026-08-22 00:13:51 +02:00
lucast e6657a6572 Trying OpenAI Codex 2026-08-22 00:12:30 +02:00
lucast a46f289865 Fixed lyrics 2026-07-20 15:11:46 +02:00
lucast b778ea9eda Small cleanup 2026-07-20 15:04:34 +02:00
lucast db86529219 Fixed tracks on artist page 2026-06-02 10:34:15 +02:00
lucast 603620a418 Made track display be in middle if no lyrics are found 2026-04-05 14:33:16 +02:00
lucast 2b01ed688c Made useAuth safer 2026-04-05 13:57:25 +02:00
lucast 686be7ca8a Merge branch 'feat/player-fullscreen' 2026-04-05 12:10:13 +02:00
lucast c868329d8b Merge branch 'feat/artist-page' 2026-04-05 12:02:53 +02:00
lucast 7cbc087049 Added album slider 2026-04-05 12:02:22 +02:00
lucast 5c9b82265d Test data cleanup 2026-04-05 11:57:21 +02:00
lucast 307f467f94 Added track title to slider 2026-04-05 11:55:45 +02:00
lucast cb481ad983 Added track slider for artist page 2026-04-05 11:55:17 +02:00
lucast 59af94de8c Small refractoring 2026-04-05 11:06:47 +02:00
lucast c5cd36f5e5 Small fix for error thrown when song doesn't have lyrics 2026-03-08 12:07:52 +01:00
lucast e8d2173f17 Created tracks page for artists 2026-03-08 12:06:17 +01:00
lucast 427437bb3b Moved artist page 2026-03-08 12:06:05 +01:00
lucast fceddf641a Added track image row component 2026-03-08 12:05:53 +01:00
lucast af3b9669dc Fixed player hiding the bottom of the page and elements behind the player being visible 2026-03-08 11:30:17 +01:00
lucast 4eb7c6d5a9 Added groups to dropdown menu 2026-03-08 11:24:46 +01:00
lucast aa93320215 Add cursor pointer style to dropdown icon 2026-03-08 11:12:42 +01:00
lucast 6c1dda9622 Fixed small bug with checkbox not having initial value 2026-03-07 19:49:45 +01:00
lucast b48db8bb37 Merge branch 'feat/player-fullscreen' 2026-03-07 19:49:18 +01:00
lucast d5482a9ea4 Added non synced text 2026-03-07 19:49:06 +01:00
lucast ab22cd6eb9 Implemented scrolling 2026-03-07 19:37:59 +01:00
lucast b6e854b1dd Add onMounted hook to refresh session token if needed 2026-03-07 19:28:59 +01:00
lucast fd27fee6c7 Updated synced lyrics system to be more smooth 2026-03-07 15:42:34 +01:00
lucast 22d6150290 Got synced lyrics working 2026-03-07 15:32:20 +01:00
lucast ddef57b802 Merge branch 'master' into feat/player-fullscreen 2026-03-07 14:45:58 +01:00
lucast ae79952ddc Update fullscreen player to show/hide close buttons 2026-02-27 14:53:31 +01:00
lucast 76c57c1ba0 Made cursor hide when no movement is detected 2026-02-27 14:51:45 +01:00
lucast 4475b95aed Implement mouse movement detection for fullscreen player and update layout structure 2026-02-27 14:48:53 +01:00
lucast 2561bd0e62 Add TrackDisplay component for fullscreen player with controls and styling 2026-02-27 14:48:45 +01:00
lucast ed53c41a87 Add monochrome slider support with styling adjustments 2026-02-27 14:48:27 +01:00
lucast 78c4a6cb8e Added fullscreen composable and background for fullscreen 2026-02-25 17:21:31 +01:00
29 changed files with 2037 additions and 98 deletions

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is a Nuxt 4 app. Application code lives under `app/`:
- `app/pages/` route pages
- `app/components/` feature components
- `app/ui/` shared UI primitives
- `app/composables/` reusable Vue logic
- `app/utils/` helpers and type utilities
- `app/styles/` global SCSS and variables
- `app/plugins/` client-side plugins
Static files belong in `public/`. Keep page-specific logic close to the page, and prefer shared code in `composables/`, `ui/`, or `utils/` when it is reused.
## Build, Test, and Development Commands
- `npm install` installs dependencies and runs Nuxt prepare through `postinstall`.
- `npm run dev` starts the local dev server on `http://localhost:3000`.
- `npm run build` creates a production build.
- `npm run preview` serves the production build locally.
- `npm run generate` builds a static output when needed.
There is no dedicated automated test script in `package.json`, so verify changes manually in the browser after building or running the dev server.
## Coding Style & Naming Conventions
Prettier is configured with tabs, `tabWidth = 4`, and no semicolons. Follow the existing Vue/Nuxt style in the repo:
- Use `PascalCase` for Vue components, such as `Player.vue` or `TrackDisplay.vue`.
- Use `camelCase` for functions, composables, and variables.
- Keep filenames descriptive and route-driven in `app/pages/` (for example `artist/[id]/index.vue`).
Prefer the current patterns in the codebase over introducing new abstractions.
When making changes that force an element's width or height, always ask the user for confirmation before doing so. This applies to any element, not just images. Do not force a width or height without explicit user approval.
## Testing Guidelines
No test framework is currently configured. For changes that affect rendering, routing, or API access, validate the affected pages manually and check browser console output. If you add tests later, colocate them near the feature or under a dedicated `tests/` directory.
## Commit & Pull Request Guidelines
Recent commits are short and direct, often written as brief past-tense summaries such as `Fixed lyrics` or `Small cleanup`. Keep commit messages similarly concise and action-oriented.
Pull requests should include:
- a short description of the change
- any related issue or context
- screenshots or screen recordings for UI work
- notes about environment variables or backend expectations when relevant
## Security & Configuration Tips
The 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.
+8 -1
View File
@@ -4,6 +4,7 @@ import { type Option } from "~/ui/SearchSelect.vue"
interface BaseField<T> {
key: keyof T
label?: string
visibleWhen?: (value: any) => boolean
}
interface TextField<T> extends BaseField<T> {
@@ -114,7 +115,12 @@ watch(
<p v-if="props.title" class="title">{{ props.title }}</p>
<form class="form" @submit.prevent="save">
<template v-for="field in props.fields">
<template
v-for="field in props.fields.filter(
(field) =>
!field.visibleWhen || field.visibleWhen(localItem),
)"
>
<SearchSelect
v-if="
field.type == 'search-select' ||
@@ -129,6 +135,7 @@ watch(
<Checkbox
v-else-if="field.type == 'checkbox'"
:label="field.label || field.key"
:model-value="localItem[field.key]"
@change="(v) => (localItem[field.key] = v)"
/>
<Input
+7 -9
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
const { data, pending, refresh } = useAsyncData("random-albums", async () => {
const { data: genres } = await cfetch("/genres/random")
const { data: genresData } = await useApiFetch<Genre[]>("/genres/random")
const results = await Promise.allSettled(
genres.map(async (genre: Genre) => {
const data = await Promise.allSettled(
(genresData.value ?? []).map(async (genre: Genre) => {
const res = await cfetch(`/genres/albums/${genre._id}`)
return {
@@ -11,9 +10,8 @@ const { data, pending, refresh } = useAsyncData("random-albums", async () => {
albums: res.data,
}
}),
)
return results
).then((results) =>
results
.filter(
(
r,
@@ -22,8 +20,8 @@ const { data, pending, refresh } = useAsyncData("random-albums", async () => {
albums: any[]
}> => r.status === "fulfilled",
)
.map((r) => r.value)
})
.map((r) => r.value),
)
</script>
<template>
+153
View File
@@ -0,0 +1,153 @@
<script setup lang="ts">
const { fullscreen, close: closeFullscreen } = useFullscreen()
const song = useSong()
const mouseMovedRecently = ref(true)
onMounted(() => {
let timeoutId: NodeJS.Timeout
const handleMouseMove = () => {
mouseMovedRecently.value = true
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
mouseMovedRecently.value = false
}, 2000)
}
window.addEventListener("mousemove", handleMouseMove)
onUnmounted(() => {
window.removeEventListener("mousemove", handleMouseMove)
})
})
const nuxtApp = useNuxtApp()
nuxtApp.hook("page:finish", () => {
closeFullscreen()
})
</script>
<template>
<section
class="fullscreen"
:class="{ showCursor: mouseMovedRecently }"
v-show="fullscreen"
>
<template v-if="song">
<NuxtImg
:src="GET_FILE(song.album.file)"
:alt="song.name"
width="500"
height="500"
class="background"
/>
</template>
<section class="screen-container" v-if="song">
<Icon
name="lucide:x"
class="close-btn"
:class="{ show: mouseMovedRecently }"
@click="closeFullscreen()"
/>
<div class="content-container" :class="{ lyrics: song.lyrics?.text }">
<PlayerFullscreenTrackDisplay :show-buttons="mouseMovedRecently" />
<PlayerFullscreenLyricsDisplay
:animation-duration="0.2"
:offset="150"
:show-scroll-bar="mouseMovedRecently"
/>
</div>
</section>
</section>
</template>
<style lang="scss" scoped>
.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 200vw;
height: 200vh;
z-index: 150;
cursor: none;
&.showCursor {
cursor: auto;
}
}
.background {
position: absolute;
top: -25%;
left: -25%;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(40px);
}
.screen-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.45);
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
cursor: pointer;
font-size: 24px;
transition: opacity 0.2s ease;
opacity: 0;
&.show {
opacity: 1;
}
}
.content-container {
/* TODO: A lot of magic numbers... should be variables */
max-width: 1280px;
width: 100%;
height: 100%;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr;
gap: 110px;
align-items: center;
& > * {
max-height: 675px;
margin: 0 auto;
}
&.lyrics {
grid-template-columns: 500px 1fr;
& > * {
margin: initial;
}
& > :deep(.synced-lyrics-container) {
max-height: none;
}
}
}
</style>
@@ -0,0 +1,254 @@
<script setup lang="ts">
const { animationDuration, offset, showScrollBar } = defineProps<{
animationDuration: number
offset: number
showScrollBar: boolean
}>()
const song = useSong()
const { fullscreen } = useFullscreen()
const { secondsPlayed } = usePlayer()
const parsedLyrics = computed(() => {
if (!song.value?.lyrics?.synced) return
if (song.value.lyrics.synced) return parseLyrics(song.value.lyrics.text)
})
const lyricsRefs = useTemplateRef("lyrics")
const activeIndex = computed(() => {
if (!parsedLyrics.value) return -1
const time = secondsPlayed.value
return parsedLyrics.value.findIndex(
(lyric, i) =>
lyric.time <= time &&
(!parsedLyrics.value![i + 1] ||
parsedLyrics.value![i + 1]!.time > time),
)
})
const scrollActiveIndex = computed(() => {
if (!parsedLyrics.value) return -1
const time = secondsPlayed.value + animationDuration
let index = parsedLyrics.value.findIndex(
(lyric, i) =>
lyric.time <= time &&
(!parsedLyrics.value![i + 1] ||
parsedLyrics.value![i + 1]!.time > time),
)
if (index >= parsedLyrics.value.length - 1)
index = parsedLyrics.value.length - 2
return index
})
const height = ref<number>()
const top = ref(0)
const syncedHeight = ref<number>()
const updateLyricsLayout = () => {
const trackDisplay = document.querySelector(".track-display")
if (!trackDisplay) return
height.value = trackDisplay.clientHeight
top.value = trackDisplay.getBoundingClientRect().top
syncedHeight.value = window.innerHeight - top.value - 100
}
watch(
fullscreen,
async () => {
await nextTick()
updateLyricsLayout()
},
{ immediate: true },
)
onMounted(() => window.addEventListener("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 outerRef = useTemplateRef("outer-ref")
const manualOffset = ref(0)
const isScrolling = ref(false)
let scrollingTimeout: any = null
const onWheel = (event: WheelEvent) => {
if (!containerRef.value || !outerRef.value) return
if (!isScrolling.value) {
manualOffset.value = transformY.value + offset
}
isScrolling.value = true
clearTimeout(scrollingTimeout)
const delta = event.deltaY
manualOffset.value -= delta
const maxTranslate = offset
const lowerViewportLimit = Math.max(
outerRef.value.offsetHeight,
window.innerHeight - 300,
)
const minTranslate =
-containerRef.value.scrollHeight + lowerViewportLimit
manualOffset.value = Math.min(
maxTranslate,
Math.max(minTranslate, manualOffset.value),
)
scrollingTimeout = setTimeout(() => (isScrolling.value = false), 3000)
}
const onLyricClick = (time: number) => {
clearTimeout(scrollingTimeout)
isScrolling.value = false
secondsPlayed.value = time
}
const finalTransform = computed(() => {
if (isScrolling.value) return manualOffset.value
return transformY.value + offset
})
</script>
<template>
<div
ref="outer-ref"
v-if="song && song.lyrics && song.lyrics.synced"
class="synced-lyrics-container"
:style="{
height: `${syncedHeight}px`,
marginTop: `${top}px`,
}"
@wheel.prevent="onWheel"
>
<div
ref="container-ref"
class="synced-lyrics"
:style="{
transform: `translateY(${finalTransform}px)`,
transition: `transform ${animationDuration}s ease`,
}"
>
<p
v-for="(lyric, i) in parsedLyrics"
:key="lyric.time"
:class="{ active: i == scrollActiveIndex }"
ref="lyrics"
@click="onLyricClick(lyric.time)"
>
{{ lyric.text }}
</p>
</div>
</div>
<div
v-else-if="song?.lyrics?.text"
class="lyrics"
:style="{
height: `${height}px`,
}"
:class="{ show: showScrollBar }"
>
<p>{{ song.lyrics.text }}</p>
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
@use "@/styles/variables" as *;
.synced-lyrics-container {
position: relative;
overflow: hidden;
align-self: start;
mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 15%,
#000 calc(100% - 200px),
transparent 100%
);
}
.synced-lyrics {
position: absolute;
scrollbar-width: none;
will-change: transform;
top: 0;
& > p {
@include fontSize(40px);
font-weight: 600;
color: white;
opacity: 0.4;
padding: 10px 0;
transition:
opacity 0.1s ease,
filter 0.1s ease;
filter: blur(1.5px);
&.active {
opacity: 1;
filter: initial;
}
&:hover {
cursor: pointer;
opacity: 1;
filter: initial;
}
}
}
.lyrics {
overflow: hidden;
& > p {
height: 100%;
overflow-y: scroll;
white-space: pre-wrap;
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 5px;
}
}
&.show > p::-webkit-scrollbar-thumb {
background-color: $color-secondary-background;
}
}
</style>
@@ -0,0 +1,181 @@
<script setup lang="ts">
const { showButtons } = defineProps<{ showButtons: boolean }>()
const song = useSong()
const {
isPlaying,
play,
pause,
next,
prev,
shuffle,
repeat,
secondsPlayed,
duration,
volume,
} = usePlayer()
const secondsPlayedFormatted = computed(() =>
new Date(secondsPlayed.value * 1000).toISOString().slice(14, 19),
)
const durationFormatted = computed(() =>
new Date((duration.value - secondsPlayed.value) * 1000)
.toISOString()
.slice(14, 19),
)
</script>
<template>
<article class="track-display" v-if="song">
<NuxtImg
class="cover"
:src="GET_FILE(song.album.file)"
:alt="song.name"
width="512"
height="512"
/>
<div class="track">
<p class="name">{{ song.name }}</p>
<ArtistName :artists="song.artists" />
</div>
<div class="slider">
<RangeSlider
:model-value="secondsPlayed"
@update:model-value="(v) => (secondsPlayed = v ?? 0)"
:max="duration"
dont-update-immediately
monochrome
/>
<div class="time">
<p>{{ secondsPlayedFormatted }}</p>
<p>{{ durationFormatted }}</p>
</div>
</div>
<div class="btns" :class="{ hidden: !showButtons }">
<Icon name="lucide:shuffle" @click="shuffle()" />
<Icon name="lucide:skip-back" @click="prev()" />
<Icon name="lucide:pause" v-if="isPlaying" @click="pause()" />
<Icon name="lucide:play" v-else @click="play()" />
<Icon name="lucide:skip-forward" @click="next()" />
<Icon
name="lucide:repeat"
:class="{ repeating: repeat }"
@click="repeat = !repeat"
/>
</div>
<div class="volume-slider" :class="{ hidden: !showButtons }">
<Icon name="lucide:volume-2" />
<RangeSlider
:model-value="volume"
@update:model-value="
(v) => {
if (v !== undefined) volume = v
}
"
:max="1"
:min="0"
:step="0.01"
monochrome
/>
</div>
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
@use "@/styles/variables" as *;
.track-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 500px;
}
.cover {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: $border-radius-standard;
}
.track {
@include fontSize(16px);
width: 100%;
line-height: 1.3;
.name {
@include fontSize(17px);
font-weight: bold;
}
}
.slider {
display: flex;
flex-direction: column;
width: 100%;
gap: 4px;
.time {
width: 100%;
display: flex;
justify-content: space-between;
opacity: 0.5;
& p {
@include fontSize(15px);
}
}
}
.btns {
cursor: pointer;
font-size: 30px;
display: flex;
justify-content: center;
width: 100%;
gap: 1rem;
transition: opacity 0.2s ease-in-out;
&.hidden {
opacity: 0;
pointer-events: none;
}
& > * {
transition: color 0.2s ease;
}
.repeating {
color: $color-accent;
}
}
.volume-slider {
align-self: stretch;
margin: 0.5rem 1rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
opacity: 1;
transition: opacity 0.2s ease-in-out;
:deep(.slider) {
flex: 1;
}
&.hidden {
opacity: 0;
pointer-events: none;
}
}
</style>
+238
View File
@@ -0,0 +1,238 @@
<script setup lang="ts">
const open = defineModel<boolean>("open", { default: false })
const { queue, playAlbumAtIndex } = usePlayer()
interface QueueRow {
key: number
track: Track
}
const queueRows = ref<QueueRow[]>([])
const draggedKey = ref<number | null>(null)
const overKey = ref<number | null>(null)
let nextRowKey = 0
const syncRows = (tracks: Track[]) => {
const previousRows = [...queueRows.value]
queueRows.value = tracks.map((track) => {
const previousIndex = previousRows.findIndex((row) => row.track === track)
if (previousIndex !== -1) {
const [row] = previousRows.splice(previousIndex, 1)
return row!
}
return { key: nextRowKey++, track }
})
}
watch(queue, syncRows, { immediate: true, deep: true })
const close = () => (open.value = false)
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") close()
}
watch(open, (isOpen) => {
if (isOpen) document.addEventListener("keydown", handleKeydown)
else document.removeEventListener("keydown", handleKeydown)
})
onBeforeUnmount(() => document.removeEventListener("keydown", handleKeydown))
const startDrag = (event: DragEvent, index: number) => {
const row = queueRows.value[index]
if (!row) return
draggedKey.value = row.key
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("text/plain", String(row.key))
}
}
const moveTo = (event: DragEvent, key: number) => {
event.preventDefault()
const from = queueRows.value.findIndex((row) => row.key === draggedKey.value)
const to = queueRows.value.findIndex((row) => row.key === key)
if (from === -1 || to === -1 || from === to) return
const [row] = queueRows.value.splice(from, 1)
queueRows.value.splice(to, 0, row!)
overKey.value = key
}
const finishDrag = () => {
if (draggedKey.value === null) return
queue.value = queueRows.value.map((row) => row.track)
draggedKey.value = null
overKey.value = null
}
const cancelDrag = () => {
if (draggedKey.value === null) return
syncRows(queue.value)
draggedKey.value = null
overKey.value = null
}
const playAt = (index: number) =>
playAlbumAtIndex(
queueRows.value.map((row) => row.track),
index,
)
</script>
<template>
<Teleport to="body">
<Transition name="queue">
<div v-if="open" class="queue-root" @click.self="close">
<div class="backdrop" @click="close"></div>
<aside
class="drawer"
role="dialog"
aria-modal="true"
aria-labelledby="queue-heading"
>
<header class="header">
<h2 id="queue-heading">Up Next</h2>
<button class="close" aria-label="Close queue" @click="close">
<Icon name="lucide:x" />
</button>
</header>
<div v-if="queueRows.length === 0" class="empty">
<p>No upcoming songs.</p>
</div>
<ol v-else class="tracks">
<li
v-for="(row, index) in queueRows"
:key="row.key"
:class="{
dragging: draggedKey === row.key,
over: overKey === row.key,
}"
draggable="true"
@dragstart="startDrag($event, index)"
@dragenter="moveTo($event, row.key)"
@dragover.prevent
@dragleave="overKey = null"
@drop="finishDrag"
@dragend="cancelDrag"
>
<TrackRow
:track="row.track"
:index="index"
show-image
queue-mode
class="queue-track"
@play-album-at-index="playAt(index)"
/>
</li>
</ol>
</aside>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
.queue-root {
position: fixed;
inset: 0;
z-index: 30;
}
.backdrop {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 55%);
}
.drawer {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
display: flex;
flex-direction: column;
inline-size: min(28rem, 92vw);
max-inline-size: 100vw;
background: $color-background;
border-inline-start: 1px solid $color-border;
box-shadow: 0 0 2rem rgb(0 0 0 / 30%);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid $color-border;
h2 {
margin: 0;
font-size: 1.1rem;
}
}
.close,
.play {
border: 0;
color: inherit;
background: transparent;
cursor: pointer;
}
.close {
display: grid;
place-items: center;
font-size: 1.25rem;
}
.tracks {
margin: 0;
padding: 0.5rem;
list-style: none;
overflow-y: auto;
}
.tracks li {
list-style: none;
border-radius: $border-radius-standard;
&.over :deep(.track) {
background: rgb(255 255 255 / 8%);
}
&.dragging :deep(.track) {
opacity: 0.45;
}
}
.empty {
flex: 1;
display: grid;
place-items: center;
padding: 2rem;
color: rgb(255 255 255 / 55%);
}
.queue-enter-active,
.queue-leave-active {
transition: opacity 0.2s ease;
.drawer {
transition: transform 0.2s ease;
}
}
.queue-enter-from,
.queue-leave-to {
opacity: 0;
.drawer {
transform: translateX(100%);
}
}
</style>
+8 -6
View File
@@ -38,7 +38,8 @@ const durationFormatted = computed(() =>
new Date(duration.value * 1000).toISOString().slice(14, 19),
)
const toast = useToast()
const { open: openFullscreen } = useFullscreen()
const queueOpen = ref(false)
</script>
<template>
@@ -104,15 +105,14 @@ const toast = useToast()
</div>
<Icon
name="lucide:list"
@click="toast.show('Not yet implemented')"
/>
<Icon
name="lucide:expand"
@click="toast.show('Not yet implemented')"
@click="queueOpen = true"
/>
<Icon name="lucide:expand" @click="openFullscreen()" />
</div>
</template>
</article>
<PlayerQueue v-model:open="queueOpen" />
<PlayerFullscreen />
</template>
<style lang="scss" scoped>
@@ -132,6 +132,8 @@ const toast = useToast()
border-radius: $border-radius-lg;
border: 1px solid $color-border;
background-color: $color-background;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
interface SearchResult {
type: "track" | "album" | "artist"
_id: string
name: string
file?: string
cover?: string
album?: { _id?: string; name?: string; file?: string; cover?: string }
artists?: Artist[]
}
const props = defineProps<{ result: SearchResult; index: number }>()
const emit = defineEmits<{ (e: "play"): void }>()
const artworkSrc = computed(() => {
const file = props.result.type === "track"
? props.result.album?.file ?? props.result.album?.cover
: props.result.file ?? props.result.cover
return file ? GET_FILE(file) : undefined
})
const entryType = computed(() =>
props.result.type === "track"
? "Song"
: props.result.type === "album"
? "Album"
: "Artist",
)
</script>
<template>
<article class="search-row" :class="{ odd: props.index % 2 === 1 }">
<div class="artwork" @click="props.result.type === 'track' && emit('play')">
<NuxtImg
v-if="artworkSrc"
:src="artworkSrc"
width="40"
height="40"
placeholder
/>
<Icon
v-if="props.result.type === 'track'"
name="lucide:play"
class="play-icon"
/>
</div>
<div class="details">
<p v-if="props.result.type === 'track'">{{ props.result.name }}</p>
<NuxtLink
v-else
:to="`/${props.result.type}/${props.result._id}`"
>
{{ props.result.name }}
</NuxtLink>
<span class="entry-type">{{ entryType }}</span>
</div>
<ArtistName
v-if="props.result.type === 'album' && props.result.artists?.length"
:artists="props.result.artists"
class="artists"
/>
<span v-else />
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "sass:color";
.search-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
padding: 0.7rem 1rem;
border-radius: $border-radius-standard;
background-color: $color-background;
line-height: 1.2;
&.odd {
background-color: color.adjust($color-background, $lightness: 3%);
}
&:hover {
background-color: color.adjust($color-background, $lightness: 5%);
}
}
.artwork {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: $border-radius-standard;
cursor: pointer;
img {
max-width: 100%;
max-height: 100%;
border-radius: inherit;
}
&:hover .play-icon {
opacity: 1;
}
}
.play-icon {
position: absolute;
opacity: 0;
font-size: 1.4rem;
transition: opacity 0.15s ease;
}
.details {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
p,
a {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
a {
color: inherit;
text-decoration: none;
}
}
.entry-type {
font-size: 0.8em;
opacity: 0.65;
}
.artists {
min-width: 0;
}
</style>
+25 -6
View File
@@ -1,22 +1,39 @@
<script setup lang="ts">
interface SliderProps {
title?: string
scrollWidth?: () => number
sliderClass?: string
}
const props = defineProps<SliderProps>()
const slider = ref<HTMLDivElement>()
const slider = useTemplateRef<HTMLDivElement>("slider")
const scrollLeft = (num: number) => {
if (slider.value) slider.value.scrollLeft += num
if (!slider.value) return
const maxScroll = slider.value.scrollWidth - slider.value.clientWidth
slider.value.scrollLeft = Math.max(
0,
Math.min(slider.value?.scrollLeft + num, maxScroll),
)
}
const left = () => {
if (slider.value) scrollLeft(-slider.value.offsetWidth)
if (slider.value)
scrollLeft(
props.scrollWidth
? -props.scrollWidth()
: -slider.value.offsetWidth,
)
}
const right = () => {
if (slider.value) scrollLeft(slider.value.offsetWidth)
if (slider.value)
scrollLeft(
props.scrollWidth ? props.scrollWidth() : slider.value.offsetWidth,
)
}
const doesScroll = ref<boolean>(false)
@@ -48,7 +65,9 @@ onMounted(() => {
class="left"
name="material-symbols:chevron-left-rounded"
/>
<section class="slider" ref="slider"><slot /></section>
<section class="slider" :class="[props.sliderClass]" ref="slider">
<slot />
</section>
<Icon
v-if="doesScroll"
@click="right"
@@ -85,7 +104,7 @@ onMounted(() => {
& > * {
flex: 0 0 auto;
scroll-snap-align: center;
scroll-snap-align: start;
}
}
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
interface TrackRowProps {
track: Track
}
const { track } = defineProps<TrackRowProps>()
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
const hovering = ref(false)
const toast = useToast()
const dropdownMenuItems: DropdownMenuItem[][] = [
[
{
label: "Play only this",
icon: "lucide:play",
onSelect() {},
},
{
label: "Play next",
icon: "lucide:list-start",
onSelect() {
toast.show("Playing next")
},
},
{
label: "Add to queue",
icon: "lucide:list-end",
onSelect() {
toast.show("Added to queue")
},
},
],
]
</script>
<template>
<article
class="track"
@mouseover="hovering = true"
@mouseleave="hovering = false"
>
<div
class="index"
:class="{ hovering }"
@click="emit('playAlbumAtIndex')"
>
<Icon name="lucide:play" />
<NuxtImg
:src="GET_FILE(track.album.file)"
width="50"
height="50"
placeholder
/>
</div>
<div>
<p>{{ track.name }}</p>
<ArtistName :artists="track.artists" class="artists" />
</div>
<DropdownMenu :items="dropdownMenuItems">
<Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu>
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util" as *;
@use "sass:color";
.track {
display: grid;
grid-template-columns: 40px 1fr 30px;
gap: 0.5rem;
align-items: center;
padding: 0.6rem 1rem;
border-radius: $border-radius-standard;
background-color: $color-background;
line-height: 1;
width: 100%;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.index {
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
position: relative;
cursor: pointer;
img {
border-radius: $border-radius-standard;
}
span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
}
img,
span {
transition: opacity 0.15s ease;
}
&.hovering {
img {
opacity: 0.4;
}
span {
opacity: 1;
}
}
}
.artists {
@include fontSize(14px);
}
.dropdown-icon {
font-size: 20px;
cursor: pointer;
}
</style>
+160 -24
View File
@@ -2,28 +2,43 @@
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
interface TrackRowProps {
track: Track
track?: Track
entity?: {
type: "album" | "artist"
_id: string
name: string
file?: string
cover?: string
artists?: Artist[]
}
index: number
extendedInfo?: boolean
showImage?: boolean
queueMode?: boolean
}
const { track, index, extendedInfo } = defineProps<TrackRowProps>()
const props = defineProps<TrackRowProps>()
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
const hovering = ref(false)
const { playAlbum, addToFrontOfQueue, addToQueue } = usePlayer()
const toast = useToast()
const dropdownMenuItems: DropdownMenuItem[] = [
const dropdownMenuItems: DropdownMenuItem[][] = [
[
{
label: "Play only this",
icon: "lucide:play",
onSelect() {},
onSelect() {
if (props.track) playAlbum([props.track])
},
},
{
label: "Play next",
icon: "lucide:list-start",
onSelect() {
if (!props.track) return
addToFrontOfQueue(props.track)
toast.show("Playing next")
},
},
@@ -31,44 +46,101 @@ const dropdownMenuItems: DropdownMenuItem[] = [
label: "Add to queue",
icon: "lucide:list-end",
onSelect() {
if (!props.track) return
addToQueue(props.track)
toast.show("Added to queue")
},
},
],
]
const durationFormatted = computed(() =>
new Date(track.durationInSeconds * 1000).toISOString().slice(14, 19),
)
const durationFormatted = computed(() => {
if (!props.track) return ""
return new Date(props.track.durationInSeconds * 1000)
.toISOString()
.slice(14, 19)
})
const artworkSrc = computed(() => {
if (props.track) return GET_FILE(props.track.album.file)
const file = props.entity?.file ?? props.entity?.cover
return file ? GET_FILE(file) : undefined
})
</script>
<template>
<article
class="track"
:class="{ extended: extendedInfo, odd: index % 2 == 1 }"
@mouseover="hovering = true"
:class="{
extended: props.extendedInfo,
odd: props.index % 2 == 1,
image: props.showImage,
queue: props.queueMode,
}"
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
<div class="index">
<div class="index" :class="{ hovering }">
<template v-if="props.showImage">
<Icon
v-if="props.track"
name="lucide:play"
class="play-icon"
@click="emit('playAlbumAtIndex')"
v-if="hovering"
/>
<p v-else>{{ index + 1 }}</p>
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
<NuxtImg
v-if="artworkSrc"
:src="artworkSrc"
width="40"
height="40"
placeholder
/>
</template>
<template v-else>
<Icon
v-if="hovering"
name="lucide:play"
class="play-icon inline"
@click="emit('playAlbumAtIndex')"
/>
<p v-else>{{ props.index + 1 }}</p>
</template>
</div>
<p>{{ track.name }}</p>
<p v-if="props.track">{{ props.track.name }}</p>
<NuxtLink
v-else-if="props.entity"
:to="`/${props.entity.type}/${props.entity._id}`"
>
{{ props.entity.name }}
</NuxtLink>
<template v-if="extendedInfo">
<ArtistName :artists="track.artists" class="artists" />
<NuxtLink :to="`/album/${track.album._id}`">
<p>{{ track.album.name }}</p>
<template v-if="props.track && props.extendedInfo">
<ArtistName :artists="props.track.artists" class="artists" />
<NuxtLink :to="`/album/${props.track.album._id}`">
<p>{{ props.track.album.name }}</p>
</NuxtLink>
</template>
<template v-else-if="props.entity">
<ArtistName
v-if="props.entity.type === 'album' && props.entity.artists?.length"
:artists="props.entity.artists"
class="artists"
/>
<span v-else />
<p>{{ props.entity.type }}</p>
</template>
<p>{{ durationFormatted }}</p>
<p v-if="props.track">{{ durationFormatted }}</p>
<DropdownMenu :items="dropdownMenuItems">
<Icon
v-if="props.queueMode"
name="lucide:grip-vertical"
class="queue-handle"
aria-hidden="true"
/>
<DropdownMenu v-else-if="props.track" :items="dropdownMenuItems">
<Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu>
</article>
@@ -81,7 +153,7 @@ const durationFormatted = computed(() =>
.track {
display: grid;
grid-template-columns: 30px 1fr 50px 30px;
grid-template-columns: 40px 1fr 50px 30px;
gap: 0.5rem;
align-items: center;
padding: 0.6rem 1rem;
@@ -90,18 +162,41 @@ const durationFormatted = computed(() =>
line-height: 1;
&.odd {
background-color: color.adjust($color-background, $lightness: 3%);
}
&:hover {
background-color: color.adjust($color-background, $lightness: 5%);
}
&.extended {
grid-template-columns: 30px 2fr 1fr 1fr 50px 30px;
grid-template-columns: 40px 2fr 1fr 1fr 50px 30px;
}
p {
&.image {
grid-template-columns: 40px 1fr 50px 30px;
padding: 0.45rem 1rem;
.index {
height: 40px;
}
}
&.queue {
cursor: grab;
}
p,
> a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
> a {
color: inherit;
text-decoration: none;
}
}
.index {
@@ -110,8 +205,43 @@ const durationFormatted = computed(() =>
align-items: center;
font-size: 20px;
height: 20px;
position: relative;
cursor: pointer;
img {
border-radius: $border-radius-standard;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
z-index: 1;
font-size: 24px;
}
.play-icon.inline {
position: static;
transform: none;
opacity: 1;
}
img,
.play-icon {
transition: opacity 0.15s ease;
}
&.hovering {
img {
opacity: 0.4;
}
.play-icon {
opacity: 1;
}
}
}
.artists {
@@ -120,5 +250,11 @@ const durationFormatted = computed(() =>
.dropdown-icon {
font-size: 20px;
cursor: pointer;
}
.queue-handle {
opacity: 0.5;
cursor: grab;
}
</style>
+9
View File
@@ -127,10 +127,19 @@ export const useAuth = () => {
return null
})
.finally(() => {
waitForPromise = false
})
return refreshPromise
}
if (getCurrentInstance()) {
onMounted(() => {
if (refreshToken.value && !sessionToken.value) refreshSessionToken()
})
}
return {
refreshToken,
sessionToken,
+66
View File
@@ -0,0 +1,66 @@
type FullscreenState = ReturnType<typeof useState<boolean>>
type FullscreenControls = {
fullscreen: FullscreenState
open: () => Promise<boolean>
close: () => Promise<boolean>
toggle: () => Promise<boolean>
}
const syncFullscreenState = (fullscreen: FullscreenState) => {
fullscreen.value = Boolean(document.fullscreenElement)
}
let fullscreenListenerInstalled = false
export const useFullscreen = () => {
const fullscreen = useState<boolean>("fullscreen", () => false)
const open = async () => {
fullscreen.value = true
if (!import.meta.client) return true
if (!document.documentElement.requestFullscreen) return true
try {
await document.documentElement.requestFullscreen()
return true
} catch {
fullscreen.value = false
return false
}
}
const close = async () => {
if (import.meta.client && document.fullscreenElement) {
try {
await document.exitFullscreen()
} catch {
// Fall through to keep the app state consistent.
}
}
fullscreen.value = false
return true
}
const toggle = async () => {
if (fullscreen.value) return close()
return open()
}
if (import.meta.client && !fullscreenListenerInstalled) {
fullscreenListenerInstalled = true
document.addEventListener("fullscreenchange", () => {
syncFullscreenState(fullscreen)
})
syncFullscreenState(fullscreen)
}
return {
fullscreen,
open,
close,
toggle,
} as FullscreenControls
}
+72 -2
View File
@@ -48,6 +48,14 @@ const albumFetchOptions = async (q?: string): Promise<Option[]> => {
}))
}
const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
const genreFetchOptions = async (): Promise<Option[]> => {
return genreData.value.map<Option>((genre) => ({
label: genre.name,
value: genre._id,
}))
}
const modalFields: Field[] = [
{ key: "name", label: "Name" },
{ key: "file", label: "Audio file", type: "file" },
@@ -57,16 +65,41 @@ const modalFields: Field[] = [
type: "search-select-array",
fetchOptions: artistFetchOptions,
},
{
key: "createSingle",
label: "Create a single album automatically",
type: "checkbox",
visibleWhen: (value) => !value._id,
},
{
key: "album",
label: "Album",
type: "search-select",
fetchOptions: albumFetchOptions,
visibleWhen: (value) => !value.createSingle,
},
{
key: "cover",
label: "Single cover",
type: "file",
visibleWhen: (value) => !value._id && value.createSingle,
},
{
key: "genre",
label: "Genre",
type: "search-select",
fetchOptions: genreFetchOptions,
visibleWhen: (value) => !value._id && value.createSingle,
},
{ key: "lyrics.text", label: "Lyrics", type: "textarea", rows: 6 },
{ key: "lyrics.synced", label: "Synced lyrics", type: "checkbox" },
]
const serializeLyrics = (lyrics: Track["lyrics"] | undefined) => {
if (!lyrics?.text?.trim()) return JSON.stringify(null)
return JSON.stringify(lyrics)
}
const showModal = ref<boolean>(false)
const item = ref<Track | undefined>(undefined)
@@ -77,7 +110,7 @@ const save = async (value: any) => {
method: "PATCH",
data: {
...value,
lyrics: JSON.stringify(value.lyrics),
lyrics: serializeLyrics(value.lyrics),
},
params: { id: value._id },
forceFormData: true,
@@ -91,11 +124,48 @@ const save = async (value: any) => {
}
} else {
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.cover || !value.genre) {
toast.show(
"A cover image and genre are required for a single",
"error",
)
return false
}
const albumResponse = await cfetch("/admin/album", {
method: "POST",
data: {
name: `${value.name} - Single`,
artists: value.artists.map((artist: Option) => artist.value),
genres: value.genre.value,
file: value.cover,
},
})
value.album = albumResponse.data._id
} else if (!value.album) {
toast.show(
"Choose an album or create a single album",
"error",
)
return false
}
await cfetch("/admin/track", {
method: "POST",
data: {
...value,
lyrics: JSON.stringify(value.lyrics),
album: value.album?.value ?? value.album,
artists: value.artists.map((artist: Option) => artist.value),
lyrics: serializeLyrics(value.lyrics),
},
})
-1
View File
@@ -1 +0,0 @@
<template></template>
+157
View File
@@ -0,0 +1,157 @@
<script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id)
const { data: artistData } = await useApiFetch<Album>(`/artists/${id.value}`)
const { data: albumsData } = await useApiFetch<Album[]>(
`/artists/${id.value}/albums`,
)
const { data: tracksData } = await useApiFetch<Track[]>(
`/artists/${id.value}/tracks`,
)
const chunkedTracks = computed(() => {
const SIZE = 3
const chunks = []
for (let i = 0; i < tracksData.value.length; i += SIZE) {
chunks.push(tracksData.value.slice(i, i + SIZE))
}
return chunks
})
const slider = useTemplateRef("slider")
const scrollWidth = () => {
if (!slider.value) return 0
const el = Array.isArray(slider.value) ? slider.value[0] : slider.value
if (!el) return 0
return el.offsetWidth
}
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
</script>
<template>
<section class="info" v-if="artistData">
<NuxtImg
:src="GET_FILE(artistData.file)"
width="300"
height="300"
placeholder
/>
<div class="details">
<h1>{{ artistData.name }}</h1>
<div class="buttons">
<Button
icon-name="lucide:play"
@click="() => playAlbum(tracksData)"
>Play</Button
>
<Button
icon-name="lucide:shuffle"
@click="() => playShuffledAlbum(tracksData)"
>Shuffle</Button
>
</div>
</div>
</section>
<Slider slider-class="slider" :scroll-width="scrollWidth" title="Tracks">
<section
class="tracks"
:class="[`tracks-${chunkedTracks[0]?.length ?? 1}`]"
v-for="(chunk, pI) in chunkedTracks"
:key="`chunk-${pI}`"
ref="slider"
>
<TrackRow
v-for="(track, i) in chunk"
:key="`track-${track._id}`"
:track="track"
:index="i + pI * 3"
@play-album-at-index="
() => playAlbumAtIndex(tracksData, i + pI * 3)
"
/>
</section>
</Slider>
<Slider title="Albums">
<AlbumCard
v-for="album in albumsData"
:key="album._id"
:name="album.name"
:file="`${BACKEND_SERVE}${album.file}`"
:artists="album.artists"
:_id="album._id"
/>
</Slider>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util";
.info {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
& img {
border-radius: $border-radius-standard;
}
.details {
padding-top: 3.5rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
& h1 {
@include util.fontSize(32px);
font-weight: 700;
line-height: 2.4rem;
}
.artist {
@include util.fontSize(18px);
}
.buttons {
margin-top: auto;
display: flex;
gap: 1rem;
}
}
}
$gap: 12px;
:deep(.slider) {
gap: $gap;
margin-bottom: 1rem;
}
.tracks {
display: grid;
gap: 0.4rem;
flex: 0 0 calc((100% - 3 * $gap) / 4);
@for $i from 1 through 3 {
&-#{$i} {
grid-template-rows: repeat(#{$i}, 1fr);
}
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id)
const { data: tracksData } = await useApiFetch<Track[]>(
`/artists/${id.value}/tracks`,
)
const { playAlbumAtIndex } = usePlayer()
</script>
<template>
<section class="tracks">
<h1 class="title">Songs</h1>
<TrackRow
v-for="(track, i) in tracksData"
:key="track._id"
:track="track"
:index="i"
show-image
@play-album-at-index="() => playAlbumAtIndex(tracksData, i)"
/>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.tracks {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.title {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1.5rem;
}
</style>
+1 -10
View File
@@ -1,13 +1,4 @@
<script setup lang="ts">
const player = usePlayer()
const { data } = useApiFetch("/all/tracks")
const play = () => {
player.addToQueue(data.value[0])
player.play()
}
</script>
<script setup lang="ts"></script>
<template>
<h1>Home</h1>
+169 -3
View File
@@ -1,7 +1,173 @@
<script setup lang="ts">
const route = useRoute()
import type { Track } from "@/utils/types"
watchEffect(() => console.log(route.query.q))
interface SearchResult {
type: "track" | "album" | "artist"
_id: string
name: string
file?: string
cover?: string
album?: { _id?: string; name?: string; file?: string; cover?: string }
artists?: Artist[]
}
const route = useRoute()
const { currentSong, playAlbum } = usePlayer()
const query = computed(() => {
const value = route.query.q
return (Array.isArray(value) ? value[0] : value ?? "").trim()
})
const debouncedQuery = ref(query.value)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
watch(query, (value) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedQuery.value = value
debounceTimeout = undefined
}, 300)
})
onBeforeUnmount(() => {
clearTimeout(debounceTimeout)
})
const { data, status, error } = await useApiFetch<SearchResult[]>("/search", {
query: { q: debouncedQuery, limit: 9 },
watch: [debouncedQuery],
})
const results = computed<SearchResult[]>(() => {
const payload: any = data.value
if (Array.isArray(payload)) return payload
if (Array.isArray(payload?.results)) return payload.results
if (Array.isArray(payload?.data)) return payload.data
if (!payload || typeof payload !== "object") return []
return (["tracks", "albums", "artists"] as const).flatMap((kind) =>
Array.isArray(payload[kind])
? payload[kind].map((result: SearchResult) => ({
...result,
type: kind.slice(0, -1) as SearchResult["type"],
}))
: [],
)
})
const recentTracks = ref<Track[]>([])
const recentStorageKey = "music-v2-recently-played"
onMounted(() => {
try {
recentTracks.value = JSON.parse(
window.localStorage.getItem(recentStorageKey) ?? "[]",
) as Track[]
} catch {
recentTracks.value = []
}
})
watch(currentSong, (track) => {
if (!track || !import.meta.client) return
const updated = [
track,
...recentTracks.value.filter((item) => item._id !== track._id),
].slice(0, 9)
recentTracks.value = updated
window.localStorage.setItem(recentStorageKey, JSON.stringify(updated))
})
const playResult = (result: SearchResult | Track) => {
if ("album" in result && result.album) playAlbum([result as Track])
}
</script>
<template></template>
<template>
<main class="search-page">
<h1>Search</h1>
<p v-if="query && status === 'pending'" class="message" role="status">
Searching…
</p>
<p v-else-if="query && error" class="message" role="alert">
Search could not be loaded. Please try again.
</p>
<p
v-else-if="query && status === 'success' && results.length === 0"
class="message"
>
No results for “{{ query }}”.
</p>
<template v-if="!query || (status === 'success' && results.length === 0)">
<h2>Recently Played</h2>
<p v-if="recentTracks.length === 0" class="message">
Your recently played tracks will appear here.
</p>
</template>
<section
v-if="query && results.length"
class="results"
aria-label="Search results"
>
<template
v-for="(result, index) in results"
:key="`${result.type}-${result._id}`"
>
<SearchRow
:result="result"
:index="index"
@play="playResult(result)"
/>
</template>
</section>
<section
v-else-if="recentTracks.length && (!query || status === 'success')"
class="results"
aria-label="Recently played tracks"
>
<TrackRow
v-for="(track, index) in recentTracks"
:key="track._id"
:track="track"
:index="index"
show-image
@play-album-at-index="playResult(track)"
/>
</section>
</main>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.search-page {
padding-bottom: 2rem;
h1 {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1rem;
}
h2 {
@include fontSize(20px);
font-weight: 600;
margin: 1.5rem 0 0.8rem;
}
}
.results {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.message {
@include fontSize(14px);
opacity: 0.65;
margin: 0.75rem 0;
}
</style>
+34 -4
View File
@@ -1,6 +1,10 @@
import { Howl } from "howler"
export default defineNuxtPlugin((nuxtApp) => {
const { close: closeFullscreen } = useFullscreen()
const VOLUME_STORAGE_KEY = "music-v2-player-volume"
const DEFAULT_VOLUME = 0.5
const sound = ref<Howl | null>(null)
const queue = ref<Track[]>([])
const prevQueue = ref<Track[]>([])
@@ -8,6 +12,29 @@ export default defineNuxtPlugin((nuxtApp) => {
const currentSong = ref<Track | null>(null)
const repeat = ref(false)
const isPlaying = ref(false)
const storedVolume = ref(DEFAULT_VOLUME)
const clampVolume = (value: number) => Math.min(1, Math.max(0, value))
const readStoredVolume = () => {
if (!import.meta.client) return DEFAULT_VOLUME
const rawVolume = window.localStorage.getItem(VOLUME_STORAGE_KEY)
if (rawVolume === null) return DEFAULT_VOLUME
const parsedVolume = Number(rawVolume)
if (Number.isNaN(parsedVolume)) return DEFAULT_VOLUME
return clampVolume(parsedVolume)
}
if (import.meta.client) {
storedVolume.value = readStoredVolume()
watch(storedVolume, (value) => {
window.localStorage.setItem(VOLUME_STORAGE_KEY, String(value))
})
}
const tick = ref(0)
@@ -23,10 +50,12 @@ export default defineNuxtPlugin((nuxtApp) => {
const volume = computed<number>({
get() {
tick.value
return sound.value?.volume() ?? 1
return storedVolume.value
},
set(value) {
sound.value?.volume(value)
const nextVolume = clampVolume(value)
storedVolume.value = nextVolume
sound.value?.volume(nextVolume)
},
})
const duration = ref(0)
@@ -83,12 +112,13 @@ export default defineNuxtPlugin((nuxtApp) => {
src: [GET_AUDIO_FILE(song.fileDir)],
html5: false,
autoplay: false,
volume: 0.5,
volume: storedVolume.value,
onload: () => {
duration.value = sound.value?.duration() || 0
volume.value = sound.value?.volume() || 1
sound.value?.volume(storedVolume.value)
},
onend: () => {
void closeFullscreen()
next()
stopTracking()
},
+2
View File
@@ -7,7 +7,9 @@ $color-border: rgba(255, 255, 255, 0.16);
$color-button: rgba(255, 255, 255, 0.08);
$color-button-hover: rgba(255, 255, 255, 0.16);
$color-attention: #fc8181;
$color-slider-monochrome: white;
$border-radius-sm: 0.15rem;
$border-radius-standard: 0.3rem;
$border-radius-lg: 0.6rem;
+54 -23
View File
@@ -6,7 +6,7 @@ export interface DropdownMenuItem {
}
interface DropdownMenuProps {
items: DropdownMenuItem[]
items: DropdownMenuItem[][]
}
const props = defineProps<DropdownMenuProps>()
@@ -14,6 +14,7 @@ const props = defineProps<DropdownMenuProps>()
const open = ref(false)
const placement = ref<"bottom" | "top">("bottom")
const root = useTemplateRef("root")
const menuPosition = ref({ top: 0, left: 0 })
const toggle = async () => {
open.value = !open.value
@@ -37,16 +38,12 @@ const updatePlacement = () => {
// TODO: Calculate height instead
const menuHeight = 220
console.log(
spaceBelow,
menuHeight,
spaceAbove,
spaceBelow < menuHeight && spaceAbove > spaceBelow,
spaceBelow < menuHeight && spaceAbove > spaceBelow ? "top" : "bottom",
)
placement.value =
spaceBelow < menuHeight && spaceAbove > spaceBelow ? "top" : "bottom"
const showAbove = spaceBelow < menuHeight && spaceAbove > spaceBelow
placement.value = showAbove ? "top" : "bottom"
menuPosition.value = {
top: showAbove ? rect.top : rect.bottom,
left: rect.right,
}
}
const handleOutsideClick = (e: MouseEvent) => {
@@ -84,10 +81,24 @@ onBeforeMount(() => {
</div>
<Transition name="dropdown">
<div v-show="open" class="menu" :class="placement">
<Teleport to="body">
<div
v-show="open"
class="dropdown-menu"
:class="placement"
:style="{
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
}"
>
<div
v-for="(group, i) in items"
:key="`group-${i}`"
class="group"
>
<button
v-for="(item, i) in items"
:key="i"
v-for="(item, i) in group"
:key="`item-${i}`"
class="item"
@click="
() => {
@@ -100,6 +111,8 @@ onBeforeMount(() => {
<span>{{ item.label }}</span>
</button>
</div>
</div>
</Teleport>
</Transition>
</div>
</template>
@@ -110,14 +123,14 @@ onBeforeMount(() => {
.dropdown {
position: relative;
display: inline-block;
}
.menu {
position: absolute;
.dropdown-menu {
position: fixed;
z-index: 1000;
right: 0;
min-width: 180px;
padding: 0.5rem;
min-width: 180px;
background-color: $color-secondary-background;
border-radius: $border-radius-standard;
@@ -125,19 +138,37 @@ onBeforeMount(() => {
display: flex;
flex-direction: column;
gap: 2px;
z-index: 1000;
transform: translateX(-100%);
&.bottom {
top: calc(100% + 0.5rem);
transform: translate(-100%, 0.5rem);
transform-origin: top right;
}
&.top {
bottom: calc(100% + 0.5rem);
transform: translate(-100%, calc(-100% - 0.5rem));
transform-origin: bottom right;
}
.group {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0.4rem 0;
border-bottom: 1px solid $color-border;
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.item {
+11 -1
View File
@@ -4,6 +4,7 @@ interface SliderProps {
max?: number
step?: number
dontUpdateImmediately?: boolean
monochrome?: boolean
}
const props = withDefaults(defineProps<SliderProps>(), {
@@ -11,6 +12,7 @@ const props = withDefaults(defineProps<SliderProps>(), {
max: 100,
step: 1,
dontUpdateImmediately: false,
monochrome: false,
})
const value = defineModel<number>()
@@ -67,7 +69,11 @@ const onMouseDown = (event: MouseEvent) => {
<template>
<div ref="slider-ref" class="slider" @mousedown="onMouseDown">
<div class="track">
<div class="fill" :style="{ width: percentage + '%' }" />
<div
class="fill"
:class="{ monochrome }"
:style="{ width: percentage + '%' }"
/>
</div>
</div>
</template>
@@ -98,4 +104,8 @@ const onMouseDown = (event: MouseEvent) => {
background: $color-accent;
border-radius: 999px;
}
.slider .monochrome {
background-color: $color-slider-monochrome;
}
</style>
+13 -4
View File
@@ -21,6 +21,17 @@ const show = ref<boolean>(false)
const isMultiple = computed(() => !!props.multiple)
const loadOptions = async (q = "") => {
const allOptions = await props.fetchOptions(q)
const normalizedQuery = q.trim().toLocaleLowerCase()
options.value = allOptions
.filter((option) =>
option.label.toLocaleLowerCase().includes(normalizedQuery),
)
.slice(0, 10)
}
watchEffect(() => {
if (!props.predefined) return
@@ -47,9 +58,7 @@ watchEffect(() => {
nextTick(() => (options.value = []))
})
watch(query, async (q) => {
options.value = await (await props.fetchOptions(q)).slice(0, 10)
})
watch(query, loadOptions)
const isSelected = (opt: Option) => {
if (isMultiple.value && Array.isArray(value.value)) {
@@ -78,7 +87,7 @@ const select = (item: Option) => {
const onFocus = async () => {
if (isMultiple.value) query.value = ""
show.value = true
options.value = await (await props.fetchOptions()).slice(0, 10)
await loadOptions(isMultiple.value ? "" : query.value)
}
const onFocusOut = () => {
+1 -1
View File
@@ -38,7 +38,7 @@
width: 100%;
height: 100%;
overflow-y: auto;
padding: 2rem 3rem;
padding: 2rem 3rem calc(2rem + $player-height);
}
}
</style>
+4 -2
View File
@@ -25,13 +25,13 @@ export const cfetch = async (
if (!baseUrl) throw new Error("BACKEND URL not set")
const { sessionToken, refreshSessionToken } = useAuth()
const defaults: AxiosRequestConfig = {
baseURL: baseUrl,
}
if (authorize) {
const { sessionToken, refreshSessionToken } = useAuth()
let token = sessionToken.value
if (token) defaults.headers = { Authorization: `Bearer ${token}` }
@@ -64,6 +64,8 @@ export const cfetch = async (
response = await axios(url, config)
} catch (error: any) {
if (error.response?.status == 401) {
const { sessionToken, refreshSessionToken } = useAuth()
if (!config.headers) config.headers = {}
config.headers.Authorization = `Bearer ${await refreshSessionToken()}`
+27
View File
@@ -0,0 +1,27 @@
export const parseLyrics = (lrc: string) => {
const regex = /^\[(\d{2}:\d{2}(.\d{2,3})?)\](.*)/
const lines = lrc.split("\n")
const output: { time: number; text: any }[] = []
const parseTime = (time: any) => {
const minsec = time.split(":")
const min = parseInt(minsec[0]) * 60
const sec = parseFloat(minsec[1])
return min + sec
}
lines.forEach((line: any) => {
const match = line.match(regex)
if (match == null) return
const time = match[1]
const text = match[3]
output.push({
time: parseTime(time),
text: text.trim(),
})
})
return output
}
+1 -1
View File
@@ -24,7 +24,7 @@ export interface Track extends Base {
lyrics: {
synced: boolean
text: string
}
} | null
album: Album
artists: Artist[]
durationInSeconds: number