diff --git a/changes/changelog.d/572.enhancement b/changes/changelog.d/572.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..cb75911db6bbe484c220c45548dfe73ab8f97231 --- /dev/null +++ b/changes/changelog.d/572.enhancement @@ -0,0 +1 @@ +Preload next track in queue (#572) diff --git a/front/src/EmbedFrame.vue b/front/src/EmbedFrame.vue index 7dcc371ea5ccefe07881dbcd630bcc2b428249fe..90aa95e7d8c4b46732f3bda4739066c2c2248db2 100644 --- a/front/src/EmbedFrame.vue +++ b/front/src/EmbedFrame.vue @@ -357,7 +357,8 @@ export default { this.$nextTick(() => { self.bindEvents() if (self.tracks.length > 0) { - var topPos = document.getElementById(`queue-item-${v}`).offsetTop; + let el = document.getElementById(`queue-item-${v}`); + var topPos = el.offsetTop; document.getElementById('queue').scrollTop = topPos-10; } }) diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 74d78fc31f62f74b518eaa3bc7936cef02e33015..dd0f921d8b2c099e94e410b64064072dcf5e6bc4 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -1,16 +1,6 @@ <template> <section class="ui inverted segment player-wrapper" :aria-label="labels.audioPlayer" :style="style"> <div class="player"> - <audio-track - ref="currentAudio" - v-if="currentTrack" - @errored="handleError" - :is-current="true" - :start-time="$store.state.player.currentTime" - :autoplay="$store.state.player.playing" - :key="audioKey" - :track="currentTrack"> - </audio-track> <div v-if="currentTrack" class="track-area ui unstackable items"> <div class="ui inverted item"> <div class="ui tiny image"> @@ -53,7 +43,7 @@ <div class="progress-area" v-if="currentTrack && !errored"> <div class="ui grid"> <div class="left floated four wide column"> - <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p> + <p class="timer start" @click="setCurrentTime(0)">{{currentTimeFormatted}}</p> </div> <div v-if="!isLoadingAudio" class="right floated four wide column"> @@ -230,8 +220,10 @@ import GlobalEvents from "@/components/utils/global-events" import ColorThief from "@/vendor/color-thief" import { Howl } from "howler" import $ from 'jquery' +import _ from '@/lodash' +import url from '@/utils/url' +import axios from 'axios' -import AudioTrack from "@/components/audio/Track" import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon" import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon" @@ -240,7 +232,6 @@ export default { TrackFavoriteIcon, TrackPlaylistIcon, GlobalEvents, - AudioTrack }, data() { let defaultAmbiantColors = [ @@ -255,8 +246,14 @@ export default { defaultAmbiantColors: defaultAmbiantColors, showVolume: false, ambiantColors: defaultAmbiantColors, - audioKey: String(new Date()), - dummyAudio: null + currentSound: null, + dummyAudio: null, + isUpdatingTime: false, + sourceErrors: 0, + progressInterval: null, + maxPreloaded: 3, + preloadDelay: 15, + soundsCache: [], } }, mounted() { @@ -270,9 +267,13 @@ export default { autoplay: false, src: ["noop.webm", "noop.mp3"] }) + if (this.currentTrack) { + this.getSound(this.currentTrack) + } }, destroyed() { this.dummyAudio.unload() + this.observeProgress(false) }, methods: { ...mapActions({ @@ -280,8 +281,24 @@ export default { mute: "player/mute", unmute: "player/unmute", clean: "queue/clean", - updateProgress: "player/updateProgress" }), + async getTrackData (trackData) { + let data = null + if (!trackData.uploads.length || trackData.uploads.length === 0) { + // we don't have upload informations for this track, we need to fetch it + await axios.get(`tracks/${trackData.id}/`).then((response) => { + data = response.data + }, error => { + data = null + }) + } else { + return trackData + } + if (data === null) { + return + } + return data + }, shuffle() { let disabled = this.queue.tracks.length === 0 if (this.isShuffling || disabled) { @@ -316,7 +333,7 @@ export default { let time let target = this.$refs.progress time = (e.layerX / target.offsetWidth) * this.duration - this.$refs.currentAudio.setCurrentTime(time) + this.setCurrentTime(time) }, updateBackground() { // delete existing canvas, if any @@ -331,6 +348,222 @@ export default { handleError({ sound, error }) { this.$store.commit("player/isLoadingAudio", false) this.$store.dispatch("player/trackErrored") + }, + getSound (trackData) { + let cached = this.getSoundFromCache(trackData) + if (cached) { + return cached.sound + } + let srcs = this.getSrcs(trackData) + let self = this + let sound = new Howl({ + src: srcs.map((s) => { return s.url }), + format: srcs.map((s) => { return s.type }), + autoplay: false, + loop: false, + html5: true, + preload: true, + volume: this.volume, + onend: function () { + self.ended() + }, + onunlock: function () { + if (this.$store.state.player.playing) { + self.sound.play() + } + }, + onload: function () { + let sound = this + let node = this._sounds[0]._node; + node.addEventListener('progress', () => { + if (sound != self.currentSound) { + return + } + self.updateBuffer(node) + }) + }, + onplay: function () { + self.$store.commit('player/isLoadingAudio', false) + self.$store.commit('player/resetErrorCount') + self.$store.commit('player/errored', false) + self.$store.commit('player/duration', this.duration()) + }, + onloaderror: function (sound, error) { + if (this != self.currentSound) { + return + } + console.log('Error while playing:', sound, error) + self.handleError({sound, error}) + }, + }) + this.addSoundToCache(sound, trackData) + return sound + }, + getSrcs: function (trackData) { + let sources = trackData.uploads.map(u => { + return { + type: u.extension, + url: this.$store.getters['instance/absoluteUrl'](u.listen_url), + } + }) + // We always add a transcoded MP3 src at the end + // because transcoding is expensive, but we want browsers that do + // not support other codecs to be able to play it :) + sources.push({ + type: 'mp3', + url: url.updateQueryString( + this.$store.getters['instance/absoluteUrl'](trackData.listen_url), + 'to', + 'mp3' + ) + }) + if (this.$store.state.auth.authenticated) { + // we need to send the token directly in url + // so authentication can be checked by the backend + // because for audio files we cannot use the regular Authentication + // header + sources.forEach(e => { + e.url = url.updateQueryString(e.url, 'jwt', this.$store.state.auth.token) + }) + } + return sources + }, + + updateBuffer (node) { + // from https://github.com/goldfire/howler.js/issues/752#issuecomment-372083163 + let range = 0; + let bf = node.buffered; + let time = node.currentTime; + try { + while(!(bf.start(range) <= time && time <= bf.end(range))) { + range += 1; + } + } catch (IndexSizeError) { + return + } + let loadPercentage + let start = bf.start(range) + let end = bf.end(range) + if (range === 0) { + // easy case, no user-seek + let loadStartPercentage = start / node.duration; + let loadEndPercentage = end / node.duration; + loadPercentage = loadEndPercentage - loadStartPercentage; + } else { + let loaded = end - start + let remainingToLoad = node.duration - start + // user seeked a specific position in the audio, our progress must be + // computed based on the remaining portion of the track + loadPercentage = loaded / remainingToLoad; + } + if (loadPercentage * 100 === this.bufferProgress) { + return + } + this.$store.commit('player/bufferProgress', loadPercentage * 100) + }, + updateProgress: function () { + this.isUpdatingTime = true + if (this.currentSound && this.currentSound.state() === 'loaded') { + let t = this.currentSound.seek() + let d = this.currentSound.duration() + this.$store.dispatch('player/updateProgress', t) + this.updateBuffer(this.currentSound._sounds[0]._node) + let toPreload = this.$store.state.queue.tracks[this.currentIndex + 1] + if (toPreload && !this.getSoundFromCache(toPreload) && (t > this.preloadDelay || d - t < 30)) { + this.getSound(toPreload) + } + } + }, + observeProgress: function (enable) { + let self = this + if (enable) { + if (self.progressInterval) { + clearInterval(self.progressInterval) + } + self.progressInterval = setInterval(() => { + self.updateProgress() + }, 1000) + } else { + clearInterval(self.progressInterval) + } + }, + setCurrentTime (t) { + if (t < 0 | t > this.duration) { + return + } + if (!this.currentSound || !this.currentSound._sounds[0]) { + return + } + if (t === this.currentSound.seek()) { + return + } + if (t === 0) { + this.updateProgressThrottled.cancel() + } + this.currentSound.seek(t) + }, + ended: function () { + let onlyTrack = this.$store.state.queue.tracks.length === 1 + if (this.looping === 1 || (onlyTrack && this.looping === 2)) { + this.currentSound.seek(0) + this.currentSound.play() + } else { + this.$store.dispatch('player/trackEnded', this.currentTrack) + } + }, + getSoundFromCache (trackData) { + return this.soundsCache.filter((d) => { + if (d.track.id !== trackData.id) { + return false + } + + return true + })[0] + }, + addSoundToCache (sound, trackData) { + let data = { + date: new Date(), + track: trackData, + sound: sound + } + this.soundsCache.push(data) + this.checkCache() + }, + checkCache () { + let self = this + let toKeep = [] + _.reverse(this.soundsCache).forEach((e) => { + if (toKeep.length < self.maxPreloaded) { + toKeep.push(e) + } else { + let src = e.sound._src + e.sound.unload() + } + }) + this.soundsCache = _.reverse(toKeep) + }, + async loadSound (newValue, oldValue) { + let trackData = newValue + let oldSound = this.currentSound + if (oldSound && trackData !== oldValue) { + oldSound.pause() + } + if (!trackData) { + return + } + if (!this.isShuffling && trackData != oldValue) { + trackData = await this.getTrackData(trackData) + if (trackData === null) { + this.handleError({}) + } + this.currentSound = this.getSound(trackData) + this.$store.commit('player/isLoadingAudio', true) + if (this.playing) { + this.currentSound.play() + this.$store.commit('player/playing', true) + this.observeProgress(true) + } + } } }, computed: { @@ -343,6 +576,7 @@ export default { duration: state => state.player.duration, bufferProgress: state => state.player.bufferProgress, errored: state => state.player.errored, + currentTime: state => state.player.currentTime, queue: state => state.queue }), ...mapGetters({ @@ -353,6 +587,9 @@ export default { currentTimeFormatted: "player/currentTimeFormatted", progress: "player/progress" }), + updateProgressThrottled () { + return _.throttle(this.updateProgress, 250) + }, labels() { let audioPlayer = this.$pgettext('Sidebar/Player/Hidden text', "Media player") let previousTrack = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Previous track") @@ -414,22 +651,45 @@ export default { }) .join(", ") return gradients - } + }, }, watch: { - currentTrack(newValue, oldValue) { - if (!this.isShuffling && newValue != oldValue) { - this.audioKey = String(new Date()) - } - if (!newValue || !newValue.album.cover) { - this.ambiantColors = this.defaultAmbiantColors - } + currentTrack: { + async handler (newValue, oldValue) { + await this.loadSound(newValue, oldValue) + if (!newValue || !trackData.album.cover) { + this.ambiantColors = this.defaultAmbiantColors + } + }, + immediate: false }, volume(newValue) { this.sliderVolume = newValue + if (this.currentSound) { + this.currentSound.volume(newValue) + } }, sliderVolume(newValue) { this.$store.commit("player/volume", newValue) + }, + playing: async function (newValue) { + if (this.currentSound) { + if (newValue === true) { + this.currentSound.play() + } else { + this.currentSound.pause() + } + } else { + await this.loadSound(this.currentTrack, null) + } + + this.observeProgress(newValue) + }, + currentTime (newValue) { + if (!this.isUpdatingTime) { + this.setCurrentTime(newValue) + } + this.isUpdatingTime = false } } } diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue deleted file mode 100644 index 2e0f8c421a0e780247224b93764b55fd476ae541..0000000000000000000000000000000000000000 --- a/front/src/components/audio/Track.vue +++ /dev/null @@ -1,225 +0,0 @@ -<template> - <i /> -</template> - -<script> -import {mapState} from 'vuex' -import _ from '@/lodash' -import url from '@/utils/url' -import {Howl} from 'howler' -import axios from 'axios' - -// import logger from '@/logging' - -export default { - props: { - track: {type: Object}, - isCurrent: {type: Boolean, default: false}, - startTime: {type: Number, default: 0}, - autoplay: {type: Boolean, default: false} - }, - data () { - return { - trackData: this.track, - sourceErrors: 0, - sound: null, - isUpdatingTime: false, - progressInterval: null - } - }, - mounted () { - let self = this - if (!this.trackData.uploads.length || this.trackData.uploads.length === 0) { - // we don't have upload informations for this track, we need to fetch it - axios.get(`tracks/${this.trackData.id}/`).then((response) => { - self.trackData = response.data - self.setupSound() - }, error => { - self.$emit('errored', {}) - }) - } else { - this.setupSound() - } - }, - destroyed () { - this.observeProgress(false) - this.sound.unload() - }, - computed: { - ...mapState({ - playing: state => state.player.playing, - currentTime: state => state.player.currentTime, - duration: state => state.player.duration, - volume: state => state.player.volume, - looping: state => state.player.looping - }), - srcs: function () { - let sources = this.trackData.uploads.map(u => { - return { - type: u.extension, - url: this.$store.getters['instance/absoluteUrl'](u.listen_url), - } - }) - // We always add a transcoded MP3 src at the end - // because transcoding is expensive, but we want browsers that do - // not support other codecs to be able to play it :) - sources.push({ - type: 'mp3', - url: url.updateQueryString( - this.$store.getters['instance/absoluteUrl'](this.trackData.listen_url), - 'to', - 'mp3' - ) - }) - if (this.$store.state.auth.authenticated) { - // we need to send the token directly in url - // so authentication can be checked by the backend - // because for audio files we cannot use the regular Authentication - // header - sources.forEach(e => { - e.url = url.updateQueryString(e.url, 'jwt', this.$store.state.auth.token) - }) - } - return sources - }, - updateProgressThrottled () { - return _.throttle(this.updateProgress, 250) - } - }, - methods: { - setupSound () { - let self = this - this.sound = new Howl({ - src: this.srcs.map((s) => { return s.url }), - format: this.srcs.map((s) => { return s.type }), - autoplay: false, - loop: false, - html5: true, - preload: true, - volume: this.volume, - onend: function () { - self.ended() - }, - onunlock: function () { - if (this.$store.state.player.playing) { - self.sound.play() - } - }, - onload: function () { - self.$store.commit('player/isLoadingAudio', false) - self.$store.commit('player/resetErrorCount') - self.$store.commit('player/errored', false) - self.$store.commit('player/duration', self.sound.duration()) - let node = self.sound._sounds[0]._node; - node.addEventListener('progress', () => { - self.updateBuffer(node) - }) - }, - onloaderror: function (sound, error) { - console.log('Error while playing:', sound, error) - self.$emit('errored', {sound, error}) - }, - }) - if (this.autoplay) { - self.$store.commit('player/isLoadingAudio', true) - this.sound.play() - this.$store.commit('player/playing', true) - this.observeProgress(true) - } - }, - updateBuffer (node) { - // from https://github.com/goldfire/howler.js/issues/752#issuecomment-372083163 - let range = 0; - let bf = node.buffered; - let time = node.currentTime; - try { - while(!(bf.start(range) <= time && time <= bf.end(range))) { - range += 1; - } - } catch (IndexSizeError) { - return - } - let loadPercentage - let start = bf.start(range) - let end = bf.end(range) - if (range === 0) { - // easy case, no user-seek - let loadStartPercentage = start / node.duration; - let loadEndPercentage = end / node.duration; - loadPercentage = loadEndPercentage - loadStartPercentage; - } else { - let loaded = end - start - let remainingToLoad = node.duration - start - // user seeked a specific position in the audio, our progress must be - // computed based on the remaining portion of the track - loadPercentage = loaded / remainingToLoad; - } - this.$store.commit('player/bufferProgress', loadPercentage * 100) - }, - updateProgress: function () { - this.isUpdatingTime = true - if (this.sound && this.sound.state() === 'loaded') { - this.$store.dispatch('player/updateProgress', this.sound.seek()) - this.updateBuffer(this.sound._sounds[0]._node) - } - }, - observeProgress: function (enable) { - let self = this - if (enable) { - if (self.progressInterval) { - clearInterval(self.progressInterval) - } - self.progressInterval = setInterval(() => { - self.updateProgress() - }, 1000) - } else { - clearInterval(self.progressInterval) - } - }, - setCurrentTime (t) { - if (t < 0 | t > this.duration) { - return - } - if (t === this.sound.seek()) { - return - } - if (t === 0) { - this.updateProgressThrottled.cancel() - } - this.sound.seek(t) - }, - ended: function () { - let onlyTrack = this.$store.state.queue.tracks.length === 1 - if (this.looping === 1 || (onlyTrack && this.looping === 2)) { - this.sound.seek(0) - this.sound.play() - } else { - this.$store.dispatch('player/trackEnded', this.trackData) - } - } - }, - watch: { - playing: function (newValue) { - if (newValue === true) { - this.sound.play() - } else { - this.sound.pause() - } - this.observeProgress(newValue) - }, - volume: function (newValue) { - this.sound.volume(newValue) - }, - currentTime (newValue) { - if (!this.isUpdatingTime) { - this.setCurrentTime(newValue) - } - this.isUpdatingTime = false - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue index 3ff07b10af8af5d8fdd254330f96f6aee500e0ec..1f89bef8898f9a1a496545688ff818a10e071e2a 100644 --- a/front/src/components/library/AlbumBase.vue +++ b/front/src/components/library/AlbumBase.vue @@ -74,7 +74,7 @@ <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> </router-link> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)" target="_blank" rel="noopener noreferrer"> diff --git a/front/src/components/library/ArtistBase.vue b/front/src/components/library/ArtistBase.vue index 71f3abd5ca2a31247d950786ce7bd41f400541d1..5da7370bacf5fba3abe0c44aa10eb82d421c4a46 100644 --- a/front/src/components/library/ArtistBase.vue +++ b/front/src/components/library/ArtistBase.vue @@ -85,7 +85,7 @@ <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> </router-link> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)" target="_blank" rel="noopener noreferrer"> diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index 6df2f7aa0caabde16847f7deb8f8aa2faedbc722..639c8f51b83c63166d1ec4d70d668a82352e48a1 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -93,7 +93,7 @@ <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> </router-link> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/track/${track.id}`)" target="_blank" rel="noopener noreferrer"> diff --git a/front/src/lodash.js b/front/src/lodash.js index afc53da2686668bfe4f28fed3f5a657f45786885..8cd3ed92f41e86b0eff9b88bd5af923742b1864e 100644 --- a/front/src/lodash.js +++ b/front/src/lodash.js @@ -11,4 +11,5 @@ export default { throttle: require('lodash/throttle'), uniq: require('lodash/uniq'), remove: require('lodash/remove'), + reverse: require('lodash/reverse'), } diff --git a/front/src/store/index.js b/front/src/store/index.js index e46aea86de226a678eaa27c0666249a44a526941..791dbb1e92fd1d3add3509ea67575dc734c64dd8 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -54,8 +54,7 @@ export default new Vuex.Store({ paths: [ 'player.looping', 'player.volume', - 'player.duration', - 'player.errored'], + 'player.duration'], filter: (mutation) => { return mutation.type.startsWith('player/') && mutation.type !== 'player/currentTime' } diff --git a/front/src/views/admin/library/AlbumDetail.vue b/front/src/views/admin/library/AlbumDetail.vue index 8de907a72bcb8d07671156e27939cc2195028c6c..b89afb945d7003cef05c7a92a68c8404b4e2e1c0 100644 --- a/front/src/views/admin/library/AlbumDetail.vue +++ b/front/src/views/admin/library/AlbumDetail.vue @@ -35,7 +35,7 @@ <i class="dropdown icon"></i> <div class="menu"> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)" target="_blank" rel="noopener noreferrer"> diff --git a/front/src/views/admin/library/ArtistDetail.vue b/front/src/views/admin/library/ArtistDetail.vue index 91b3542b2dc1c93721f4036699f6f84aa90643cb..0c4175bae4a84a68bb1f526deafd1af2fce42cea 100644 --- a/front/src/views/admin/library/ArtistDetail.vue +++ b/front/src/views/admin/library/ArtistDetail.vue @@ -34,7 +34,7 @@ <i class="dropdown icon"></i> <div class="menu"> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)" target="_blank" rel="noopener noreferrer"> diff --git a/front/src/views/admin/library/LibraryDetail.vue b/front/src/views/admin/library/LibraryDetail.vue index a4df048da668db2f77ef304052204d09d69f4bf1..beec7e2b408cf6b032c5f0114c979d77f958a64c 100644 --- a/front/src/views/admin/library/LibraryDetail.vue +++ b/front/src/views/admin/library/LibraryDetail.vue @@ -27,7 +27,7 @@ <div class="ui icon buttons"> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="ui labeled icon button" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/library/${object.id}`)" target="_blank" rel="noopener noreferrer"> @@ -38,7 +38,7 @@ <i class="dropdown icon"></i> <div class="menu"> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/library/${object.id}`)" target="_blank" rel="noopener noreferrer"> diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue index 3256d0d639a88a65839186c627533c5c8bd5d499..29cd29810ee619bf96be7ac499b6a5f6d3d5cbcb 100644 --- a/front/src/views/admin/library/TrackDetail.vue +++ b/front/src/views/admin/library/TrackDetail.vue @@ -34,7 +34,7 @@ <i class="dropdown icon"></i> <div class="menu"> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/track/${object.id}`)" target="_blank" rel="noopener noreferrer"> diff --git a/front/src/views/admin/library/UploadDetail.vue b/front/src/views/admin/library/UploadDetail.vue index 604d0af3046312605a06e5bf0e1e0b6b400d5954..4dbd83793c5b52374d174417265eae7b5cabcf4b 100644 --- a/front/src/views/admin/library/UploadDetail.vue +++ b/front/src/views/admin/library/UploadDetail.vue @@ -28,7 +28,7 @@ <div class="ui icon buttons"> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="ui labeled icon button" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object.id}`)" target="_blank" rel="noopener noreferrer"> @@ -39,7 +39,7 @@ <i class="dropdown icon"></i> <div class="menu"> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object.id}`)" target="_blank" rel="noopener noreferrer">