12 Commits
Author SHA1 Message Date
lucast 14b7fbf014 Animated mobile player dismissal 2026-10-06 21:34:08 +02:00
lucast 81b1581ed7 Updated mobile player layout 2026-10-06 21:23:13 +02:00
lucast 812884de1a Added PWA support 2026-10-06 20:52:55 +02:00
lucast 27d92c55e5 Improved mobile sign in 2026-10-06 04:17:48 +02:00
lucast cb6fa29e4b Improved mobile detail pages 2026-10-06 04:14:39 +02:00
lucast 8f6f9ef78e Improved mobile library pages 2026-10-06 04:13:02 +02:00
lucast ce0bf50223 Improved mobile search experience 2026-10-06 04:08:50 +02:00
lucast 1fce02ad84 Improved mobile artist carousels 2026-10-06 04:00:14 +02:00
lucast f202f07585 Centered mobile fullscreen player 2026-10-06 03:47:17 +02:00
lucast ae52b1754b Adjusted mobile player layout 2026-10-06 03:39:08 +02:00
lucast 4c883c60a4 Added mobile navigation 2026-10-06 03:26:15 +02:00
lucast 4ab58c8400 Added repeat modes 2026-10-06 03:10:44 +02:00
40 changed files with 2198 additions and 154 deletions

No files matched your search

+11
View File
@@ -3,6 +3,16 @@ const { currentSong, isPlaying } = usePlayer()
const debugView = useCookie("DEBUG_VIEW") const debugView = useCookie("DEBUG_VIEW")
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1") const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
useHead({
meta: [
{ name: "theme-color", content: "#09090b" },
{ name: "viewport", content: "width=device-width, initial-scale=1, viewport-fit=cover" },
{ name: "apple-mobile-web-app-capable", content: "yes" },
{ name: "apple-mobile-web-app-status-bar-style", content: "black-translucent" },
],
link: [{ rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png" }],
})
useHead( useHead(
computed(() => ({ computed(() => ({
titleTemplate: titleTemplate:
@@ -14,6 +24,7 @@ useHead(
</script> </script>
<template> <template>
<NuxtPwaManifest />
<ToastContainer /> <ToastContainer />
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
+14 -1
View File
@@ -4,13 +4,14 @@ import type { Album } from "@/utils/types"
type AlbumProps = Omit<Omit<Album, "genre">, "artists"> & { type AlbumProps = Omit<Omit<Album, "genre">, "artists"> & {
artists: Array<Omit<Artist, "file">> artists: Array<Omit<Artist, "file">>
genre?: Genre genre?: Genre
carousel?: boolean
} }
const props = defineProps<AlbumProps>() const props = defineProps<AlbumProps>()
</script> </script>
<template> <template>
<article class="card"> <article class="card" :class="{ carousel: props.carousel }">
<NuxtLink :to="`/album/${props._id}`"> <NuxtLink :to="`/album/${props._id}`">
<NuxtImg <NuxtImg
:src="props.file" :src="props.file"
@@ -48,4 +49,16 @@ const props = defineProps<AlbumProps>()
} }
} }
} }
@media (max-width: 767px) {
.card.carousel {
min-width: 0;
max-width: none;
flex: 0 0 calc(45.455% - 9.091px);
& > a > img {
width: 100%;
}
}
}
</style> </style>
+1
View File
@@ -49,6 +49,7 @@ onMounted(async () => {
:file="GET_FILE(album.file)" :file="GET_FILE(album.file)"
:artists="album.artists" :artists="album.artists"
:_id="album._id" :_id="album._id"
carousel
/> />
</Slider> </Slider>
</template> </template>
+231 -13
View File
@@ -4,9 +4,125 @@ import { GET_FILE } from "@/utils/file"
const { fullscreen, close: closeFullscreen } = useFullscreen() const { fullscreen, close: closeFullscreen } = useFullscreen()
const song = useSong() const song = useSong()
const mouseMovedRecently = ref(true) const mouseMovedRecently = ref(true)
const mobileView = ref(false)
const showMobileLyrics = ref(false)
const queueOpen = useState<boolean>("queueOpen", () => false)
const dragOffset = ref(0)
const isDragging = ref(false)
let dragPointerId: number | null = null
let dragStartX = 0
let dragStartY = 0
let dragStartedAt = 0
let clickSuppressionTimer: number | undefined
let closeAnimationTimer: number | undefined
function suppressClickThrough(event: MouseEvent) {
event.preventDefault()
event.stopImmediatePropagation()
if (clickSuppressionTimer !== undefined)
window.clearTimeout(clickSuppressionTimer)
clickSuppressionTimer = undefined
document.removeEventListener("click", suppressClickThrough, true)
}
function startDrag(event: PointerEvent) {
if (!mobileView.value || !event.isPrimary) return
if (event.pointerType === "mouse" && event.button !== 0) return
if (event.clientY > window.innerHeight * 0.16) return
const target = event.target
if (
target instanceof Element &&
target.closest(
"button, a, input, [role='slider'], .slider, .btns, .volume-slider, .mobile-tabs",
)
)
return
dragPointerId = event.pointerId
dragStartX = event.clientX
dragStartY = event.clientY
dragStartedAt = performance.now()
const element = event.currentTarget as HTMLElement
element.setPointerCapture(event.pointerId)
}
function moveDrag(event: PointerEvent) {
if (dragPointerId !== event.pointerId) return
const deltaX = event.clientX - dragStartX
const deltaY = event.clientY - dragStartY
if (Math.abs(deltaX) > Math.abs(deltaY)) return
if (deltaY <= 0) {
dragOffset.value = 0
return
}
event.preventDefault()
isDragging.value = true
dragOffset.value = deltaY
}
function endDrag(event: PointerEvent) {
if (dragPointerId !== event.pointerId) return
const deltaY = Math.max(0, event.clientY - dragStartY)
const deltaX = Math.abs(event.clientX - dragStartX)
const elapsed = performance.now() - dragStartedAt
const closeThreshold = Math.max(96, window.innerHeight * 0.16)
const shouldClose =
deltaX <= deltaY &&
(deltaY >= closeThreshold || (deltaY >= 56 && elapsed < 220))
dragPointerId = null
if (shouldClose) {
document.addEventListener("click", suppressClickThrough, true)
clickSuppressionTimer = window.setTimeout(() => {
document.removeEventListener("click", suppressClickThrough, true)
clickSuppressionTimer = undefined
}, 400)
isDragging.value = false
dragOffset.value = window.innerHeight
if (closeAnimationTimer !== undefined)
window.clearTimeout(closeAnimationTimer)
closeAnimationTimer = window.setTimeout(finishCloseAnimation, 400)
return
}
isDragging.value = false
dragOffset.value = 0
}
function finishCloseAnimation() {
if (closeAnimationTimer !== undefined)
window.clearTimeout(closeAnimationTimer)
closeAnimationTimer = undefined
closeFullscreen()
dragOffset.value = 0
}
function onFullscreenTransitionEnd(event: TransitionEvent) {
if (
closeAnimationTimer !== undefined &&
event.target === event.currentTarget &&
event.propertyName === "transform"
)
finishCloseAnimation()
}
function cancelDrag(event: PointerEvent) {
if (dragPointerId !== event.pointerId) return
dragPointerId = null
isDragging.value = false
dragOffset.value = 0
}
onMounted(() => { onMounted(() => {
let timeoutId: NodeJS.Timeout let timeoutId: NodeJS.Timeout
const mobileQuery = window.matchMedia("(max-width: 767px)")
const updateMobileView = () => (mobileView.value = mobileQuery.matches)
updateMobileView()
mobileQuery.addEventListener("change", updateMobileView)
const handleMouseMove = () => { const handleMouseMove = () => {
mouseMovedRecently.value = true mouseMovedRecently.value = true
@@ -28,6 +144,12 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("mousemove", handleMouseMove) window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("keydown", handleKeydown) window.removeEventListener("keydown", handleKeydown)
mobileQuery.removeEventListener("change", updateMobileView)
document.removeEventListener("click", suppressClickThrough, true)
if (clickSuppressionTimer !== undefined)
window.clearTimeout(clickSuppressionTimer)
if (closeAnimationTimer !== undefined)
window.clearTimeout(closeAnimationTimer)
}) })
}) })
@@ -41,7 +163,9 @@ nuxtApp.hook("page:finish", () => {
<template> <template>
<section <section
class="fullscreen" class="fullscreen"
:class="{ showCursor: mouseMovedRecently }" :class="{ showCursor: mouseMovedRecently, dragging: isDragging }"
:style="{ '--drag-offset': `${dragOffset}px` }"
@transitionend="onFullscreenTransitionEnd"
v-show="fullscreen" v-show="fullscreen"
> >
<template v-if="song"> <template v-if="song">
@@ -51,8 +175,16 @@ nuxtApp.hook("page:finish", () => {
/> />
</template> </template>
<section class="screen-container" v-if="song"> <section
class="screen-container"
v-if="song"
@pointerdown="startDrag"
@pointermove="moveDrag"
@pointerup="endDrag"
@pointercancel="cancelDrag"
>
<Icon <Icon
v-if="!mobileView"
name="lucide:x" name="lucide:x"
class="close-btn" class="close-btn"
:class="{ show: mouseMovedRecently }" :class="{ show: mouseMovedRecently }"
@@ -66,9 +198,28 @@ nuxtApp.hook("page:finish", () => {
<PlayerFullscreenTrackDisplay <PlayerFullscreenTrackDisplay
:show-buttons="mouseMovedRecently" :show-buttons="mouseMovedRecently"
/> />
<nav
v-if="mobileView"
class="mobile-tabs"
aria-label="Player views"
>
<button
:class="{ selected: showMobileLyrics }"
aria-label="Toggle lyrics"
@click="showMobileLyrics = !showMobileLyrics"
>
<Icon name="lucide:message-square-quote" />
</button>
<button aria-label="Open queue" @click="queueOpen = true">
<Icon name="lucide:list" />
</button>
</nav>
<PlayerFullscreenLyricsDisplay <PlayerFullscreenLyricsDisplay
v-if="
!mobileView || (showMobileLyrics && song.lyrics?.text)
"
:animation-duration="0.2" :animation-duration="0.2"
:offset="250" :offset="mobileView ? 100 : 250"
:show-scroll-bar="mouseMovedRecently" :show-scroll-bar="mouseMovedRecently"
/> />
</div> </div>
@@ -79,12 +230,9 @@ nuxtApp.hook("page:finish", () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.fullscreen { .fullscreen {
position: fixed; position: fixed;
top: 0; inset: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 150; z-index: 150;
overflow: hidden;
cursor: none; cursor: none;
@@ -95,11 +243,7 @@ nuxtApp.hook("page:finish", () => {
.screen-container { .screen-container {
position: fixed; position: fixed;
top: 0; inset: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.34); background-color: rgba(0, 0, 0, 0.34);
} }
@@ -119,6 +263,10 @@ nuxtApp.hook("page:finish", () => {
} }
} }
.mobile-tabs {
display: none;
}
.content-container { .content-container {
/* TODO: A lot of magic numbers... should be variables */ /* TODO: A lot of magic numbers... should be variables */
max-width: 1280px; max-width: 1280px;
@@ -149,4 +297,74 @@ nuxtApp.hook("page:finish", () => {
} }
} }
} }
@media (max-width: 767px) {
.screen-container {
touch-action: none;
}
.fullscreen {
transition: transform 280ms ease-out;
transform: translate3d(0, var(--drag-offset, 0px), 0);
&.dragging {
transition: none;
}
}
.close-btn {
top: calc(0.75rem + env(safe-area-inset-top));
right: 0.75rem;
z-index: 2;
padding: 0.6rem;
opacity: 1;
font-size: 1.4rem;
&.show {
opacity: 1;
}
}
.mobile-tabs {
display: flex;
justify-content: center;
gap: 3rem;
margin: 0 auto;
color: rgba(255, 255, 255, 0.72);
button {
border: 0;
padding: 0.5rem;
color: inherit;
background: transparent;
font-size: 1.5rem;
}
.selected {
color: white;
}
}
.content-container {
max-width: none;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: space-between;
gap: 1.5rem;
overflow: hidden;
padding: max(11vh, calc(2.75rem + env(safe-area-inset-top))) 1.5rem
calc(env(safe-area-inset-bottom));
height: 100vh;
& > * {
max-height: none;
margin: 0 auto;
}
&.lyrics {
grid-template-columns: 1fr;
}
}
}
</style> </style>
@@ -50,6 +50,7 @@ const scrollActiveIndex = computed(() => {
const height = ref<number>() const height = ref<number>()
const top = ref(0) const top = ref(0)
const syncedHeight = ref<number>() const syncedHeight = ref<number>()
const mobileView = ref(false)
const layoutVersion = ref(0) const layoutVersion = ref(0)
const lyricOffsets = ref<number[]>([]) const lyricOffsets = ref<number[]>([])
const topFadeExtension = 75 const topFadeExtension = 75
@@ -60,7 +61,14 @@ const updateLyricsLayout = () => {
if (trackDisplay) { if (trackDisplay) {
height.value = trackDisplay.clientHeight height.value = trackDisplay.clientHeight
top.value = trackDisplay.getBoundingClientRect().top top.value = trackDisplay.getBoundingClientRect().top
syncedHeight.value = window.innerHeight - top.value - 100 syncedHeight.value = mobileView.value
? Math.max(
180,
window.innerHeight -
trackDisplay.getBoundingClientRect().bottom -
100,
)
: window.innerHeight - top.value - 100
} }
const lines = containerRef.value?.children const lines = containerRef.value?.children
@@ -93,8 +101,20 @@ watch(
{ immediate: true }, { immediate: true },
) )
onMounted(() => window.addEventListener("resize", updateLyricsLayout)) onMounted(() => {
onUnmounted(() => window.removeEventListener("resize", updateLyricsLayout)) const mobileQuery = window.matchMedia("(max-width: 767px)")
const updateMobileView = () => {
mobileView.value = mobileQuery.matches
updateLyricsLayout()
}
updateMobileView()
window.addEventListener("resize", updateLyricsLayout)
mobileQuery.addEventListener("change", updateMobileView)
onUnmounted(() => {
window.removeEventListener("resize", updateLyricsLayout)
mobileQuery.removeEventListener("change", updateMobileView)
})
})
const containerRef = useTemplateRef("container-ref") const containerRef = useTemplateRef("container-ref")
const outerRef = useTemplateRef("outer-ref") const outerRef = useTemplateRef("outer-ref")
@@ -185,7 +205,7 @@ const finalTransform = computed(() => {
class="synced-lyrics-container" class="synced-lyrics-container"
:style="{ :style="{
height: `${(syncedHeight ?? 0) + topFadeExtension}px`, height: `${(syncedHeight ?? 0) + topFadeExtension}px`,
marginTop: `${top - topFadeExtension}px`, marginTop: mobileView ? '0px' : `${top - topFadeExtension}px`,
}" }"
@wheel.prevent="onWheel" @wheel.prevent="onWheel"
> >
@@ -306,4 +326,26 @@ const finalTransform = computed(() => {
background-color: $color-secondary-background; background-color: $color-secondary-background;
} }
} }
@media (max-width: 767px) {
.synced-lyrics-container {
align-self: stretch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 12%,
#000 calc(100% - 70px),
transparent 100%
);
}
.synced-lyrics > p {
@include fontSize(30px);
padding: 0.4rem 0;
}
.lyrics {
height: auto !important;
}
}
</style> </style>
@@ -12,6 +12,8 @@ const {
prev, prev,
shuffle, shuffle,
repeat, repeat,
repeatOnce,
cycleRepeat,
secondsPlayed, secondsPlayed,
duration, duration,
volume, volume,
@@ -38,10 +40,17 @@ const durationFormatted = computed(() =>
height="512" height="512"
/> />
<div class="track-row">
<div class="track"> <div class="track">
<p class="name">{{ song.name }}</p> <p class="name">{{ song.name }}</p>
<ArtistName :artists="song.artists" /> <ArtistName :artists="song.artists" />
</div> </div>
<div class="track-actions">
<button aria-label="More track options">
<Icon name="lucide:ellipsis" />
</button>
</div>
</div>
<div class="slider"> <div class="slider">
<RangeSlider <RangeSlider
@@ -64,9 +73,9 @@ const durationFormatted = computed(() =>
<Icon name="lucide:play" v-else @click="play()" /> <Icon name="lucide:play" v-else @click="play()" />
<Icon name="lucide:skip-forward" @click="next()" /> <Icon name="lucide:skip-forward" @click="next()" />
<Icon <Icon
name="lucide:repeat" :name="repeatOnce ? 'lucide:repeat-1' : 'lucide:repeat'"
:class="{ repeating: repeat }" :class="{ repeating: repeat || repeatOnce }"
@click="repeat = !repeat" @click="cycleRepeat()"
/> />
</div> </div>
@@ -120,6 +129,31 @@ const durationFormatted = computed(() =>
} }
} }
.track-row,
.track-actions {
display: flex;
align-items: center;
}
.track-row {
width: 100%;
justify-content: space-between;
gap: 0.75rem;
}
.track-actions {
display: none;
gap: 0.75rem;
button {
border: 0;
padding: 0;
color: inherit;
background: transparent;
font-size: 1.8rem;
}
}
.slider { .slider {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -180,4 +214,38 @@ const durationFormatted = computed(() =>
pointer-events: none; pointer-events: none;
} }
} }
@media (max-width: 767px) {
.track-display {
display: contents;
}
.track-actions {
display: flex;
gap: 0.6rem;
}
.btns {
justify-content: space-between;
gap: 0.75rem;
font-size: 2.5rem;
opacity: 1 !important;
pointer-events: auto !important;
& > :first-child,
& > :last-child {
display: none;
}
& > * {
padding: 0.5rem;
}
}
.volume-slider {
margin-inline: 0;
opacity: 1 !important;
pointer-events: auto !important;
}
}
</style> </style>
+123
View File
@@ -79,6 +79,15 @@ const playAt = (index: number) =>
queueRows.value.map((row) => row.track), queueRows.value.map((row) => row.track),
index, index,
) )
const moveRow = (index: number, direction: -1 | 1) => {
const destination = index + direction
if (destination < 0 || destination >= queueRows.value.length) return
const [row] = queueRows.value.splice(index, 1)
queueRows.value.splice(destination, 0, row!)
queue.value = queueRows.value.map((item) => item.track)
}
</script> </script>
<template> <template>
@@ -118,6 +127,7 @@ const playAt = (index: number) =>
@drop="finishDrag" @drop="finishDrag"
@dragend="cancelDrag" @dragend="cancelDrag"
> >
<div class="queue-row">
<TrackRow <TrackRow
:track="row.track" :track="row.track"
:index="index" :index="index"
@@ -126,6 +136,29 @@ const playAt = (index: number) =>
class="queue-track" class="queue-track"
@play-album-at-index="playAt(index)" @play-album-at-index="playAt(index)"
/> />
<div
class="queue-order-actions"
role="group"
:aria-label="`Reorder ${row.track.name}`"
>
<button
type="button"
:aria-label="`Move ${row.track.name} up in queue`"
:disabled="index === 0"
@click="moveRow(index, -1)"
>
<Icon name="lucide:chevron-up" aria-hidden="true" />
</button>
<button
type="button"
:aria-label="`Move ${row.track.name} down in queue`"
:disabled="index === queueRows.length - 1"
@click="moveRow(index, 1)"
>
<Icon name="lucide:chevron-down" aria-hidden="true" />
</button>
</div>
</div>
</li> </li>
</ol> </ol>
</aside> </aside>
@@ -210,6 +243,14 @@ const playAt = (index: number) =>
} }
} }
.queue-row {
position: relative;
}
.queue-order-actions {
display: none;
}
.empty { .empty {
flex: 1; flex: 1;
display: grid; display: grid;
@@ -235,4 +276,86 @@ const playAt = (index: number) =>
transform: translateX(100%); transform: translateX(100%);
} }
} }
@media (max-width: 767px) {
.queue-root {
z-index: 120;
}
.drawer {
inset-block: 18dvh 0;
inset-inline: 0;
inline-size: 100%;
border-inline-start: 0;
border-top: 1px solid $color-border;
border-radius: 1rem 1rem 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
.header {
padding: 0.85rem 1rem;
}
.close {
padding: 0.75rem;
}
.tracks {
min-height: 0;
flex: 1;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.queue-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.queue-track {
flex: 1;
min-width: 0;
grid-template-columns: 40px minmax(0, 1fr);
padding-inline: 0.5rem;
}
.queue-row :deep(.queue-track > :nth-last-child(-n + 2)) {
display: none;
}
.queue-order-actions {
display: flex;
flex-direction: column;
button {
display: grid;
place-items: center;
border: 0;
padding: 0.875rem;
color: inherit;
background: transparent;
font-size: 1rem;
}
button:disabled {
color: rgb(255 255 255 / 30%);
cursor: default;
}
}
.queue-enter-active,
.queue-leave-active {
.drawer {
transition: transform 0.2s ease;
}
}
.queue-enter-from,
.queue-leave-to {
.drawer {
transform: translateY(100%);
}
}
}
</style> </style>
+138 -15
View File
@@ -27,6 +27,8 @@ const {
prev, prev,
shuffle, shuffle,
repeat, repeat,
repeatOnce,
cycleRepeat,
secondsPlayed, secondsPlayed,
duration, duration,
volume, volume,
@@ -41,12 +43,13 @@ const durationFormatted = computed(() =>
) )
const { open: openFullscreen } = useFullscreen() const { open: openFullscreen } = useFullscreen()
const queueOpen = ref(false) const queueOpen = useState<boolean>("queueOpen", () => false)
</script> </script>
<template> <template>
<div id="width-fixer" ref="width-fixer-ref"></div> <div id="width-fixer" ref="width-fixer-ref"></div>
<article class="player" ref="player-ref" :class="{ active: song }"> <article class="player" ref="player-ref" :class="{ active: song }">
<Icon name="lucide:repeat-1" class="icon-preload" aria-hidden="true" />
<template v-if="song"> <template v-if="song">
<div class="track"> <div class="track">
<NuxtImg <NuxtImg
@@ -63,20 +66,50 @@ const queueOpen = ref(false)
</div> </div>
<div class="controls"> <div class="controls">
<div class="btns"> <div class="btns">
<Icon name="lucide:shuffle" @click="shuffle()" /> <button
<Icon name="lucide:skip-back" @click="prev()" /> class="icon-button"
aria-label="Shuffle queue"
@click="shuffle()"
>
<Icon name="lucide:shuffle" aria-hidden="true" />
</button>
<button
class="icon-button"
aria-label="Previous track"
@click="prev()"
>
<Icon name="lucide:skip-back" aria-hidden="true" />
</button>
<button
class="icon-button"
:aria-label="isPlaying ? 'Pause' : 'Play'"
@click="isPlaying ? pause() : play()"
>
<Icon <Icon
name="lucide:pause" :name="isPlaying ? 'lucide:pause' : 'lucide:play'"
v-if="isPlaying" aria-hidden="true"
@click="pause()"
/> />
<Icon name="lucide:play" v-else @click="play()" /> </button>
<Icon name="lucide:skip-forward" @click="next()" /> <button class="icon-button" aria-label="Next track" @click="next()">
<Icon name="lucide:skip-forward" aria-hidden="true" />
</button>
<button
class="icon-button"
:class="{ repeating: repeat || repeatOnce }"
:aria-label="
repeatOnce
? 'Repeat current track'
: repeat
? 'Repeat queue'
: 'Repeat off'
"
@click="cycleRepeat()"
>
<Icon <Icon
name="lucide:repeat" :name="repeatOnce ? 'lucide:repeat-1' : 'lucide:repeat'"
:class="{ repeating: repeat }" aria-hidden="true"
@click="repeat = !repeat"
/> />
</button>
</div> </div>
<div class="slider"> <div class="slider">
<p>{{ secondsPlayedFormatted }}</p> <p>{{ secondsPlayedFormatted }}</p>
@@ -105,11 +138,20 @@ const queueOpen = ref(false)
:step="0.01" :step="0.01"
/> />
</div> </div>
<Icon <button
name="lucide:list" class="icon-button"
aria-label="Open queue"
@click="queueOpen = true" @click="queueOpen = true"
/> >
<Icon name="lucide:expand" @click="openFullscreen()" /> <Icon name="lucide:list" aria-hidden="true" />
</button>
<button
class="icon-button"
aria-label="Open fullscreen player"
@click="openFullscreen()"
>
<Icon name="lucide:expand" aria-hidden="true" />
</button>
</div> </div>
</template> </template>
</article> </article>
@@ -121,6 +163,10 @@ const queueOpen = ref(false)
@use "@/styles/variables" as *; @use "@/styles/variables" as *;
@use "@/styles/util" as *; @use "@/styles/util" as *;
.icon-preload {
display: none;
}
#width-fixer { #width-fixer {
width: 100%; width: 100%;
} }
@@ -154,6 +200,17 @@ const queueOpen = ref(false)
cursor: pointer; cursor: pointer;
transition: color 0.2s ease; transition: color 0.2s ease;
} }
.icon-button {
display: grid;
place-items: center;
border: 0;
padding: 0;
color: inherit;
background: transparent;
cursor: pointer;
transition: color 0.2s ease;
}
} }
.track, .track,
@@ -234,4 +291,70 @@ const queueOpen = ref(false)
} }
} }
} }
@media (max-width: 767px) {
#width-fixer {
width: calc(100% + 1rem);
margin-inline: -0.5rem;
}
.player {
bottom: calc(4.5rem + env(safe-area-inset-bottom));
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25rem;
padding: 0.5rem 0.5rem;
margin-inline: -0.5rem;
border-radius: $border-radius-lg;
background-color: rgba(26, 32, 44, 0.96);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.track {
min-width: 0;
gap: 0.6rem;
.details {
min-width: 0;
}
img {
flex: none;
}
}
.controls {
width: auto;
align-items: center;
.slider,
.btns > :not(:nth-child(3)) {
display: none;
}
.btns {
width: auto;
padding: 0;
font-size: 1.5rem;
}
}
.misc-btns {
width: auto;
gap: 0.1rem;
.volume {
display: none;
}
.icon-button {
padding: 0.625rem;
font-size: 1.35rem;
}
}
.controls .icon-button {
padding: 0.625rem;
}
}
</style> </style>
+59 -1
View File
@@ -30,7 +30,16 @@ const entryType = computed(() =>
<template> <template>
<article class="search-row" :class="{ odd: props.index % 2 === 1 }"> <article class="search-row" :class="{ odd: props.index % 2 === 1 }">
<div class="artwork" @click="props.result.type === 'track' && emit('play')"> <div
class="artwork"
:class="{ playable: props.result.type === 'track' }"
:role="props.result.type === 'track' ? 'button' : undefined"
:tabindex="props.result.type === 'track' ? 0 : undefined"
:aria-label="props.result.type === 'track' ? `Play ${props.result.name}` : undefined"
@click="props.result.type === 'track' && emit('play')"
@keydown.enter.prevent="props.result.type === 'track' && emit('play')"
@keydown.space.prevent="props.result.type === 'track' && emit('play')"
>
<NuxtImg <NuxtImg
v-if="artworkSrc" v-if="artworkSrc"
:src="artworkSrc" :src="artworkSrc"
@@ -93,6 +102,10 @@ const entryType = computed(() =>
justify-content: center; justify-content: center;
border-radius: $border-radius-standard; border-radius: $border-radius-standard;
cursor: pointer; cursor: pointer;
border: 0;
padding: 0;
color: inherit;
background: transparent;
img { img {
max-width: 100%; max-width: 100%;
@@ -140,4 +153,49 @@ const entryType = computed(() =>
.artists { .artists {
min-width: 0; min-width: 0;
} }
@media (max-width: 767px) {
.search-row {
grid-template-columns: 40px minmax(0, 1fr);
grid-template-areas:
"art details"
"art artists";
column-gap: 0.75rem;
row-gap: 0.25rem;
padding-inline: 0.75rem;
}
.artwork {
grid-area: art;
&.playable .play-icon {
opacity: 1;
padding: 0.5rem;
border-radius: 50%;
background: rgb(0 0 0 / 55%);
}
}
.details {
grid-area: details;
min-width: 0;
}
.artists {
grid-area: artists;
overflow-wrap: anywhere;
:deep(.artist-name) {
min-width: 0;
}
:deep(.artist-name a) {
overflow-wrap: anywhere;
}
}
.search-row > span:last-child {
display: none;
}
}
</style> </style>
+13
View File
@@ -126,4 +126,17 @@ onMounted(() => {
.right { .right {
right: -48px; right: -48px;
} }
@media (max-width: 767px) {
.left,
.right {
display: none;
}
.slider {
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scroll-padding-inline: 0.25rem;
}
}
</style> </style>
+4 -1
View File
@@ -112,7 +112,10 @@ const visibleDropdownMenuItems = computed(() =>
<ArtistName :artists="track.artists" class="artists" /> <ArtistName :artists="track.artists" class="artists" />
</div> </div>
<DropdownMenu :items="visibleDropdownMenuItems"> <DropdownMenu
:items="visibleDropdownMenuItems"
:label="`Options for ${track.name}`"
>
<Icon name="lucide:ellipsis" class="dropdown-icon" /> <Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu> </DropdownMenu>
</article> </article>
+100 -3
View File
@@ -15,6 +15,8 @@ interface TrackRowProps {
extendedInfo?: boolean extendedInfo?: boolean
showImage?: boolean showImage?: boolean
queueMode?: boolean queueMode?: boolean
touchFriendly?: boolean
libraryList?: boolean
} }
const props = defineProps<TrackRowProps>() const props = defineProps<TrackRowProps>()
@@ -130,6 +132,7 @@ const artworkSrc = computed(() => {
odd: props.index % 2 == 1, odd: props.index % 2 == 1,
image: props.showImage, image: props.showImage,
queue: props.queueMode, queue: props.queueMode,
'library-list': props.libraryList,
}" }"
@mouseenter="hovering = true" @mouseenter="hovering = true"
@mouseleave="hovering = false" @mouseleave="hovering = false"
@@ -140,7 +143,21 @@ const artworkSrc = computed(() => {
v-if="props.track" v-if="props.track"
name="lucide:play" name="lucide:play"
class="play-icon" class="play-icon"
:class="{ 'touch-friendly': props.touchFriendly }"
:role="props.touchFriendly ? 'button' : undefined"
:tabindex="props.touchFriendly ? 0 : undefined"
:aria-label="
props.touchFriendly
? `Play ${props.track.name}`
: undefined
"
@click="emit('playAlbumAtIndex')" @click="emit('playAlbumAtIndex')"
@keydown.enter.prevent="
props.touchFriendly && emit('playAlbumAtIndex')
"
@keydown.space.prevent="
props.touchFriendly && emit('playAlbumAtIndex')
"
/> />
<!-- Keep the existing TrackRow artwork dimensions unchanged. --> <!-- Keep the existing TrackRow artwork dimensions unchanged. -->
<NuxtImg <NuxtImg
@@ -162,7 +179,14 @@ const artworkSrc = computed(() => {
</template> </template>
</div> </div>
<p v-if="props.track">{{ props.track.name }}</p> <div v-if="props.track && props.libraryList" class="library-info">
<p class="library-title">{{ props.track.name }}</p>
<ArtistName :artists="props.track.artists" class="artists" />
<NuxtLink :to="`/album/${props.track.album._id}`" class="library-album">
{{ props.track.album.name }}
</NuxtLink>
</div>
<p v-else-if="props.track">{{ props.track.name }}</p>
<NuxtLink <NuxtLink
v-else-if="props.entity" v-else-if="props.entity"
:to="`/${props.entity.type}/${props.entity._id}`" :to="`/${props.entity.type}/${props.entity._id}`"
@@ -170,7 +194,7 @@ const artworkSrc = computed(() => {
{{ props.entity.name }} {{ props.entity.name }}
</NuxtLink> </NuxtLink>
<template v-if="props.track && props.extendedInfo"> <template v-if="props.track && props.extendedInfo && !props.libraryList">
<ArtistName :artists="props.track.artists" class="artists" /> <ArtistName :artists="props.track.artists" class="artists" />
<NuxtLink :to="`/album/${props.track.album._id}`"> <NuxtLink :to="`/album/${props.track.album._id}`">
<p>{{ props.track.album.name }}</p> <p>{{ props.track.album.name }}</p>
@@ -194,7 +218,11 @@ const artworkSrc = computed(() => {
class="queue-handle" class="queue-handle"
aria-hidden="true" aria-hidden="true"
/> />
<DropdownMenu v-else-if="props.track" :items="visibleDropdownMenuItems"> <DropdownMenu
v-else-if="props.track"
:items="visibleDropdownMenuItems"
:label="`Options for ${props.track.name}`"
>
<Icon name="lucide:ellipsis" class="dropdown-icon" /> <Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu> </DropdownMenu>
</article> </article>
@@ -240,6 +268,10 @@ const artworkSrc = computed(() => {
cursor: grab; cursor: grab;
} }
&.library-list {
min-width: 0;
}
p, p,
> a { > a {
overflow: hidden; overflow: hidden;
@@ -274,6 +306,10 @@ const artworkSrc = computed(() => {
opacity: 0; opacity: 0;
z-index: 1; z-index: 1;
font-size: 24px; font-size: 24px;
&:focus-visible {
opacity: 1;
}
} }
.play-icon.inline { .play-icon.inline {
@@ -298,10 +334,71 @@ const artworkSrc = computed(() => {
} }
} }
@media (max-width: 767px) {
.track.library-list {
grid-template-columns: 28px minmax(0, 1fr) auto 40px;
gap: 0.5rem;
padding-inline: 0.5rem;
.index {
grid-area: 1 / 1 / 3 / 2;
}
.library-info {
grid-area: 1 / 2 / 3 / 3;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
line-height: 1.25;
}
.library-title,
.library-album,
.artists {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-title {
font-weight: 600;
}
.artists,
.library-album {
font-size: 0.8em;
opacity: 0.75;
}
> p {
grid-area: 1 / 3 / 3 / 4;
font-variant-numeric: tabular-nums;
}
:deep(.dropdown) {
grid-area: 1 / 4 / 3 / 5;
justify-self: end;
}
}
.play-icon.touch-friendly {
opacity: 1;
padding: 0.5rem;
border-radius: 50%;
background: rgb(0 0 0 / 55%);
}
}
.artists { .artists {
opacity: 1; opacity: 1;
} }
.library-info {
display: contents;
}
.dropdown-icon { .dropdown-icon {
font-size: 20px; font-size: 20px;
cursor: pointer; cursor: pointer;
+1 -1
View File
@@ -26,7 +26,7 @@ export const useFullscreen = () => {
await document.documentElement.requestFullscreen() await document.documentElement.requestFullscreen()
return true return true
} catch { } catch {
fullscreen.value = false // Keep the in-app fullscreen player open when native fullscreen is denied.
return false return false
} }
} }
+113 -1
View File
@@ -1,5 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
const router = useRouter() const router = useRouter()
const route = useRoute()
const activeTab = computed(() => {
if (route.path === "/") return "home"
if (route.path === "/search") return "search"
if (route.path.startsWith("/library/")) return "library"
return ""
})
let timeout: ReturnType<typeof setTimeout> let timeout: ReturnType<typeof setTimeout>
@@ -13,10 +21,11 @@ const onSearchInput = (v: string) => {
} }
const { data: user } = useApiFetch<any>("/user") const { data: user } = useApiFetch<any>("/user")
const song = useSong()
</script> </script>
<template> <template>
<Sidebar> <Sidebar class="default-shell" :class="{ 'has-player': song }">
<template #sidebar> <template #sidebar>
<p class="title">Music</p> <p class="title">Music</p>
@@ -89,12 +98,55 @@ const { data: user } = useApiFetch<any>("/user")
<template #default> <template #default>
<slot /> <slot />
<nav class="mobile-tabs" aria-label="Primary navigation">
<NuxtLink
to="/"
class="tab"
:class="{ active: activeTab === 'home' }"
:aria-current="activeTab === 'home' ? 'page' : undefined"
>
<Icon
name="lucide:house"
class="tab-icon"
aria-hidden="true"
/>
<span>Home</span>
</NuxtLink>
<NuxtLink
to="/library/songs"
class="tab"
:class="{ active: activeTab === 'library' }"
:aria-current="activeTab === 'library' ? 'page' : undefined"
>
<Icon
name="lucide:library"
class="tab-icon"
aria-hidden="true"
/>
<span>Library</span>
</NuxtLink>
<NuxtLink
to="/search"
class="tab"
:class="{ active: activeTab === 'search' }"
:aria-current="activeTab === 'search' ? 'page' : undefined"
>
<Icon
name="lucide:search"
class="tab-icon"
aria-hidden="true"
/>
<span>Search</span>
</NuxtLink>
</nav>
</template> </template>
</Sidebar> </Sidebar>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/util" as *; @use "@/styles/util" as *;
@use "@/styles/variables" as *;
.title { .title {
@include fontSize(24px); @include fontSize(24px);
@@ -114,4 +166,64 @@ const { data: user } = useApiFetch<any>("/user")
margin-bottom: 0.1rem; margin-bottom: 0.1rem;
} }
} }
.mobile-tabs {
display: none;
}
@media (max-width: 767px) {
.default-shell {
display: block;
overflow: hidden;
:deep(> aside) {
display: none;
}
:deep(> main) {
padding: calc(1.25rem + env(safe-area-inset-top)) 1rem
calc(6rem + env(safe-area-inset-bottom));
}
&.has-player :deep(> main) {
padding-bottom: calc(6rem + env(safe-area-inset-bottom) + $player-height);
}
}
.mobile-tabs {
position: fixed;
z-index: 100;
inset: auto 0 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 0.45rem 0.75rem calc(0.45rem + env(safe-area-inset-bottom));
border-top: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(22, 22, 24, 0.96);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.tab {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
border-radius: 0.65rem;
color: rgba(255, 255, 255, 0.62);
text-decoration: none;
font-size: 0.75rem;
font-weight: 600;
-webkit-tap-highlight-color: transparent;
.tab-icon {
font-size: 1.75rem;
}
&.active {
color: white;
}
}
}
</style> </style>
+59 -2
View File
@@ -17,12 +17,14 @@ useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Alb
<template> <template>
<section class="info" v-if="albumData"> <section class="info" v-if="albumData">
<div class="album-art">
<NuxtImg <NuxtImg
:src="GET_FILE(albumData.file)" :src="GET_FILE(albumData.file)"
width="300" width="300"
height="300" height="300"
placeholder placeholder
/> />
</div>
<div class="details"> <div class="details">
<h1>{{ albumData.name }}</h1> <h1>{{ albumData.name }}</h1>
@@ -31,13 +33,15 @@ useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Alb
<div class="buttons"> <div class="buttons">
<Button <Button
icon-name="lucide:play" icon-name="lucide:play"
:aria-label="`Play ${albumData.name}`"
@click="() => playAlbum(tracks)" @click="() => playAlbum(tracks)"
>Play</Button ><span class="button-label">Play</span></Button
> >
<Button <Button
icon-name="lucide:shuffle" icon-name="lucide:shuffle"
:aria-label="`Shuffle ${albumData.name}`"
@click="() => playShuffledAlbum(tracks)" @click="() => playShuffledAlbum(tracks)"
>Shuffle</Button ><span class="button-label">Shuffle</span></Button
> >
</div> </div>
</div> </div>
@@ -97,4 +101,57 @@ useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Alb
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
@media (max-width: 767px) {
.info {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
margin-bottom: 1rem;
.album-art {
padding: 0.5rem;
img {
display: block;
width: 100%;
height: auto;
}
}
}
.info .details {
align-items: center;
padding-top: 0;
text-align: center;
h1 {
margin: 0;
@include util.fontSize(26px);
line-height: 1.2;
}
.artist {
@include util.fontSize(16px);
}
.buttons {
margin-top: 0.375rem;
gap: 0.75rem;
}
}
.details .buttons :deep(.button) {
padding: 0.75rem;
border-radius: 50%;
}
.details .buttons :deep(.icon-wrapper) {
font-size: 1.5rem;
}
.details .buttons :deep(.button-label) {
display: none;
}
}
</style> </style>
+61 -2
View File
@@ -44,12 +44,15 @@ useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - A
<template> <template>
<section class="info" v-if="artistData"> <section class="info" v-if="artistData">
<div class="artist-art">
<NuxtImg <NuxtImg
class="artist-image"
:src="GET_FILE(artistData.file)" :src="GET_FILE(artistData.file)"
width="300" width="300"
height="300" height="300"
placeholder placeholder
/> />
</div>
<div class="details"> <div class="details">
<h1>{{ artistData.name }}</h1> <h1>{{ artistData.name }}</h1>
@@ -57,14 +60,18 @@ useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - A
<div class="buttons"> <div class="buttons">
<Button <Button
icon-name="lucide:play" icon-name="lucide:play"
:aria-label="`Play ${artistData.name}`"
@click="() => playAlbum(tracks)" @click="() => playAlbum(tracks)"
>Play</Button
> >
<span class="button-label">Play</span>
</Button>
<Button <Button
icon-name="lucide:shuffle" icon-name="lucide:shuffle"
:aria-label="`Shuffle ${artistData.name}`"
@click="() => playShuffledAlbum(tracks)" @click="() => playShuffledAlbum(tracks)"
>Shuffle</Button
> >
<span class="button-label">Shuffle</span>
</Button>
</div> </div>
</div> </div>
</section> </section>
@@ -97,6 +104,7 @@ useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - A
:file="GET_FILE(album.file)" :file="GET_FILE(album.file)"
:artists="album.artists" :artists="album.artists"
:_id="album._id" :_id="album._id"
carousel
/> />
</Slider> </Slider>
</template> </template>
@@ -158,4 +166,55 @@ $gap: 12px;
} }
} }
} }
@media (max-width: 767px) {
.info {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
.artist-art {
padding: 0.5rem;
}
.artist-image {
width: 100%;
height: auto;
}
.info .details {
align-items: center;
padding-top: 0;
text-align: center;
h1 {
margin-top: 0;
margin-bottom: 0;
}
.buttons {
margin-top: 0.375rem;
gap: 0.75rem;
}
}
.details .buttons :deep(.button) {
padding: 0.75rem;
border-radius: 50%;
}
.details .buttons :deep(.icon-wrapper) {
font-size: 1.5rem;
}
.details .buttons :deep(.button-label) {
display: none;
}
.tracks {
flex-basis: 100%;
min-width: 0;
}
}
</style> </style>
+14
View File
@@ -21,6 +21,7 @@ useHead({ title: "Artist Songs" })
:track="track" :track="track"
:index="i" :index="i"
show-image show-image
touch-friendly
@play-album-at-index="() => playAlbumAtIndex(tracks, i)" @play-album-at-index="() => playAlbumAtIndex(tracks, i)"
/> />
</section> </section>
@@ -41,4 +42,17 @@ useHead({ title: "Artist Songs" })
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@media (max-width: 767px) {
.title {
@include fontSize(26px);
margin-bottom: 1rem;
}
.tracks :deep(.track.image) {
grid-template-columns: 40px minmax(0, 1fr) auto auto;
gap: 0.5rem;
padding-inline: 0.5rem;
}
}
</style> </style>
+7
View File
@@ -30,4 +30,11 @@ useHead({ title: "Sign In" })
width: 40%; width: 40%;
max-width: 450px; max-width: 450px;
} }
@media (max-width: 600px) {
.card {
box-sizing: border-box;
width: calc(100% - 2rem);
}
}
</style> </style>
+11
View File
@@ -58,4 +58,15 @@ h1 {
flex-direction: column; flex-direction: column;
gap: 0.8rem; gap: 0.8rem;
} }
@media (max-width: 480px) {
h1 {
@include fontSize(22px);
margin-bottom: 0.65em;
}
.form {
gap: 0.75rem;
}
}
</style> </style>
+19
View File
@@ -64,4 +64,23 @@ useHead({ title: "Albums" })
opacity: 0.7; opacity: 0.7;
margin: 0.75rem 0; margin: 0.75rem 0;
} }
@media (max-width: 767px) {
.album-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.album-list :deep(.card) {
min-width: 0;
max-width: none;
}
.album-list :deep(.card img) {
display: block;
width: 100%;
height: auto;
}
}
</style> </style>
+18
View File
@@ -60,4 +60,22 @@ useHead({ title: "Artists" })
opacity: 0.7; opacity: 0.7;
margin: 0.75rem 0; margin: 0.75rem 0;
} }
@media (max-width: 767px) {
.artist-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.artist-list :deep(.card) {
min-width: 0;
}
.artist-list :deep(.card img) {
display: block;
width: 100%;
height: auto;
}
}
</style> </style>
+8
View File
@@ -54,6 +54,7 @@ useHead({ title: "Songs" })
:track="track" :track="track"
:index="index" :index="index"
:extended-info="true" :extended-info="true"
library-list
@play-album-at-index="() => playAlbumAtIndex(tracks, index)" @play-album-at-index="() => playAlbumAtIndex(tracks, index)"
@remove-from-library="removeFromLibrary" @remove-from-library="removeFromLibrary"
/> />
@@ -97,4 +98,11 @@ useHead({ title: "Songs" })
opacity: 0.7; opacity: 0.7;
margin: 0.75rem 0; margin: 0.75rem 0;
} }
@media (max-width: 359px) {
.page-header {
flex-wrap: wrap;
justify-content: flex-start;
}
}
</style> </style>
+54 -8
View File
@@ -12,16 +12,20 @@ interface SearchResult {
} }
const route = useRoute() const route = useRoute()
const router = useRouter()
const { currentSong, playAlbum } = usePlayer() const { currentSong, playAlbum } = usePlayer()
const query = computed(() => { const query = computed(() => {
const value = route.query.q const value = route.query.q
const firstValue = Array.isArray(value) ? value[0] : value const firstValue = Array.isArray(value) ? value[0] : value
return (firstValue ?? "").trim() return (firstValue ?? "").trim()
}) })
const searchInput = ref(query.value)
const debouncedQuery = ref(query.value) const debouncedQuery = ref(query.value)
const querySettled = computed(() => query.value === debouncedQuery.value)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined let debounceTimeout: ReturnType<typeof setTimeout> | undefined
watch(query, (value) => { watch(query, (value) => {
searchInput.value = value
clearTimeout(debounceTimeout) clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => { debounceTimeout = setTimeout(() => {
debouncedQuery.value = value debouncedQuery.value = value
@@ -29,9 +33,14 @@ watch(query, (value) => {
}, 300) }, 300)
}) })
onBeforeUnmount(() => { const updateSearchRoute = (value: string) => {
clearTimeout(debounceTimeout) const trimmed = value.trim()
}) if (trimmed)
router.replace(`/search?q=${encodeURIComponent(trimmed)}`)
else router.replace("/search")
}
onBeforeUnmount(() => clearTimeout(debounceTimeout))
const { data, status, error } = await useApiFetch<SearchResult[]>("/search", { const { data, status, error } = await useApiFetch<SearchResult[]>("/search", {
query: { q: debouncedQuery, limit: 9 }, query: { q: debouncedQuery, limit: 9 },
@@ -87,15 +96,30 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
<template> <template>
<main class="search-page"> <main class="search-page">
<h1>Search</h1> <h1>Search</h1>
<Input
class="mobile-search"
v-model:value="searchInput"
full
type="search"
name="mobile-search-query"
label="Search the library"
placeholder="Songs, albums, artists"
icon-name="lucide:search"
@input="updateSearchRoute"
/>
<p v-if="query && status === 'pending'" class="message" role="status"> <p
v-if="query && (!querySettled || status === 'pending')"
class="message"
role="status"
>
Searching… Searching…
</p> </p>
<p v-else-if="query && error" class="message" role="alert"> <p v-else-if="query && querySettled && error" class="message" role="alert">
Search could not be loaded. Please try again. Search could not be loaded. Please try again.
</p> </p>
<p <p
v-else-if="query && status === 'success' && results.length === 0" v-else-if="query && querySettled && status === 'success' && results.length === 0"
class="message" class="message"
> >
No results for “{{ query }}”. No results for “{{ query }}”.
@@ -109,7 +133,7 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
</template> </template>
<section <section
v-if="query && results.length" v-if="query && querySettled && status === 'success' && results.length"
class="results" class="results"
aria-label="Search results" aria-label="Search results"
> >
@@ -126,7 +150,10 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
</section> </section>
<section <section
v-else-if="recentTracks.length && (!query || status === 'success')" v-else-if="
recentTracks.length &&
(!query || (querySettled && status === 'success'))
"
class="results" class="results"
aria-label="Recently played tracks" aria-label="Recently played tracks"
> >
@@ -136,6 +163,7 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
:track="track" :track="track"
:index="index" :index="index"
show-image show-image
touch-friendly
@play-album-at-index="playResult(track)" @play-album-at-index="playResult(track)"
/> />
</section> </section>
@@ -161,6 +189,10 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
} }
} }
.mobile-search {
display: none;
}
.results { .results {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -172,4 +204,18 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
opacity: 0.65; opacity: 0.65;
margin: 0.75rem 0; margin: 0.75rem 0;
} }
@media (max-width: 767px) {
.mobile-search {
display: block;
margin-bottom: 1rem;
}
.results :deep(.track.image) {
grid-template-columns: 40px minmax(0, 1fr) auto auto;
gap: 0.5rem;
padding-inline: 0.5rem;
}
}
</style> </style>
+21 -1
View File
@@ -29,6 +29,7 @@ export default defineNuxtPlugin((nuxtApp) => {
const currentQueue = ref<Track[]>([]) const currentQueue = ref<Track[]>([])
const currentSong = ref<Track | null>(null) const currentSong = ref<Track | null>(null)
const repeat = ref(false) const repeat = ref(false)
const repeatOnce = ref(false)
const isPlaying = ref(false) const isPlaying = ref(false)
const beatEnergy = ref(0) const beatEnergy = ref(0)
const storedVolume = ref(DEFAULT_VOLUME) const storedVolume = ref(DEFAULT_VOLUME)
@@ -369,7 +370,13 @@ export default defineNuxtPlugin((nuxtApp) => {
onend: () => { onend: () => {
stopBeatAnalysis("track ended") stopBeatAnalysis("track ended")
void closeFullscreen() void closeFullscreen()
next() if (repeatOnce.value && sound.value === newSound) {
repeatOnce.value = false
newSound.seek(0)
newSound.play()
} else {
void next()
}
stopTracking() stopTracking()
}, },
onstop: () => { onstop: () => {
@@ -421,6 +428,17 @@ export default defineNuxtPlugin((nuxtApp) => {
updateMediaSession() updateMediaSession()
} }
const cycleRepeat = () => {
if (repeatOnce.value) {
repeatOnce.value = false
repeat.value = true
} else if (repeat.value) {
repeat.value = false
} else {
repeatOnce.value = true
}
}
const next = async (autoplay: boolean = true) => { const next = async (autoplay: boolean = true) => {
if (currentSong.value) { if (currentSong.value) {
prevQueue.value.unshift(currentSong.value) prevQueue.value.unshift(currentSong.value)
@@ -571,6 +589,8 @@ export default defineNuxtPlugin((nuxtApp) => {
beatEnergy, beatEnergy,
currentSong, currentSong,
repeat, repeat,
repeatOnce,
cycleRepeat,
play, play,
pause, pause,
next, next,
+1 -1
View File
@@ -1,5 +1,5 @@
$color-accent: rgb(255, 69, 58); $color-accent: rgb(255, 69, 58);
$color-background: oklch(14.1% 0.005 285.823); // #1a202c $color-background: oklch(14.1% 0.005 285.823); // #09090b
$color-secondary-background: oklch(26.862% 0.00003 271.152); $color-secondary-background: oklch(26.862% 0.00003 271.152);
$color-text: rgba(255, 255, 255, 0.92); $color-text: rgba(255, 255, 255, 0.92);
$color-placeholder-text: rgba(255, 255, 255, 0.44); $color-placeholder-text: rgba(255, 255, 255, 0.44);
+6
View File
@@ -1,11 +1,17 @@
@use "reset" as *; @use "reset" as *;
@use "util" as *; @use "util" as *;
@use "variables" as *;
html { html {
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
color: white; color: white;
background-color: $color-background;
}
body {
background-color: $color-background;
} }
main > h1 { main > h1 {
+92 -11
View File
@@ -7,6 +7,7 @@ export interface DropdownMenuItem {
interface DropdownMenuProps { interface DropdownMenuProps {
items: DropdownMenuItem[][] items: DropdownMenuItem[][]
label?: string
} }
const props = defineProps<DropdownMenuProps>() const props = defineProps<DropdownMenuProps>()
@@ -15,17 +16,46 @@ const open = ref(false)
const mounted = ref(false) const mounted = ref(false)
const placement = ref<"bottom" | "top">("bottom") const placement = ref<"bottom" | "top">("bottom")
const root = useTemplateRef("root") const root = useTemplateRef("root")
const triggerRef = useTemplateRef<HTMLButtonElement>("trigger-ref")
const menuRef = useTemplateRef<HTMLDivElement>("menu-ref")
const menuPosition = ref({ top: 0, left: 0 }) const menuPosition = ref({ top: 0, left: 0 })
const toggle = async () => { const menuItems = () =>
open.value = !open.value Array.from(
menuRef.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
)
if (open.value) { const focusMenuItem = (index: number) => {
const items = menuItems()
items[index]?.focus()
}
const close = (restoreFocus = false) => {
open.value = false
if (restoreFocus) nextTick(() => triggerRef.value?.focus())
}
const openMenu = async (last = false) => {
open.value = true
await nextTick() await nextTick()
updatePlacement() updatePlacement()
} const items = menuItems()
focusMenuItem(last ? items.length - 1 : 0)
}
const toggle = async () => {
if (open.value) close(true)
else await openMenu()
}
const handleTriggerKeydown = (event: KeyboardEvent) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
event.preventDefault()
event.stopPropagation()
if (!open.value) void openMenu(event.key === "ArrowUp")
else focusMenuItem(event.key === "ArrowUp" ? menuItems().length - 1 : 0)
} }
const close = () => (open.value = false)
const updatePlacement = () => { const updatePlacement = () => {
const el = root.value const el = root.value
@@ -48,12 +78,34 @@ const updatePlacement = () => {
} }
const handleOutsideClick = (e: MouseEvent) => { const handleOutsideClick = (e: MouseEvent) => {
if (!root.value) return const target = e.target as Node
if (!root.value.contains(e.target as Node)) close() if (root.value?.contains(target) || menuRef.value?.contains(target)) return
close()
} }
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key == "Escape") close() if (!open.value) return
const items = menuItems()
const activeIndex = items.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === "Escape") {
e.preventDefault()
close(true)
} else if (e.key === "ArrowDown") {
e.preventDefault()
focusMenuItem((activeIndex + 1 + items.length) % items.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
focusMenuItem((activeIndex - 1 + items.length) % items.length)
} else if (e.key === "Home") {
e.preventDefault()
focusMenuItem(0)
} else if (e.key === "End") {
e.preventDefault()
focusMenuItem(items.length - 1)
} else if (e.key === "Tab") {
close()
}
} }
const handleReposition = () => { const handleReposition = () => {
@@ -68,7 +120,7 @@ onMounted(() => {
mounted.value = true mounted.value = true
}) })
onBeforeMount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", handleOutsideClick) document.removeEventListener("click", handleOutsideClick)
document.removeEventListener("keydown", handleKey) document.removeEventListener("keydown", handleKey)
window.removeEventListener("resize", handleReposition) window.removeEventListener("resize", handleReposition)
@@ -78,15 +130,26 @@ onBeforeMount(() => {
<template> <template>
<div ref="root" class="dropdown"> <div ref="root" class="dropdown">
<div @click="toggle" class="trigger"> <button
ref="trigger-ref"
type="button"
class="trigger"
:aria-label="props.label ?? 'Open options'"
aria-haspopup="menu"
:aria-expanded="open"
@click="toggle"
@keydown="handleTriggerKeydown"
>
<slot /> <slot />
</div> </button>
<Transition name="dropdown"> <Transition name="dropdown">
<Teleport v-if="mounted" to="body"> <Teleport v-if="mounted" to="body">
<div <div
ref="menu-ref"
v-show="open" v-show="open"
class="dropdown-menu" class="dropdown-menu"
role="menu"
:class="placement" :class="placement"
:style="{ :style="{
top: `${menuPosition.top}px`, top: `${menuPosition.top}px`,
@@ -97,11 +160,13 @@ onBeforeMount(() => {
v-for="(group, i) in items" v-for="(group, i) in items"
:key="`group-${i}`" :key="`group-${i}`"
class="group" class="group"
role="group"
> >
<button <button
v-for="(item, i) in group" v-for="(item, i) in group"
:key="`item-${i}`" :key="`item-${i}`"
class="item" class="item"
role="menuitem"
@click=" @click="
() => { () => {
item.onSelect?.() item.onSelect?.()
@@ -127,6 +192,22 @@ onBeforeMount(() => {
display: inline-block; display: inline-block;
} }
.trigger {
display: inline-flex;
align-items: center;
border: 0;
padding: 0;
color: inherit;
background: transparent;
cursor: pointer;
}
@media (max-width: 767px) {
.trigger {
padding: 0.75rem;
}
}
.dropdown-menu { .dropdown-menu {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
+2
View File
@@ -27,6 +27,7 @@ const song = useSong()
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 100; z-index: 100;
pointer-events: none;
padding: 2rem; padding: 2rem;
@@ -50,6 +51,7 @@ const song = useSong()
min-width: 200px; min-width: 200px;
cursor: pointer; cursor: pointer;
pointer-events: auto;
transition: transition:
transform 0.2s ease, transform 0.2s ease,
+665 -84
View File
File diff suppressed because it is too large. Load diff
+44 -1
View File
@@ -25,7 +25,50 @@ export default defineNuxtConfig({
mediaBaseUrl: "", mediaBaseUrl: "",
}, },
}, },
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"], modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image", "@vite-pwa/nuxt"],
pwa: {
strategies: "injectManifest",
srcDir: "../public",
filename: "sw.js",
registerType: "autoUpdate",
registerWebManifestInRouteRules: true,
injectManifest: {
injectionPoint: undefined,
},
devOptions: {
enabled: false,
},
manifest: {
id: "/",
name: "Music",
short_name: "Music",
description: "Your personal music library and player.",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#09090b",
theme_color: "#09090b",
categories: ["music", "entertainment"],
icons: [
{
src: "/icons/pwa-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icons/pwa-512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/icons/pwa-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
},
components: [ components: [
{ {
path: "~/ui", path: "~/ui",
+2 -1
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build && bun scripts/copy-nitro-public-assets.ts",
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
@@ -31,6 +31,7 @@
"devDependencies": { "devDependencies": {
"@golar/vue": "^0.1.10", "@golar/vue": "^0.1.10",
"@types/node": "^24.9.1", "@types/node": "^24.9.1",
"@vite-pwa/nuxt": "^1.1.1",
"golar": "^0.1.10", "golar": "^0.1.10",
"sass-embedded": "^1.93.2" "sass-embedded": "^1.93.2"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#08090b" />
<path
d="M96 352 174 176l78 176h44l70-138 64 138"
fill="none"
stroke="#00db81"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="34"
/>
</svg>

After

Width:  |  Height:  |  Size: 281 B

+30
View File
@@ -0,0 +1,30 @@
{
"id": "/",
"name": "Music",
"short_name": "Music",
"description": "Your personal music library and player.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#09090b",
"categories": ["music", "entertainment"],
"icons": [
{
"src": "/icons/pwa-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/pwa-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/pwa-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#09090b" />
<title>Offline · Music</title>
<style>
:root {
color-scheme: dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #09090b;
color: white;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
text-align: center;
}
main {
max-width: 26rem;
padding: 2rem;
}
img {
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
}
p {
color: #b8b8bd;
line-height: 1.5;
}
button {
margin-top: 1rem;
padding: 0.75rem 1.25rem;
border: 0;
border-radius: 999px;
background: #00db81;
color: #09090b;
font: inherit;
font-weight: 700;
}
</style>
</head>
<body>
<main>
<img src="/icons/pwa-192.png" alt="" />
<h1>You’re offline</h1>
<p>Reconnect to browse your library and play music.</p>
<button type="button" onclick="location.reload()">Try again</button>
</main>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
const SHELL_CACHE = "music-pwa-shell-v1"
const ASSET_CACHE = "music-pwa-assets-v1"
const OFFLINE_PAGE = "/offline.html"
const SHELL_ASSETS = [
OFFLINE_PAGE,
"/icons/pwa-192.png",
"/icons/pwa-512.png",
"/icons/pwa-maskable-512.png",
"/icons/apple-touch-icon.png",
]
const SHELL_ASSET_PATHS = new Set(SHELL_ASSETS)
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => cache.addAll(SHELL_ASSETS))
.then(() => self.skipWaiting()),
)
})
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then(async (keys) => {
await Promise.all(
keys
.filter(
(key) =>
key.startsWith("music-pwa-") &&
key !== SHELL_CACHE &&
key !== ASSET_CACHE,
)
.map((key) => caches.delete(key)),
)
await self.clients.claim()
}),
)
})
self.addEventListener("fetch", (event) => {
const request = event.request
if (request.method !== "GET") return
const url = new URL(request.url)
if (url.origin !== self.location.origin) return
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(async () => {
const offlinePage = await caches.match(OFFLINE_PAGE)
return offlinePage ?? Response.error()
}),
)
return
}
if (SHELL_ASSET_PATHS.has(url.pathname)) {
event.respondWith(
caches.match(request).then((cached) => cached ?? fetch(request)),
)
return
}
if (url.pathname.startsWith("/_nuxt/")) {
event.respondWith(
caches.open(ASSET_CACHE).then(async (cache) => {
const cached = await cache.match(request)
if (cached) return cached
const response = await fetch(request)
if (response.ok) {
await cache.put(request, response.clone())
const entries = await cache.keys()
if (entries.length > 100) await cache.delete(entries[0])
}
return response
}),
)
}
})
+13
View File
@@ -0,0 +1,13 @@
import { cp, mkdir } from "node:fs/promises"
import { resolve } from "node:path"
const publicOutput = resolve(".output/public")
const nitroAssetDirectory = resolve(".output/server/chunks/public")
const nuxtAssetsDirectory = resolve(publicOutput, "_nuxt")
await mkdir(nitroAssetDirectory, { recursive: true })
await cp(publicOutput, nitroAssetDirectory, {
recursive: true,
force: true,
filter: (path) => path !== nuxtAssetsDirectory,
})