Files
music-v2/app/components/Player/index.vue
T
2026-10-06 21:23:13 +02:00

361 lines
6.3 KiB
Vue

<script setup lang="tsx">
import { GET_FILE } from "@/utils/file"
const widthFixerRef = useTemplateRef("width-fixer-ref")
const playerRef = useTemplateRef("player-ref")
onMounted(() => {
const resizePlayer = () => {
if (playerRef.value && widthFixerRef.value)
playerRef.value.style.width = `calc(${widthFixerRef.value.clientWidth}px)`
}
const resizeObserver = new ResizeObserver(() => {
resizePlayer()
})
if (widthFixerRef.value) resizeObserver.observe(widthFixerRef.value)
resizePlayer()
})
const song = useSong()
const {
isPlaying,
play,
pause,
next,
prev,
shuffle,
repeat,
repeatOnce,
cycleRepeat,
secondsPlayed,
duration,
volume,
} = usePlayer()
const secondsPlayedFormatted = computed(() =>
new Date(secondsPlayed.value * 1000).toISOString().slice(14, 19),
)
const durationFormatted = computed(() =>
new Date(duration.value * 1000).toISOString().slice(14, 19),
)
const { open: openFullscreen } = useFullscreen()
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
:src="GET_FILE(song.album.file)"
:alt="song.name"
:height="56"
:width="56"
/>
<div class="details">
<p>{{ song.name }}</p>
<ArtistName :artists="song.artists" class="artists" />
</div>
</div>
<div class="controls">
<div class="btns">
<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>
<RangeSlider
:model-value="secondsPlayed"
@update:model-value="(v) => (secondsPlayed = v ?? 0)"
:max="duration"
dont-update-immediately
/>
<p>{{ durationFormatted }}</p>
</div>
</div>
<div class="misc-btns">
<div class="volume">
<Icon name="lucide:volume-2" />
<RangeSlider
class="slider"
:model-value="volume"
@update:model-value="
(v) => {
if (v !== undefined) volume = v
}
"
:max="1"
:min="0"
:step="0.01"
/>
</div>
<button
class="icon-button"
aria-label="Open queue"
@click="queueOpen = true"
>
<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>
<PlayerQueue v-model:open="queueOpen" />
<PlayerFullscreen />
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util" as *;
.icon-preload {
display: none;
}
#width-fixer {
width: 100%;
}
.player {
position: fixed;
bottom: 1rem;
height: $player-height;
border-radius: $border-radius-lg;
border: 1px solid $color-border;
background-color: $color-background;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
padding: 0.5rem 2rem;
transform: translateY(150%);
transition: transform 0.2s ease-out;
&.active {
transform: translateY(0);
}
span {
font-size: 24px;
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,
.controls,
.misc-btns,
.controls {
display: flex;
width: 100%;
gap: 0.5rem;
align-items: center;
}
.track {
.details {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.375;
}
img {
height: 100%;
border-radius: $border-radius-standard;
}
}
.controls {
flex-direction: column;
justify-content: center;
align-items: flex-start;
.btns,
.slider {
display: flex;
justify-content: center;
width: 100%;
}
.btns {
gap: 1rem;
}
.slider {
gap: 12px;
align-items: center;
& p {
@include fontSize(15px);
opacity: 0.8;
}
}
.repeating {
color: $color-accent;
}
}
.misc-btns {
justify-content: end;
.volume {
min-width: 100px;
max-width: 150px;
display: flex;
gap: 0.5rem;
span {
font-size: 18px;
}
.slider {
flex: 1;
}
}
}
@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>