346 lines
8.5 KiB
Vue
346 lines
8.5 KiB
Vue
<script setup lang="ts">
|
|
import type { PlaylistTrack, Playlist, Track } from '~/types'
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
import { ref, computed } from 'vue'
|
|
import { useStore } from '~/store'
|
|
|
|
import axios from 'axios'
|
|
|
|
import defaultCover from '~/assets/audio/default-cover.png'
|
|
import PlaylistEditor from '~/components/playlists/Editor.vue'
|
|
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
|
import HumanDate from '~/components/common/HumanDate.vue'
|
|
import TrackTable from '~/components/audio/track/Table.vue'
|
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
import Layout from '~/components/ui/Layout.vue'
|
|
import Header from '~/components/ui/Header.vue'
|
|
import Spacer from '~/components/ui/Spacer.vue'
|
|
import Loader from '~/components/ui/Loader.vue'
|
|
import Button from '~/components/ui/Button.vue'
|
|
import Modal from '~/components/ui/Modal.vue'
|
|
import Alert from '~/components/ui/Alert.vue'
|
|
|
|
import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue'
|
|
|
|
import useErrorHandler from '~/composables/useErrorHandler'
|
|
|
|
// TODO: Is this event ever caught somewhere?
|
|
// interface Events {
|
|
// (e: 'libraries-loaded', libraries: Library[]): void
|
|
// }
|
|
|
|
interface Props {
|
|
id: string
|
|
defaultEdit?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
defaultEdit: false
|
|
})
|
|
|
|
const store = useStore()
|
|
const isLoadingMoreTracks = ref(false)
|
|
const edit = ref(props.defaultEdit)
|
|
const playlist = ref<Playlist | null>(null)
|
|
const playlistTracks = ref<PlaylistTrack[]>([])
|
|
const showEmbedModal = ref(false)
|
|
|
|
type FullPlaylistTrack = Omit<PlaylistTrack, 'track'> & { track: Track }
|
|
const fullPlaylistTracks = ref<FullPlaylistTrack[]>([])
|
|
|
|
const tracks = computed(() => fullPlaylistTracks.value.map(({ track }, index) => ({ ...track as Track, position: index + 1 })))
|
|
|
|
const { t } = useI18n()
|
|
const labels = computed(() => ({
|
|
playlist: t('views.playlists.Detail.title')
|
|
}))
|
|
|
|
const isLoading = ref(false)
|
|
const nextPage = ref<string | null>(null) // Tracks the next page URL
|
|
const previousPage = ref<string | null>(null) // Tracks the previous page URL
|
|
const totalTracks = ref<number>(0) // Total number of tracks
|
|
|
|
const fetchData = async () => {
|
|
isLoading.value = true
|
|
|
|
try {
|
|
const [playlistResponse, tracksResponse] = await Promise.all([
|
|
axios.get(`playlists/${props.id}/`),
|
|
axios.get(`playlists/${props.id}/tracks?page=1`)
|
|
])
|
|
|
|
playlist.value = playlistResponse.data
|
|
fullPlaylistTracks.value = tracksResponse.data.results
|
|
nextPage.value = tracksResponse.data.next
|
|
previousPage.value = tracksResponse.data.previous
|
|
totalTracks.value = tracksResponse.data.count
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
isLoading.value = false
|
|
}
|
|
|
|
const loadMoreTracks = async () => {
|
|
if (nextPage.value) {
|
|
isLoadingMoreTracks.value = true; // Set loading state for the button
|
|
try {
|
|
const response = await axios.get(nextPage.value);
|
|
|
|
// Append new tracks to the existing list
|
|
fullPlaylistTracks.value = [...fullPlaylistTracks.value, ...response.data.results];
|
|
|
|
// Update pagination metadata
|
|
nextPage.value = response.data.next;
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
} finally {
|
|
isLoadingMoreTracks.value = false; // Reset loading state
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchData()
|
|
|
|
const images = computed(() => {
|
|
const urls = playlist.value?.album_covers.slice(0, 4).map(url => store.getters['instance/absoluteUrl'](url)) || []
|
|
|
|
while (urls.length < 4) {
|
|
urls.push(defaultCover)
|
|
}
|
|
|
|
return urls
|
|
})
|
|
|
|
const bgcolors = ref([
|
|
'#f2efef',
|
|
'#eee9e9',
|
|
'#ddd9d9',
|
|
'#cfcaca',
|
|
'#b3afaf',
|
|
'#908888',
|
|
'#605656',
|
|
'#4d4547',
|
|
'#292525',
|
|
'#403a3b',
|
|
'#322f2f'
|
|
])
|
|
|
|
function shuffleArray (array: string[]): string[] {
|
|
return [...array].sort(() => Math.random() - 0.5)
|
|
}
|
|
|
|
const randomizedColors = computed(() => shuffleArray(bgcolors.value))
|
|
|
|
// TODO: Check if this ref is still needed
|
|
// const updatedTitle = computed(() => {
|
|
// const date = momentFormat(new Date(playlist.value?.modification_date ?? '1970-01-01'))
|
|
// return t('components.audio.ChannelCard.title', { date })
|
|
// })
|
|
|
|
// TODO: Implement shuffle
|
|
const shuffle = () => {}
|
|
</script>
|
|
|
|
<template>
|
|
<Loader
|
|
v-if="isLoading"
|
|
v-title="labels.playlist"
|
|
/>
|
|
<Header
|
|
v-if="!isLoading && playlist"
|
|
:h1="playlist.name"
|
|
page-heading
|
|
>
|
|
<template #image>
|
|
<div class="playlist-grid">
|
|
<img
|
|
v-for="(url, idx) in images"
|
|
:key="idx"
|
|
v-lazy="url"
|
|
:alt="playlist.name"
|
|
:style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }"
|
|
>
|
|
</div>
|
|
</template>
|
|
<Layout
|
|
gap-4
|
|
class="meta"
|
|
>
|
|
<Layout
|
|
flex
|
|
gap-4
|
|
>
|
|
{{ playlist.tracks_count }}
|
|
{{ t('views.playlists.Detail.header.tracks') }}
|
|
<i class="bi bi-dot" />
|
|
<Duration :seconds="playlist.duration" />
|
|
</Layout>
|
|
<Layout
|
|
flex
|
|
gap-4
|
|
>
|
|
{{ t('views.playlists.Detail.meta.attribution') }}
|
|
{{ playlist.actor.full_username }}
|
|
<i class="bi bi-dot" />
|
|
{{ t('views.playlists.Detail.meta.updated') }}
|
|
<HumanDate
|
|
:date="playlist.modification_date"
|
|
/>
|
|
</Layout>
|
|
</Layout>
|
|
<RenderedDescription
|
|
:content="{ html: playlist.description }"
|
|
:truncate-length="100"
|
|
/>
|
|
<Layout
|
|
flex
|
|
class="header-buttons"
|
|
>
|
|
<PlayButton
|
|
split
|
|
low-height
|
|
:is-playable="true"
|
|
:tracks="tracks"
|
|
>
|
|
{{ t('views.playlists.Detail.button.playAll') }}
|
|
</PlayButton>
|
|
<Button
|
|
v-if="playlist.tracks_count > 1"
|
|
primary
|
|
icon="bi-shuffle"
|
|
low-height
|
|
:aria-label="t('components.audio.Player.label.shuffleQueue')"
|
|
@click.prevent.stop="shuffle()"
|
|
>
|
|
{{ t('components.audio.Player.label.shuffleQueue') }}
|
|
</Button>
|
|
<Button
|
|
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
|
|
secondary
|
|
low-height
|
|
icon="bi-pencil"
|
|
@click="edit = !edit"
|
|
>
|
|
<template v-if="edit">
|
|
{{ t('views.playlists.Detail.button.stopEdit') }}
|
|
</template>
|
|
<template v-else>
|
|
{{ t('views.playlists.Detail.button.edit') }}
|
|
</template>
|
|
</Button>
|
|
<Spacer
|
|
h
|
|
grow
|
|
/>
|
|
<playlist-dropdown
|
|
:playlist="playlist"
|
|
@import="fetchData"
|
|
/>
|
|
</Layout>
|
|
</Header>
|
|
|
|
<Layout stack>
|
|
<template v-if="edit">
|
|
<playlist-editor
|
|
v-model:playlist="playlist"
|
|
v-model:playlist-tracks="playlistTracks"
|
|
/>
|
|
</template>
|
|
<template v-else-if="tracks.length > 0">
|
|
<track-table
|
|
:show-position="true"
|
|
:tracks="tracks"
|
|
:unique="false"
|
|
/>
|
|
<Button
|
|
v-if="nextPage"
|
|
primary
|
|
:is-loading="isLoadingMoreTracks"
|
|
@click="loadMoreTracks"
|
|
>
|
|
{{ t('views.playlists.Detail.button.loadMoreTracks') }}
|
|
</Button>
|
|
</template>
|
|
<Alert
|
|
v-else-if="!isLoading"
|
|
blue
|
|
align-items="center"
|
|
>
|
|
<Layout
|
|
flex
|
|
:gap="8"
|
|
>
|
|
<i class="bi bi-music-note-list" />
|
|
{{ t('views.playlists.Detail.empty.noTracks') }}
|
|
</Layout>
|
|
<Spacer size-16 />
|
|
<Button
|
|
primary
|
|
icon="bi-pencil"
|
|
align-self="center"
|
|
@click="edit = !edit"
|
|
>
|
|
{{ t('views.playlists.Detail.button.edit') }}
|
|
</Button>
|
|
</Alert>
|
|
</Layout>
|
|
|
|
<Modal
|
|
v-if="playlist?.privacy_level === 'everyone' && playlist?.is_playable"
|
|
v-model="showEmbedModal"
|
|
title="t('views.playlists.Detail.modal.embed.header')"
|
|
>
|
|
<div class="scrolling content">
|
|
<div class="description">
|
|
<embed-wizard
|
|
:uuid="playlist.uuid"
|
|
type="playlist"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<template #actions>
|
|
<Button variant="outline">
|
|
{{ t('views.playlists.Detail.button.cancel') }}
|
|
</Button>
|
|
</template>
|
|
</Modal>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
.playlist-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
grid-template-rows: repeat(2, 1fr);
|
|
gap: 2px;
|
|
width: 200px;
|
|
height: 200px;
|
|
}
|
|
|
|
.playlist-grid img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.meta {
|
|
font-size: 15px;
|
|
@include light-theme {
|
|
color: var(--fw-gray-700);
|
|
}
|
|
@include dark-theme {
|
|
color: var(--fw-gray-500);
|
|
}
|
|
}
|
|
|
|
.playlist-action {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 0 8px;
|
|
}
|
|
</style>
|