Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3402178932 | ||
|
|
3255a97339 | ||
|
|
fdf2aeb675 | ||
|
|
8ee6164218 | ||
|
|
a6e8bb51c5 | ||
|
|
9cbf96564f | ||
|
|
23f5709c68 | ||
|
|
30c7972647 | ||
|
|
16e309c346 | ||
|
|
1d2bec7317 | ||
|
|
6b49c7c4d5 | ||
|
|
13081c4771 | ||
|
|
d6f8bb8588 | ||
|
|
64646f778e | ||
|
|
4196db1759 | ||
|
|
d0886cd9a3 | ||
|
|
ec69c85102 | ||
|
|
e7bab2a95c | ||
|
|
cf5ef8d429 | ||
|
|
1eb52f1c94 | ||
|
|
3aaa3eb42e | ||
|
|
f7adfeac22 | ||
|
|
74ba579919 | ||
|
|
1a62aa192e | ||
|
|
e6657a6572 | ||
|
|
a46f289865 | ||
|
|
b778ea9eda | ||
|
|
db86529219 | ||
|
|
603620a418 | ||
|
|
2b01ed688c | ||
|
|
686be7ca8a | ||
|
|
c868329d8b | ||
|
|
7cbc087049 | ||
|
|
5c9b82265d | ||
|
|
307f467f94 | ||
|
|
cb481ad983 | ||
|
|
59af94de8c | ||
|
|
c5cd36f5e5 | ||
|
|
e8d2173f17 | ||
|
|
427437bb3b | ||
|
|
fceddf641a | ||
|
|
af3b9669dc | ||
|
|
4eb7c6d5a9 | ||
|
|
aa93320215 | ||
|
|
6c1dda9622 | ||
|
|
b48db8bb37 | ||
|
|
d5482a9ea4 | ||
|
|
ab22cd6eb9 | ||
|
|
b6e854b1dd | ||
|
|
fd27fee6c7 | ||
|
|
22d6150290 | ||
|
|
ddef57b802 | ||
|
|
522e062a36 | ||
|
|
ae79952ddc | ||
|
|
76c57c1ba0 | ||
|
|
4475b95aed | ||
|
|
2561bd0e62 | ||
|
|
ed53c41a87 | ||
|
|
78c4a6cb8e | ||
|
|
4754d61279 | ||
|
|
dbb91a3918 | ||
|
|
6a3dffae7d | ||
|
|
c1079dcbba | ||
|
|
5f572e769e | ||
|
|
464bbaef9e | ||
|
|
581c522b00 | ||
|
|
470cf122a3 | ||
|
|
464b016fcb | ||
|
|
e5ee065468 | ||
|
|
49d4bd86f1 | ||
|
|
6c10a53a75 | ||
|
|
1060eeba58 | ||
|
|
6e54868cc3 | ||
|
|
fcd12aa27d | ||
|
|
b7262be0a6 | ||
|
|
e1f5077c20 | ||
|
|
5b2d1bb977 | ||
|
|
bd0fc5394f | ||
|
|
2012e7f566 | ||
|
|
9c3f6af7b3 |
No files matched your search
@@ -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.
|
||||
@@ -1,17 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { type Option } from "~/ui/SearchSelect.vue"
|
||||
|
||||
type InputType = "text" | "file" | "search-select"
|
||||
|
||||
interface BaseField<T> {
|
||||
key: keyof T
|
||||
label?: string
|
||||
visibleWhen?: (value: any) => boolean
|
||||
}
|
||||
|
||||
interface TextField<T> extends BaseField<T> {
|
||||
type?: "text"
|
||||
}
|
||||
|
||||
interface TextAreaField<T> extends BaseField<T> {
|
||||
type?: "textarea"
|
||||
rows: number
|
||||
}
|
||||
|
||||
interface FileField<T> extends BaseField<T> {
|
||||
type: "file"
|
||||
}
|
||||
@@ -26,11 +30,17 @@ interface SearchSelectArrayField<T> extends BaseField<T> {
|
||||
fetchOptions: (q?: string) => Promise<Option[]>
|
||||
}
|
||||
|
||||
interface CheckboxField<T> extends BaseField<T> {
|
||||
type?: "checkbox"
|
||||
}
|
||||
|
||||
export type Field<T = any> =
|
||||
| TextField<T>
|
||||
| TextAreaField<T>
|
||||
| FileField<T>
|
||||
| SearchSelectField<T>
|
||||
| SearchSelectArrayField<T>
|
||||
| CheckboxField<T>
|
||||
|
||||
interface ModalFormProps<T> {
|
||||
item?: T
|
||||
@@ -44,27 +54,60 @@ const open = defineModel("open", { required: true })
|
||||
|
||||
let localItem = reactive<any>({})
|
||||
|
||||
const getNestedValue = (obj: any, path: string) =>
|
||||
path.split(".").reduce((acc, key) => acc?.[key], obj)
|
||||
|
||||
const setNestedValue = (obj: any, path: string, value: any) => {
|
||||
const keys = path.split(".")
|
||||
keys.reduce((acc, key, i) => {
|
||||
if (i === keys.length - 1) acc[key] = value
|
||||
else acc[key] ??= {}
|
||||
return acc[key]
|
||||
}, obj)
|
||||
}
|
||||
|
||||
const expandDotKeys = (flat: Record<string, any>) => {
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(flat)) {
|
||||
setNestedValue(result, key, toRaw(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const expanded = expandDotKeys(localItem)
|
||||
|
||||
Object.keys(expanded).forEach((k) => {
|
||||
if (expanded[k] == undefined || expanded[k] == null) delete expanded[k]
|
||||
})
|
||||
|
||||
if (await props.onSave(expanded)) open.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.item,
|
||||
(item) => {
|
||||
Object.keys(localItem).forEach((k) => delete localItem[k])
|
||||
|
||||
if (!item) return
|
||||
|
||||
for (const [key, value] of Object.entries(item)) {
|
||||
localItem[key] = value
|
||||
}
|
||||
if (item)
|
||||
for (const [key, value] of Object.entries(item))
|
||||
localItem[key] = value
|
||||
|
||||
for (const field of props.fields) {
|
||||
if (field.type == "file") localItem[field.key as string] = null
|
||||
const key = field.key as string
|
||||
if (field.type === "file") {
|
||||
localItem[key] = null
|
||||
} else if (field.type === "checkbox") {
|
||||
localItem[key] = item
|
||||
? (getNestedValue(item, key) ?? false)
|
||||
: false
|
||||
} else {
|
||||
localItem[key] = item ? getNestedValue(item, key) : undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const save = async () => {
|
||||
if (await props.onSave(localItem)) open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,7 +115,12 @@ const save = async () => {
|
||||
<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' ||
|
||||
@@ -84,12 +132,20 @@ const save = async () => {
|
||||
:predefined="props.item?.[field.key]"
|
||||
:multiple="field.type == 'search-select-array'"
|
||||
/>
|
||||
<Checkbox
|
||||
v-else-if="field.type == 'checkbox'"
|
||||
:label="field.label || field.key"
|
||||
:model-value="localItem[field.key]"
|
||||
@change="(v) => (localItem[field.key] = v)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:type="field.type"
|
||||
:key="field.key"
|
||||
:label="field.label || field.key"
|
||||
v-model:value="localItem[field.key]"
|
||||
:textarea="field.type == 'textarea'"
|
||||
:rows="field.type == 'textarea' ? field.rows : 0"
|
||||
full
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<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 res = await cfetch(`/genres/albums/${genre._id}`)
|
||||
const data = await Promise.allSettled(
|
||||
(genresData.value ?? []).map(async (genre: Genre) => {
|
||||
const res = await cfetch(`/genres/albums/${genre._id}`)
|
||||
|
||||
return {
|
||||
genre,
|
||||
albums: res.data,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return results
|
||||
return {
|
||||
genre,
|
||||
albums: res.data,
|
||||
}
|
||||
}),
|
||||
).then((results) =>
|
||||
results
|
||||
.filter(
|
||||
(
|
||||
r,
|
||||
@@ -22,21 +20,31 @@ const { data, pending, refresh } = useAsyncData("random-albums", async () => {
|
||||
albums: any[]
|
||||
}> => r.status === "fulfilled",
|
||||
)
|
||||
.map((r) => r.value)
|
||||
})
|
||||
.map((r) => r.value),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="item in data" :key="item.genre._id">
|
||||
<Slider v-if="item.albums.length" :title="item.genre.name">
|
||||
<AlbumCard
|
||||
v-for="album in item.albums"
|
||||
:key="album._id"
|
||||
:name="album.name"
|
||||
:file="`${BACKEND_SERVE}${album.file}`"
|
||||
:artists="album.artists"
|
||||
:_id="album._id"
|
||||
/>
|
||||
</Slider>
|
||||
</template>
|
||||
<section class="slider">
|
||||
<template v-for="item in data" :key="item.genre._id">
|
||||
<Slider v-if="item.albums.length" :title="item.genre.name">
|
||||
<AlbumCard
|
||||
v-for="album in item.albums"
|
||||
:key="album._id"
|
||||
:name="album.name"
|
||||
:file="`${BACKEND_SERVE}${album.file}`"
|
||||
:artists="album.artists"
|
||||
:_id="album._id"
|
||||
/>
|
||||
</Slider>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -127,11 +127,13 @@ const toast = useToast()
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
|
||||
height: 72px;
|
||||
height: $player-height;
|
||||
|
||||
border-radius: $border-radius-lg;
|
||||
border: 1px solid $color-border;
|
||||
|
||||
background-color: $color-background;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
interface SearchResult {
|
||||
type: "track" | "album" | "artist"
|
||||
_id: string
|
||||
name: string
|
||||
file?: string
|
||||
cover?: string
|
||||
album?: { _id?: string; name?: string; file?: string; cover?: string }
|
||||
artists?: Artist[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ result: SearchResult; index: number }>()
|
||||
const emit = defineEmits<{ (e: "play"): void }>()
|
||||
|
||||
const artworkSrc = computed(() => {
|
||||
const file = props.result.type === "track"
|
||||
? props.result.album?.file ?? props.result.album?.cover
|
||||
: props.result.file ?? props.result.cover
|
||||
return file ? GET_FILE(file) : undefined
|
||||
})
|
||||
|
||||
const entryType = computed(() =>
|
||||
props.result.type === "track"
|
||||
? "Song"
|
||||
: props.result.type === "album"
|
||||
? "Album"
|
||||
: "Artist",
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="search-row" :class="{ odd: props.index % 2 === 1 }">
|
||||
<div class="artwork" @click="props.result.type === 'track' && emit('play')">
|
||||
<NuxtImg
|
||||
v-if="artworkSrc"
|
||||
:src="artworkSrc"
|
||||
width="40"
|
||||
height="40"
|
||||
placeholder
|
||||
/>
|
||||
<Icon
|
||||
v-if="props.result.type === 'track'"
|
||||
name="lucide:play"
|
||||
class="play-icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="details">
|
||||
<p v-if="props.result.type === 'track'">{{ props.result.name }}</p>
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="`/${props.result.type}/${props.result._id}`"
|
||||
>
|
||||
{{ props.result.name }}
|
||||
</NuxtLink>
|
||||
<span class="entry-type">{{ entryType }}</span>
|
||||
</div>
|
||||
<ArtistName
|
||||
v-if="props.result.type === 'album' && props.result.artists?.length"
|
||||
:artists="props.result.artists"
|
||||
class="artists"
|
||||
/>
|
||||
<span v-else />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/variables" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.search-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: $border-radius-standard;
|
||||
background-color: $color-background;
|
||||
line-height: 1.2;
|
||||
|
||||
&.odd {
|
||||
background-color: color.adjust($color-background, $lightness: 3%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: color.adjust($color-background, $lightness: 5%);
|
||||
}
|
||||
}
|
||||
|
||||
.artwork {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $border-radius-standard;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
&:hover .play-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
font-size: 1.4rem;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.details {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
|
||||
p,
|
||||
a {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-type {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.artists {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
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)
|
||||
@@ -29,7 +46,7 @@ const computeIfScroll = () => {
|
||||
onMounted(() => {
|
||||
computeIfScroll()
|
||||
|
||||
window.addEventListener("reisze", computeIfScroll)
|
||||
window.addEventListener("resize", computeIfScroll)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", computeIfScroll)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
|
||||
|
||||
interface TrackRowProps {
|
||||
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 props = defineProps<TrackRowProps>()
|
||||
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
|
||||
|
||||
const hovering = ref(false)
|
||||
const { playAlbum, addToFrontOfQueue, addToQueue } = usePlayer()
|
||||
const toast = useToast()
|
||||
|
||||
const dropdownMenuItems: DropdownMenuItem[][] = [
|
||||
[
|
||||
{
|
||||
label: "Play only this",
|
||||
icon: "lucide:play",
|
||||
onSelect() {
|
||||
if (props.track) playAlbum([props.track])
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Play next",
|
||||
icon: "lucide:list-start",
|
||||
onSelect() {
|
||||
if (!props.track) return
|
||||
addToFrontOfQueue(props.track)
|
||||
toast.show("Playing next")
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Add to queue",
|
||||
icon: "lucide:list-end",
|
||||
onSelect() {
|
||||
if (!props.track) return
|
||||
addToQueue(props.track)
|
||||
toast.show("Added to queue")
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
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: props.extendedInfo,
|
||||
odd: props.index % 2 == 1,
|
||||
image: props.showImage,
|
||||
queue: props.queueMode,
|
||||
}"
|
||||
@mouseenter="hovering = true"
|
||||
@mouseleave="hovering = false"
|
||||
>
|
||||
<div class="index" :class="{ hovering }">
|
||||
<template v-if="props.showImage">
|
||||
<Icon
|
||||
v-if="props.track"
|
||||
name="lucide:play"
|
||||
class="play-icon"
|
||||
@click="emit('playAlbumAtIndex')"
|
||||
/>
|
||||
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
|
||||
<NuxtImg
|
||||
v-if="artworkSrc"
|
||||
:src="artworkSrc"
|
||||
width="40"
|
||||
height="40"
|
||||
placeholder
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon
|
||||
v-if="hovering"
|
||||
name="lucide:play"
|
||||
class="play-icon inline"
|
||||
@click="emit('playAlbumAtIndex')"
|
||||
/>
|
||||
<p v-else>{{ props.index + 1 }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<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="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 v-if="props.track">{{ durationFormatted }}</p>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/variables" as *;
|
||||
@use "@/styles/util" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.track {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 50px 30px;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: $border-radius-standard;
|
||||
background-color: $color-background;
|
||||
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: 40px 2fr 1fr 1fr 50px 30px;
|
||||
}
|
||||
|
||||
&.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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-handle {
|
||||
opacity: 0.5;
|
||||
cursor: grab;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@ import defu from "defu"
|
||||
export function useApiFetch<DataT = any, ErrorT = any>(
|
||||
url: string | Request | Ref<string | Request> | (() => string | Request),
|
||||
options?: UseFetchOptions<DataT, ErrorT>,
|
||||
authorize: boolean = true
|
||||
authorize: boolean = true,
|
||||
): ReturnType<typeof useFetch<DataT, ErrorT>> {
|
||||
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND
|
||||
|
||||
@@ -15,26 +15,25 @@ export function useApiFetch<DataT = any, ErrorT = any>(
|
||||
const defaults: UseFetchOptions<DataT, ErrorT> = {
|
||||
baseURL: baseUrl,
|
||||
|
||||
retry: 1,
|
||||
retryDelay: 0,
|
||||
retryStatusCodes: [401],
|
||||
|
||||
headers:
|
||||
authorize && sessionToken.value
|
||||
? { Authorization: `Bearer ${sessionToken.value}` }
|
||||
: {},
|
||||
onResponseError: async ({ response, options }) => {
|
||||
onResponseError: async ({ request, response, options }) => {
|
||||
if (response.status == 401 && authorize) {
|
||||
const newToken = await refreshSessionToken()
|
||||
|
||||
if (!newToken) return
|
||||
|
||||
const newOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
},
|
||||
options.headers = {
|
||||
...(options.headers as unknown as Record<string, string>),
|
||||
// @ts-ignore
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return await $fetch<DataT, ErrorT>(url, newOptions)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -79,7 +79,10 @@ export const useAuth = () => {
|
||||
|
||||
const signup = async (name: string, email: string, password: string) => {}
|
||||
|
||||
const signout = () => {}
|
||||
const signout = () => {
|
||||
refreshToken.value = null
|
||||
sessionToken.value = null
|
||||
}
|
||||
|
||||
const refreshSessionToken = async () => {
|
||||
if (waitForPromise) {
|
||||
@@ -124,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const tableRows: Row[] = [
|
||||
name: "file",
|
||||
displayName: "Cover",
|
||||
type: "image",
|
||||
prefix: `${BACKEND_SERVE}/static/`,
|
||||
prefix: `${BACKEND_SERVE}`,
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
@@ -31,7 +31,7 @@ const tableRows: Row[] = [
|
||||
|
||||
import { type Option } from "~/ui/SearchSelect.vue"
|
||||
|
||||
const { data: artistData } = useApiFetch<Genre[]>("/all/artists")
|
||||
const { data: artistData } = useApiFetch<Artist[]>("/all/artists")
|
||||
const artistFetchOptions = async (q?: string): Promise<Option[]> => {
|
||||
return artistData.value.map<Option>((g) => ({
|
||||
label: g.name,
|
||||
@@ -65,7 +65,7 @@ const modalFields: Field[] = [
|
||||
]
|
||||
|
||||
const showModal = ref<boolean>(false)
|
||||
const item = ref<Track | undefined>(undefined)
|
||||
const item = ref<Album | undefined>(undefined)
|
||||
|
||||
const save = async (value: any) => {
|
||||
if (value._id) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Field } from "~/components/Admin/ModalForm.vue"
|
||||
import type { Row } from "~/ui/Table.vue"
|
||||
|
||||
const { data, refresh } = useApiFetch<Track[]>("/all/artists")
|
||||
const { data, refresh } = useApiFetch<Artist[]>("/all/artists")
|
||||
const toast = useToast()
|
||||
|
||||
const tableRows: Row[] = [
|
||||
@@ -31,7 +31,7 @@ const modalFields: Field[] = [
|
||||
]
|
||||
|
||||
const showModal = ref<boolean>(false)
|
||||
const item = ref<Track | undefined>(undefined)
|
||||
const item = ref<Artist | undefined>(undefined)
|
||||
|
||||
const save = async (value: any) => {
|
||||
if (value._id) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Field } from "~/components/Admin/ModalForm.vue"
|
||||
import type { Row } from "~/ui/Table.vue"
|
||||
|
||||
const { data, refresh } = useApiFetch<Track[]>("/all/genres")
|
||||
const { data, refresh } = useApiFetch<Genre[]>("/all/genres")
|
||||
const toast = useToast()
|
||||
|
||||
const tableRows: Row[] = [
|
||||
@@ -22,7 +22,7 @@ const tableRows: Row[] = [
|
||||
const modalFields: Field[] = [{ key: "name", label: "Name" }]
|
||||
|
||||
const showModal = ref<boolean>(false)
|
||||
const item = ref<Track | undefined>(undefined)
|
||||
const item = ref<Genre | undefined>(undefined)
|
||||
|
||||
const save = async (value: any) => {
|
||||
if (value._id) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Field } from "~/components/Admin/ModalForm.vue"
|
||||
import type { Row } from "~/ui/Table.vue"
|
||||
|
||||
const { data, refresh } = useApiFetch<Album[]>("/all/tracks")
|
||||
const { data, refresh } = useApiFetch<Track[]>("/all/tracks")
|
||||
const toast = useToast()
|
||||
|
||||
const tableRows: Row[] = [
|
||||
@@ -15,7 +15,7 @@ const tableRows: Row[] = [
|
||||
displayName: "Audio File",
|
||||
type: "audio",
|
||||
prefix: `${BACKEND_SERVE}`,
|
||||
suffix: "/original.m4a",
|
||||
suffix: "/high.m4a",
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
@@ -32,7 +32,7 @@ const tableRows: Row[] = [
|
||||
|
||||
import { type Option } from "~/ui/SearchSelect.vue"
|
||||
|
||||
const { data: artistData } = useApiFetch<Genre[]>("/all/artists")
|
||||
const { data: artistData } = useApiFetch<Artist[]>("/all/artists")
|
||||
const artistFetchOptions = async (q?: string): Promise<Option[]> => {
|
||||
return artistData.value.map<Option>((g) => ({
|
||||
label: g.name,
|
||||
@@ -40,7 +40,7 @@ const artistFetchOptions = async (q?: string): Promise<Option[]> => {
|
||||
}))
|
||||
}
|
||||
|
||||
const { data: albumData } = useApiFetch<Genre[]>("/all/albums")
|
||||
const { data: albumData } = useApiFetch<Album[]>("/all/albums")
|
||||
const albumFetchOptions = async (q?: string): Promise<Option[]> => {
|
||||
return albumData.value.map<Option>((g) => ({
|
||||
label: g.name,
|
||||
@@ -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,14 +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)
|
||||
|
||||
@@ -73,7 +108,10 @@ const save = async (value: any) => {
|
||||
try {
|
||||
await cfetch("/admin/track", {
|
||||
method: "PATCH",
|
||||
data: value,
|
||||
data: {
|
||||
...value,
|
||||
lyrics: serializeLyrics(value.lyrics),
|
||||
},
|
||||
params: { id: value._id },
|
||||
forceFormData: true,
|
||||
})
|
||||
@@ -86,9 +124,49 @@ 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,
|
||||
data: {
|
||||
...value,
|
||||
album: value.album?.value ?? value.album,
|
||||
artists: value.artists.map((artist: Option) => artist.value),
|
||||
lyrics: serializeLyrics(value.lyrics),
|
||||
},
|
||||
})
|
||||
|
||||
toast.show("Song added successfully", "success")
|
||||
@@ -137,8 +215,6 @@ const show = (id?: string) => {
|
||||
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
watch(data, () => console.log(data))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1 +1,96 @@
|
||||
<template></template>
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const id = computed(() => route.params.id)
|
||||
|
||||
const { data: albumData } = await useApiFetch<Album>(`/albums/${id.value}`)
|
||||
|
||||
const { data: tracksData } = await useApiFetch<Track[]>(
|
||||
`/albums/${id.value}/tracks`,
|
||||
)
|
||||
|
||||
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info" v-if="albumData">
|
||||
<NuxtImg
|
||||
:src="GET_FILE(albumData.file)"
|
||||
width="300"
|
||||
height="300"
|
||||
placeholder
|
||||
/>
|
||||
|
||||
<div class="details">
|
||||
<h1>{{ albumData.name }}</h1>
|
||||
<ArtistName :artists="albumData.artists" class="artist" />
|
||||
|
||||
<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>
|
||||
|
||||
<section class="tracks">
|
||||
<TrackRow
|
||||
v-for="(track, i) in tracksData"
|
||||
:key="track._id"
|
||||
:track="track"
|
||||
:index="i"
|
||||
@play-album-at-index="() => playAlbumAtIndex(tracksData, i)"
|
||||
/>
|
||||
</section>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
<template></template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+2
-13
@@ -1,18 +1,7 @@
|
||||
<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>
|
||||
<Button @click="play">Test play</Button>
|
||||
<h1>Home</h1>
|
||||
|
||||
<AlbumSlider />
|
||||
|
||||
<div :style="{ height: '100vh' }"></div>
|
||||
</template>
|
||||
+169
-3
@@ -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>
|
||||
+74
-6
@@ -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()
|
||||
},
|
||||
@@ -138,7 +168,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
updateMediaSession()
|
||||
}
|
||||
|
||||
const next = async () => {
|
||||
const next = async (autoplay: boolean = true) => {
|
||||
if (currentSong.value) {
|
||||
prevQueue.value.unshift(currentSong.value)
|
||||
}
|
||||
@@ -153,7 +183,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
currentSong.value = queue.value.shift() || null
|
||||
}
|
||||
|
||||
if (currentSong.value) await play()
|
||||
if (currentSong.value && autoplay) await play()
|
||||
|
||||
updateMediaSession()
|
||||
}
|
||||
@@ -184,6 +214,11 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
currentQueue.value.push(song)
|
||||
}
|
||||
|
||||
const addToFrontOfQueue = (song: Track) => {
|
||||
queue.value.unshift(song)
|
||||
currentQueue.value.unshift(song)
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
queue.value = []
|
||||
currentQueue.value = []
|
||||
@@ -196,6 +231,35 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
queue.value.sort(() => Math.random() - 0.5)
|
||||
}
|
||||
|
||||
// Album
|
||||
const playAlbum = async (songs: Track[]) => {
|
||||
clear()
|
||||
|
||||
songs.forEach((song) => addToQueue(song))
|
||||
|
||||
await next()
|
||||
}
|
||||
|
||||
const playShuffledAlbum = async (songs: Track[]) => {
|
||||
clear()
|
||||
|
||||
songs.forEach((song) => addToQueue(song))
|
||||
shuffle()
|
||||
|
||||
await next()
|
||||
}
|
||||
|
||||
const playAlbumAtIndex = async (songs: Track[], index: number) => {
|
||||
clear()
|
||||
songs.forEach((song) => addToQueue(song))
|
||||
|
||||
for (let i = 0; i < index + 1; i++) {
|
||||
await next(false)
|
||||
}
|
||||
|
||||
await play()
|
||||
}
|
||||
|
||||
// Info
|
||||
const getDuration = () =>
|
||||
new Promise<number>((resolve) => {
|
||||
@@ -218,8 +282,12 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
next,
|
||||
prev,
|
||||
addToQueue,
|
||||
addToFrontOfQueue,
|
||||
clear,
|
||||
shuffle,
|
||||
playAlbum,
|
||||
playShuffledAlbum,
|
||||
playAlbumAtIndex,
|
||||
getDuration,
|
||||
secondsPlayed,
|
||||
duration,
|
||||
|
||||
@@ -7,6 +7,10 @@ $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;
|
||||
|
||||
$player-height: 72px;
|
||||
+10
-1
@@ -1,7 +1,16 @@
|
||||
@use "reset" as *;
|
||||
@use "util" as *;
|
||||
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
|
||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
color: white;
|
||||
}
|
||||
|
||||
main > h1 {
|
||||
@include fontSize(32px);
|
||||
font-weight: 700;
|
||||
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
export interface DropdownMenuItem {
|
||||
label: string
|
||||
icon: string
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
interface DropdownMenuProps {
|
||||
items: DropdownMenuItem[][]
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (open.value) {
|
||||
await nextTick()
|
||||
updatePlacement()
|
||||
}
|
||||
}
|
||||
const close = () => (open.value = false)
|
||||
|
||||
const updatePlacement = () => {
|
||||
const el = root.value
|
||||
if (!el) return
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
// TODO: Calculate height instead
|
||||
const menuHeight = 220
|
||||
|
||||
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) => {
|
||||
if (!root.value) return
|
||||
if (!root.value.contains(e.target as Node)) close()
|
||||
}
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key == "Escape") close()
|
||||
}
|
||||
|
||||
const handleReposition = () => {
|
||||
if (open.value) updatePlacement()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleOutsideClick)
|
||||
document.addEventListener("keydown", handleKey)
|
||||
window.addEventListener("resize", handleReposition)
|
||||
window.addEventListener("scroll", handleReposition, true)
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
document.removeEventListener("click", handleOutsideClick)
|
||||
document.removeEventListener("keydown", handleKey)
|
||||
window.removeEventListener("resize", handleReposition)
|
||||
window.removeEventListener("scroll", handleReposition, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="dropdown">
|
||||
<div @click="toggle" class="trigger">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<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 group"
|
||||
:key="`item-${i}`"
|
||||
class="item"
|
||||
@click="
|
||||
() => {
|
||||
item.onSelect?.()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<Icon :name="item.icon" class="icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
|
||||
padding: 0.5rem;
|
||||
min-width: 180px;
|
||||
|
||||
background-color: $color-secondary-background;
|
||||
border-radius: $border-radius-standard;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
transform: translateX(-100%);
|
||||
|
||||
&.bottom {
|
||||
transform: translate(-100%, 0.5rem);
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
&.top {
|
||||
transform: translate(-100%, calc(-100% - 0.5rem));
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.group {
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
padding: 0.5rem;
|
||||
border-radius: $border-radius-standard;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: $color-button-hover;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition:
|
||||
opacity 0.14s ease-out,
|
||||
transform 0.14s ease-out;
|
||||
}
|
||||
|
||||
/* Starting state */
|
||||
.dropdown-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.96);
|
||||
}
|
||||
|
||||
/* End state */
|
||||
.dropdown-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* Leaving */
|
||||
.dropdown-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
</style>
|
||||
+23
-2
@@ -11,6 +11,8 @@ interface InputProps {
|
||||
required?: boolean
|
||||
autocomplete?: AutocompleteType
|
||||
label?: string
|
||||
textarea?: boolean
|
||||
rows?: number
|
||||
}
|
||||
|
||||
const value = defineModel<string | File | File[] | null>("value")
|
||||
@@ -63,7 +65,7 @@ const onFileChange = (e: Event) => {
|
||||
</span>
|
||||
|
||||
<input
|
||||
v-if="props.type != 'file'"
|
||||
v-if="props.type != 'file' && props.type != 'textarea'"
|
||||
:placeholder="props.placeholder"
|
||||
v-model="value"
|
||||
@input="(e) => emit('input', e.target?.value)"
|
||||
@@ -76,6 +78,20 @@ const onFileChange = (e: Event) => {
|
||||
:autocomplete="props.autocomplete"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-else-if="props.textarea"
|
||||
:placeholder="props.placeholder"
|
||||
v-model="value"
|
||||
@input="(e) => emit('input', e.target?.value)"
|
||||
@focus="emit('focus')"
|
||||
@blur="emit('blur')"
|
||||
:name="props.name"
|
||||
:id="props.name"
|
||||
:required="props.required"
|
||||
:autocomplete="props.autocomplete"
|
||||
:rows="props.rows"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else
|
||||
@focus="emit('focus')"
|
||||
@@ -120,7 +136,8 @@ const onFileChange = (e: Event) => {
|
||||
outline: 2px solid $color-accent;
|
||||
}
|
||||
|
||||
& input {
|
||||
& input,
|
||||
& textarea {
|
||||
width: 100%;
|
||||
background: none;
|
||||
|
||||
@@ -132,6 +149,10 @@ const onFileChange = (e: Event) => {
|
||||
}
|
||||
}
|
||||
|
||||
& textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
+11
-1
@@ -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
@@ -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
-2
@@ -38,8 +38,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 2rem 3rem;
|
||||
position: relative;
|
||||
padding: 2rem 3rem calc(2rem + $player-height);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
const { toasts, remove } = useToast()
|
||||
const song = useSong()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container">
|
||||
<div class="toast-container" :class="{ playerShown: song != undefined }">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
@@ -27,11 +28,17 @@ const { toasts, remove } = useToast()
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
|
||||
padding: 20px;
|
||||
padding: 2rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
transition: trasnform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.playerShown {
|
||||
transform: translateY(-$player-height);
|
||||
}
|
||||
|
||||
.toast {
|
||||
@@ -44,7 +51,9 @@ const { toasts, remove } = useToast()
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
|
||||
+7
-5
@@ -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}` }
|
||||
@@ -62,13 +62,15 @@ export const cfetch = async (
|
||||
let response
|
||||
try {
|
||||
response = await axios(url, config)
|
||||
} catch {
|
||||
if (response?.status == 401) {
|
||||
} catch (error: any) {
|
||||
if (error.response?.status == 401) {
|
||||
const { sessionToken, refreshSessionToken } = useAuth()
|
||||
|
||||
if (!config.headers) config.headers = {}
|
||||
config.headers.Authorization = `Bearer ${await refreshSessionToken()}`
|
||||
|
||||
response = await axios(url, config)
|
||||
}
|
||||
} else throw error
|
||||
}
|
||||
|
||||
if (!response) throw new Error("Something went wrong. Please try again")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+2
-1
@@ -24,7 +24,8 @@ export interface Track extends Base {
|
||||
lyrics: {
|
||||
synced: boolean
|
||||
text: string
|
||||
}
|
||||
} | null
|
||||
album: Album
|
||||
artists: Artist[]
|
||||
durationInSeconds: number
|
||||
}
|
||||
Reference in new issue
Block a user