Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b7fbf014 | ||
|
|
81b1581ed7 | ||
|
|
812884de1a | ||
|
|
27d92c55e5 | ||
|
|
cb6fa29e4b | ||
|
|
8f6f9ef78e | ||
|
|
ce0bf50223 | ||
|
|
1fce02ad84 | ||
|
|
f202f07585 | ||
|
|
ae52b1754b | ||
|
|
4c883c60a4 | ||
|
|
4ab58c8400 |
No files matched your search
+11
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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,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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+44
-1
@@ -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
@@ -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 |
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
Reference in new issue
Block a user