kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
389 wiersze
12 KiB
Vue
389 wiersze
12 KiB
Vue
<script setup lang="ts">
|
|
import type { QueueItemSource } from '~/types'
|
|
|
|
import { whenever, watchDebounced, useCurrentElement, useScrollLock } from '@vueuse/core'
|
|
import { nextTick, ref, computed, watchEffect, onMounted } from 'vue'
|
|
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
|
import { useGettext } from 'vue3-gettext'
|
|
import { useRouter } from 'vue-router'
|
|
import { useStore } from '~/store'
|
|
|
|
import { usePlayer } from '~/composables/audio/player'
|
|
import { useQueue } from '~/composables/audio/queue'
|
|
|
|
import time from '~/utils/time'
|
|
|
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
|
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
|
import PlayerControls from '~/components/audio/PlayerControls.vue'
|
|
import VirtualList from '~/components/vui/list/VirtualList.vue'
|
|
import QueueItem from '~/components/QueueItem.vue'
|
|
|
|
const {
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
progress,
|
|
bufferProgress,
|
|
seekTo,
|
|
loading: isLoadingAudio,
|
|
errored
|
|
} = usePlayer()
|
|
|
|
const {
|
|
hasNext,
|
|
currentTrack,
|
|
currentIndex,
|
|
queue,
|
|
dequeue,
|
|
playTrack,
|
|
reorder,
|
|
endsIn: timeLeft,
|
|
clear
|
|
} = useQueue()
|
|
|
|
const queueModal = ref()
|
|
const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
|
|
|
|
const { $pgettext } = useGettext()
|
|
const scrollLock = useScrollLock(document.body)
|
|
const store = useStore()
|
|
|
|
const labels = computed(() => ({
|
|
queue: $pgettext('*/*/*', 'Queue'),
|
|
populating: $pgettext('*/*/*', 'Fetching radio track'),
|
|
duration: $pgettext('*/*/*', 'Duration'),
|
|
addArtistContentFilter: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…'),
|
|
restart: $pgettext('*/*/*', 'Restart track'),
|
|
previous: $pgettext('*/*/*', 'Previous track'),
|
|
next: $pgettext('*/*/*', 'Next track'),
|
|
pause: $pgettext('*/*/*', 'Pause'),
|
|
play: $pgettext('*/*/*', 'Play')
|
|
}))
|
|
|
|
watchEffect(async () => {
|
|
scrollLock.value = !!store.state.ui.queueFocused
|
|
if (store.state.ui.queueFocused) {
|
|
await nextTick()
|
|
activate()
|
|
} else {
|
|
deactivate()
|
|
}
|
|
})
|
|
|
|
const list = ref()
|
|
const el = useCurrentElement()
|
|
const scrollToCurrent = (behavior: ScrollBehavior = 'smooth') => {
|
|
const item = el.value?.querySelector('.queue-item.active')
|
|
item?.scrollIntoView({
|
|
behavior,
|
|
block: 'center'
|
|
})
|
|
}
|
|
|
|
watchDebounced(currentTrack, () => scrollToCurrent(), { debounce: 100 })
|
|
|
|
const scrollLoop = () => {
|
|
const visible = [...(list.value?.scroller.$_views.values() ?? [])].map(item => item.nr.index)
|
|
if (!visible.includes(currentIndex.value)) {
|
|
list.value?.scrollToIndex(currentIndex.value)
|
|
requestAnimationFrame(scrollLoop)
|
|
}
|
|
}
|
|
|
|
onMounted(scrollLoop)
|
|
|
|
whenever(
|
|
() => queue.value.length === 0,
|
|
() => store.commit('ui/queueFocused', null),
|
|
{ immediate: true }
|
|
)
|
|
|
|
const router = useRouter()
|
|
router.beforeEach(() => store.commit('ui/queueFocused', null))
|
|
|
|
const progressBar = ref()
|
|
const touchProgress = (event: MouseEvent) => {
|
|
const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value
|
|
seekTo(time)
|
|
}
|
|
|
|
const play = async (index: number) => {
|
|
isPlaying.value = true
|
|
return playTrack(index)
|
|
}
|
|
|
|
const queueItems = computed(() => queue.value.map((track, index) => ({
|
|
...track,
|
|
id: `${index}-${track.id}`,
|
|
labels: {
|
|
remove: $pgettext('*/*/*', 'Remove'),
|
|
selectTrack: $pgettext('*/*/*', 'Select track'),
|
|
favorite: $pgettext('*/*/*', 'Favorite track')
|
|
}
|
|
}) as QueueItemSource))
|
|
|
|
const reorderTracks = async (from: number, to: number) => {
|
|
reorder(from, to)
|
|
|
|
await nextTick()
|
|
if (to === currentIndex.value) {
|
|
scrollToCurrent()
|
|
}
|
|
}
|
|
|
|
const hideArtist = () => {
|
|
if (currentTrack.value.artistId !== -1) {
|
|
return store.dispatch('moderation/hide', {
|
|
type: 'artist',
|
|
target: {
|
|
id: currentTrack.value.artistId,
|
|
name: currentTrack.value.artistName
|
|
}
|
|
})
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section
|
|
class="main with-background component-queue"
|
|
:aria-label="labels.queue"
|
|
>
|
|
<div
|
|
id="queue-grid"
|
|
:class="store.state.ui.queueFocused && `show-${store.state.ui.queueFocused}`"
|
|
>
|
|
<div
|
|
id="player"
|
|
class="ui basic segment"
|
|
>
|
|
<template v-if="currentTrack">
|
|
<div class="cover-container">
|
|
<div class="cover">
|
|
<img
|
|
ref="cover"
|
|
alt=""
|
|
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
|
>
|
|
</div>
|
|
</div>
|
|
<h1 class="ui header">
|
|
<div class="content ellipsis">
|
|
<router-link
|
|
class="small header discrete link track"
|
|
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
|
>
|
|
{{ currentTrack.title }}
|
|
</router-link>
|
|
<div class="sub header ellipsis">
|
|
<router-link
|
|
class="discrete link artist"
|
|
:to="{name: 'library.artists.detail', params: {id: currentTrack.artistId }}"
|
|
>
|
|
{{ currentTrack.artistName }}
|
|
</router-link>
|
|
<template v-if="currentTrack.albumId !== -1">
|
|
/
|
|
<router-link
|
|
class="discrete link album"
|
|
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
|
>
|
|
{{ currentTrack.albumTitle }}
|
|
</router-link>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</h1>
|
|
<div
|
|
v-if="currentTrack && errored"
|
|
class="ui small warning message"
|
|
>
|
|
<h3 class="header">
|
|
<translate translate-context="Sidebar/Player/Error message.Title">
|
|
The track cannot be loaded
|
|
</translate>
|
|
</h3>
|
|
<p v-if="hasNext && isPlaying && errored">
|
|
<translate translate-context="Sidebar/Player/Error message.Paragraph">
|
|
The next track will play automatically in a few seconds…
|
|
</translate>
|
|
<i class="loading spinner icon" />
|
|
</p>
|
|
<p>
|
|
<translate translate-context="Sidebar/Player/Error message.Paragraph">
|
|
You may have a connectivity issue.
|
|
</translate>
|
|
</p>
|
|
</div>
|
|
<div class="additional-controls desktop-and-below">
|
|
<track-favorite-icon
|
|
v-if="$store.state.auth.authenticated"
|
|
:track="currentTrack"
|
|
/>
|
|
<track-playlist-icon
|
|
v-if="$store.state.auth.authenticated"
|
|
:track="currentTrack"
|
|
/>
|
|
<button
|
|
v-if="$store.state.auth.authenticated"
|
|
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']"
|
|
:aria-label="labels.addArtistContentFilter"
|
|
:title="labels.addArtistContentFilter"
|
|
@click="hideArtist"
|
|
>
|
|
<i :class="['eye slash outline', 'basic', 'icon']" />
|
|
</button>
|
|
</div>
|
|
<div class="progress-wrapper">
|
|
<div
|
|
v-if="currentTrack && !errored"
|
|
class="progress-area"
|
|
>
|
|
<div
|
|
ref="progressBar"
|
|
:class="['ui', 'small', 'vibrant', {'indicating': isLoadingAudio}, 'progress']"
|
|
@click="touchProgress"
|
|
>
|
|
<div
|
|
class="buffer bar"
|
|
:style="{ 'transform': `translateX(${bufferProgress - 100}%)` }"
|
|
/>
|
|
<div
|
|
class="position bar"
|
|
:style="{ 'transform': `translateX(calc(${progress}% - 100%)` }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="progress-area"
|
|
>
|
|
<div
|
|
ref="progress"
|
|
:class="['ui', 'small', 'vibrant', 'progress']"
|
|
>
|
|
<div class="buffer bar" />
|
|
<div class="position bar" />
|
|
</div>
|
|
</div>
|
|
<div class="progress">
|
|
<template v-if="!isLoadingAudio">
|
|
<a
|
|
href=""
|
|
:aria-label="labels.restart"
|
|
class="left floated timer discrete start"
|
|
@click.prevent="currentTime = 0"
|
|
>
|
|
{{ time.parse(Math.round(currentTime)) }}
|
|
</a>
|
|
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
|
|
</template>
|
|
<template v-else>
|
|
<span class="left floated timer">00:00</span>
|
|
<span class="right floated timer">00:00</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<player-controls class="desktop-and-below" />
|
|
</template>
|
|
</div>
|
|
<div id="queue">
|
|
<div class="ui basic clearing segment">
|
|
<h2 class="ui header">
|
|
<div class="content">
|
|
<button
|
|
class="ui right floated basic button"
|
|
@click="$store.commit('ui/queueFocused', null)"
|
|
>
|
|
<translate translate-context="*/Queue/*/Verb">
|
|
Close
|
|
</translate>
|
|
</button>
|
|
<button
|
|
class="ui right floated basic button danger"
|
|
@click="clear"
|
|
>
|
|
<translate translate-context="*/Queue/*/Verb">
|
|
Clear
|
|
</translate>
|
|
</button>
|
|
{{ labels.queue }}
|
|
<div class="sub header">
|
|
<div>
|
|
<translate
|
|
translate-context="Sidebar/Queue/Text"
|
|
:translate-params="{index: currentIndex + 1, length: queue.length}"
|
|
>
|
|
Track %{ index } of %{ length }
|
|
</translate>
|
|
<template v-if="!$store.state.radios.running">
|
|
-
|
|
<span :title="labels.duration">
|
|
{{ timeLeft }}
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</h2>
|
|
</div>
|
|
<virtual-list
|
|
ref="list"
|
|
:list="queueItems"
|
|
:component="QueueItem"
|
|
:size="50"
|
|
@reorder="reorderTracks"
|
|
@visible="scrollToCurrent('auto')"
|
|
@hidden="scrollLoop"
|
|
>
|
|
<template #default="{ index, item, classList }">
|
|
<queue-item
|
|
:data-index="index"
|
|
:index="index"
|
|
:source="item"
|
|
:class="[...classList, currentIndex === index && 'active']"
|
|
@play="play"
|
|
@remove="dequeue"
|
|
/>
|
|
</template>
|
|
<template #footer>
|
|
<div
|
|
v-if="$store.state.radios.populating"
|
|
class="radio-populating"
|
|
>
|
|
<i class="loading spinner icon" />
|
|
{{ labels.populating }}
|
|
</div>
|
|
<div
|
|
v-if="$store.state.radios.running"
|
|
class="ui info message radio-message"
|
|
>
|
|
<div class="content">
|
|
<h3 class="header">
|
|
<i class="feed icon" /> <translate translate-context="Sidebar/Player/Title">
|
|
You have a radio playing
|
|
</translate>
|
|
</h3>
|
|
<p>
|
|
<translate translate-context="Sidebar/Player/Paragraph">
|
|
New tracks will be appended here automatically.
|
|
</translate>
|
|
</p>
|
|
<button
|
|
class="ui basic primary button"
|
|
@click="$store.dispatch('radios/stop')"
|
|
>
|
|
<translate translate-context="*/Player/Button.Label/Short, Verb">
|
|
Stop radio
|
|
</translate>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</virtual-list>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|