mirror of
https://code.eliotberriot.com/funkwhale/funkwhale.git
synced 2025-10-04 16:49:16 +02:00
Resolve "Redesign the sidebar/navigation to simplify the UI"
This commit is contained in:
parent
cdd6f3d759
commit
e15d806634
38 changed files with 2073 additions and 1579 deletions
576
front/src/components/Queue.vue
Normal file
576
front/src/components/Queue.vue
Normal file
|
@ -0,0 +1,576 @@
|
|||
<template>
|
||||
<section class="main with-background" :aria-label="labels.queue">
|
||||
<div :class="['ui vertical stripe queue segment', playerFocused ? 'player-focused' : '']">
|
||||
<div class="ui fluid container">
|
||||
<div class="ui stackable grid" id="queue-grid">
|
||||
<div class="ui six wide column current-track">
|
||||
<div class="ui basic segment" id="player">
|
||||
<template v-if="currentTrack">
|
||||
<img class="ui image" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.square_crop)">
|
||||
<img class="ui image" v-else src="../assets/audio/default-cover.png">
|
||||
<h1 class="ui header">
|
||||
<div class="content">
|
||||
<router-link class="small header discrete link track" :title="currentTrack.title" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
|
||||
{{ currentTrack.title | truncate(35) }}
|
||||
</router-link>
|
||||
<div class="sub header">
|
||||
<router-link class="discrete link artist" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
|
||||
{{ currentTrack.artist.name | truncate(35) }}</router-link> /<router-link class="discrete link album" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
|
||||
{{ currentTrack.album.title | truncate(35) }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</h1>
|
||||
<div class="ui small warning message" v-if="currentTrack && errored">
|
||||
<div class="header">
|
||||
<translate translate-context="Sidebar/Player/Error message.Title">The track cannot be loaded</translate>
|
||||
</div>
|
||||
<p v-if="hasNext && playing && $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>
|
||||
<i class="loading spinner icon"></i>
|
||||
</p>
|
||||
<p>
|
||||
<translate translate-context="Sidebar/Player/Error message.Paragraph">You may have a connectivity issue.</translate>
|
||||
</p>
|
||||
</div>
|
||||
<div class="additional-controls">
|
||||
<track-favorite-icon
|
||||
class="tablet-and-below"
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:track="currentTrack"></track-favorite-icon>
|
||||
<track-playlist-icon
|
||||
class="tablet-and-below"
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:track="currentTrack"></track-playlist-icon>
|
||||
<button
|
||||
v-if="$store.state.auth.authenticated"
|
||||
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
|
||||
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'tablet-and-below']"
|
||||
:aria-label="labels.addArtistContentFilter"
|
||||
:title="labels.addArtistContentFilter">
|
||||
<i :class="['eye slash outline', 'basic', 'icon']"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="progress-wrapper">
|
||||
<div class="progress-area" v-if="currentTrack && !errored">
|
||||
<div
|
||||
ref="progress"
|
||||
:class="['ui', 'small', 'orange', {'indicating': isLoadingAudio}, 'progress']"
|
||||
@click="touchProgress">
|
||||
<div class="buffer bar" :data-percent="bufferProgress" :style="{ 'width': bufferProgress + '%' }"></div>
|
||||
<div class="position bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-area" v-else>
|
||||
<div
|
||||
ref="progress"
|
||||
:class="['ui', 'small', 'orange', 'progress']">
|
||||
<div class="buffer bar"></div>
|
||||
<div class="position bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<template v-if="!isLoadingAudio">
|
||||
<span role="button" class="left floated timer start" @click="setCurrentTime(0)">{{currentTimeFormatted}}</span>
|
||||
<span class="right floated timer total">{{durationFormatted}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="left floated timer">00:00</span>
|
||||
<span class="right floated timer">00:00</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="player-controls tablet-and-below">
|
||||
<template>
|
||||
<span
|
||||
role="button"
|
||||
:title="labels.previousTrack"
|
||||
:aria-label="labels.previousTrack"
|
||||
class="control"
|
||||
@click.prevent.stop="$store.dispatch('queue/previous')"
|
||||
:disabled="emptyQueue">
|
||||
<i :class="['ui', 'backward step', {'disabled': emptyQueue}, 'icon']"></i>
|
||||
</span>
|
||||
|
||||
<span
|
||||
role="button"
|
||||
v-if="!playing"
|
||||
:title="labels.play"
|
||||
:aria-label="labels.play"
|
||||
@click.prevent.stop="togglePlay"
|
||||
class="control">
|
||||
<i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']"></i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
v-else
|
||||
:title="labels.pause"
|
||||
:aria-label="labels.pause"
|
||||
@click.prevent.stop="togglePlay"
|
||||
class="control">
|
||||
<i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']"></i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
:title="labels.next"
|
||||
:aria-label="labels.next"
|
||||
class="control"
|
||||
@click.prevent.stop="$store.dispatch('queue/next')"
|
||||
:disabled="!hasNext">
|
||||
<i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" ></i>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui sixteen wide mobile ten wide computer column queue-column">
|
||||
<div class="ui basic clearing fixed-header segment">
|
||||
<h2 class="ui header">
|
||||
<div class="content">
|
||||
<button
|
||||
class="ui right floated basic icon button"
|
||||
@click="$store.dispatch('queue/clean')">
|
||||
<translate translate-context="*/Queue/*/Verb">Clear</translate>
|
||||
</button>
|
||||
{{ labels.queue }}
|
||||
<div class="sub header">
|
||||
<div>
|
||||
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.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 v-if="$store.state.radios.running" class="ui info message">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<i class="feed icon"></i> <translate translate-context="Sidebar/Player/Title">You have a radio playing</translate>
|
||||
</div>
|
||||
<p><translate translate-context="Sidebar/Player/Paragraph">New tracks will be appended here automatically.</translate></p>
|
||||
<div @click="$store.dispatch('radios/stop')" class="ui basic primary button"><translate translate-context="*/Player/Button.Label/Short, Verb">Stop radio</translate></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="ui compact very basic fixed single line selectable unstackable table">
|
||||
<draggable v-model="tracks" tag="tbody" @update="reorder" handle=".handle">
|
||||
<tr
|
||||
v-for="(track, index) in tracks"
|
||||
:key="index"
|
||||
:class="['queue-item', {'active': index === queue.currentIndex}]">
|
||||
<td class="handle">
|
||||
<i class="grip lines grey icon"></i>
|
||||
</td>
|
||||
<td class="image-cell" @click="$store.dispatch('queue/currentIndex', index)">
|
||||
<img class="ui mini image" v-if="track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.square_crop)">
|
||||
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="3" @click="$store.dispatch('queue/currentIndex', index)">
|
||||
<button class="title reset ellipsis" :title="track.title" :aria-label="labels.selectTrack">
|
||||
<strong>{{ track.title }}</strong><br />
|
||||
<span>
|
||||
{{ track.artist.name }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="duration-cell">
|
||||
<template v-if="track.uploads.length > 0">
|
||||
{{ time.durationFormatted(track.uploads[0].duration) }}
|
||||
</template>
|
||||
</td>
|
||||
<td class="controls">
|
||||
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
|
||||
<i class="pink heart icon"></i>
|
||||
</template>
|
||||
<button :title="labels.removeFromQueue" @click.stop="cleanTrack(index)" :class="['ui', 'really', 'tiny', 'basic', 'circular', 'icon', 'button']">
|
||||
<i class="x icon"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</draggable>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script>
|
||||
import { mapState, mapGetters, mapActions } from "vuex"
|
||||
import $ from 'jquery'
|
||||
import moment from "moment"
|
||||
import lodash from '@/lodash'
|
||||
import time from "@/utils/time"
|
||||
|
||||
import store from "@/store"
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TrackFavoriteIcon: () => import(/* webpackChunkName: "auth-audio" */ "@/components/favorites/TrackFavoriteIcon"),
|
||||
TrackPlaylistIcon: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/TrackPlaylistIcon"),
|
||||
VolumeControl: () => import(/* webpackChunkName: "audio" */ "@/components/audio/VolumeControl"),
|
||||
draggable: () => import(/* webpackChunkName: "draggable" */ "vuedraggable"),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showVolume: false,
|
||||
isShuffling: false,
|
||||
tracksChangeBuffer: null,
|
||||
time
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
let self = this
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
this.scrollToCurrent()
|
||||
// delay is to let transition work
|
||||
}, 400);
|
||||
})
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
currentIndex: state => state.queue.currentIndex,
|
||||
playing: state => state.player.playing,
|
||||
isLoadingAudio: state => state.player.isLoadingAudio,
|
||||
volume: state => state.player.volume,
|
||||
looping: state => state.player.looping,
|
||||
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({
|
||||
currentTrack: "queue/currentTrack",
|
||||
hasNext: "queue/hasNext",
|
||||
emptyQueue: "queue/isEmpty",
|
||||
durationFormatted: "player/durationFormatted",
|
||||
currentTimeFormatted: "player/currentTimeFormatted",
|
||||
progress: "player/progress"
|
||||
}),
|
||||
tracks: {
|
||||
get() {
|
||||
return this.$store.state.queue.tracks
|
||||
},
|
||||
set(value) {
|
||||
this.tracksChangeBuffer = value
|
||||
}
|
||||
},
|
||||
labels () {
|
||||
return {
|
||||
queue: this.$pgettext('*/*/*', 'Queue'),
|
||||
duration: this.$pgettext('*/*/*', 'Duration'),
|
||||
}
|
||||
},
|
||||
timeLeft () {
|
||||
let seconds = lodash.sum(
|
||||
this.queue.tracks.slice(this.queue.currentIndex).map((t) => {
|
||||
return (t.uploads || []).map((u) => {
|
||||
return u.duration || 0
|
||||
})[0] || 0
|
||||
})
|
||||
)
|
||||
return moment(this.$store.state.ui.lastDate).add(seconds, 'seconds').fromNow(true)
|
||||
},
|
||||
sliderVolume: {
|
||||
get () {
|
||||
return this.volume
|
||||
},
|
||||
set (v) {
|
||||
this.$store.commit("player/volume", v)
|
||||
}
|
||||
},
|
||||
playerFocused () {
|
||||
return this.$store.state.ui.queueFocused === 'player'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
cleanTrack: "queue/cleanTrack",
|
||||
mute: "player/mute",
|
||||
unmute: "player/unmute",
|
||||
clean: "queue/clean",
|
||||
toggleMute: "player/toggleMute",
|
||||
togglePlay: "player/togglePlay",
|
||||
}),
|
||||
reorder: function(event) {
|
||||
this.$store.commit("queue/reorder", {
|
||||
tracks: this.tracksChangeBuffer,
|
||||
oldIndex: event.oldIndex,
|
||||
newIndex: event.newIndex
|
||||
})
|
||||
},
|
||||
scrollToCurrent() {
|
||||
let current = $(this.$el).find('.queue-item.active')[0]
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const elementRect = current.getBoundingClientRect();
|
||||
const absoluteElementTop = elementRect.top + window.pageYOffset;
|
||||
const middle = absoluteElementTop - (window.innerHeight / 2);
|
||||
window.scrollTo({top: middle, behaviour: 'smooth'});
|
||||
},
|
||||
touchProgress(e) {
|
||||
let time
|
||||
let target = this.$refs.progress
|
||||
time = (e.layerX / target.offsetWidth) * this.duration
|
||||
this.$emit('touch-progress', time)
|
||||
},
|
||||
shuffle() {
|
||||
let disabled = this.queue.tracks.length === 0
|
||||
if (this.isShuffling || disabled) {
|
||||
return
|
||||
}
|
||||
let self = this
|
||||
let msg = this.$pgettext('Content/Queue/Message', "Queue shuffled!")
|
||||
this.isShuffling = true
|
||||
setTimeout(() => {
|
||||
self.$store.dispatch("queue/shuffle", () => {
|
||||
self.isShuffling = false
|
||||
self.$store.commit("ui/addMessage", {
|
||||
content: msg,
|
||||
date: new Date()
|
||||
})
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"$store.state.ui.queueFocused": {
|
||||
handler (v) {
|
||||
if (v === 'queue') {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToCurrent()
|
||||
})
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
'$store.state.queue.currentIndex': {
|
||||
handler () {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToCurrent()
|
||||
})
|
||||
},
|
||||
},
|
||||
'$store.state.queue.tracks': {
|
||||
handler (v) {
|
||||
if (!v || v.length === 0) {
|
||||
this.$store.commit('ui/queueFocused', null)
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
"$route.fullPath" () {
|
||||
this.$store.commit('ui/queueFocused', null)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../style/vendor/media";
|
||||
|
||||
.main {
|
||||
position: absolute;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
z-index: 1000;
|
||||
padding-bottom: 3em;
|
||||
}
|
||||
.main > .button {
|
||||
position: fixed;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
z-index: 9999999;
|
||||
@include media("<desktop") {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.queue.segment:not(.player-focused) {
|
||||
#player {
|
||||
@include media("<desktop") {
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.queue.segment #player {
|
||||
padding: 0em;
|
||||
> * {
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
.player-focused .grid > .ui.queue-column {
|
||||
@include media("<desktop") {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.queue-column {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.queue-column .table {
|
||||
margin-top: 4em !important;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
.ui.table > tbody > tr > td.controls {
|
||||
text-align: right;
|
||||
}
|
||||
.ui.table > tbody > tr > td {
|
||||
border: none;
|
||||
}
|
||||
td:first-child {
|
||||
padding-left: 1em !important;
|
||||
}
|
||||
td:last-child {
|
||||
padding-right: 1em !important;
|
||||
}
|
||||
.image-cell {
|
||||
width: 4em;
|
||||
}
|
||||
.queue.segment {
|
||||
@include media("<desktop") {
|
||||
padding: 0;
|
||||
}
|
||||
> .container {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
.handle {
|
||||
@include media("<desktop") {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.duration-cell {
|
||||
@include media("<tablet") {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.fixed-header {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
@include media("<desktop") {
|
||||
padding: 1em;
|
||||
}
|
||||
@include media(">desktop") {
|
||||
right: 1em;
|
||||
left: 38%;
|
||||
}
|
||||
.header .content {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.current-track #player {
|
||||
font-size: 1.8em;
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
width: 32%;
|
||||
@include media("<desktop") {
|
||||
padding: 0.5em;
|
||||
font-size: 1.5em;
|
||||
width: 100%;
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
right: 0;
|
||||
> .image {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
> *:not(.image) {
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
.progress-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-wrapper, .warning.message {
|
||||
max-width: 25em;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.ui.progress .buffer.bar {
|
||||
position: absolute;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.ui.progress:not([data-percent]):not(.indeterminate)
|
||||
.bar.position:not(.buffer) {
|
||||
background: #ff851b;
|
||||
}
|
||||
|
||||
.indicating.progress .bar {
|
||||
left: -46px;
|
||||
width: 200% !important;
|
||||
color: grey;
|
||||
background: repeating-linear-gradient(
|
||||
-55deg,
|
||||
grey 1px,
|
||||
grey 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
) !important;
|
||||
|
||||
animation-name: MOVE-BG;
|
||||
animation-duration: 2s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
.ui.progress {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.timer {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.progress {
|
||||
cursor: pointer;
|
||||
.bar {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
.control:not(:first-child) {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
.handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.sortable-chosen {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.queue-item.sortable-ghost {
|
||||
td {
|
||||
border-top: 3px dashed rgba(0, 0, 0, 0.15) !important;
|
||||
border-bottom: 3px dashed rgba(0, 0, 0, 0.15) !important;
|
||||
&:first-child {
|
||||
border-left: 3px dashed rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
&:last-child {
|
||||
border-right: 3px dashed rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue