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 522e062a36 Got lyrics editing working for PATCH 2026-03-07 14:44:47 +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
lucast 4754d61279 Added gap between sliders 2026-02-25 16:56:21 +01:00
lucast dbb91a3918 Update image prefix in albums and change audio suffix in songs 2026-02-25 16:53:23 +01:00
lucast 6a3dffae7d Add cursor pointer style to index in Track Row component 2026-02-25 16:40:51 +01:00
lucast c1079dcbba Added h1 to index and added styling for it 2026-02-25 16:39:27 +01:00
lucast 5f572e769e Implement signout functionality to clear authentication tokens 2026-02-25 16:35:50 +01:00
lucast 464bbaef9e Updated fetch to actually retry on 401 with new token 2026-02-24 19:27:14 +01:00
lucast 581c522b00 Fixed some types and wrong types 2026-02-24 19:02:40 +01:00
lucast 470cf122a3 Small fix for visual bug with Modal 2026-02-24 18:50:36 +01:00
lucast 464b016fcb Added play functionality to album page 2026-02-24 18:49:04 +01:00
lucast e5ee065468 Merge branch 'feat/player' 2026-02-24 18:30:56 +01:00
lucast 49d4bd86f1 Updated ToastContainer to move above player when visible 2026-02-24 18:30:39 +01:00
lucast 6c10a53a75 Made small architectural change for TrackRow 2026-02-24 18:26:19 +01:00
lucast 1060eeba58 Added TrackRow to album page 2026-02-22 16:09:01 +01:00
lucast 6e54868cc3 Added TrackRow 2026-02-22 16:08:53 +01:00
lucast fcd12aa27d Updated track to be correct 2026-02-22 16:08:39 +01:00
lucast b7262be0a6 Merge branch 'master' into feat/album-page 2026-02-22 15:26:21 +01:00
lucast e1f5077c20 Added DropdownMenu 2026-02-21 16:48:05 +01:00
lucast 5b2d1bb977 Moved to Nuxt Image for album page 2026-02-04 14:04:33 +01:00
lucast bd0fc5394f Merge branch 'master' into feat/album-page 2026-02-04 14:03:50 +01:00
lucast 2012e7f566 Added simple details panel to /album/:id 2026-02-04 13:39:45 +01:00
lucast 9c3f6af7b3 Add GET_FILE function to construct file URLs from backend server 2026-02-04 13:38:26 +01:00
37 changed files with 2603 additions and 105 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.
+68 -12
View File
@@ -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)) {
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>
+17 -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,11 +20,12 @@ const { data, pending, refresh } = useAsyncData("random-albums", async () => {
albums: any[]
}> => r.status === "fulfilled",
)
.map((r) => r.value)
})
.map((r) => r.value),
)
</script>
<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
@@ -39,4 +38,13 @@ const { data, pending, refresh } = useAsyncData("random-albums", async () => {
/>
</Slider>
</template>
</section>
</template>
<style lang="scss" scoped>
.slider {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
+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>
+9 -7
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>
@@ -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;
+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>
+26 -7
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)
@@ -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;
}
}
+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>
+260
View File
@@ -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>
+10 -11
View File
@@ -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
return await $fetch<DataT, ErrorT>(url, newOptions)
Authorization: `Bearer ${newToken}`,
}
}
},
}
+13 -1
View File
@@ -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,
+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
}
+3 -3
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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) {
+84 -8
View File
@@ -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>
+96 -1
View File
@@ -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
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>
+2 -13
View File
@@ -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
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>
+74 -6
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()
},
@@ -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,
+4
View File
@@ -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
View File
@@ -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;
}
+227
View File
@@ -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
View File
@@ -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
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 -2
View File
@@ -38,8 +38,7 @@
width: 100%;
height: 100%;
overflow-y: auto;
padding: 2rem 3rem;
position: relative;
padding: 2rem 3rem calc(2rem + $player-height);
}
}
</style>
+12 -3
View File
@@ -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
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}` }
@@ -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")
+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
}
+2 -1
View File
@@ -24,7 +24,8 @@ export interface Track extends Base {
lyrics: {
synced: boolean
text: string
}
} | null
album: Album
artists: Artist[]
durationInSeconds: number
}