kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
				
				
				
			Migrate queue component
							rodzic
							
								
									bef0d1dec4
								
							
						
					
					
						commit
						ccb905b004
					
				| 
						 | 
				
			
			@ -16,14 +16,15 @@ export interface Sound {
 | 
			
		|||
  dispose(): void
 | 
			
		||||
 | 
			
		||||
  readonly audioNode: IAudioNode<IAudioContext>
 | 
			
		||||
  readonly isErrored: Ref<boolean>
 | 
			
		||||
  readonly isLoaded: Ref<boolean>
 | 
			
		||||
  readonly currentTime: number
 | 
			
		||||
  readonly duration: number
 | 
			
		||||
  readonly buffered: number
 | 
			
		||||
  looping: boolean
 | 
			
		||||
 | 
			
		||||
  play(): void | Promise<void>
 | 
			
		||||
  pause(): void | Promise<void>
 | 
			
		||||
  play(): void | Promise<void>
 | 
			
		||||
 | 
			
		||||
  seekTo(seconds: number): void | Promise<void>
 | 
			
		||||
  seekBy(seconds: number): void | Promise<void>
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +47,7 @@ export class HTMLSound implements Sound {
 | 
			
		|||
  #soundLoopEventHook = createEventHook<HTMLSound>()
 | 
			
		||||
  #soundEndEventHook = createEventHook<HTMLSound>()
 | 
			
		||||
 | 
			
		||||
  readonly isErrored = ref(false)
 | 
			
		||||
  readonly isLoaded = ref(false)
 | 
			
		||||
 | 
			
		||||
  audioNode = createAudioSource(this.#audio)
 | 
			
		||||
| 
						 | 
				
			
			@ -69,6 +71,11 @@ export class HTMLSound implements Sound {
 | 
			
		|||
      this.isLoaded.value = this.#audio.readyState >= 2
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    useEventListener(this.#audio, 'error', () => {
 | 
			
		||||
      this.isErrored.value = true
 | 
			
		||||
      this.isLoaded.value = true
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    this.onSoundLoop = this.#soundLoopEventHook.on
 | 
			
		||||
    this.onSoundEnd = this.#soundEndEventHook.on
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,21 +1,43 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
import type { QueueItemSource } from '~/types'
 | 
			
		||||
 | 
			
		||||
import { useStore } from '~/store'
 | 
			
		||||
import { nextTick, ref, computed, watchEffect, onMounted } from 'vue'
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
 | 
			
		||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
 | 
			
		||||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
 | 
			
		||||
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 useQueue from '~/composables/audio/useQueue'
 | 
			
		||||
import usePlayer from '~/composables/audio/usePlayer'
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
import { useStore } from '~/store'
 | 
			
		||||
 | 
			
		||||
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'
 | 
			
		||||
 | 
			
		||||
import { queue } from '~/composables/audio/queue'
 | 
			
		||||
import {
 | 
			
		||||
  isPlaying,
 | 
			
		||||
  currentTime,
 | 
			
		||||
  duration,
 | 
			
		||||
  progress,
 | 
			
		||||
  bufferProgress,
 | 
			
		||||
  seekTo,
 | 
			
		||||
  loading as isLoadingAudio,
 | 
			
		||||
  errored
 | 
			
		||||
} from '~/composables/audio/player'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  hasNext,
 | 
			
		||||
  currentTrack,
 | 
			
		||||
  currentIndex,
 | 
			
		||||
  queue,
 | 
			
		||||
  tracks,
 | 
			
		||||
  dequeue,
 | 
			
		||||
  playTrack,
 | 
			
		||||
  reorder,
 | 
			
		||||
  endsIn as timeLeft
 | 
			
		||||
} from '~/composables/audio/queue'
 | 
			
		||||
 | 
			
		||||
const queueModal = ref()
 | 
			
		||||
const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
 | 
			
		||||
| 
						 | 
				
			
			@ -24,34 +46,6 @@ const { $pgettext } = useGettext()
 | 
			
		|||
const scrollLock = useScrollLock(document.body)
 | 
			
		||||
const store = useStore()
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
  playing,
 | 
			
		||||
  loading: isLoadingAudio,
 | 
			
		||||
  errored,
 | 
			
		||||
  duration,
 | 
			
		||||
  durationFormatted,
 | 
			
		||||
  currentTimeFormatted,
 | 
			
		||||
  progress,
 | 
			
		||||
  bufferProgress,
 | 
			
		||||
  currentTime,
 | 
			
		||||
  pause,
 | 
			
		||||
  resume
 | 
			
		||||
} = usePlayer()
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
  currentTrack,
 | 
			
		||||
  hasNext,
 | 
			
		||||
  isEmpty: emptyQueue,
 | 
			
		||||
  tracks,
 | 
			
		||||
  reorder,
 | 
			
		||||
  endsIn: timeLeft,
 | 
			
		||||
  currentIndex,
 | 
			
		||||
  removeTrack,
 | 
			
		||||
  clear,
 | 
			
		||||
  next,
 | 
			
		||||
  previous
 | 
			
		||||
} = useQueue()
 | 
			
		||||
 | 
			
		||||
const labels = computed(() => ({
 | 
			
		||||
  queue: $pgettext('*/*/*', 'Queue'),
 | 
			
		||||
  duration: $pgettext('*/*/*', 'Duration'),
 | 
			
		||||
| 
						 | 
				
			
			@ -96,7 +90,7 @@ const scrollLoop = () => {
 | 
			
		|||
onMounted(scrollLoop)
 | 
			
		||||
 | 
			
		||||
whenever(
 | 
			
		||||
  () => tracks.value.length === 0,
 | 
			
		||||
  () => queue.value.length === 0,
 | 
			
		||||
  () => store.commit('ui/queueFocused', null),
 | 
			
		||||
  { immediate: true }
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			@ -107,12 +101,12 @@ 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
 | 
			
		||||
  currentTime.value = time
 | 
			
		||||
  seekTo(time)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const play = (index: unknown) => {
 | 
			
		||||
  store.dispatch('queue/currentIndex', index as number)
 | 
			
		||||
  resume()
 | 
			
		||||
const play = async (index: number) => {
 | 
			
		||||
  isPlaying.value = true
 | 
			
		||||
  return playTrack(index)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const queueItems = computed(() => queue.value.map((track, index) => ({
 | 
			
		||||
| 
						 | 
				
			
			@ -152,22 +146,9 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
          <div class="cover-container">
 | 
			
		||||
            <div class="cover">
 | 
			
		||||
              <img
 | 
			
		||||
                v-if="currentTrack.cover && currentTrack.cover.urls.large_square_crop"
 | 
			
		||||
                ref="cover"
 | 
			
		||||
                alt=""
 | 
			
		||||
                :src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.large_square_crop)"
 | 
			
		||||
              >
 | 
			
		||||
              <img
 | 
			
		||||
                v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls.large_square_crop"
 | 
			
		||||
                ref="cover"
 | 
			
		||||
                alt=""
 | 
			
		||||
                :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.large_square_crop)"
 | 
			
		||||
              >
 | 
			
		||||
              <img
 | 
			
		||||
                v-else
 | 
			
		||||
                class="ui image"
 | 
			
		||||
                alt=""
 | 
			
		||||
                src="../assets/audio/default-cover.png"
 | 
			
		||||
                :src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
 | 
			
		||||
              >
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -182,17 +163,17 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
              <div class="sub header ellipsis">
 | 
			
		||||
                <router-link
 | 
			
		||||
                  class="discrete link artist"
 | 
			
		||||
                  :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"
 | 
			
		||||
                  :to="{name: 'library.artists.detail', params: {id: currentTrack.artistId }}"
 | 
			
		||||
                >
 | 
			
		||||
                  {{ currentTrack.artist.name }}
 | 
			
		||||
                  {{ currentTrack.artistName }}
 | 
			
		||||
                </router-link>
 | 
			
		||||
                <template v-if="currentTrack.album">
 | 
			
		||||
                <template v-if="currentTrack.albumId !== -1">
 | 
			
		||||
                  /
 | 
			
		||||
                  <router-link
 | 
			
		||||
                    class="discrete link album"
 | 
			
		||||
                    :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"
 | 
			
		||||
                    :to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
 | 
			
		||||
                  >
 | 
			
		||||
                    {{ currentTrack.album.title }}
 | 
			
		||||
                    {{ currentTrack.albumTitle }}
 | 
			
		||||
                  </router-link>
 | 
			
		||||
                </template>
 | 
			
		||||
              </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -207,7 +188,7 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
                The track cannot be loaded
 | 
			
		||||
              </translate>
 | 
			
		||||
            </h3>
 | 
			
		||||
            <p v-if="hasNext && playing && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors">
 | 
			
		||||
            <p v-if="hasNext && isPlaying && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors">
 | 
			
		||||
              <translate translate-context="Sidebar/Player/Error message.Paragraph">
 | 
			
		||||
                The next track will play automatically in a few seconds…
 | 
			
		||||
              </translate>
 | 
			
		||||
| 
						 | 
				
			
			@ -220,7 +201,8 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="additional-controls desktop-and-below">
 | 
			
		||||
            <track-favorite-icon
 | 
			
		||||
            <!-- TODO (wvffle): Update props -->
 | 
			
		||||
            <!-- <track-favorite-icon
 | 
			
		||||
              v-if="$store.state.auth.authenticated"
 | 
			
		||||
              :track="currentTrack"
 | 
			
		||||
            />
 | 
			
		||||
| 
						 | 
				
			
			@ -236,7 +218,7 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
              @click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
 | 
			
		||||
            >
 | 
			
		||||
              <i :class="['eye slash outline', 'basic', 'icon']" />
 | 
			
		||||
            </button>
 | 
			
		||||
            </button> -->
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="progress-wrapper">
 | 
			
		||||
            <div
 | 
			
		||||
| 
						 | 
				
			
			@ -277,8 +259,10 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
                  :aria-label="labels.restart"
 | 
			
		||||
                  class="left floated timer discrete start"
 | 
			
		||||
                  @click.prevent="currentTime = 0"
 | 
			
		||||
                >{{ currentTimeFormatted }}</a>
 | 
			
		||||
                <span class="right floated timer total">{{ durationFormatted }}</span>
 | 
			
		||||
                >
 | 
			
		||||
                  {{ 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>
 | 
			
		||||
| 
						 | 
				
			
			@ -286,49 +270,7 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
              </template>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="player-controls desktop-and-below">
 | 
			
		||||
            <span
 | 
			
		||||
              role="button"
 | 
			
		||||
              :title="labels.previous"
 | 
			
		||||
              :aria-label="labels.previous"
 | 
			
		||||
              class="control"
 | 
			
		||||
              :disabled="emptyQueue || null"
 | 
			
		||||
              @click.prevent.stop="previous"
 | 
			
		||||
            >
 | 
			
		||||
              <i :class="['ui', 'backward step', {'disabled': emptyQueue}, 'icon']" />
 | 
			
		||||
            </span>
 | 
			
		||||
 | 
			
		||||
            <span
 | 
			
		||||
              v-if="!playing"
 | 
			
		||||
              role="button"
 | 
			
		||||
              :title="labels.play"
 | 
			
		||||
              :aria-label="labels.play"
 | 
			
		||||
              class="control"
 | 
			
		||||
              @click.prevent.stop="resume"
 | 
			
		||||
            >
 | 
			
		||||
              <i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']" />
 | 
			
		||||
            </span>
 | 
			
		||||
            <span
 | 
			
		||||
              v-else
 | 
			
		||||
              role="button"
 | 
			
		||||
              :title="labels.pause"
 | 
			
		||||
              :aria-label="labels.pause"
 | 
			
		||||
              class="control"
 | 
			
		||||
              @click.prevent.stop="pause"
 | 
			
		||||
            >
 | 
			
		||||
              <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']" />
 | 
			
		||||
            </span>
 | 
			
		||||
            <span
 | 
			
		||||
              role="button"
 | 
			
		||||
              :title="labels.next"
 | 
			
		||||
              :aria-label="labels.next"
 | 
			
		||||
              class="control"
 | 
			
		||||
              :disabled="hasNext || null"
 | 
			
		||||
              @click.prevent.stop="next"
 | 
			
		||||
            >
 | 
			
		||||
              <i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" />
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
          <player-controls class="desktop-and-below" />
 | 
			
		||||
        </template>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="queue">
 | 
			
		||||
| 
						 | 
				
			
			@ -345,7 +287,7 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
              </button>
 | 
			
		||||
              <button
 | 
			
		||||
                class="ui right floated basic button danger"
 | 
			
		||||
                @click="clear"
 | 
			
		||||
                @click="tracks.length = 0"
 | 
			
		||||
              >
 | 
			
		||||
                <translate translate-context="*/Queue/*/Verb">
 | 
			
		||||
                  Clear
 | 
			
		||||
| 
						 | 
				
			
			@ -356,7 +298,7 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
                <div>
 | 
			
		||||
                  <translate
 | 
			
		||||
                    translate-context="Sidebar/Queue/Text"
 | 
			
		||||
                    :translate-params="{index: currentIndex + 1, length: tracks.length}"
 | 
			
		||||
                    :translate-params="{index: currentIndex + 1, length: queue.length}"
 | 
			
		||||
                  >
 | 
			
		||||
                    Track %{ index } of %{ length }
 | 
			
		||||
                  </translate>
 | 
			
		||||
| 
						 | 
				
			
			@ -387,7 +329,7 @@ const reorderTracks = async (from: number, to: number) => {
 | 
			
		|||
              :source="item"
 | 
			
		||||
              :class="[...classList, currentIndex === index && 'active']"
 | 
			
		||||
              @play="play"
 | 
			
		||||
              @remove="removeTrack"
 | 
			
		||||
              @remove="dequeue"
 | 
			
		||||
            />
 | 
			
		||||
          </template>
 | 
			
		||||
        </virtual-list>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
import type { QueueItemSource } from '~/types'
 | 
			
		||||
 | 
			
		||||
import time from '~/utils/time'
 | 
			
		||||
 | 
			
		||||
interface Events {
 | 
			
		||||
  (e: 'play', index: number): void
 | 
			
		||||
  (e: 'remove', index: number): void
 | 
			
		||||
| 
						 | 
				
			
			@ -47,7 +49,7 @@ defineProps<Props>()
 | 
			
		|||
    </div>
 | 
			
		||||
    <div class="duration-cell">
 | 
			
		||||
      <template v-if="source.sources.length > 0">
 | 
			
		||||
        {{ source.duration }}
 | 
			
		||||
        {{ time.parse(Math.round(source.sources[0].duration ?? 0)) }}
 | 
			
		||||
      </template>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="controls">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,9 +17,7 @@ import {
 | 
			
		|||
} from '~/composables/audio/player'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  hasPrevious,
 | 
			
		||||
  playPrevious,
 | 
			
		||||
  hasNext,
 | 
			
		||||
  playNext,
 | 
			
		||||
  queue,
 | 
			
		||||
  currentIndex,
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +36,7 @@ import time from '~/utils/time'
 | 
			
		|||
// import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
 | 
			
		||||
// import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
 | 
			
		||||
import VolumeControl from './VolumeControl.vue'
 | 
			
		||||
import PlayerControls from './PlayerControls.vue'
 | 
			
		||||
 | 
			
		||||
const store = useStore()
 | 
			
		||||
const { $pgettext } = useGettext()
 | 
			
		||||
| 
						 | 
				
			
			@ -104,8 +103,6 @@ const loopingTitle = computed(() => {
 | 
			
		|||
      ? $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping on a single track. Click to switch to whole queue looping.')
 | 
			
		||||
      : $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping on whole queue. Click to disable looping.')
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.value)))
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
| 
						 | 
				
			
			@ -233,45 +230,7 @@ const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.va
 | 
			
		|||
            <i :class="['eye slash outline', 'basic', 'icon']" />
 | 
			
		||||
          </button> -->
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="player-controls controls queue-not-focused">
 | 
			
		||||
          <button
 | 
			
		||||
            :title="labels.previous"
 | 
			
		||||
            :aria-label="labels.previous"
 | 
			
		||||
            :disabled="!hasPrevious"
 | 
			
		||||
            class="circular button control tablet-and-up"
 | 
			
		||||
            @click.prevent.stop="playPrevious"
 | 
			
		||||
          >
 | 
			
		||||
            <i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" />
 | 
			
		||||
          </button>
 | 
			
		||||
          <button
 | 
			
		||||
            v-if="!isPlaying"
 | 
			
		||||
            :title="labels.play"
 | 
			
		||||
            :aria-label="labels.play"
 | 
			
		||||
            class="circular button control"
 | 
			
		||||
            @click.prevent.stop="isPlaying = true"
 | 
			
		||||
          >
 | 
			
		||||
            <i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
 | 
			
		||||
          </button>
 | 
			
		||||
          <button
 | 
			
		||||
            v-else
 | 
			
		||||
            :title="labels.pause"
 | 
			
		||||
            :aria-label="labels.pause"
 | 
			
		||||
            class="circular button control"
 | 
			
		||||
            @click.prevent.stop="isPlaying = false"
 | 
			
		||||
          >
 | 
			
		||||
            <i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
 | 
			
		||||
          </button>
 | 
			
		||||
          <button
 | 
			
		||||
            :title="labels.next"
 | 
			
		||||
            :aria-label="labels.next"
 | 
			
		||||
            :disabled="!hasNext"
 | 
			
		||||
            class="circular button control"
 | 
			
		||||
            @click.prevent.stop="playNext"
 | 
			
		||||
          >
 | 
			
		||||
            <i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <player-controls class="controls queue-not-focused" />
 | 
			
		||||
        <div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
 | 
			
		||||
          <div class="timer">
 | 
			
		||||
            <template v-if="!isLoadingAudio">
 | 
			
		||||
| 
						 | 
				
			
			@ -279,7 +238,7 @@ const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.va
 | 
			
		|||
                class="start"
 | 
			
		||||
                @click.stop.prevent="seekTo(0)"
 | 
			
		||||
              >
 | 
			
		||||
                {{ currentTimeFormatted }}
 | 
			
		||||
                {{ time.parse(Math.round(currentTime)) }}
 | 
			
		||||
              </span>
 | 
			
		||||
              |
 | 
			
		||||
              <span class="total">{{ time.parse(Math.round(duration)) }}</span>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,62 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
import { useGettext } from 'vue3-gettext'
 | 
			
		||||
import { computed } from 'vue'
 | 
			
		||||
 | 
			
		||||
import { isPlaying } from '~/composables/audio/player'
 | 
			
		||||
import {
 | 
			
		||||
  hasPrevious,
 | 
			
		||||
  playPrevious,
 | 
			
		||||
  hasNext,
 | 
			
		||||
  playNext,
 | 
			
		||||
  currentTrack
 | 
			
		||||
} from '~/composables/audio/queue'
 | 
			
		||||
 | 
			
		||||
const { $pgettext } = useGettext()
 | 
			
		||||
const labels = computed(() => ({
 | 
			
		||||
  previous: $pgettext('Sidebar/Player/Icon.Tooltip', 'Previous track'),
 | 
			
		||||
  play: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play'),
 | 
			
		||||
  pause: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause'),
 | 
			
		||||
  next: $pgettext('Sidebar/Player/Icon.Tooltip', 'Next track')
 | 
			
		||||
}))
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="player-controls">
 | 
			
		||||
    <button
 | 
			
		||||
      :title="labels.previous"
 | 
			
		||||
      :aria-label="labels.previous"
 | 
			
		||||
      :disabled="!hasPrevious"
 | 
			
		||||
      class="circular button control tablet-and-up"
 | 
			
		||||
      @click.prevent.stop="playPrevious()"
 | 
			
		||||
    >
 | 
			
		||||
      <i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" />
 | 
			
		||||
    </button>
 | 
			
		||||
    <button
 | 
			
		||||
      v-if="!isPlaying"
 | 
			
		||||
      :title="labels.play"
 | 
			
		||||
      :aria-label="labels.play"
 | 
			
		||||
      class="circular button control"
 | 
			
		||||
      @click.prevent.stop="isPlaying = true"
 | 
			
		||||
    >
 | 
			
		||||
      <i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
 | 
			
		||||
    </button>
 | 
			
		||||
    <button
 | 
			
		||||
      v-else
 | 
			
		||||
      :title="labels.pause"
 | 
			
		||||
      :aria-label="labels.pause"
 | 
			
		||||
      class="circular button control"
 | 
			
		||||
      @click.prevent.stop="isPlaying = false"
 | 
			
		||||
    >
 | 
			
		||||
      <i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
 | 
			
		||||
    </button>
 | 
			
		||||
    <button
 | 
			
		||||
      :title="labels.next"
 | 
			
		||||
      :aria-label="labels.next"
 | 
			
		||||
      :disabled="!hasNext"
 | 
			
		||||
      class="circular button control"
 | 
			
		||||
      @click.prevent.stop="playNext()"
 | 
			
		||||
    >
 | 
			
		||||
      <i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import { tryOnMounted, useIntervalFn, useRafFn, useStorage, whenever } from '@vueuse/core'
 | 
			
		||||
import { currentTrack, currentIndex } from '~/composables/audio/queue'
 | 
			
		||||
import { tryOnMounted, useIntervalFn, useRafFn, useStorage, useTimeoutFn, whenever } from '@vueuse/core'
 | 
			
		||||
import { currentTrack, currentIndex, playNext } from '~/composables/audio/queue'
 | 
			
		||||
import { currentSound, createTrack } from '~/composables/audio/tracks'
 | 
			
		||||
import { computed, ref, watch, watchEffect, type Ref } from 'vue'
 | 
			
		||||
import { setGain } from './audio-api'
 | 
			
		||||
| 
						 | 
				
			
			@ -168,3 +168,14 @@ export const loading = computed(() => {
 | 
			
		|||
  if (!sound) return false
 | 
			
		||||
  return !sound.isLoaded.value
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// Errored
 | 
			
		||||
export const errored = computed(() => {
 | 
			
		||||
  const sound = currentSound.value
 | 
			
		||||
  if (!sound) return false
 | 
			
		||||
  return sound.isErrored.value
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const { start, stop } = useTimeoutFn(() => playNext(), 3000, { immediate: false })
 | 
			
		||||
watch(currentIndex, stop)
 | 
			
		||||
whenever(errored, start)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import type { Track, Upload } from '~/types'
 | 
			
		||||
 | 
			
		||||
import { computedAsync, useStorage } from '@vueuse/core'
 | 
			
		||||
import { shuffle as shuffleArray, uniq } from 'lodash-es'
 | 
			
		||||
import { computedAsync, useNow, useStorage, useTimeAgo } from '@vueuse/core'
 | 
			
		||||
import { shuffle as shuffleArray, sum, uniq } from 'lodash-es'
 | 
			
		||||
import { getMany, setMany } from 'idb-keyval'
 | 
			
		||||
import { useClamp } from '@vueuse/math'
 | 
			
		||||
import { computed } from 'vue'
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +76,7 @@ const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
 | 
			
		|||
      ?? new URL('~/assets/audio/default-cover.png', import.meta.url).href,
 | 
			
		||||
    sources: track.uploads.map(upload => ({
 | 
			
		||||
      uuid: upload.uuid,
 | 
			
		||||
      duration: upload.duration,
 | 
			
		||||
      mimetype: upload.mimetype,
 | 
			
		||||
      bitrate: upload.bitrate,
 | 
			
		||||
      url: upload.listen_url
 | 
			
		||||
| 
						 | 
				
			
			@ -83,6 +84,7 @@ const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Adding tracks
 | 
			
		||||
export const enqueue = async (...newTracks: Track[]) => {
 | 
			
		||||
  const queueTracks = await Promise.all(newTracks.map(createQueueTrack))
 | 
			
		||||
  await setMany(queueTracks.map(track => [track.id, track]))
 | 
			
		||||
| 
						 | 
				
			
			@ -96,6 +98,21 @@ export const enqueue = async (...newTracks: Track[]) => {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Removing tracks
 | 
			
		||||
export const dequeue = async (index: number) => {
 | 
			
		||||
  if (currentIndex.value === index) {
 | 
			
		||||
    await playNext(true)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  tracks.value.splice(index, 1)
 | 
			
		||||
 | 
			
		||||
  if (index <= currentIndex.value) {
 | 
			
		||||
    currentIndex.value -= 1
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // TODO (wvffle): Check if removing last element works well
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Current Index
 | 
			
		||||
export const currentIndex = useClamp(useStorage('queue:index', 0), 0, () => tracks.value.length)
 | 
			
		||||
export const currentTrack = computed(() => queue.value[currentIndex.value])
 | 
			
		||||
| 
						 | 
				
			
			@ -119,11 +136,11 @@ export const playTrack = async (trackIndex: number, force = false) => {
 | 
			
		|||
 | 
			
		||||
// Previous track
 | 
			
		||||
export const hasPrevious = computed(() => /* looping.value === LoopingMode.LoopQueue || */ currentIndex.value !== 0)
 | 
			
		||||
export const playPrevious = async () => {
 | 
			
		||||
export const playPrevious = async (force = false) => {
 | 
			
		||||
  const { looping, LoopingMode } = await import('~/composables/audio/player')
 | 
			
		||||
 | 
			
		||||
  // Loop entire queue / change track to the next one
 | 
			
		||||
  if (looping.value === LoopingMode.LoopQueue && currentIndex.value === 0) {
 | 
			
		||||
  if (looping.value === LoopingMode.LoopQueue && currentIndex.value === 0 && force !== true) {
 | 
			
		||||
    // Loop track programmatically if it is the only track in the queue
 | 
			
		||||
    if (tracks.value.length === 1) return playTrack(currentIndex.value, true)
 | 
			
		||||
    return playTrack(tracks.value.length - 1)
 | 
			
		||||
| 
						 | 
				
			
			@ -134,11 +151,11 @@ export const playPrevious = async () => {
 | 
			
		|||
 | 
			
		||||
// Next track
 | 
			
		||||
export const hasNext = computed(() => /* looping.value === LoopingMode.LoopQueue || */ currentIndex.value !== tracks.value.length - 1)
 | 
			
		||||
export const playNext = async () => {
 | 
			
		||||
export const playNext = async (force = false) => {
 | 
			
		||||
  const { looping, LoopingMode } = await import('~/composables/audio/player')
 | 
			
		||||
 | 
			
		||||
  // Loop entire queue / change track to the next one
 | 
			
		||||
  if (looping.value === LoopingMode.LoopQueue && currentIndex.value === tracks.value.length - 1) {
 | 
			
		||||
  if (looping.value === LoopingMode.LoopQueue && currentIndex.value === tracks.value.length - 1 && force !== true) {
 | 
			
		||||
    // Loop track programmatically if it is the only track in the queue
 | 
			
		||||
    if (tracks.value.length === 1) return playTrack(currentIndex.value, true)
 | 
			
		||||
    return playTrack(0)
 | 
			
		||||
| 
						 | 
				
			
			@ -147,6 +164,28 @@ export const playNext = async () => {
 | 
			
		|||
  return playTrack(currentIndex.value + 1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Reorder
 | 
			
		||||
export const reorder = (from: number, to: number) => {
 | 
			
		||||
  const [id] = tracks.value.splice(from, 1)
 | 
			
		||||
  tracks.value.splice(to, 0, id)
 | 
			
		||||
 | 
			
		||||
  const current = currentIndex.value
 | 
			
		||||
  if (current === from) {
 | 
			
		||||
    currentIndex.value = to
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (from < current && to >= current) {
 | 
			
		||||
    // item before was moved after
 | 
			
		||||
    currentIndex.value -= 1
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (from > current && to <= current) {
 | 
			
		||||
    // item after was moved before
 | 
			
		||||
    currentIndex.value += 1
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Shuffle
 | 
			
		||||
const shuffledIds = useStorage('queue:tracks:shuffled', [] as number[])
 | 
			
		||||
export const isShuffled = computed(() => shuffledIds.value.length !== 0)
 | 
			
		||||
| 
						 | 
				
			
			@ -158,3 +197,17 @@ export const shuffle = () => {
 | 
			
		|||
 | 
			
		||||
  shuffledIds.value = shuffleArray(tracks.value)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Ends in
 | 
			
		||||
const now = useNow()
 | 
			
		||||
export const endsIn = useTimeAgo(computed(() => {
 | 
			
		||||
  const seconds = sum(
 | 
			
		||||
    queue.value
 | 
			
		||||
      .slice(currentIndex.value)
 | 
			
		||||
      .map((track) => track.sources[0]?.duration ?? 0)
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const date = new Date(now.value)
 | 
			
		||||
  date.setSeconds(date.getSeconds() + seconds)
 | 
			
		||||
  return date
 | 
			
		||||
}))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -207,6 +207,13 @@
 | 
			
		|||
    .icon {
 | 
			
		||||
      font-size: 1.1em;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    button {
 | 
			
		||||
      padding: 0;
 | 
			
		||||
      border: none;
 | 
			
		||||
      background-color: transparent;
 | 
			
		||||
      color: inherit;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .handle {
 | 
			
		||||
| 
						 | 
				
			
			@ -349,4 +356,4 @@
 | 
			
		|||
.drag-container:not(.dragging) .hover .queue-item {
 | 
			
		||||
  background: rgba(0,0,0,.05);
 | 
			
		||||
  color: #000000f2;
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Ładowanie…
	
		Reference in New Issue