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 2228 additions and 184 deletions

No files matched your search

+11
View File
@@ -3,6 +3,16 @@ const { currentSong, isPlaying } = usePlayer()
const debugView = useCookie("DEBUG_VIEW")
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(
computed(() => ({
titleTemplate:
@@ -14,6 +24,7 @@ useHead(
</script>
<template>
<NuxtPwaManifest />
<ToastContainer />
<NuxtLayout>
<NuxtPage />
+14 -1
View File
@@ -4,13 +4,14 @@ import type { Album } from "@/utils/types"
type AlbumProps = Omit<Omit<Album, "genre">, "artists"> & {
artists: Array<Omit<Artist, "file">>
genre?: Genre
carousel?: boolean
}
const props = defineProps<AlbumProps>()
</script>
<template>
<article class="card">
<article class="card" :class="{ carousel: props.carousel }">
<NuxtLink :to="`/album/${props._id}`">
<NuxtImg
: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>
+1
View File
@@ -49,6 +49,7 @@ onMounted(async () => {
:file="GET_FILE(album.file)"
:artists="album.artists"
:_id="album._id"
carousel
/>
</Slider>
</template>
+232 -14
View File
@@ -4,9 +4,125 @@ import { GET_FILE } from "@/utils/file"
const { fullscreen, close: closeFullscreen } = useFullscreen()
const song = useSong()
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(() => {
let timeoutId: NodeJS.Timeout
const mobileQuery = window.matchMedia("(max-width: 767px)")
const updateMobileView = () => (mobileView.value = mobileQuery.matches)
updateMobileView()
mobileQuery.addEventListener("change", updateMobileView)
const handleMouseMove = () => {
mouseMovedRecently.value = true
@@ -28,6 +144,12 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener("mousemove", handleMouseMove)
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>
<section
class="fullscreen"
:class="{ showCursor: mouseMovedRecently }"
:class="{ showCursor: mouseMovedRecently, dragging: isDragging }"
:style="{ '--drag-offset': `${dragOffset}px` }"
@transitionend="onFullscreenTransitionEnd"
v-show="fullscreen"
>
<template v-if="song">
@@ -51,8 +175,16 @@ nuxtApp.hook("page:finish", () => {
/>
</template>
<section class="screen-container" v-if="song">
<section
class="screen-container"
v-if="song"
@pointerdown="startDrag"
@pointermove="moveDrag"
@pointerup="endDrag"
@pointercancel="cancelDrag"
>
<Icon
v-if="!mobileView"
name="lucide:x"
class="close-btn"
:class="{ show: mouseMovedRecently }"
@@ -66,9 +198,28 @@ nuxtApp.hook("page:finish", () => {
<PlayerFullscreenTrackDisplay
: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
v-if="
!mobileView || (showMobileLyrics && song.lyrics?.text)
"
:animation-duration="0.2"
:offset="250"
:offset="mobileView ? 100 : 250"
:show-scroll-bar="mouseMovedRecently"
/>
</div>
@@ -79,12 +230,9 @@ nuxtApp.hook("page:finish", () => {
<style lang="scss" scoped>
.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
inset: 0;
z-index: 150;
overflow: hidden;
cursor: none;
@@ -95,13 +243,9 @@ nuxtApp.hook("page:finish", () => {
.screen-container {
position: fixed;
top: 0;
left: 0;
inset: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.34);
background-color: rgba(0, 0, 0, 0.34);
}
.close-btn {
@@ -119,6 +263,10 @@ nuxtApp.hook("page:finish", () => {
}
}
.mobile-tabs {
display: none;
}
.content-container {
/* TODO: A lot of magic numbers... should be variables */
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>
@@ -50,6 +50,7 @@ const scrollActiveIndex = computed(() => {
const height = ref<number>()
const top = ref(0)
const syncedHeight = ref<number>()
const mobileView = ref(false)
const layoutVersion = ref(0)
const lyricOffsets = ref<number[]>([])
const topFadeExtension = 75
@@ -60,7 +61,14 @@ const updateLyricsLayout = () => {
if (trackDisplay) {
height.value = trackDisplay.clientHeight
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
@@ -93,8 +101,20 @@ watch(
{ immediate: true },
)
onMounted(() => window.addEventListener("resize", updateLyricsLayout))
onUnmounted(() => window.removeEventListener("resize", updateLyricsLayout))
onMounted(() => {
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 outerRef = useTemplateRef("outer-ref")
@@ -185,7 +205,7 @@ const finalTransform = computed(() => {
class="synced-lyrics-container"
:style="{
height: `${(syncedHeight ?? 0) + topFadeExtension}px`,
marginTop: `${top - topFadeExtension}px`,
marginTop: mobileView ? '0px' : `${top - topFadeExtension}px`,
}"
@wheel.prevent="onWheel"
>
@@ -306,4 +326,26 @@ const finalTransform = computed(() => {
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>
@@ -12,6 +12,8 @@ const {
prev,
shuffle,
repeat,
repeatOnce,
cycleRepeat,
secondsPlayed,
duration,
volume,
@@ -38,9 +40,16 @@ const durationFormatted = computed(() =>
height="512"
/>
<div class="track">
<p class="name">{{ song.name }}</p>
<ArtistName :artists="song.artists" />
<div class="track-row">
<div class="track">
<p class="name">{{ song.name }}</p>
<ArtistName :artists="song.artists" />
</div>
<div class="track-actions">
<button aria-label="More track options">
<Icon name="lucide:ellipsis" />
</button>
</div>
</div>
<div class="slider">
@@ -64,9 +73,9 @@ const durationFormatted = computed(() =>
<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"
:name="repeatOnce ? 'lucide:repeat-1' : 'lucide:repeat'"
:class="{ repeating: repeat || repeatOnce }"
@click="cycleRepeat()"
/>
</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 {
display: flex;
flex-direction: column;
@@ -180,4 +214,38 @@ const durationFormatted = computed(() =>
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>
+131 -8
View File
@@ -79,6 +79,15 @@ const playAt = (index: number) =>
queueRows.value.map((row) => row.track),
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>
<template>
@@ -118,14 +127,38 @@ const playAt = (index: number) =>
@drop="finishDrag"
@dragend="cancelDrag"
>
<TrackRow
:track="row.track"
:index="index"
show-image
queue-mode
class="queue-track"
@play-album-at-index="playAt(index)"
/>
<div class="queue-row">
<TrackRow
:track="row.track"
:index="index"
show-image
queue-mode
class="queue-track"
@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>
</ol>
</aside>
@@ -210,6 +243,14 @@ const playAt = (index: number) =>
}
}
.queue-row {
position: relative;
}
.queue-order-actions {
display: none;
}
.empty {
flex: 1;
display: grid;
@@ -235,4 +276,86 @@ const playAt = (index: number) =>
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>
+142 -19
View File
@@ -27,6 +27,8 @@ const {
prev,
shuffle,
repeat,
repeatOnce,
cycleRepeat,
secondsPlayed,
duration,
volume,
@@ -41,12 +43,13 @@ const durationFormatted = computed(() =>
)
const { open: openFullscreen } = useFullscreen()
const queueOpen = ref(false)
const queueOpen = useState<boolean>("queueOpen", () => false)
</script>
<template>
<div id="width-fixer" ref="width-fixer-ref"></div>
<article class="player" ref="player-ref" :class="{ active: song }">
<Icon name="lucide:repeat-1" class="icon-preload" aria-hidden="true" />
<template v-if="song">
<div class="track">
<NuxtImg
@@ -63,20 +66,50 @@ const queueOpen = ref(false)
</div>
<div class="controls">
<div class="btns">
<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"
/>
<button
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
:name="isPlaying ? 'lucide:pause' : 'lucide:play'"
aria-hidden="true"
/>
</button>
<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
:name="repeatOnce ? 'lucide:repeat-1' : 'lucide:repeat'"
aria-hidden="true"
/>
</button>
</div>
<div class="slider">
<p>{{ secondsPlayedFormatted }}</p>
@@ -105,11 +138,20 @@ const queueOpen = ref(false)
:step="0.01"
/>
</div>
<Icon
name="lucide:list"
<button
class="icon-button"
aria-label="Open queue"
@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>
</template>
</article>
@@ -121,6 +163,10 @@ const queueOpen = ref(false)
@use "@/styles/variables" as *;
@use "@/styles/util" as *;
.icon-preload {
display: none;
}
#width-fixer {
width: 100%;
}
@@ -154,6 +200,17 @@ const queueOpen = ref(false)
cursor: pointer;
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,
@@ -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>
+59 -1
View File
@@ -30,7 +30,16 @@ const entryType = computed(() =>
<template>
<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
v-if="artworkSrc"
:src="artworkSrc"
@@ -93,6 +102,10 @@ const entryType = computed(() =>
justify-content: center;
border-radius: $border-radius-standard;
cursor: pointer;
border: 0;
padding: 0;
color: inherit;
background: transparent;
img {
max-width: 100%;
@@ -140,4 +153,49 @@ const entryType = computed(() =>
.artists {
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>
+13
View File
@@ -126,4 +126,17 @@ onMounted(() => {
.right {
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>
+4 -1
View File
@@ -112,7 +112,10 @@ const visibleDropdownMenuItems = computed(() =>
<ArtistName :artists="track.artists" class="artists" />
</div>
<DropdownMenu :items="visibleDropdownMenuItems">
<DropdownMenu
:items="visibleDropdownMenuItems"
:label="`Options for ${track.name}`"
>
<Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu>
</article>
+100 -3
View File
@@ -15,6 +15,8 @@ interface TrackRowProps {
extendedInfo?: boolean
showImage?: boolean
queueMode?: boolean
touchFriendly?: boolean
libraryList?: boolean
}
const props = defineProps<TrackRowProps>()
@@ -130,6 +132,7 @@ const artworkSrc = computed(() => {
odd: props.index % 2 == 1,
image: props.showImage,
queue: props.queueMode,
'library-list': props.libraryList,
}"
@mouseenter="hovering = true"
@mouseleave="hovering = false"
@@ -140,7 +143,21 @@ const artworkSrc = computed(() => {
v-if="props.track"
name="lucide:play"
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')"
@keydown.enter.prevent="
props.touchFriendly && emit('playAlbumAtIndex')
"
@keydown.space.prevent="
props.touchFriendly && emit('playAlbumAtIndex')
"
/>
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
<NuxtImg
@@ -162,7 +179,14 @@ const artworkSrc = computed(() => {
</template>
</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
v-else-if="props.entity"
:to="`/${props.entity.type}/${props.entity._id}`"
@@ -170,7 +194,7 @@ const artworkSrc = computed(() => {
{{ props.entity.name }}
</NuxtLink>
<template v-if="props.track && props.extendedInfo">
<template v-if="props.track && props.extendedInfo && !props.libraryList">
<ArtistName :artists="props.track.artists" class="artists" />
<NuxtLink :to="`/album/${props.track.album._id}`">
<p>{{ props.track.album.name }}</p>
@@ -194,7 +218,11 @@ const artworkSrc = computed(() => {
class="queue-handle"
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" />
</DropdownMenu>
</article>
@@ -240,6 +268,10 @@ const artworkSrc = computed(() => {
cursor: grab;
}
&.library-list {
min-width: 0;
}
p,
> a {
overflow: hidden;
@@ -274,6 +306,10 @@ const artworkSrc = computed(() => {
opacity: 0;
z-index: 1;
font-size: 24px;
&:focus-visible {
opacity: 1;
}
}
.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 {
opacity: 1;
}
.library-info {
display: contents;
}
.dropdown-icon {
font-size: 20px;
cursor: pointer;
+1 -1
View File
@@ -26,7 +26,7 @@ export const useFullscreen = () => {
await document.documentElement.requestFullscreen()
return true
} catch {
fullscreen.value = false
// Keep the in-app fullscreen player open when native fullscreen is denied.
return false
}
}
+113 -1
View File
@@ -1,5 +1,13 @@
<script setup lang="ts">
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>
@@ -13,10 +21,11 @@ const onSearchInput = (v: string) => {
}
const { data: user } = useApiFetch<any>("/user")
const song = useSong()
</script>
<template>
<Sidebar>
<Sidebar class="default-shell" :class="{ 'has-player': song }">
<template #sidebar>
<p class="title">Music</p>
@@ -89,12 +98,55 @@ const { data: user } = useApiFetch<any>("/user")
<template #default>
<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>
</Sidebar>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
@use "@/styles/variables" as *;
.title {
@include fontSize(24px);
@@ -114,4 +166,64 @@ const { data: user } = useApiFetch<any>("/user")
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>
+65 -8
View File
@@ -17,12 +17,14 @@ useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Alb
<template>
<section class="info" v-if="albumData">
<NuxtImg
:src="GET_FILE(albumData.file)"
width="300"
height="300"
placeholder
/>
<div class="album-art">
<NuxtImg
:src="GET_FILE(albumData.file)"
width="300"
height="300"
placeholder
/>
</div>
<div class="details">
<h1>{{ albumData.name }}</h1>
@@ -31,13 +33,15 @@ useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Alb
<div class="buttons">
<Button
icon-name="lucide:play"
:aria-label="`Play ${albumData.name}`"
@click="() => playAlbum(tracks)"
>Play</Button
><span class="button-label">Play</span></Button
>
<Button
icon-name="lucide:shuffle"
:aria-label="`Shuffle ${albumData.name}`"
@click="() => playShuffledAlbum(tracks)"
>Shuffle</Button
><span class="button-label">Shuffle</span></Button
>
</div>
</div>
@@ -97,4 +101,57 @@ useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Alb
flex-direction: column;
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>
+67 -8
View File
@@ -44,12 +44,15 @@ useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - A
<template>
<section class="info" v-if="artistData">
<NuxtImg
:src="GET_FILE(artistData.file)"
width="300"
height="300"
placeholder
/>
<div class="artist-art">
<NuxtImg
class="artist-image"
:src="GET_FILE(artistData.file)"
width="300"
height="300"
placeholder
/>
</div>
<div class="details">
<h1>{{ artistData.name }}</h1>
@@ -57,14 +60,18 @@ useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - A
<div class="buttons">
<Button
icon-name="lucide:play"
:aria-label="`Play ${artistData.name}`"
@click="() => playAlbum(tracks)"
>Play</Button
>
<span class="button-label">Play</span>
</Button>
<Button
icon-name="lucide:shuffle"
:aria-label="`Shuffle ${artistData.name}`"
@click="() => playShuffledAlbum(tracks)"
>Shuffle</Button
>
<span class="button-label">Shuffle</span>
</Button>
</div>
</div>
</section>
@@ -97,6 +104,7 @@ useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - A
:file="GET_FILE(album.file)"
:artists="album.artists"
:_id="album._id"
carousel
/>
</Slider>
</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>
+14
View File
@@ -21,6 +21,7 @@ useHead({ title: "Artist Songs" })
:track="track"
:index="i"
show-image
touch-friendly
@play-album-at-index="() => playAlbumAtIndex(tracks, i)"
/>
</section>
@@ -41,4 +42,17 @@ useHead({ title: "Artist Songs" })
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>
+7
View File
@@ -30,4 +30,11 @@ useHead({ title: "Sign In" })
width: 40%;
max-width: 450px;
}
@media (max-width: 600px) {
.card {
box-sizing: border-box;
width: calc(100% - 2rem);
}
}
</style>
+11
View File
@@ -58,4 +58,15 @@ h1 {
flex-direction: column;
gap: 0.8rem;
}
@media (max-width: 480px) {
h1 {
@include fontSize(22px);
margin-bottom: 0.65em;
}
.form {
gap: 0.75rem;
}
}
</style>
+19
View File
@@ -64,4 +64,23 @@ useHead({ title: "Albums" })
opacity: 0.7;
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>
+18
View File
@@ -60,4 +60,22 @@ useHead({ title: "Artists" })
opacity: 0.7;
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>
+8
View File
@@ -54,6 +54,7 @@ useHead({ title: "Songs" })
:track="track"
:index="index"
:extended-info="true"
library-list
@play-album-at-index="() => playAlbumAtIndex(tracks, index)"
@remove-from-library="removeFromLibrary"
/>
@@ -97,4 +98,11 @@ useHead({ title: "Songs" })
opacity: 0.7;
margin: 0.75rem 0;
}
@media (max-width: 359px) {
.page-header {
flex-wrap: wrap;
justify-content: flex-start;
}
}
</style>
+54 -8
View File
@@ -12,16 +12,20 @@ interface SearchResult {
}
const route = useRoute()
const router = useRouter()
const { currentSong, playAlbum } = usePlayer()
const query = computed(() => {
const value = route.query.q
const firstValue = Array.isArray(value) ? value[0] : value
return (firstValue ?? "").trim()
})
const searchInput = ref(query.value)
const debouncedQuery = ref(query.value)
const querySettled = computed(() => query.value === debouncedQuery.value)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
watch(query, (value) => {
searchInput.value = value
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedQuery.value = value
@@ -29,9 +33,14 @@ watch(query, (value) => {
}, 300)
})
onBeforeUnmount(() => {
clearTimeout(debounceTimeout)
})
const updateSearchRoute = (value: string) => {
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", {
query: { q: debouncedQuery, limit: 9 },
@@ -87,15 +96,30 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
<template>
<main class="search-page">
<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…
</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.
</p>
<p
v-else-if="query && status === 'success' && results.length === 0"
v-else-if="query && querySettled && status === 'success' && results.length === 0"
class="message"
>
No results for “{{ query }}”.
@@ -109,7 +133,7 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
</template>
<section
v-if="query && results.length"
v-if="query && querySettled && status === 'success' && results.length"
class="results"
aria-label="Search results"
>
@@ -126,7 +150,10 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
</section>
<section
v-else-if="recentTracks.length && (!query || status === 'success')"
v-else-if="
recentTracks.length &&
(!query || (querySettled && status === 'success'))
"
class="results"
aria-label="Recently played tracks"
>
@@ -136,6 +163,7 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
:track="track"
:index="index"
show-image
touch-friendly
@play-album-at-index="playResult(track)"
/>
</section>
@@ -161,6 +189,10 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
}
}
.mobile-search {
display: none;
}
.results {
display: flex;
flex-direction: column;
@@ -172,4 +204,18 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
opacity: 0.65;
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>
+21 -1
View File
@@ -29,6 +29,7 @@ export default defineNuxtPlugin((nuxtApp) => {
const currentQueue = ref<Track[]>([])
const currentSong = ref<Track | null>(null)
const repeat = ref(false)
const repeatOnce = ref(false)
const isPlaying = ref(false)
const beatEnergy = ref(0)
const storedVolume = ref(DEFAULT_VOLUME)
@@ -369,7 +370,13 @@ export default defineNuxtPlugin((nuxtApp) => {
onend: () => {
stopBeatAnalysis("track ended")
void closeFullscreen()
next()
if (repeatOnce.value && sound.value === newSound) {
repeatOnce.value = false
newSound.seek(0)
newSound.play()
} else {
void next()
}
stopTracking()
},
onstop: () => {
@@ -421,6 +428,17 @@ export default defineNuxtPlugin((nuxtApp) => {
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) => {
if (currentSong.value) {
prevQueue.value.unshift(currentSong.value)
@@ -571,6 +589,8 @@ export default defineNuxtPlugin((nuxtApp) => {
beatEnergy,
currentSong,
repeat,
repeatOnce,
cycleRepeat,
play,
pause,
next,
+1 -1
View File
@@ -1,5 +1,5 @@
$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-text: rgba(255, 255, 255, 0.92);
$color-placeholder-text: rgba(255, 255, 255, 0.44);
+6
View File
@@ -1,11 +1,17 @@
@use "reset" as *;
@use "util" as *;
@use "variables" as *;
html {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
color: white;
background-color: $color-background;
}
body {
background-color: $color-background;
}
main > h1 {
+94 -13
View File
@@ -7,6 +7,7 @@ export interface DropdownMenuItem {
interface DropdownMenuProps {
items: DropdownMenuItem[][]
label?: string
}
const props = defineProps<DropdownMenuProps>()
@@ -15,17 +16,46 @@ const open = ref(false)
const mounted = ref(false)
const placement = ref<"bottom" | "top">("bottom")
const root = useTemplateRef("root")
const triggerRef = useTemplateRef<HTMLButtonElement>("trigger-ref")
const menuRef = useTemplateRef<HTMLDivElement>("menu-ref")
const menuPosition = ref({ top: 0, left: 0 })
const toggle = async () => {
open.value = !open.value
const menuItems = () =>
Array.from(
menuRef.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
)
if (open.value) {
await nextTick()
updatePlacement()
}
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()
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 el = root.value
@@ -48,12 +78,34 @@ const updatePlacement = () => {
}
const handleOutsideClick = (e: MouseEvent) => {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()
const target = e.target as Node
if (root.value?.contains(target) || menuRef.value?.contains(target)) return
close()
}
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 = () => {
@@ -68,7 +120,7 @@ onMounted(() => {
mounted.value = true
})
onBeforeMount(() => {
onBeforeUnmount(() => {
document.removeEventListener("click", handleOutsideClick)
document.removeEventListener("keydown", handleKey)
window.removeEventListener("resize", handleReposition)
@@ -78,15 +130,26 @@ onBeforeMount(() => {
<template>
<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 />
</div>
</button>
<Transition name="dropdown">
<Teleport v-if="mounted" to="body">
<div
ref="menu-ref"
v-show="open"
class="dropdown-menu"
role="menu"
:class="placement"
:style="{
top: `${menuPosition.top}px`,
@@ -97,11 +160,13 @@ onBeforeMount(() => {
v-for="(group, i) in items"
:key="`group-${i}`"
class="group"
role="group"
>
<button
v-for="(item, i) in group"
:key="`item-${i}`"
class="item"
role="menuitem"
@click="
() => {
item.onSelect?.()
@@ -127,6 +192,22 @@ onBeforeMount(() => {
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 {
position: fixed;
z-index: 1000;
+2
View File
@@ -27,6 +27,7 @@ const song = useSong()
bottom: 0;
right: 0;
z-index: 100;
pointer-events: none;
padding: 2rem;
@@ -50,6 +51,7 @@ const song = useSong()
min-width: 200px;
cursor: pointer;
pointer-events: auto;
transition:
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: "",
},
},
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: [
{
path: "~/ui",
+2 -1
View File
@@ -3,7 +3,7 @@
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"build": "nuxt build && bun scripts/copy-nitro-public-assets.ts",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
@@ -31,6 +31,7 @@
"devDependencies": {
"@golar/vue": "^0.1.10",
"@types/node": "^24.9.1",
"@vite-pwa/nuxt": "^1.1.1",
"golar": "^0.1.10",
"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,
})