From e15d806634443d0832d228426e383a3355ac247f Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Thu, 26 Dec 2019 11:38:26 +0100 Subject: [PATCH] Resolve "Redesign the sidebar/navigation to simplify the UI" --- changes/changelog.d/594.feature | 1 + changes/notes.rst | 7 + front/package.json | 3 +- front/public/index.html | 80 ++- front/src/App.vue | 255 ++++++- front/src/assets/logo/text-white.svg | 117 +++ front/src/components/Queue.vue | 576 +++++++++++++++ front/src/components/ShortcutsModal.vue | 7 +- front/src/components/Sidebar.vue | 583 ++++++++------- front/src/components/audio/PlayButton.vue | 38 +- front/src/components/audio/Player.vue | 679 +++++++----------- front/src/components/audio/SearchBar.vue | 2 +- front/src/components/audio/VolumeControl.vue | 118 +++ front/src/components/common/ActorAvatar.vue | 7 + front/src/components/common/ExpandableDiv.vue | 2 +- .../favorites/TrackFavoriteIcon.vue | 4 +- front/src/components/globals.js | 74 +- front/src/components/library/Albums.vue | 2 + front/src/components/library/Artists.vue | 2 + front/src/components/library/Home.vue | 13 +- front/src/components/library/Library.vue | 17 - front/src/components/library/Radios.vue | 6 +- .../manage/moderation/InstancePolicyCard.vue | 2 +- .../src/components/moderation/ReportModal.vue | 6 +- .../playlists/TrackPlaylistIcon.vue | 4 +- front/src/components/semantic/Modal.vue | 2 +- front/src/lodash.js | 1 + front/src/router/index.js | 160 +++-- front/src/store/player.js | 4 +- front/src/store/queue.js | 5 +- front/src/store/ui.js | 25 + front/src/style/_main.scss | 110 ++- front/src/style/themes/_dark.scss | 14 + front/src/style/themes/_light.scss | 16 +- front/src/vendor/color-thief.js | 661 ----------------- front/src/views/playlists/List.vue | 4 +- front/vue.config.js | 6 +- front/yarn.lock | 23 +- 38 files changed, 2065 insertions(+), 1571 deletions(-) create mode 100644 changes/changelog.d/594.feature create mode 100644 front/src/assets/logo/text-white.svg create mode 100644 front/src/components/Queue.vue create mode 100644 front/src/components/audio/VolumeControl.vue delete mode 100644 front/src/vendor/color-thief.js diff --git a/changes/changelog.d/594.feature b/changes/changelog.d/594.feature new file mode 100644 index 0000000000..3ab7ccdd9a --- /dev/null +++ b/changes/changelog.d/594.feature @@ -0,0 +1 @@ +Brand new navigation, queue and player redesign (#594) diff --git a/changes/notes.rst b/changes/notes.rst index 1323a70eea..3ffbcfd25c 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -6,6 +6,13 @@ Next release notes Those release notes refer to the current development branch and are reset after each release. +Redesigned navigation, player and queue +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This release includes a full redesign of our navigation, player and queue. Overall, it should provide +a better, less confusing experience, especially on mobile devices. This redesign was suggested +14 months ago, and took a while, but thanks to the involvement and feedback of many people, we got it done! + Improved search performance ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/front/package.json b/front/package.json index c6b71944e8..2d24fb9528 100644 --- a/front/package.json +++ b/front/package.json @@ -25,7 +25,7 @@ "qs": "^6.7.0", "sanitize-html": "^1.20.1", "showdown": "^1.8.6", - "vue": "^2.5.17", + "vue": "^2.6.10", "vue-gettext": "^2.1.0", "vue-lazyload": "^1.2.6", "vue-masonry": "^0.11.5", @@ -50,6 +50,7 @@ "mocha": "^5.2.0", "moxios": "^0.4.0", "node-sass": "^4.9.3", + "preload-webpack-plugin": "^3.0.0-beta.4", "purgecss-webpack-plugin": "^1.6.0", "sass-loader": "^7.1.0", "sinon": "^6.1.5", diff --git a/front/public/index.html b/front/public/index.html index 142419ca63..42adc6d414 100644 --- a/front/public/index.html +++ b/front/public/index.html @@ -7,13 +7,85 @@ <meta name="viewport" content="width=device-width,initial-scale=1.0"> <link rel="icon" href="<%= BASE_URL %>favicon.png"> <title>Funkwhale</title> + <style> + #fake-app { + width: 100vw; + height: 100vh; + z-index: -1; + position: fixed; + top: 0; + left: 0; + display: flex; + font-family: sans-serif; + } + #fake-sidebar { + width: 275px; + height: 100vh; + background-color: #2D2F33; + } + #fake-sidebar.loaded, #fake-content.loaded { + display: none; + } + #orange-square { + width: 56px; + height: 56px; + background-color: #f2711c + } + #fake-content { + height: 100vh; + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + #fake-content h1 { + margin-bottom: 2em; + } + #fake-content .placeholder { + width: 20em; + max-width: 95%; + } + @media only screen and (max-width: 768px) { + #fake-app { + flex-direction: column; + } + #fake-sidebar { + width: 100%; + height: 56px; + } + } + </style> </head> <body class="theme-light" id="body"> - <noscript> - <strong>We're sorry but Funkwhale doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> - </noscript> - <div id="app"></div> + <div id="fake-app"> + <div id="fake-sidebar"> + <div id="orange-square"></div> + </div> + <div id="fake-content"> + <noscript> + <strong>We're sorry but Funkwhale doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> + </noscript> + <h1>Loading Funkwhale…</h1> + <div class="ui placeholder"> + <div class="image header"> + <div class="full line"></div> + <div class="line"></div> + </div> + <div class="image header"> + <div class="line"></div> + <div class="full line"></div> + </div> + <div class="image header"> + <div class="medium line"></div> + <div class="full line"></div> + </div> + </div> + </div> + </div> + <div id="app"> + </div> <!-- built files will be auto injected --> </body> diff --git a/front/src/App.vue b/front/src/App.vue index 5be97dfb56..c1fee631bf 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,5 +1,5 @@ <template> - <div id="app" :key="String($store.state.instance.instanceUrl)"> + <div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}]"> <!-- here, we display custom stylesheets, if any --> <link v-for="url in customStylesheets" @@ -12,9 +12,13 @@ <sidebar></sidebar> <set-instance-modal @update:show="showSetInstanceModal = $event" :show="showSetInstanceModal"></set-instance-modal> <service-messages v-if="messages.length > 0"/> - <router-view :key="$route.fullPath"></router-view> - <div class="ui fitted divider"></div> + <transition name="queue"> + <queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue> + </transition> + <router-view :class="{hidden: $store.state.ui.queueFocused}" :key="$route.fullPath"></router-view> + <player ref="player"></player> <app-footer + :class="{hidden: $store.state.ui.queueFocused}" :version="version" @show:shortcuts-modal="showShortcutsModal = !showShortcutsModal" @show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal" @@ -32,39 +36,33 @@ import Vue from 'vue' import axios from 'axios' import _ from '@/lodash' -import {mapState, mapGetters} from 'vuex' +import {mapState, mapGetters, mapActions} from 'vuex' import { WebSocketBridge } from 'django-channels' import GlobalEvents from '@/components/utils/global-events' -import Sidebar from '@/components/Sidebar' -import AppFooter from '@/components/Footer' -import ServiceMessages from '@/components/ServiceMessages' import moment from 'moment' import locales from './locales' -import PlaylistModal from '@/components/playlists/PlaylistModal' -import FilterModal from '@/components/moderation/FilterModal' -import ReportModal from '@/components/moderation/ReportModal' -import ShortcutsModal from '@/components/ShortcutsModal' -import SetInstanceModal from '@/components/SetInstanceModal' export default { name: 'app', components: { - Sidebar, - AppFooter, - FilterModal, - ReportModal, - PlaylistModal, - ShortcutsModal, + Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"), + Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"), + PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"), + Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"), + AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"), + ServiceMessages: () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"), + SetInstanceModal: () => import(/* webpackChunkName: "core" */ "@/components/SetInstanceModal"), + ShortcutsModal: () => import(/* webpackChunkName: "core" */ "@/components/ShortcutsModal"), + FilterModal: () => import(/* webpackChunkName: "moderation" */ "@/components/moderation/FilterModal"), + ReportModal: () => import(/* webpackChunkName: "moderation" */ "@/components/moderation/ReportModal"), GlobalEvents, - ServiceMessages, - SetInstanceModal, }, data () { return { bridge: null, instanceUrl: null, showShortcutsModal: false, - showSetInstanceModal: false, + showSetInstanceModal: false } }, async created () { @@ -82,6 +80,10 @@ export default { if (serverUrl) { this.$store.commit('instance/instanceUrl', serverUrl) } + const url = urlParams.get('_url') + if (url) { + this.$router.replace(url) + } else if (!this.$store.state.instance.instanceUrl) { // we have several way to guess the API server url. By order of precedence: // 1. use the url provided in settings.json, if any @@ -127,6 +129,9 @@ export default { self.$router.push(event.target.getAttribute('href')) event.preventDefault(); }, false); + this.$nextTick(() => { + document.getElementById('fake-content').classList.add('loaded') + }) }, destroyed () { @@ -238,10 +243,27 @@ export default { ...mapState({ messages: state => state.ui.messages, nodeinfo: state => state.instance.nodeinfo, + playing: state => state.player.playing, + bufferProgress: state => state.player.bufferProgress, + isLoadingAudio: state => state.player.isLoadingAudio, }), ...mapGetters({ - currentTrack: 'queue/currentTrack' + hasNext: "queue/hasNext", + currentTrack: 'queue/currentTrack', + progress: "player/progress", }), + labels() { + let play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Play track") + let pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Pause track") + let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track") + let expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Expand queue") + return { + play, + pause, + next, + expandQueue, + } + }, suggestedInstances () { let instances = this.$store.state.instance.knownInstances.slice(0) if (this.$store.state.instance.frontSettings.defaultServerUrl) { @@ -264,7 +286,7 @@ export default { if (this.$store.state.instance.frontSettings) { return this.$store.state.instance.frontSettings.additionalStylesheets || [] } - } + }, }, watch: { '$store.state.instance.instanceUrl' () { @@ -290,7 +312,7 @@ export default { immediate: true, handler(newValue) { let self = this - import(`./translations/${newValue}.json`).then((response) =>{ + import(/* webpackChunkName: "locale-[request]" */ `./translations/${newValue}.json`).then((response) =>{ Vue.$translations[newValue] = response.default[newValue] }).finally(() => { // set current language twice, otherwise we seem to have a cache somewhere @@ -302,12 +324,12 @@ export default { return self.$store.commit('ui/momentLocale', 'en') } let momentLocale = newValue.replace('_', '-').toLowerCase() - import(`moment/locale/${momentLocale}.js`).then(() => { + import(/* webpackChunkName: "moment-locale-[request]" */ `moment/locale/${momentLocale}.js`).then(() => { self.$store.commit('ui/momentLocale', momentLocale) }).catch(() => { console.log('No momentjs locale available for', momentLocale) let shortLocale = momentLocale.split('-')[0] - import(`moment/locale/${shortLocale}.js`).then(() => { + import(/* webpackChunkName: "moment-locale-[request]" */ `moment/locale/${shortLocale}.js`).then(() => { self.$store.commit('ui/momentLocale', shortLocale) }).catch(() => { console.log('No momentjs locale available for', shortLocale) @@ -333,4 +355,185 @@ export default { <style lang="scss"> @import "style/_main"; + +.ui.bottom-player { + z-index: 999999; + width: 100%; + width: 100vw; +} +#app.queue-focused { + .queue-not-focused { + @include media("<desktop") { + display: none; + } + } +} +.when-queue-focused { + .group { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 1.1em; + > * { + margin-left: 0.5em; + } + } + @include media("<desktop") { + width: 100%; + justify-content: space-between !important; + } +} +#app:not(.queue-focused) { + .when-queue-focused { + @include media("<desktop") { + display: none; + } + } +} +.ui.bottom-player > .segment.fixed-controls { + width: 100%; + width: 100vw; + border-radius: 0; + padding: 0em; + position: fixed; + bottom: 0; + left: 0; + margin: 0; + z-index: 1001; + height: $bottom-player-height; + .controls-row { + height: $bottom-player-height; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + @include media(">desktop") { + padding: 0 1em; + justify-content: space-around; + } + } + cursor: pointer; + .indicating.progress { + overflow: hidden; + } + + .ui.progress .bar { + transition: none; + } + + .ui.progress .buffer.bar { + position: absolute; + } + + @keyframes MOVE-BG { + from { + transform: translateX(0px); + } + to { + transform: translateX(46px); + } + } + .discrete.link { + color: inherit; + } + .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:not([data-percent]):not(.indeterminate) + .bar.position:not(.buffer) { + background: #ff851b; + min-width: 0; + } + + .track-controls { + display: flex; + align-items: center; + justify-content: start; + flex-grow: 1; + .image { + padding: 0.5em; + width: auto; + margin-right: 0.5em; + > img { + max-height: 3.7em; + max-width: 4.7em; + } + } + } + .controls { + min-width: 8em; + font-size: 1.1em; + @include media(">desktop") { + &:not(.fluid) { + width: 20%; + } + &.queue-controls { + width: 32.5%; + } + &.progress-controls { + width: 10%; + } + &.player-controls { + width: 15%; + } + } + &.small, .small { + @include media(">desktop") { + font-size: 0.9em; + } + } + .icon { + font-size: 1.1em; + } + .icon.large { + font-size: 1.4em; + } + &:not(.track-controls) { + @include media(">desktop") { + line-height: 1em; + } + justify-content: center; + align-items: center; + &.align-right { + justify-content: flex-end; + } + &.align-left { + justify-content: flex-start; + } + > * { + margin: 0 0.5em; + } + } + &.player-controls { + .icon { + margin: 0; + } + } + + } +} +.queue-enter-active, .queue-leave-active { + transition: all 0.2s ease-in-out; + .current-track, .queue-column { + opacity: 0; + } +} +.queue-enter, .queue-leave-to { + transform: translateY(100vh); + opacity: 0; +} </style> diff --git a/front/src/assets/logo/text-white.svg b/front/src/assets/logo/text-white.svg new file mode 100644 index 0000000000..f812c7c992 --- /dev/null +++ b/front/src/assets/logo/text-white.svg @@ -0,0 +1,117 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="206.66678mm" + height="28.491329mm" + viewBox="0 0 206.66678 28.491329" + version="1.1" + id="svg4600" + inkscape:version="0.92.4 (5da689c313, 2019-01-14)" + sodipodi:docname="text-white.svg"> + <defs + id="defs4594" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="0.7" + inkscape:cx="135.70772" + inkscape:cy="-23.988564" + inkscape:document-units="mm" + inkscape:current-layer="g5240" + showgrid="false" + inkscape:window-width="1920" + inkscape:window-height="1044" + inkscape:window-x="1920" + inkscape:window-y="36" + inkscape:window-maximized="1" /> + <metadata + id="metadata4597"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(34.652951,-134.48185)"> + <g + id="g5240"> + <g + transform="translate(-66.52381,12.019644)" + id="g5221" + style="fill:#ffffff;fill-opacity:0.95454544"> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 32.845914,132.89252 c 0,-6.69443 2.603389,-9.29781 10.413554,-9.29781 1.636415,0 3.719126,0.14876 4.834864,0.37191 0.59506,0.14876 1.115738,0.59506 1.115738,1.11574 v 2.00832 c 0,0.59506 -0.446295,1.11574 -1.115738,1.11574 h -0.669443 c -0.818208,0 -1.48765,-0.29753 -2.529006,-0.29753 -4.834864,0 -5.801837,0.96698 -5.801837,4.98363 v 0.29753 h 6.620045 c 0.59506,0 1.115738,0.4463 1.115738,1.11574 v 2.15709 c 0,0.66945 -0.446295,1.11574 -1.115738,1.11574 h -6.620045 v 11.30614 c 0,0.59506 -0.446295,1.11574 -1.115737,1.11574 h -4.016657 c -0.59506,0 -1.115738,-0.52068 -1.115738,-1.11574 z" + id="path5166" /> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 57.020235,141.59528 c 0,3.04968 1.413268,4.31418 3.495978,4.31418 1.785181,0 3.495979,-1.2645 4.834864,-2.60339 v -12.12435 c 0,-0.59506 0.520678,-1.11573 1.115738,-1.11573 h 4.091039 c 0.59506,0 1.115738,0.52067 1.115738,1.11573 v 17.70304 c 0,0.59506 -0.446295,1.11574 -1.115738,1.11574 h -4.091039 c -0.59506,0 -1.115738,-0.52068 -1.115738,-1.11574 v -1.19012 c -1.710798,1.48765 -3.570361,2.67777 -6.322514,2.67777 -4.834864,0 -8.25646,-2.529 -8.25646,-8.70275 v -10.41355 c 0,-0.59506 0.446295,-1.11574 1.115738,-1.11574 h 4.091038 c 0.595061,0 1.115738,0.52068 1.115738,1.11574 v 10.33917 z" + id="path5168" /> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 90.715518,138.47121 c 0,-3.04968 -1.413268,-4.31419 -3.495979,-4.31419 -1.78518,0 -3.570361,1.26451 -4.909246,2.60339 v 12.19874 c 0,0.59506 -0.446295,1.11573 -1.115738,1.11573 h -4.091039 c -0.669442,0 -1.115738,-0.52067 -1.115738,-1.11573 v -17.77743 c 0,-0.59506 0.446296,-1.11573 1.115738,-1.11573 h 4.165422 c 0.59506,0 1.115737,0.52067 1.115737,1.11573 v 1.19012 c 1.710798,-1.48765 3.570362,-2.67777 6.396897,-2.67777 4.834865,0 8.256461,2.52901 8.256461,8.70276 v 10.41355 c 0,0.59506 -0.446295,1.11574 -1.115738,1.11574 h -4.091039 c -0.59506,0 -1.115738,-0.52068 -1.115738,-1.11574 z" + id="path5170" /> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 107.67473,137.95053 c 2.1571,0 3.57036,-0.89259 4.31419,-2.45462 l 1.78518,-3.71913 c 0.4463,-1.04135 1.56203,-1.71079 2.60339,-1.71079 h 3.42159 c 0.96698,0 1.11574,0.74382 0.59507,1.71079 l -2.38025,4.98363 c -0.74382,1.63642 -2.23147,2.90092 -3.86789,3.27283 1.48765,0.4463 2.75216,1.48765 3.86789,3.27283 l 3.12407,4.98363 c 0.59506,0.96698 0.29753,1.7108 -0.59506,1.7108 h -3.4216 c -1.19012,0 -2.15709,-0.74382 -2.75215,-1.7108 l -2.30586,-3.71912 c -0.96697,-1.63642 -2.67777,-2.38024 -4.31418,-2.38024 v 6.76881 c 0,0.59506 -0.4463,1.11573 -1.11574,1.11573 h -4.09104 c -0.59506,0 -1.11574,-0.52067 -1.11574,-1.11573 v -23.80241 c 0,-0.59506 0.4463,-1.11574 1.11574,-1.11574 h 4.09104 c 0.59506,0 1.11574,0.52068 1.11574,1.11574 v 12.79379 z" + id="path5172" /> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 140.1799,130.06599 c 1.04135,0 1.78518,0.59506 2.08271,1.56203 l 2.9753,9.81849 2.9753,-9.81849 c 0.29753,-1.04136 1.33888,-1.56203 2.45462,-1.56203 h 3.34722 c 0.89259,0 1.04135,0.66944 0.74382,1.56203 l -5.50431,16.73607 c -0.29753,0.96697 -1.33888,1.56203 -2.30585,1.56203 h -2.60339 c -0.89259,0 -2.00833,-0.59506 -2.30586,-1.56203 l -3.49598,-10.78547 -3.49598,10.85985 c -0.29753,0.96697 -1.41327,1.56203 -2.30586,1.56203 h -2.60338 c -0.96698,0 -1.93395,-0.59506 -2.30586,-1.56203 l -5.50431,-16.73607 c -0.29753,-0.89259 -0.0744,-1.56203 0.74383,-1.56203 h 3.34721 c 1.11574,0 2.08271,0.59506 2.45462,1.56203 l 2.9753,9.81849 2.9753,-9.81849 c 0.29753,-0.96697 1.04136,-1.56203 2.08272,-1.56203 h 3.27283 z" + id="path5174" /> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 172.09,138.47121 c 0,-3.04968 -1.41327,-4.31419 -3.4216,-4.31419 -1.78518,0 -3.57036,1.26451 -4.90924,2.60339 v 12.19874 c 0,0.59506 -0.4463,1.11573 -1.11574,1.11573 h -4.09104 c -0.59506,0 -1.11574,-0.52067 -1.11574,-1.11573 v -23.80241 c 0,-0.59506 0.52068,-1.11574 1.11574,-1.11574 h 4.09104 c 0.59506,0 1.11574,0.52068 1.11574,1.11574 v 7.2151 c 1.71079,-1.48765 3.57036,-2.67777 6.39689,-2.67777 4.83487,0 8.25646,2.52901 8.25646,8.70276 v 10.41355 c 0,0.59506 -0.52067,1.11574 -1.11573,1.11574 h -4.09104 c -0.59506,0 -1.11574,-0.52068 -1.11574,-1.11574 z" + id="path5176" /> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 189.04921,135.04961 c -0.44629,0.59506 -1.19012,0.96698 -2.08271,0.96698 h -2.67777 c -0.59506,0 -1.11573,-0.4463 -1.11573,-1.11574 0,-3.86789 3.86789,-5.20678 9.89287,-5.20678 5.35554,0 9.59535,2.23148 9.59535,7.88455 v 11.23176 c 0,0.59506 -0.52068,1.11574 -1.11574,1.11574 h -3.49598 c -0.59506,0 -1.11574,-0.52068 -1.11574,-1.11574 v -0.59506 c -1.7108,1.19012 -3.71912,2.08271 -6.62004,2.08271 -4.83487,0 -8.55399,-2.15709 -8.55399,-6.39689 0,-4.23981 3.71912,-6.32252 8.55399,-6.32252 h 6.02498 c 0,-2.90092 -1.19012,-3.79351 -3.71912,-3.79351 -1.56204,0.0744 -2.9753,0.52068 -3.57037,1.2645 z m 7.28949,9.52097 v -2.82654 h -5.57869 c -1.78518,0 -2.75215,0.96697 -2.75215,2.23148 0,1.2645 0.96697,2.23147 2.9753,2.23147 2.15709,0 4.01666,-0.8182 5.35554,-1.63641 z" + id="path5178" /> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 208.16552,150.0005 c -0.59506,0 -1.11573,-0.52068 -1.11573,-1.11574 v -23.8024 c 0,-0.59506 0.52067,-1.11574 1.11573,-1.11574 h 4.09104 c 0.59506,0 1.11574,0.52068 1.11574,1.11574 v 23.8024 c 0,0.59506 -0.44629,1.11574 -1.11574,1.11574 z" + id="path5180" /> + <path + style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526" + inkscape:connector-curvature="0" + d="m 223.04203,141.96719 c 0.22315,2.9753 1.56203,4.2398 4.61171,4.2398 1.56204,0 2.97531,-0.44629 3.57037,-1.19012 0.52067,-0.59506 1.19012,-0.96697 2.08271,-0.96697 h 2.67777 c 0.59506,0 1.11574,0.52068 1.11574,1.11574 0,3.86789 -3.94228,5.20677 -9.89288,5.20677 -6.62004,0 -10.6367,-3.57036 -10.6367,-10.26478 0,-6.69443 4.01666,-10.33917 10.6367,-10.33917 6.62004,0 10.56232,3.57036 10.56232,10.11602 v 1.04135 c 0,0.59506 -0.4463,1.11574 -1.11574,1.11574 h -13.612 z m 0,-3.86789 h 8.47961 c -0.14877,-2.75216 -1.48765,-4.23981 -4.23981,-4.23981 -2.67777,0 -4.09104,1.48765 -4.2398,4.23981 z" + id="path5182" /> + </g> + </g> + </g> + <style + id="style2" + type="text/css"> + .st0{fill:#FFFFFF;} + .st1{fill:#009FE3;} + .st2{fill:#3C3C3B;} +</style> +</svg> diff --git a/front/src/components/Queue.vue b/front/src/components/Queue.vue new file mode 100644 index 0000000000..f03b12c627 --- /dev/null +++ b/front/src/components/Queue.vue @@ -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> diff --git a/front/src/components/ShortcutsModal.vue b/front/src/components/ShortcutsModal.vue index 999d24dd38..097672f2ca 100644 --- a/front/src/components/ShortcutsModal.vue +++ b/front/src/components/ShortcutsModal.vue @@ -42,12 +42,11 @@ </template> <script> -import Modal from '@/components/semantic/Modal' export default { props: ['show'], components: { - Modal, + Modal: () => import(/* webpackChunkName: "modal" */ "@/components/semantic/Modal"), }, computed: { general () { @@ -131,6 +130,10 @@ export default { key: 'm', summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle mute') }, + { + key: 'e', + summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Expand queue/player view') + }, { key: 'l', summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle queue looping') diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index ddeba0bb8b..da693b257c 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -1,216 +1,178 @@ <template> <aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]"> - <header class="ui inverted segment header-wrapper"> - <search-bar @search="isCollapsed = false"> - <router-link :title="'Funkwhale'" :to="{name: logoUrl}"> - <i class="logo bordered inverted orange big icon"> - <logo class="logo"></logo> - </i> - </router-link><span - slot="after" - @click="isCollapsed = !isCollapsed" - :class="['ui', 'basic', 'big', {'inverted': isCollapsed}, 'orange', 'icon', 'collapse', 'button']"> - <i class="sidebar icon"></i></span> - </search-bar> - </header> + <header class="ui basic segment header-wrapper"> + <router-link :title="'Funkwhale'" :to="{name: logoUrl}"> + <i class="logo bordered inverted orange big icon"> + <logo class="logo"></logo> + </i> + </router-link> + <router-link v-if="!$store.state.auth.authenticated" class="logo-wrapper" :to="{name: logoUrl}"> + <img src="../assets/logo/text-white.svg" /> + </router-link> + <nav class="top ui compact right aligned inverted text menu"> + <template v-if="$store.state.auth.authenticated"> + + <div class="right menu"> + <div class="item" :title="labels.administration" v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']"> + <div class="item ui inline admin-dropdown dropdown"> + <i class="wrench icon"></i> + <div + v-if="$store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports > 0" + :class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ $store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports }}</div> + <div class="menu"> + <div class="header"> + <translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate> + </div> + <div class="divider"></div> + <router-link + v-if="$store.state.auth.availablePermissions['library']" + class="item" + :to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"> + <div + v-if="$store.state.ui.notifications.pendingReviewEdits > 0" + :title="labels.pendingReviewEdits" + :class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']"> + {{ $store.state.ui.notifications.pendingReviewEdits }}</div> + <translate translate-context="*/*/*/Noun">Library</translate> + </router-link> + <router-link + v-if="$store.state.auth.availablePermissions['moderation']" + class="item" + :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"> + <div + v-if="$store.state.ui.notifications.pendingReviewReports > 0" + :title="labels.pendingReviewReports" + :class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div> + <translate translate-context="*/Moderation/*">Moderation</translate> + </router-link> + <router-link + v-if="$store.state.auth.availablePermissions['settings']" + class="item" + :to="{name: 'manage.users.users.list'}"> + <translate translate-context="*/*/*/Noun">Users</translate> + </router-link> + <router-link + v-if="$store.state.auth.availablePermissions['settings']" + class="item" + :to="{path: '/manage/settings'}"> + <translate translate-context="*/*/*/Noun">Settings</translate> + </router-link> + </div> + </div> + </div> + </div> + <router-link + class="item" + v-if="$store.state.auth.authenticated" + :title="labels.addContent" + :to="{name: 'content.index'}"><i class="upload icon"></i></router-link> - <div class="menu-area"> - <div class="ui compact fluid two item inverted menu"> - <a :class="[{active: selectedTab === 'library'}, 'item']" role="button" @click.prevent.stop="selectedTab = 'library'" data-tab="library"><translate translate-context="*/Library/*/Verb">Browse</translate></a> - <a :class="[{active: selectedTab === 'queue'}, 'item']" role="button" @click.prevent.stop="selectedTab = 'queue'" data-tab="queue"> - <translate translate-context="Sidebar/Queue/Tab.Title/Noun">Queue</translate> - <template v-if="queue.tracks.length === 0"> - <translate translate-context="Sidebar/Queue/Tab.Title">(empty)</translate> - </template> - <translate translate-context="Sidebar/Queue/Tab.Title" v-else :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"> - (%{ index } of %{ length }) - </translate> - </a> - </div> + <router-link class="item" v-if="$store.state.auth.authenticated" :title="labels.notifications" :to="{name: 'notifications'}"> + <i class="bell icon"></i><div + v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0" + :class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ $store.state.ui.notifications.inbox + additionalNotifications }}</div> + </router-link> + <div class="item"> + <div class="ui user-dropdown dropdown" > + <img class="ui avatar image" v-if="$store.state.auth.profile.avatar.square_crop" v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" /> + <actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" /> + <div class="menu"> + <router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link> + <router-link class="item" :to="{path: '/settings'}"></i><translate translate-context="*/*/*/Noun">Settings</translate></router-link> + <router-link class="item" :to="{name: 'logout'}"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link> + </div> + </div> + </div> + </template> + <div class="item collapse-button-wrapper"> + + <span + @click="isCollapsed = !isCollapsed" + :class="['ui', 'basic', 'big', {'orange': !isCollapsed}, 'inverted icon', 'collapse', 'button']"> + <i class="sidebar icon"></i></span> + </div> + </nav> + </header> + <div class="ui basic search-wrapper segment"> + <search-bar @search="isCollapsed = false"></search-bar> + </div> + <div v-if="!$store.state.auth.authenticated" class="ui basic signup segment"> + <router-link class="ui fluid tiny primary button" :to="{name: 'login'}"><translate translate-context="*/Login/*/Verb">Login</translate></router-link> + <div class="ui small hidden divider"></div> + <router-link class="ui fluid tiny button" :to="{path: '/signup'}"> + <translate translate-context="*/Signup/Link/Verb">Create an account</translate> + </router-link> </div> - <div class="tabs"> + <nav class="secondary" role="navigation"> + <div class="ui small hidden divider"></div> <section :class="['ui', 'bottom', 'attached', {active: selectedTab === 'library'}, 'tab']" :aria-label="labels.mainMenu"> - <nav class="ui inverted vertical large fluid menu" role="navigation" :aria-label="labels.mainMenu"> - <div class="item"> - <header class="header"><translate translate-context="Sidebar/Profile/Title">My account</translate></header> + <nav class="ui vertical large fluid inverted menu" role="navigation" :aria-label="labels.mainMenu"> + <div :class="[{collapsed: !exploreExpanded}, 'collaspable item']"> + <header class="header" @click="exploreExpanded = true" tabindex="0" @focus="exploreExpanded = true"> + <translate translate-context="*/*/*/Verb">Explore</translate> + <i class="angle right icon" v-if="!exploreExpanded"></i> + </header> <div class="menu"> - <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"> - <i class="user icon"></i> - <translate translate-context="Sidebar/Profile/List item.Link" :translate-params="{username: $store.state.auth.username}"> - Logged in as %{ username } - </translate> - <img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" /> - </router-link> - <router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/settings'}"><i class="setting icon"></i><translate translate-context="*/*/*/Noun">Settings</translate></router-link> - <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'notifications'}"> - <i class="feed icon"></i> - <translate translate-context="*/Notifications/*">Notifications</translate> - <div - v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0" - :class="['ui', 'teal', 'label']"> - {{ $store.state.ui.notifications.inbox + additionalNotifications }}</div> - </router-link> - <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link> - <template v-else> - <router-link class="item" :to="{name: 'login'}"><i class="sign in icon"></i><translate translate-context="*/Login/*/Verb">Login</translate></router-link> - <router-link class="item" :to="{path: '/signup'}"> - <i class="corner add icon"></i> - <translate translate-context="*/Signup/Link/Verb">Create an account</translate> - </router-link> - </template> + <router-link class="item" :exact="true" :to="{name: 'library.index'}"><i class="music icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Browse</translate></router-link> + <router-link class="item" :to="{name: 'library.albums.browse'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link> + <router-link class="item" :to="{name: 'library.artists.browse'}"><i class="user icon"></i><translate translate-context="*/*/*">Artists</translate></router-link> + <router-link class="item" :to="{name: 'library.playlists.browse'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link> + <router-link class="item" :to="{name: 'library.radios.browse'}"><i class="feed icon"></i><translate translate-context="*/*/*">Radios</translate></router-link> </div> </div> - <div class="item"> - <header class="header"><translate translate-context="*/*/*/Noun">Music</translate></header> + <div :class="[{collapsed: !myLibraryExpanded}, 'collaspable item']" v-if="$store.state.auth.authenticated"> + <header class="header" @click="myLibraryExpanded = true" tabindex="0" @focus="myLibraryExpanded = true"> + <translate translate-context="*/*/*/Noun">My Library</translate> + <i class="angle right icon" v-if="!myLibraryExpanded"></i> + </header> <div class="menu"> - <router-link class="item" :to="{path: '/library'}"><i class="sound icon"></i><translate translate-context="Sidebar/Library/List item.Link/Verb">Browse library</translate></router-link> - <router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i><translate translate-context="Sidebar/Favorites/List item.Link/Noun">Favorites</translate></router-link> - <a - @click="$store.commit('playlists/chooseTrack', null)" - v-if="$store.state.auth.authenticated" - class="item"> - <i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate> - </a> - <router-link - v-if="$store.state.auth.authenticated" - class="item" :to="{name: 'content.index'}"><i class="upload icon"></i><translate translate-context="*/Library/*/Verb">Add content</translate></router-link> + <router-link class="item" :exact="true" :to="{name: 'library.me'}"><i class="music icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Browse</translate></router-link> + <router-link class="item" :to="{name: 'library.albums.me'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link> + <router-link class="item" :to="{name: 'library.artists.me'}"><i class="user icon"></i><translate translate-context="*/*/*">Artists</translate></router-link> + <router-link class="item" :to="{name: 'library.playlists.me'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link> + <router-link class="item" :to="{name: 'library.radios.me'}"><i class="feed icon"></i><translate translate-context="*/*/*">Radios</translate></router-link> + <router-link class="item" :to="{name: 'favorites'}"><i class="heart icon"></i><translate translate-context="Sidebar/Favorites/List item.Link/Noun">Favorites</translate></router-link> </div> </div> - <div class="item" v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']"> - <header class="header"><translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate></header> + <div class="item"> + <header class="header"> + <translate translate-context="Footer/About/List item.Link">More</translate> + </header> <div class="menu"> - <router-link - v-if="$store.state.auth.availablePermissions['library']" - class="item" - :to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"> - <i class="book icon"></i><translate translate-context="*/*/*/Noun">Library</translate> - <div - v-if="$store.state.ui.notifications.pendingReviewEdits > 0" - :title="labels.pendingReviewEdits" - :class="['ui', 'teal', 'label']"> - {{ $store.state.ui.notifications.pendingReviewEdits }}</div> - </router-link> - <router-link - v-if="$store.state.auth.availablePermissions['moderation']" - class="item" - :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"> - <i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate> - <div - v-if="$store.state.ui.notifications.pendingReviewReports > 0" - :title="labels.pendingReviewReports" - :class="['ui', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div> - </router-link> - <router-link - v-if="$store.state.auth.availablePermissions['settings']" - class="item" - :to="{name: 'manage.users.users.list'}"> - <i class="users icon"></i><translate translate-context="*/*/*/Noun">Users</translate> - </router-link> - <router-link - v-if="$store.state.auth.availablePermissions['settings']" - class="item" - :to="{path: '/manage/settings'}"> - <i class="settings icon"></i><translate translate-context="*/*/*/Noun">Settings</translate> + <router-link class="item" to="/about"> + <i class="info icon"></i><translate translate-context="Sidebar/*/List item.Link">About this pod</translate> </router-link> </div> </div> </nav> </section> - <div v-if="queue.previousQueue " class="ui black icon message"> - <i class="history icon"></i> - <div class="content"> - <div class="header"> - <translate translate-context="Sidebar/Queue/Message">Do you want to restore your previous queue?</translate> - </div> - <p> - <translate translate-context="*/*/*" - translate-plural="%{ count } tracks" - :translate-n="queue.previousQueue.tracks.length" - :translate-params="{count: queue.previousQueue.tracks.length}"> - %{ count } track - </translate> - </p> - <div class="ui two buttons"> - <div @click="queue.restore()" class="ui basic inverted green button"><translate translate-context="*/*/*">Yes</translate></div> - <div @click="queue.removePrevious()" class="ui basic inverted red button"><translate translate-context="*/*/*">No</translate></div> - </div> - </div> - </div> - <section :class="['ui', 'bottom', 'attached', {active: selectedTab === 'queue'}, 'tab']"> - <table class="ui compact inverted very basic fixed single line unstackable table"> - <draggable v-model="tracks" tag="tbody" @update="reorder"> - <tr - @click="$store.dispatch('queue/currentIndex', index)" - v-for="(track, index) in tracks" - :key="index" - :class="[{'active': index === queue.currentIndex}]"> - <td class="right aligned">{{ index + 1}}</td> - <td class="center aligned"> - <img class="ui mini image" v-if="track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)"> - <img class="ui mini image" v-else src="../assets/audio/default-cover.png"> - </td> - <td colspan="4"> - <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> - <template v-if="$store.getters['favorites/isFavorite'](track.id)"> - <i class="pink heart icon"></i> - </template> - </td> - <td> - <button :title="labels.removeFromQueue" @click.stop="cleanTrack(index)" :class="['ui', {'inverted': index != queue.currentIndex}, 'really', 'tiny', 'basic', 'circular', 'icon', 'button']"> - <i class="trash icon"></i> - </button> - </td> - </tr> - </draggable> - </table> - <div v-if="$store.state.radios.running" class="ui black 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 inverted red button"><translate translate-context="*/Player/Button.Label/Short, Verb">Stop radio</translate></div> - </div> - </div> - </section> - </div> - <player @next="scrollToCurrent" @previous="scrollToCurrent"></player> + </nav> </aside> </template> <script> import { mapState, mapActions, mapGetters } from "vuex" -import Player from "@/components/audio/Player" import Logo from "@/components/Logo" import SearchBar from "@/components/audio/SearchBar" import backend from "@/audio/backend" -import draggable from "vuedraggable" import $ from "jquery" export default { name: "sidebar", components: { - Player, SearchBar, - Logo, - draggable + Logo }, data() { return { selectedTab: "library", backend: backend, - tracksChangeBuffer: null, isCollapsed: true, - fetchInterval: null + fetchInterval: null, + exploreExpanded: false, + myLibraryExpanded: false, } }, destroy() { @@ -218,6 +180,11 @@ export default { clearInterval(this.fetchInterval) } }, + mounted () { + this.$nextTick(() => { + document.getElementById('fake-sidebar').classList.add('loaded') + }) + }, computed: { ...mapGetters({ additionalNotifications: "ui/additionalNotifications", @@ -235,15 +202,10 @@ export default { pendingFollows, mainMenu, selectTrack, - pendingReviewEdits - } - }, - tracks: { - get() { - return this.$store.state.queue.tracks - }, - set(value) { - this.tracksChangeBuffer = value + pendingReviewEdits, + addContent: this.$pgettext("*/Library/*/Verb", 'Add content'), + notifications: this.$pgettext("*/Notifications/*", 'Notifications'), + administration: this.$pgettext("Sidebar/Admin/Title/Noun", 'Administration'), } }, logoUrl() { @@ -252,36 +214,42 @@ export default { } else { return "index" } + }, + focusedMenu () { + let mapping = { + "library.index": 'exploreExpanded', + "library.albums.browse": 'exploreExpanded', + "library.albums.detail": 'exploreExpanded', + "library.artists.browse": 'exploreExpanded', + "library.artists.detail": 'exploreExpanded', + "library.tracks.detail": 'exploreExpanded', + "library.playlists.browse": 'exploreExpanded', + "library.playlists.detail": 'exploreExpanded', + "library.radios.browse": 'exploreExpanded', + "library.radios.detail": 'exploreExpanded', + 'library.me': "myLibraryExpanded", + 'library.albums.me': "myLibraryExpanded", + 'library.artists.me': "myLibraryExpanded", + 'library.playlists.me': "myLibraryExpanded", + 'library.radios.me': "myLibraryExpanded", + 'favorites': "myLibraryExpanded", + } + let m = mapping[this.$route.name] + if (m) { + return m + } + + if (this.$store.state.auth.authenticated) { + return 'myLibraryExpanded' + } else { + return 'exploreExpanded' + } } }, methods: { ...mapActions({ cleanTrack: "queue/cleanTrack" }), - reorder: function(event) { - this.$store.commit("queue/reorder", { - tracks: this.tracksChangeBuffer, - oldIndex: event.oldIndex, - newIndex: event.newIndex - }) - }, - scrollToCurrent() { - let current = $(this.$el).find('[data-tab="queue"] .active')[0] - if (!current) { - return - } - let container = $(this.$el).find(".tabs")[0] - // Position container at the top line then scroll current into view - container.scrollTop = 0 - current.scrollIntoView(true) - // Scroll back nothing if element is at bottom of container else do it - // for half the height of the containers display area - var scrollBack = - container.scrollHeight - container.scrollTop <= container.clientHeight - ? 0 - : container.clientHeight / 2 - container.scrollTop = container.scrollTop - scrollBack - }, applyContentFilters () { let artistIds = this.$store.getters['moderation/artistFilters']().map((f) => { return f.target.id @@ -303,26 +271,66 @@ export default { return await self.cleanTrack(realIndex) } }) - + }, + setupDropdown (selector) { + let self = this + $(self.$el).find(selector).dropdown({ + selectOnKeydown: false, + action: function (text, value, $el) { + // used ton ensure focusing the dropdown and clicking via keyboard + // works as expected + let link = $($el).closest('a') + let url = link.attr('href') + self.$router.push(url) + $(self.$el).find(selector).dropdown('hide') + } + }) } }, watch: { url: function() { this.isCollapsed = true }, - selectedTab: function(newValue) { - if (newValue === "queue") { - this.scrollToCurrent() + "$store.state.moderation.lastUpdate": function () { + this.applyContentFilters() + }, + "$store.state.auth.authenticated": { + immediate: true, + handler (v) { + if (v) { + this.$nextTick(() => { + this.setupDropdown('.user-dropdown') + }) + } + } + }, + "$store.state.auth.availablePermissions": { + immediate: true, + handler (v) { + this.$nextTick(() => { + this.setupDropdown('.admin-dropdown') + }) + }, + deep: true, + }, + focusedMenu: { + immediate: true, + handler (n) { + if (n) { + this[n] = true + } } }, - "$store.state.queue.currentIndex": function() { - if (this.selectedTab !== "queue") { - this.scrollToCurrent() + myLibraryExpanded (v) { + if (v) { + this.exploreExpanded = false + } + }, + exploreExpanded (v) { + if (v) { + this.myLibraryExpanded = false } }, - "$store.state.moderation.lastUpdate": function () { - this.applyContentFilters() - } } } </script> @@ -331,16 +339,24 @@ export default { <style scoped lang="scss"> @import "../style/vendor/media"; -$sidebar-color: #3d3e3f; +$sidebar-color: #2D2F33; .sidebar { background: $sidebar-color; - @include media(">tablet") { + @include media(">desktop") { display: flex; flex-direction: column; justify-content: space-between; + padding-bottom: 4em; + } + > nav { + flex-grow: 1; + overflow-y: auto; } @include media(">desktop") { + .menu .item.collapse-button-wrapper { + padding: 0; + } .collapse.button { display: none !important; } @@ -349,9 +365,10 @@ $sidebar-color: #3d3e3f; position: static !important; width: 100% !important; &.collapsed { - .menu-area, .player-wrapper, - .tabs { + .search, + .signup.segment, + nav.secondary { display: none; } } @@ -366,23 +383,7 @@ $sidebar-color: #3d3e3f; } } -.menu-area { - .menu .item:not(.active):not(:hover) { - opacity: 0.75; - } - - .menu .item { - border-radius: 0; - } - - .menu .item.active { - background-color: $sidebar-color; - &:hover { - background-color: rgba(255, 255, 255, 0.06); - } - } -} -.vertical.menu { +.ui.vertical.menu { .item .item { font-size: 1em; > i.icon { @@ -390,9 +391,29 @@ $sidebar-color: #3d3e3f; margin: 0 0.5em 0 0; } &:not(.active) { - color: rgba(255, 255, 255, 0.75); + // color: rgba(255, 255, 255, 0.75); } } + .item.active { + border-right: 5px solid #F2711C; + border-radius: 0 !important; + background-color: rgba(255, 255, 255, 0.15) !important; + } + .item.collapsed { + &:not(:focus) > .menu { + display: none; + } + .header { + margin-bottom: 0; + } + } + .collaspable.item .header { + cursor: pointer; + } +} +.ui.secondary.menu { + margin-left: 0; + margin-right: 0; } .tabs { flex: 1; @@ -416,6 +437,10 @@ $sidebar-color: #3d3e3f; width: 55px; } } +.item .header .angle.icon { + float: right; + margin: 0; +} .tab[data-tab="library"] { flex-direction: column; flex: 1 1 auto; @@ -432,8 +457,30 @@ $sidebar-color: #3d3e3f; border-radius: 0; } -.ui.inverted.segment.header-wrapper { +.ui.menu .item.inline.admin-dropdown.dropdown > .menu { + left: 0; + right: auto; +} +.ui.segment.header-wrapper { padding: 0; + display: flex; + justify-content: space-between; + align-items: center; + height: 4em; + nav { + > .item, > .menu > .item > .item { + &:hover { + background-color: transparent; + } + } + } +} + +nav.top.title-menu { + flex-grow: 1; + .item { + font-size: 1.5em; + } } .logo { @@ -442,20 +489,14 @@ $sidebar-color: #3d3e3f; margin: 0px; } +.collapsed .search-wrapper { + @include media("<desktop") { + padding: 0; + } +} .ui.search { display: flex; - - .collapse.button, - .collapse.button:hover, - .collapse.button:active { - box-shadow: none !important; - margin: 0px; - display: flex; - flex-direction: column; - justify-content: center; - } } - .ui.message.black { background: $sidebar-color; } @@ -463,10 +504,48 @@ $sidebar-color: #3d3e3f; .ui.mini.image { width: 100%; } +nav.top { + align-items: self-end; + padding: 0.5em 0; + > .item, > .right.menu > .item { + // color: rgba(255, 255, 255, 0.9) !important; + font-size: 1.2em; + &:hover, > .dropdown > .icon { + // color: rgba(255, 255, 255, 0.9) !important; + } + > .label, > .dropdown > .label { + font-size: 0.5em; + right: 1.7em; + bottom: -0.5em; + z-index: 0 !important; + } + } +} +.ui.user-dropdown > .text > .label { + margin-right: 0; +} +.logo-wrapper { + display: inline-block; + margin: 0 auto; + @include media("<desktop") { + margin: 0; + } + img { + height: 1em; + display: inline-block; + margin: 0 auto; + } + @include media(">tablet") { + img { + height: 1.5em; + } + } +} </style> <style lang="scss"> -.sidebar { +aside.ui.sidebar { + overflow-y: visible !important; .ui.search .input { flex: 1; .prompt { diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 77fffddd3d..970ca55679 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -9,9 +9,12 @@ <i :class="[playIconClass, 'icon']"></i> <template v-if="!discrete && !iconOnly"><slot><translate translate-context="*/Queue/Button.Label/Short, Verb">Play</translate></slot></template> </button> - <div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"> + <div + v-if="!discrete && !iconOnly" + @click.prevent="clicked = true" + :class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"> <i :class="dropdownIconClasses.concat(['icon'])" :title="title" ></i> - <div class="menu"> + <div class="menu" v-if="clicked"> <button class="item basic" ref="add" data-ref="add" :disabled="!playable" @click.stop.prevent="add" :title="labels.addToQueue"> <i class="plus icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate> </button> @@ -70,20 +73,9 @@ export default { data () { return { isLoading: false, + clicked: false } }, - mounted () { - let self = this - jQuery(this.$el).find('.ui.dropdown').dropdown({ - selectOnKeydown: false, - action: function (text, value, $el) { - // used ton ensure focusing the dropdown and clicking via keyboard - // works as expected - self.$refs[$el.data('ref')].click() - jQuery(self.$el).find('.ui.dropdown').dropdown('hide') - } - }) - }, computed: { labels () { return { @@ -250,6 +242,24 @@ export default { date: new Date() }) }, + }, + watch: { + clicked () { + + let self = this + this.$nextTick(() => { + jQuery(this.$el).find('.ui.dropdown').dropdown({ + selectOnKeydown: false, + action: function (text, value, $el) { + // used ton ensure focusing the dropdown and clicking via keyboard + // works as expected + self.$refs[$el.data('ref')].click() + jQuery(self.$el).find('.ui.dropdown').dropdown('hide') + } + }) + jQuery(this.$el).find('.ui.dropdown').dropdown('show') + }) + } } } </script> diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index c5467e416e..71365a8933 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -1,261 +1,249 @@ <template> - <section class="ui inverted segment player-wrapper" :aria-label="labels.audioPlayer" :style="style"> - <div class="player"> - <div v-if="currentTrack" class="track-area ui unstackable items"> - <div class="ui inverted item"> - <div class="ui tiny image"> - <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)"> + <section v-if="currentTrack" class="player-wrapper ui bottom-player"> + <div class="ui inverted segment fixed-controls" @click.prevent.stop="toggleMobilePlayer"> + <div + :class="['ui', 'top attached', 'small', 'orange', 'inverted', {'indicating': isLoadingAudio}, 'progress']"> + <div class="buffer bar" :data-percent="bufferProgress" :style="{ 'width': bufferProgress + '%' }"></div> + <div class="position bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div> + </div> + <div class="controls-row"> + + <div class="controls track-controls queue-not-focused desktop-and-up"> + <div @click.stop.prevent="" class="ui tiny image" @click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"> + <img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)"> <img v-else src="../../assets/audio/default-cover.png"> </div> - <div class="middle aligned content"> - <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"> - {{ currentTrack.title }} - </router-link> + <div @click.stop.prevent="" class="middle aligned content ellipsis"> + <strong> + <router-link @click.stop.prevent="" class="small header discrete link track" :title="currentTrack.title" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"> + {{ currentTrack.title }} + </router-link> + </strong> <div class="meta"> - <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"> - {{ currentTrack.artist.name }} - </router-link> / - <router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"> + <router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"> + {{ currentTrack.artist.name }}</router-link> /<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"> {{ currentTrack.album.title }} </router-link> </div> - <div class="description"> - <track-favorite-icon - v-if="$store.state.auth.authenticated" - :class="{'inverted': !$store.getters['favorites/isFavorite'](currentTrack.id)}" - :track="currentTrack"></track-favorite-icon> - <track-playlist-icon - v-if="$store.state.auth.authenticated" - :class="['inverted']" - :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', 'inverted', 'icon', 'button']" - :aria-label="labels.addArtistContentFilter" - :title="labels.addArtistContentFilter"> - <i :class="['eye slash outline', 'basic', 'icon']"></i> - </button> - </div> </div> </div> - </div> - <div class="progress-area" v-if="currentTrack && !errored"> - <div class="ui grid"> - <div class="left floated four wide column"> - <p class="timer start" @click="setCurrentTime(0)">{{currentTimeFormatted}}</p> + <div class="controls track-controls queue-not-focused tablet-and-below"> + <div class="ui tiny image"> + <img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)"> + <img v-else src="../../assets/audio/default-cover.png"> </div> - - <div v-if="!isLoadingAudio" class="right floated four wide column"> - <p class="timer total">{{durationFormatted}}</p> + <div class="middle aligned content ellipsis"> + <strong> + {{ currentTrack.title }} + </strong> + <div class="meta"> + {{ currentTrack.artist.name }} / {{ currentTrack.album.title }} + </div> </div> </div> - <div - ref="progress" - :class="['ui', 'small', 'orange', 'inverted', {'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 class="controls desktop-and-up fluid align-right" v-if="$store.state.auth.authenticated"> + <track-favorite-icon + class="control white" + :track="currentTrack"></track-favorite-icon> + <track-playlist-icon + class="control white" + :track="currentTrack"></track-playlist-icon> + <button + @click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})" + :class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']" + :aria-label="labels.addArtistContentFilter" + :title="labels.addArtistContentFilter"> + <i :class="['eye slash outline', 'basic', 'icon']"></i> + </button> </div> - </div> - <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="two wide column controls ui grid"> - <span - role="button" - :title="labels.previousTrack" - :aria-label="labels.previousTrack" - class="two wide column control" - @click.prevent.stop="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="two wide column 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="two wide column control"> - <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']"></i> - </span> - <span - role="button" - :title="labels.next" - :aria-label="labels.next" - class="two wide column control" - @click.prevent.stop="next" - :disabled="!hasNext"> - <i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" ></i> - </span> - <div - class="wide column control volume-control" - v-on:mouseover="showVolume = true" - v-on:mouseleave="showVolume = false" - v-bind:class="{ active : showVolume }"> + <div class="player-controls controls queue-not-focused"> <span role="button" - v-if="volume === 0" - :title="labels.unmute" - :aria-label="labels.unmute" - @click.prevent.stop="unmute"> - <i class="volume off icon"></i> + :title="labels.previous" + :aria-label="labels.previous" + class="control tablet-and-up" + @click.prevent.stop="$store.dispatch('queue/previous')" + :disabled="!hasPrevious"> + <i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" ></i> </span> <span role="button" - v-else-if="volume < 0.5" - :title="labels.mute" - :aria-label="labels.mute" - @click.prevent.stop="mute"> - <i class="volume down icon"></i> + v-if="!playing" + :title="labels.play" + :aria-label="labels.play" + @click.prevent.stop="togglePlay" + class="control"> + <i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']"></i> </span> <span role="button" v-else - :title="labels.mute" - :aria-label="labels.mute" - @click.prevent.stop="mute"> - <i class="volume up icon"></i> - </span> - <input - type="range" - step="0.05" - min="0" - max="1" - v-model="sliderVolume" - v-if="showVolume" /> - </div> - <div class="two wide column control looping" v-if="!showVolume"> - <span - role="button" - v-if="looping === 0" - :title="labels.loopingDisabled" - :aria-label="labels.loopingDisabled" - @click.prevent.stop="$store.commit('player/looping', 1)" - :disabled="!currentTrack"> - <i :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'icon']"></i> + :title="labels.pause" + :aria-label="labels.pause" + @click.prevent.stop="togglePlay" + class="control"> + <i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']"></i> </span> <span role="button" - @click.prevent.stop="$store.commit('player/looping', 2)" - :title="labels.loopingSingle" - :aria-label="labels.loopingSingle" - v-if="looping === 1" - :disabled="!currentTrack"> - <i - class="repeat icon"> - <span class="ui circular tiny orange label">1</span> - </i> - </span> - <span - role="button" - :title="labels.loopingWhole" - :aria-label="labels.loopingWhole" - v-if="looping === 2" - :disabled="!currentTrack" - @click.prevent.stop="$store.commit('player/looping', 0)"> - <i - class="repeat orange icon"> - </i> + :title="labels.next" + :aria-label="labels.next" + class="control" + @click.prevent.stop="$store.dispatch('queue/next')" + :disabled="!hasNext"> + <i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" ></i> </span> </div> - <span - role="button" - :disabled="queue.tracks.length === 0" - :title="labels.shuffle" - :aria-label="labels.shuffle" - v-if="!showVolume" - @click.prevent.stop="shuffle()" - class="two wide column control"> - <div v-if="isShuffling" class="ui inline shuffling inverted tiny active loader"></div> - <i v-else :class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> - </span> - <div class="one wide column" v-if="!showVolume"></div> - <span - role="button" - :disabled="queue.tracks.length === 0" - :title="labels.clear" - :aria-label="labels.clear" - v-if="!showVolume" - @click.prevent.stop="clean()" - class="two wide column control"> - <i class="icons"> - <i :class="['ui', 'trash', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> - <i :class="['ui corner inverted', 'list', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> - </i> - </span> + + <div class="controls progress-controls queue-not-focused tablet-and-up small align-left"> + <div class="timer"> + <template v-if="!isLoadingAudio"> + <span role="button" class="start" @click.stop.prevent="setCurrentTime(0)">{{currentTimeFormatted}}</span> + | <span class="total">{{durationFormatted}}</span> + </template> + <template v-else> + 00:00 | 00:00 + </template> + </div> + </div> + <div class="controls queue-controls when-queue-focused align-right"> + <div class="group"> + <volume-control class="expandable" /> + <span + role="button" + v-if="looping === 0" + :title="labels.loopingDisabled" + :aria-label="labels.loopingDisabled" + @click.prevent.stop="$store.commit('player/looping', 1)" + :disabled="!currentTrack"> + <i :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'icon']"></i> + </span> + <span + role="button" + @click.prevent.stop="$store.commit('player/looping', 2)" + :title="labels.loopingSingle" + :aria-label="labels.loopingSingle" + v-if="looping === 1" + class="looping" + :disabled="!currentTrack"> + <i + class="repeat icon"> + <span class="ui circular tiny orange label">1</span> + </i> + </span> + <span + role="button" + :title="labels.loopingWhole" + :aria-label="labels.loopingWhole" + v-if="looping === 2" + :disabled="!currentTrack" + class="looping" + @click.prevent.stop="$store.commit('player/looping', 0)"> + <i + class="repeat icon"> + <span class="ui circular tiny orange label">∞</span> + </i> + </span> + <span + role="button" + :disabled="queue.tracks.length === 0" + :title="labels.shuffle" + :aria-label="labels.shuffle" + @click.prevent.stop="shuffle()"> + <div v-if="isShuffling" class="ui inline shuffling inverted tiny active loader"></div> + <i v-else :class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> + </span> + </div> + <div class="group"> + <div class="fake-dropdown"> + <span class="position control desktop-and-up" role="button" @click.stop="toggleMobilePlayer"> + <i class="stream icon"></i> + <translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"> + %{ index } of %{ length } + </translate> + </span> + <span class="position control tablet-and-below" role="button" @click.stop="switchTab"> + <i class="stream icon"></i> + <translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"> + %{ index } of %{ length } + </translate> + </span> + + <span + class="control close-control desktop-and-up" + v-if="$store.state.ui.queueFocused" + @click.stop="toggleMobilePlayer"> + <i class="large down angle icon"></i> + </span> + <span + class="control desktop-and-up" + v-else + @click.stop="toggleMobilePlayer"> + <i class="large up angle icon"></i> + </span> + <span + class="control close-control tablet-and-below" + v-if="$store.state.ui.queueFocused === 'player'" + @click.stop="switchTab"> + <i class="large up angle icon"></i> + </span> + <span + class="control tablet-and-below" + v-if="$store.state.ui.queueFocused === 'queue'" + @click.stop="switchTab"> + <i class="large down angle icon"></i> + </span> + </div> + <span + class="control close-control tablet-and-below" + @click.stop="$store.commit('ui/queueFocused', null)"> + <i class="x icon"></i> + </span> + </div> + </div> </div> - <GlobalEvents - @keydown.space.prevent.exact="togglePlay" - @keydown.ctrl.shift.left.prevent.exact="previous" - @keydown.ctrl.shift.right.prevent.exact="next" - @keydown.shift.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)" - @keydown.shift.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)" - @keydown.right.prevent.exact="seek (5)" - @keydown.left.prevent.exact="seek (-5)" - @keydown.shift.right.prevent.exact="seek (30)" - @keydown.shift.left.prevent.exact="seek (-30)" - @keydown.m.prevent.exact="toggleMute" - @keydown.l.exact="$store.commit('player/toggleLooping')" - @keydown.s.exact="shuffle" - @keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)" - @keydown.q.exact="clean" - /> </div> + <GlobalEvents + @keydown.space.prevent.exact="togglePlay" + @keydown.ctrl.shift.left.prevent.exact="previous" + @keydown.ctrl.shift.right.prevent.exact="next" + @keydown.shift.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)" + @keydown.shift.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)" + @keydown.right.prevent.exact="seek (5)" + @keydown.left.prevent.exact="seek (-5)" + @keydown.shift.right.prevent.exact="seek (30)" + @keydown.shift.left.prevent.exact="seek (-30)" + @keydown.m.prevent.exact="toggleMute" + @keydown.l.exact="$store.commit('player/toggleLooping')" + @keydown.s.exact="shuffle" + @keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)" + @keydown.q.exact="clean" + @keydown.e.exact="toggleMobilePlayer" + /> </section> </template> <script> import { mapState, mapGetters, mapActions } from "vuex" 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 TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon" -import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon" - export default { components: { - TrackFavoriteIcon, - TrackPlaylistIcon, + VolumeControl: () => import(/* webpackChunkName: "audio" */ "./VolumeControl"), + TrackFavoriteIcon: () => import(/* webpackChunkName: "auth-audio" */ "@/components/favorites/TrackFavoriteIcon"), + TrackPlaylistIcon: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/TrackPlaylistIcon"), GlobalEvents, }, data() { - let defaultAmbiantColors = [ - [46, 46, 46], - [46, 46, 46], - [46, 46, 46], - [46, 46, 46] - ] return { isShuffling: false, sliderVolume: this.volume, - defaultAmbiantColors: defaultAmbiantColors, showVolume: false, - ambiantColors: defaultAmbiantColors, currentSound: null, dummyAudio: null, isUpdatingTime: false, @@ -350,26 +338,6 @@ export default { self.$emit("previous") }) }, - touchProgress(e) { - let time - let target = this.$refs.progress - time = (e.layerX / target.offsetWidth) * this.duration - this.setCurrentTime(time) - }, - updateBackground() { - // delete existing canvas, if any - $('canvas.color-thief').remove() - if (!this.currentTrack.album.cover) { - this.ambiantColors = this.defaultAmbiantColors - return - } - let image = this.$refs.cover - try { - this.ambiantColors = ColorThief.prototype.getPalette(image, 4).slice(0, 4) - } catch (e) { - console.log('Cannot generate player background from cover image, likely a cross-origin tainted canvas issue') - } - }, handleError({ sound, error }) { this.$store.commit("player/isLoadingAudio", false) this.$store.dispatch("player/trackErrored") @@ -621,7 +589,22 @@ export default { this.observeProgress(true) } } - } + }, + toggleMobilePlayer () { + if (['queue', 'player'].indexOf(this.$store.state.ui.queueFocused) > -1) { + this.$store.commit('ui/queueFocused', null) + } else { + this.$store.commit('ui/queueFocused', 'player') + } + }, + switchTab () { + if (this.$store.state.ui.queueFocused === 'player') { + this.$store.commit('ui/queueFocused', 'queue') + } else { + this.$store.commit('ui/queueFocused', 'player') + + } + }, }, computed: { ...mapState({ @@ -639,6 +622,7 @@ export default { ...mapGetters({ currentTrack: "queue/currentTrack", hasNext: "queue/hasNext", + hasPrevious: "queue/hasPrevious", emptyQueue: "queue/isEmpty", durationFormatted: "player/durationFormatted", currentTimeFormatted: "player/currentTimeFormatted", @@ -655,6 +639,7 @@ export default { let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track") let unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute") let mute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute") + let expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Expand queue") let loopingDisabled = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Looping disabled. Click to switch to single-track looping." ) @@ -680,35 +665,10 @@ export default { loopingWhole, shuffle, clear, + expandQueue, addArtistContentFilter, } }, - style: function() { - let style = { - background: this.ambiantGradiant - } - return style - }, - ambiantGradiant: function() { - let indexConf = [ - { orientation: 330, percent: 100, opacity: 0.7 }, - { orientation: 240, percent: 90, opacity: 0.7 }, - { orientation: 150, percent: 80, opacity: 0.7 }, - { orientation: 60, percent: 70, opacity: 0.7 } - ] - let gradients = this.ambiantColors - .map((e, i) => { - let [r, g, b] = e - let conf = indexConf[i] - return `linear-gradient(${ - conf.orientation - }deg, rgba(${r}, ${g}, ${b}, ${ - conf.opacity - }) 10%, rgba(255, 255, 255, 0) ${conf.percent}%)` - }) - .join(", ") - return gradients - }, }, watch: { currentTrack: { @@ -725,9 +685,6 @@ export default { this.$store.commit("player/isLoadingAudio", true) this.playTimeout = setTimeout(async () => { await self.loadSound(newValue, oldValue) - if (!newValue || !newValue.album.cover) { - self.ambiantColors = self.defaultAmbiantColors - } }, 500); }, immediate: false @@ -771,43 +728,10 @@ export default { <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped lang="scss"> -.ui.progress { - margin: 0.5rem 0 1rem; -} -.progress { - cursor: pointer; - .bar { - min-width: 0 !important; - } -} - -.ui.inverted.item > .content > .description { - color: rgba(255, 255, 255, 0.9) !important; -} - -.ui.item { - .meta { - font-size: 90%; - line-height: 1.2; - } -} -.timer.total { - text-align: right; -} -.timer.start { - cursor: pointer; -} -.track-area { - margin-top: 0; - .header, - .meta, - .artist, - .album { - color: white !important; - } -} -.controls a { - color: white; +@import "../../style/vendor/media"; +.controls { + display: flex; + justify-content: space-between; } .controls .icon.big { @@ -819,150 +743,55 @@ export default { cursor: pointer; vertical-align: middle; } - -.control .icon { - font-size: 1.5em; -} -.progress-area .actions { - text-align: center; -} -.ui.progress:not([data-percent]):not(.indeterminate) - .bar.position:not(.buffer) { - background: #ff851b; -} -.volume-control { - position: relative; - width: 12.5% !important; - [type="range"] { - max-width: 70%; - position: absolute; - bottom: 1.1rem; - left: 25%; - cursor: pointer; - background-color: transparent; - } - input[type="range"]:focus { - outline: none; - } - input[type="range"]::-webkit-slider-runnable-track { - cursor: pointer; - } - input[type="range"]::-webkit-slider-thumb { - background: white; - cursor: pointer; - -webkit-appearance: none; - border-radius: 3px; - width: 10px; - } - input[type="range"]::-moz-range-track { - cursor: pointer; - background: white; - opacity: 0.3; - } - input[type="range"]::-moz-focus-outer { - border: 0; - } - input[type="range"]::-moz-range-thumb { - background: white; - cursor: pointer; - border-radius: 3px; - width: 10px; - } - input[type="range"]::-ms-track { - cursor: pointer; - background: transparent; - border-color: transparent; - color: transparent; - } - input[type="range"]::-ms-fill-lower { - background: white; - opacity: 0.3; - } - input[type="range"]::-ms-fill-upper { - background: white; - opacity: 0.3; - } - input[type="range"]::-ms-thumb { - background: white; - cursor: pointer; - border-radius: 3px; - width: 10px; - } - input[type="range"]:focus::-ms-fill-lower { - background: white; - } - input[type="range"]:focus::-ms-fill-upper { - background: white; - } -} - -.active.volume-control { - width: 60% !important; +.timer { + font-size: 1.2em; } - -.looping.control { +.looping { i { position: relative; } - .label { + .ui.circular.label { + font-family: sans-serif; position: absolute; - font-size: 0.7rem; + font-size: 0.5em !important; bottom: -0.7rem; right: -0.7rem; + padding: 2px 0 !important; + width: 15px !important; + height: 15px !important; + min-width: 15px !important; + min-height: 15px !important; + @include media(">desktop") { + font-size: 0.6em !important; + } } } -.ui.feed.icon { - margin: 0; -} .shuffling.loader.inline { margin: 0; } - -@keyframes MOVE-BG { - from { - transform: translateX(0px); +.control.circular.button { + padding: 0; + border: none; + background-color: transparent; + color: inherit; + &:focus { + box-shadow: none; } - to { - transform: translateX(46px); - } -} - -.indicating.progress { - overflow: hidden; -} -.ui.progress .bar { - transition: none; } - -.ui.inverted.progress .buffer.bar { - position: absolute; - background-color: rgba(255, 255, 255, 0.15); -} -.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; -} - -.icons { - position: absolute; -} - -i.icons .corner.icon { - font-size: 1em; - right: -0.3em; +.fake-dropdown { + border: 1px solid gray; + border-radius: 3px; + padding: 0.5em; + display: flex; + align-items: center; + justify-content: space-between; + min-width: 10em; + .position.control { + margin-right: 1em; + } + .angle.icon { + margin-right: 0; + } } </style> diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index ae2ae08fb3..ed18805aa8 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -1,7 +1,7 @@ <template> <div class="ui fluid category search"> <slot></slot><div class="ui icon input"> - <input class="prompt" ref="search" name="search" :placeholder="labels.placeholder" type="text" @keydown.esc="$event.target.blur()"> + <input ref="search" class="prompt" name="search" :placeholder="labels.placeholder" type="text" @keydown.esc="$event.target.blur()"> <i class="search icon"></i> </div> <div class="results"></div> diff --git a/front/src/components/audio/VolumeControl.vue b/front/src/components/audio/VolumeControl.vue new file mode 100644 index 0000000000..2ed6874838 --- /dev/null +++ b/front/src/components/audio/VolumeControl.vue @@ -0,0 +1,118 @@ +<template> + <span :class="['volume-control', {'expanded': expanded}]" @click.prevent.stop="" @mouseover="handleOver" @mouseleave="handleLeave"> + <span + role="button" + v-if="sliderVolume === 0" + :title="labels.unmute" + :aria-label="labels.unmute" + @click.prevent.stop="unmute"> + <i class="volume off icon"></i> + </span> + <span + role="button" + v-else-if="sliderVolume < 0.5" + :title="labels.mute" + :aria-label="labels.mute" + @click.prevent.stop="mute"> + <i class="volume down icon"></i> + </span> + <span + role="button" + v-else + :title="labels.mute" + :aria-label="labels.mute" + @click.prevent.stop="mute"> + <i class="volume up icon"></i> + </span> + <div class="popup"> + <input + type="range" + step="0.05" + min="0" + max="1" + v-model="sliderVolume" /> + </div> + </span> +</template> +<script> +import { mapState, mapGetters, mapActions } from "vuex" + +export default { + data () { + return { + expanded: false, + timeout: null, + } + }, + computed: { + sliderVolume: { + get () { + return this.$store.state.player.volume + }, + set (v) { + this.$store.commit("player/volume", v) + } + }, + labels () { + return { + unmute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute"), + mute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute"), + + } + } + }, + methods: { + ...mapActions({ + mute: "player/mute", + unmute: "player/unmute", + toggleMute: "player/toggleMute", + }), + handleOver () { + if (this.timeout) { + clearTimeout(this.timeout) + } + this.expanded = true + }, + handleLeave () { + if (this.timeout) { + clearTimeout(this.timeout) + } + this.timeout = setTimeout(() => {this.expanded = false}, 500) + } + } +} +</script> +<style lang="scss" scoped> + +.volume-control { + display: flex; + line-height: inherit; + align-items: center; + position: relative; + overflow: visible; + input { + max-width: 5.5em; + height: 4px; + } + &.expandable { + .popup { + background-color: #1B1C1D; + position: absolute; + left: -4em; + top: -7em; + transform: rotate(-90deg); + display: flex; + align-items: center; + height: 2.5em; + padding: 0 0.5em; + box-shadow: 1px 1px 3px rgba(125, 125, 125, 0.5); + } + input { + max-width: 8.5em; + } + &:not(:hover):not(.expanded) .popup { + display: none; + } + } +} +</style> diff --git a/front/src/components/common/ActorAvatar.vue b/front/src/components/common/ActorAvatar.vue index be21eb5816..d88dd6321c 100644 --- a/front/src/components/common/ActorAvatar.vue +++ b/front/src/components/common/ActorAvatar.vue @@ -19,3 +19,10 @@ export default { } } </script> +<style lang="scss"> +.ui.circular.avatar.label { + width: 28px; + height: 28px; + font-size: 1em !important; +} +</style> diff --git a/front/src/components/common/ExpandableDiv.vue b/front/src/components/common/ExpandableDiv.vue index 653286ad21..2a95a11a27 100644 --- a/front/src/components/common/ExpandableDiv.vue +++ b/front/src/components/common/ExpandableDiv.vue @@ -11,7 +11,7 @@ </div> </template> <script> -import sanitize from "@/sanitize" +// import sanitize from "@/sanitize" export default { props: { diff --git a/front/src/components/favorites/TrackFavoriteIcon.vue b/front/src/components/favorites/TrackFavoriteIcon.vue index e6cde22653..e2b90460ca 100644 --- a/front/src/components/favorites/TrackFavoriteIcon.vue +++ b/front/src/components/favorites/TrackFavoriteIcon.vue @@ -1,12 +1,12 @@ <template> - <button @click="$store.dispatch('favorites/toggle', track.id)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']"> + <button @click.stop="$store.dispatch('favorites/toggle', track.id)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']"> <i class="heart icon"></i> <translate v-if="isFavorite" translate-context="Content/Track/Button.Message">In favorites</translate> <translate v-else translate-context="Content/Track/*/Verb">Add to favorites</translate> </button> <button v-else - @click="$store.dispatch('favorites/toggle', track.id)" + @click.stop="$store.dispatch('favorites/toggle', track.id)" :class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', 'really', 'button']" :aria-label="title" :title="title"> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 01e33b00fa..2644324696 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -1,63 +1,19 @@ import Vue from 'vue' -import HumanDate from '@/components/common/HumanDate' - -Vue.component('human-date', HumanDate) - -import Username from '@/components/common/Username' - -Vue.component('username', Username) - -import UserLink from '@/components/common/UserLink' - -Vue.component('user-link', UserLink) - -import ActorLink from '@/components/common/ActorLink' - -Vue.component('actor-link', ActorLink) - -import ActorAvatar from '@/components/common/ActorAvatar' - -Vue.component('actor-avatar', ActorAvatar) - -import Duration from '@/components/common/Duration' - -Vue.component('duration', Duration) - -import DangerousButton from '@/components/common/DangerousButton' - -Vue.component('dangerous-button', DangerousButton) - -import Message from '@/components/common/Message' - -Vue.component('message', Message) - -import CopyInput from '@/components/common/CopyInput' - -Vue.component('copy-input', CopyInput) - -import AjaxButton from '@/components/common/AjaxButton' - -Vue.component('ajax-button', AjaxButton) - -import Tooltip from '@/components/common/Tooltip' - -Vue.component('tooltip', Tooltip) - -import EmptyState from '@/components/common/EmptyState' - -Vue.component('empty-state', EmptyState) - -import ExpandableDiv from '@/components/common/ExpandableDiv' - -Vue.component('expandable-div', ExpandableDiv) - -import CollapseLink from '@/components/common/CollapseLink' - -Vue.component('collapse-link', CollapseLink) - -import ActionFeedback from '@/components/common/ActionFeedback' - -Vue.component('action-feedback', ActionFeedback) +Vue.component('human-date', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDate")) +Vue.component('username', () => import(/* webpackChunkName: "common" */ "@/components/common/Username")) +Vue.component('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink")) +Vue.component('actor-link', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink")) +Vue.component('actor-avatar', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorAvatar")) +Vue.component('duration', () => import(/* webpackChunkName: "common" */ "@/components/common/Duration")) +Vue.component('dangerous-button', () => import(/* webpackChunkName: "common" */ "@/components/common/DangerousButton")) +Vue.component('message', () => import(/* webpackChunkName: "common" */ "@/components/common/Message")) +Vue.component('copy-input', () => import(/* webpackChunkName: "common" */ "@/components/common/CopyInput")) +Vue.component('ajax-button', () => import(/* webpackChunkName: "common" */ "@/components/common/AjaxButton")) +Vue.component('tooltip', () => import(/* webpackChunkName: "common" */ "@/components/common/Tooltip")) +Vue.component('empty-state', () => import(/* webpackChunkName: "common" */ "@/components/common/EmptyState")) +Vue.component('expandable-div', () => import(/* webpackChunkName: "common" */ "@/components/common/ExpandableDiv")) +Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink")) +Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback")) export default {} diff --git a/front/src/components/library/Albums.vue b/front/src/components/library/Albums.vue index 3964cbad3b..2fd3ec783e 100644 --- a/front/src/components/library/Albums.vue +++ b/front/src/components/library/Albums.vue @@ -112,6 +112,7 @@ export default { props: { defaultQuery: { type: String, required: false, default: "" }, defaultTags: { type: Array, required: false, default: () => { return [] } }, + scope: { type: String, required: false, default: "all" }, }, components: { AlbumCard, @@ -164,6 +165,7 @@ export default { this.isLoading = true let url = FETCH_URL let params = { + scope: this.scope, page: this.page, page_size: this.paginateBy, q: this.query, diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 905c33d373..7f83fb0a0d 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -100,6 +100,7 @@ export default { props: { defaultQuery: { type: String, required: false, default: "" }, defaultTags: { type: Array, required: false, default: () => { return [] } }, + scope: { type: String, required: false, default: "all" }, }, components: { ArtistCard, @@ -152,6 +153,7 @@ export default { this.isLoading = true let url = FETCH_URL let params = { + scope: this.scope, page: this.page, page_size: this.paginateBy, q: this.query, diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index 60c3d53942..9cb8119825 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -3,17 +3,17 @@ <section class="ui vertical stripe segment"> <div class="ui stackable three column grid"> <div class="column"> - <track-widget :url="'history/listenings/'" :filters="{scope: 'all', ordering: '-creation_date'}"> + <track-widget :url="'history/listenings/'" :filters="{scope: scope, ordering: '-creation_date'}"> <template slot="title"><translate translate-context="Content/Home/Title">Recently listened</translate></template> </track-widget> </div> <div class="column"> - <track-widget :url="'favorites/tracks/'" :filters="{scope: 'all', ordering: '-creation_date'}"> + <track-widget :url="'favorites/tracks/'" :filters="{scope: scope, ordering: '-creation_date'}"> <template slot="title"><translate translate-context="Content/Home/Title">Recently favorited</translate></template> </track-widget> </div> <div class="column"> - <playlist-widget :url="'playlists/'" :filters="{scope: 'all', playable: true, ordering: '-modification_date'}"> + <playlist-widget :url="'playlists/'" :filters="{scope: scope, playable: true, ordering: '-modification_date'}"> <template slot="title"><translate translate-context="*/*/*">Playlists</translate></template> </playlist-widget> </div> @@ -21,7 +21,7 @@ <div class="ui section hidden divider"></div> <div class="ui stackable one column grid"> <div class="column"> - <album-widget :filters="{playable: true, ordering: '-creation_date'}"> + <album-widget :filters="{scope: scope, playable: true, ordering: '-creation_date'}"> <template slot="title"><translate translate-context="Content/Home/Title">Recently added</translate></template> </album-widget> </div> @@ -43,6 +43,9 @@ const ARTISTS_URL = "artists/" export default { name: "library", + props: { + scope: {default: 'all'} + }, components: { Search, ArtistCard, @@ -53,7 +56,7 @@ export default { data() { return { artists: [], - isLoadingArtists: false + isLoadingArtists: false, } }, created() { diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index ea4d98e654..ed528cd965 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -1,22 +1,5 @@ <template> <div class="main library pusher"> - <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu"> - <router-link class="ui item" to="/library" exact> - <translate translate-context="*/Library/*/Verb">Browse</translate> - </router-link> - <router-link class="ui item" to="/library/albums" exact> - <translate translate-context="*/*/*">Albums</translate> - </router-link> - <router-link class="ui item" to="/library/artists" exact> - <translate translate-context="*/*/*/Noun">Artists</translate> - </router-link> - <router-link class="ui item" to="/library/radios" exact> - <translate translate-context="*/*/*">Radios</translate> - </router-link> - <router-link class="ui item" to="/library/playlists" exact> - <translate translate-context="*/*/*">Playlists</translate> - </router-link> - </nav> <router-view :key="$route.fullPath"></router-view> </div> </template> diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue index fcc14b807e..2c579c1709 100644 --- a/front/src/components/library/Radios.vue +++ b/front/src/components/library/Radios.vue @@ -127,7 +127,8 @@ const FETCH_URL = "radios/radios/" export default { mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], props: { - defaultQuery: { type: String, required: false, default: "" } + defaultQuery: { type: String, required: false, default: "" }, + scope: { type: String, required: false, default: "all" }, }, components: { RadioCard, @@ -183,10 +184,11 @@ export default { this.isLoading = true let url = FETCH_URL let params = { + scope: this.scope, page: this.page, page_size: this.paginateBy, name__icontains: this.query, - ordering: this.getOrderingAsString() + ordering: this.getOrderingAsString(), } logger.default.debug("Fetching radios") axios.get(url, { params: params }).then(response => { diff --git a/front/src/components/manage/moderation/InstancePolicyCard.vue b/front/src/components/manage/moderation/InstancePolicyCard.vue index 994003e3a1..5ece6f1f99 100644 --- a/front/src/components/manage/moderation/InstancePolicyCard.vue +++ b/front/src/components/manage/moderation/InstancePolicyCard.vue @@ -61,7 +61,7 @@ export default { }, created () { let self = this - import('showdown').then(module => { + import(/* webpackChunkName: "showdown" */ 'showdown').then(module => { self.markdown = new module.default.Converter({simplifiedAutoLink: true, openLinksInNewWindow: true}) }) } diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue index da2c1bfcb5..a0ca73e553 100644 --- a/front/src/components/moderation/ReportModal.vue +++ b/front/src/components/moderation/ReportModal.vue @@ -74,13 +74,11 @@ import axios from 'axios' import {mapState} from 'vuex' import logger from '@/logging' -import Modal from '@/components/semantic/Modal' -import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown' export default { components: { - Modal, - ReportCategoryDropdown, + ReportCategoryDropdown: () => import(/* webpackChunkName: "reports" */ "@/components/moderation/ReportCategoryDropdown"), + Modal: () => import(/* webpackChunkName: "modal" */ "@/components/semantic/Modal"), }, data () { return { diff --git a/front/src/components/playlists/TrackPlaylistIcon.vue b/front/src/components/playlists/TrackPlaylistIcon.vue index 2f57eb163a..04787406d3 100644 --- a/front/src/components/playlists/TrackPlaylistIcon.vue +++ b/front/src/components/playlists/TrackPlaylistIcon.vue @@ -1,6 +1,6 @@ <template> <button - @click="$store.commit('playlists/chooseTrack', track)" + @click.stop="$store.commit('playlists/chooseTrack', track)" v-if="button" :class="['ui', 'icon', 'labeled', 'button']"> <i class="list icon"></i> @@ -8,7 +8,7 @@ </button> <button v-else - @click="$store.commit('playlists/chooseTrack', track)" + @click.stop="$store.commit('playlists/chooseTrack', track)" :class="['ui', 'basic', 'circular', 'icon', 'really', 'button']" :aria-label="labels.addToPlaylist" :title="labels.addToPlaylist"> diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue index c3af1524b0..828e842ab8 100644 --- a/front/src/components/semantic/Modal.vue +++ b/front/src/components/semantic/Modal.vue @@ -1,7 +1,7 @@ <template> <div :class="['ui', {'active': show}, 'modal']"> <i class="close icon"></i> - <slot> + <slot v-if="show"> </slot> </div> diff --git a/front/src/lodash.js b/front/src/lodash.js index 213711bd34..f633e9a94f 100644 --- a/front/src/lodash.js +++ b/front/src/lodash.js @@ -14,4 +14,5 @@ export default { remove: require('lodash/remove'), reverse: require('lodash/reverse'), isEqual: require('lodash/isEqual'), + sum: require('lodash/sum'), } diff --git a/front/src/router/index.js b/front/src/router/index.js index 1a99b60720..3871f7fe71 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -38,26 +38,26 @@ export default new Router({ path: "/about", name: "about", component: () => - import(/* webpackChunkName: "core" */ "@/components/About") + import(/* webpackChunkName: "about" */ "@/components/About") }, { path: "/login", name: "login", component: () => - import(/* webpackChunkName: "core" */ "@/views/auth/Login"), + import(/* webpackChunkName: "login" */ "@/views/auth/Login"), props: route => ({ next: route.query.next || "/library" }) }, { path: "/notifications", name: "notifications", component: () => - import(/* webpackChunkName: "core" */ "@/views/Notifications") + import(/* webpackChunkName: "notifications" */ "@/views/Notifications") }, { path: "/auth/password/reset", name: "auth.password-reset", component: () => - import(/* webpackChunkName: "core" */ "@/views/auth/PasswordReset"), + import(/* webpackChunkName: "password-reset" */ "@/views/auth/PasswordReset"), props: route => ({ defaultEmail: route.query.email }) @@ -66,7 +66,7 @@ export default new Router({ path: "/auth/email/confirm", name: "auth.email-confirm", component: () => - import(/* webpackChunkName: "core" */ "@/views/auth/EmailConfirm"), + import(/* webpackChunkName: "signup" */ "@/views/auth/EmailConfirm"), props: route => ({ defaultKey: route.query.key }) @@ -76,7 +76,7 @@ export default new Router({ name: "auth.password-reset-confirm", component: () => import( - /* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm" + /* webpackChunkName: "password-reset" */ "@/views/auth/PasswordResetConfirm" ), props: route => ({ defaultUid: route.query.uid, @@ -87,7 +87,7 @@ export default new Router({ path: "/authorize", name: "authorize", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Authorize"), + import(/* webpackChunkName: "settings" */ "@/components/auth/Authorize"), props: route => ({ clientId: route.query.client_id, redirectUri: route.query.redirect_uri, @@ -101,7 +101,7 @@ export default new Router({ path: "/signup", name: "signup", component: () => - import(/* webpackChunkName: "core" */ "@/views/auth/Signup"), + import(/* webpackChunkName: "signup" */ "@/views/auth/Signup"), props: route => ({ defaultInvitation: route.query.invitation }) @@ -110,13 +110,13 @@ export default new Router({ path: "/logout", name: "logout", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Logout") + import(/* webpackChunkName: "login" */ "@/components/auth/Logout") }, { path: "/settings", name: "settings", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Settings") + import(/* webpackChunkName: "settings" */ "@/components/auth/Settings") }, { path: "/settings/applications/new", @@ -128,7 +128,7 @@ export default new Router({ }), component: () => import( - /* webpackChunkName: "core" */ "@/components/auth/ApplicationNew" + /* webpackChunkName: "settings" */ "@/components/auth/ApplicationNew" ) }, { @@ -136,7 +136,7 @@ export default new Router({ name: "settings.applications.edit", component: () => import( - /* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit" + /* webpackChunkName: "settings" */ "@/components/auth/ApplicationEdit" ), props: true }, @@ -144,13 +144,14 @@ export default new Router({ path: "/@:username", name: "profile", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Profile"), + import(/* webpackChunkName: "core" */ "@/components/auth/Profile"), props: true }, { path: "/favorites", + name: "favorites", component: () => - import(/* webpackChunkName: "core" */ "@/components/favorites/List"), + import(/* webpackChunkName: "favorites" */ "@/components/favorites/List"), props: route => ({ defaultOrdering: route.query.ordering, defaultPage: route.query.page, @@ -173,14 +174,14 @@ export default new Router({ { path: "/content/libraries/tracks", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/Base"), + import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"), children: [ { path: "", name: "content.libraries.files", component: () => import( - /* webpackChunkName: "core" */ "@/views/content/libraries/Files" + /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Files" ), props: route => ({ query: route.query.q @@ -191,14 +192,14 @@ export default new Router({ { path: "/content/libraries", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/Base"), + import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"), children: [ { path: "", name: "content.libraries.index", component: () => import( - /* webpackChunkName: "core" */ "@/views/content/libraries/Home" + /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Home" ) }, { @@ -206,7 +207,7 @@ export default new Router({ name: "content.libraries.detail.upload", component: () => import( - /* webpackChunkName: "core" */ "@/views/content/libraries/Upload" + /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Upload" ), props: route => ({ id: route.params.id, @@ -218,7 +219,7 @@ export default new Router({ name: "content.libraries.detail", component: () => import( - /* webpackChunkName: "core" */ "@/views/content/libraries/Detail" + /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Detail" ), props: true } @@ -227,13 +228,13 @@ export default new Router({ { path: "/content/remote", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/Base"), + import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"), children: [ { path: "", name: "content.remote.index", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/remote/Home") + import(/* webpackChunkName: "auth-libraries" */ "@/views/content/remote/Home") } ] }, @@ -498,12 +499,21 @@ export default new Router({ import(/* webpackChunkName: "core" */ "@/components/library/Home"), name: "library.index" }, + { + path: "me", + component: () => + import(/* webpackChunkName: "core" */ "@/components/library/Home"), + name: "library.me", + props: route => ({ + scope: 'me', + }) + }, { path: "artists/", name: "library.artists.browse", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/Artists" + /* webpackChunkName: "artists" */ "@/components/library/Artists" ), props: route => ({ defaultOrdering: route.query.ordering, @@ -515,12 +525,30 @@ export default new Router({ defaultPage: route.query.page }) }, + { + path: "me/artists", + name: "library.artists.me", + component: () => + import( + /* webpackChunkName: "artists" */ "@/components/library/Artists" + ), + props: route => ({ + scope: 'me', + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultTags: Array.isArray(route.query.tag || []) + ? route.query.tag + : [route.query.tag], + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, { path: "albums/", name: "library.albums.browse", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/Albums" + /* webpackChunkName: "albums" */ "@/components/library/Albums" ), props: route => ({ defaultOrdering: route.query.ordering, @@ -532,14 +560,47 @@ export default new Router({ defaultPage: route.query.page }) }, + { + path: "me/albums", + name: "library.albums.me", + component: () => + import( + /* webpackChunkName: "albums" */ "@/components/library/Albums" + ), + props: route => ({ + scope: 'me', + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultTags: Array.isArray(route.query.tag || []) + ? route.query.tag + : [route.query.tag], + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, { path: "radios/", name: "library.radios.browse", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/Radios" + /* webpackChunkName: "radios" */ "@/components/library/Radios" + ), + props: route => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, + { + path: "me/radios/", + name: "library.radios.me", + component: () => + import( + /* webpackChunkName: "radios" */ "@/components/library/Radios" ), props: route => ({ + scope: 'me', defaultOrdering: route.query.ordering, defaultQuery: route.query.query, defaultPaginateBy: route.query.paginateBy, @@ -551,7 +612,7 @@ export default new Router({ name: "library.radios.build", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/radios/Builder" + /* webpackChunkName: "radios" */ "@/components/library/radios/Builder" ), props: true }, @@ -560,7 +621,7 @@ export default new Router({ name: "library.radios.edit", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/radios/Builder" + /* webpackChunkName: "radios" */ "@/components/library/radios/Builder" ), props: true }, @@ -568,15 +629,28 @@ export default new Router({ path: "radios/:id", name: "library.radios.detail", component: () => - import(/* webpackChunkName: "core" */ "@/views/radios/Detail"), + import(/* webpackChunkName: "radios" */ "@/views/radios/Detail"), props: true }, { path: "playlists/", name: "library.playlists.browse", component: () => - import(/* webpackChunkName: "core" */ "@/views/playlists/List"), + import(/* webpackChunkName: "playlists" */ "@/views/playlists/List"), + props: route => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, + { + path: "me/playlists/", + name: "library.playlists.me", + component: () => + import(/* webpackChunkName: "playlists" */ "@/views/playlists/List"), props: route => ({ + scope: 'me', defaultOrdering: route.query.ordering, defaultQuery: route.query.query, defaultPaginateBy: route.query.paginateBy, @@ -587,7 +661,7 @@ export default new Router({ path: "playlists/:id", name: "library.playlists.detail", component: () => - import(/* webpackChunkName: "core" */ "@/views/playlists/Detail"), + import(/* webpackChunkName: "playlists" */ "@/views/playlists/Detail"), props: route => ({ id: route.params.id, defaultEdit: route.query.mode === "edit" @@ -598,7 +672,7 @@ export default new Router({ name: "library.tags.detail", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/TagDetail" + /* webpackChunkName: "tags" */ "@/components/library/TagDetail" ), props: true }, @@ -606,7 +680,7 @@ export default new Router({ path: "artists/:id", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/ArtistBase" + /* webpackChunkName: "artists" */ "@/components/library/ArtistBase" ), props: true, children: [ @@ -615,7 +689,7 @@ export default new Router({ name: "library.artists.detail", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/ArtistDetail" + /* webpackChunkName: "artists" */ "@/components/library/ArtistDetail" ) }, { @@ -623,7 +697,7 @@ export default new Router({ name: "library.artists.edit", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/ArtistEdit" + /* webpackChunkName: "edits" */ "@/components/library/ArtistEdit" ) }, { @@ -631,7 +705,7 @@ export default new Router({ name: "library.artists.edit.detail", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/EditDetail" + /* webpackChunkName: "edits" */ "@/components/library/EditDetail" ), props: true } @@ -641,7 +715,7 @@ export default new Router({ path: "albums/:id", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/AlbumBase" + /* webpackChunkName: "albums" */ "@/components/library/AlbumBase" ), props: true, children: [ @@ -650,7 +724,7 @@ export default new Router({ name: "library.albums.detail", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/AlbumDetail" + /* webpackChunkName: "albums" */ "@/components/library/AlbumDetail" ) }, { @@ -658,7 +732,7 @@ export default new Router({ name: "library.albums.edit", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/AlbumEdit" + /* webpackChunkName: "edits" */ "@/components/library/AlbumEdit" ) }, { @@ -666,7 +740,7 @@ export default new Router({ name: "library.albums.edit.detail", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/EditDetail" + /* webpackChunkName: "edits" */ "@/components/library/EditDetail" ), props: true } @@ -676,7 +750,7 @@ export default new Router({ path: "tracks/:id", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/TrackBase" + /* webpackChunkName: "tracks" */ "@/components/library/TrackBase" ), props: true, children: [ @@ -685,7 +759,7 @@ export default new Router({ name: "library.tracks.detail", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/TrackDetail" + /* webpackChunkName: "tracks" */ "@/components/library/TrackDetail" ) }, { @@ -693,7 +767,7 @@ export default new Router({ name: "library.tracks.edit", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/TrackEdit" + /* webpackChunkName: "edits" */ "@/components/library/TrackEdit" ) }, { @@ -701,7 +775,7 @@ export default new Router({ name: "library.tracks.edit.detail", component: () => import( - /* webpackChunkName: "core" */ "@/components/library/EditDetail" + /* webpackChunkName: "edits" */ "@/components/library/EditDetail" ), props: true } diff --git a/front/src/store/player.js b/front/src/store/player.js index 6757f0beef..14affce90a 100644 --- a/front/src/store/player.js +++ b/front/src/store/player.js @@ -9,7 +9,7 @@ export default { errorCount: 0, playing: false, isLoadingAudio: false, - volume: 0.5, + volume: 1, tempVolume: 0.5, duration: 0, currentTime: 0, @@ -88,7 +88,7 @@ export default { return time.parse(Math.round(state.currentTime)) }, progress: state => { - return Math.round(state.currentTime / state.duration * 100) + return Math.round((state.currentTime / state.duration * 100) * 10) / 10 } }, actions: { diff --git a/front/src/store/queue.js b/front/src/store/queue.js index 9a0d55c0bf..a750dd7f01 100644 --- a/front/src/store/queue.js +++ b/front/src/store/queue.js @@ -7,14 +7,12 @@ export default { tracks: [], currentIndex: -1, ended: true, - previousQueue: null }, mutations: { reset (state) { state.tracks = [] state.currentIndex = -1 state.ended = true - state.previousQueue = null }, currentIndex (state, value) { state.currentIndex = value @@ -56,6 +54,9 @@ export default { hasNext: state => { return state.currentIndex < state.tracks.length - 1 }, + hasPrevious: state => { + return state.currentIndex > 0 && state.tracks.length > 1 + }, isEmpty: state => state.tracks.length === 0 }, actions: { diff --git a/front/src/store/ui.js b/front/src/store/ui.js index 0ddd94555c..09344b2cc4 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -6,6 +6,7 @@ export default { state: { currentLanguage: 'en_US', selectedLanguage: false, + queueFocused: null, momentLocale: 'en', lastDate: new Date(), maxMessages: 100, @@ -46,6 +47,26 @@ export default { orderingDirection: "-", ordering: "creation_date", }, + "library.albums.me": { + paginateBy: 25, + orderingDirection: "-", + ordering: "creation_date", + }, + "library.artists.me": { + paginateBy: 30, + orderingDirection: "-", + ordering: "creation_date", + }, + "library.radios.me": { + paginateBy: 12, + orderingDirection: "-", + ordering: "creation_date", + }, + "library.playlists.me": { + paginateBy: 25, + orderingDirection: "-", + ordering: "creation_date", + }, }, }, getters: { @@ -104,6 +125,10 @@ export default { computeLastDate: (state) => { state.lastDate = new Date() }, + queueFocused: (state, value) => { + state.queueFocused = value + }, + theme: (state, value) => { state.theme = value }, diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index b98584f7d2..06124c8f4a 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -79,8 +79,9 @@ // see https://github.com/webpack/webpack/issues/215 @import "./vendor/media"; -$desktop-sidebar-width: 300px; -$widedesktop-sidebar-width: 350px; +$desktop-sidebar-width: 275px; +$widedesktop-sidebar-width: 275px; +$bottom-player-height: 4rem; html, body { @@ -88,6 +89,15 @@ body { font-size: 90%; } } + +html { + scroll-behavior: smooth; +} +@media screen and (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} #app { font-family: "Avenir", Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; @@ -95,8 +105,19 @@ body { display: flex; min-height: 100vh; flex-direction: column; + &.has-bottom-player { + padding-bottom: $bottom-player-height; + .service-messages { + bottom: $bottom-player-height + 1rem; + } + + } } +#footer { + border-bottom: none; + border-top: 1px solid rgba(34, 36, 38, 0.15); +} #app > main, #app > .main { flex: 1; } @@ -114,19 +135,24 @@ body { width: $widedesktop-sidebar-width; } } -.main.pusher, -.footer { - @include media(">desktop") { - margin-left: $desktop-sidebar-width !important; - margin-top: 50px; - } - @include media(">widedesktop") { - margin-left: $widedesktop-sidebar-width !important;; +#app { + > .main.pusher, + > .footer { + @include media(">desktop") { + margin-left: $desktop-sidebar-width !important; + } + + @include media(">widedesktop") { + margin-left: $widedesktop-sidebar-width !important;; + } + transform: none !important; } - transform: none !important; } +.main.pusher.hidden { + display: none; +} .main.pusher > .ui.secondary.menu { margin-left: 0; margin-right: 0; @@ -140,16 +166,6 @@ body { @include media(">tablet") { padding: 0 2.5rem; } - @include media(">desktop") { - position: fixed; - left: $desktop-sidebar-width; - right: 0px; - top: 0px; - z-index: 99; - } - @include media(">widedesktop") { - left: $widedesktop-sidebar-width; - } .item { padding-top: 1.5em; padding-bottom: 1.5em; @@ -159,13 +175,7 @@ body { .service-messages { position: fixed; bottom: 1em; - left: 1em; - @include media(">desktop") { - left: $desktop-sidebar-width; - } - @include media(">widedesktop") { - left: $widedesktop-sidebar-width; - } + right: 1em; > .ui.message { box-shadow: 0px 0px 7px rgba(0, 0, 0, 0.7); } @@ -306,10 +316,6 @@ label .tooltip { margin-left: 1em; } -canvas.color-thief { - display: none; -} - .ui.list .list.icon { padding-left: 0; } @@ -392,5 +398,45 @@ input + .help { max-width: 100% !important; } +.ui.small.divider { + margin: 0.5rem 0; +} + +.queue.segment.player-focused #queue-grid #player { + @include media("<desktop") { + padding-bottom: $bottom-player-height + 2rem; + } +} +.queue-controls { + + @include media("<desktop") { + height: $bottom-player-height; + } +} + +.desktop-and-up { + @include media("<desktop") { + display: none !important; + } +} +.tablet-and-up { + @include media("<tablet") { + display: none !important; + } +} +.tablet-and-below { + @include media(">desktop") { + display: none !important; + } +} +:not(.menu) > { + a, .link { + &:not(.button):not(.list) { + &:hover { + text-decoration: underline; + } + } + } +} @import "./themes/_light.scss"; @import "./themes/_dark.scss"; diff --git a/front/src/style/themes/_dark.scss b/front/src/style/themes/_dark.scss index a34b60cb53..9308032598 100644 --- a/front/src/style/themes/_dark.scss +++ b/front/src/style/themes/_dark.scss @@ -31,6 +31,9 @@ $link-color: rgb(255, 144, 0); color: $text-color; } } + .main.with-background { + background-color: $background-color; + } .ui.link.list.list .active.item, .ui.link.list.list .active.item a:not(.ui) { color: inherit; @@ -281,6 +284,17 @@ $link-color: rgb(255, 144, 0); color: $text-color; } } + + .ui.fixed-header.segment { + background-color: $background-color; + box-shadow: inset 0px -1px 0px 0px rgba(34, 36, 38, 0.15); + } + .ui.fixed-footer.segment { + box-shadow: inset 0px 1px 0px 0px rgba(34, 36, 38, 0.15); + } + @include media("<desktop") { + background-color: $background-color; + } } /* purgecss end ignore */ diff --git a/front/src/style/themes/_light.scss b/front/src/style/themes/_light.scss index a6e1a0cdf6..b609075dc5 100644 --- a/front/src/style/themes/_light.scss +++ b/front/src/style/themes/_light.scss @@ -11,6 +11,9 @@ } } } + .main.with-background { + background-color: white; + } .discrete { color: rgba(0, 0, 0, 0.87); @@ -31,5 +34,16 @@ footer#footer div.item:hover { color: rgba(0, 0, 0, 0.87); } - + .ui.fixed-header.segment { + background-color: white; + box-shadow: inset 0px -1px 0px 0px rgba(34, 36, 38, 0.15); + } + .ui.fixed-footer.segment { + box-shadow: inset 0px 1px 0px 0px rgba(34, 36, 38, 0.15); + } + .queue.segment .queue-controls { + @include media("<desktop") { + background-color: white; + } + } } diff --git a/front/src/vendor/color-thief.js b/front/src/vendor/color-thief.js deleted file mode 100644 index ed0612f847..0000000000 --- a/front/src/vendor/color-thief.js +++ /dev/null @@ -1,661 +0,0 @@ -/* eslint-disable */ -/* - * Color Thief v2.0 - * by Lokesh Dhakar - http://www.lokeshdhakar.com - * - * Thanks - * ------ - * Nick Rabinowitz - For creating quantize.js. - * John Schulz - For clean up and optimization. @JFSIII - * Nathan Spady - For adding drag and drop support to the demo page. - * - * License - * ------- - * Copyright 2011, 2015 Lokesh Dhakar - * Released under the MIT license - * https://raw.githubusercontent.com/lokesh/color-thief/master/LICENSE - * - * @license - */ - - -/* - CanvasImage Class - Class that wraps the html image element and canvas. - It also simplifies some of the canvas context manipulation - with a set of helper functions. -*/ -var CanvasImage = function (image) { - this.canvas = document.createElement('canvas'); - this.canvas.className = "color-thief hidden"; - this.context = this.canvas.getContext('2d'); - - document.body.appendChild(this.canvas); - - this.width = this.canvas.width = image.width; - this.height = this.canvas.height = image.height; - - this.context.drawImage(image, 0, 0, this.width, this.height); -}; - -CanvasImage.prototype.clear = function () { - this.context.clearRect(0, 0, this.width, this.height); -}; - -CanvasImage.prototype.update = function (imageData) { - this.context.putImageData(imageData, 0, 0); -}; - -CanvasImage.prototype.getPixelCount = function () { - return this.width * this.height; -}; - -CanvasImage.prototype.getImageData = function () { - return this.context.getImageData(0, 0, this.width, this.height); -}; - -CanvasImage.prototype.removeCanvas = function () { - this.canvas.parentNode.removeChild(this.canvas); -}; - - -var ColorThief = function () {}; - -/* - * getColor(sourceImage[, quality]) - * returns {r: num, g: num, b: num} - * - * Use the median cut algorithm provided by quantize.js to cluster similar - * colors and return the base color from the largest cluster. - * - * Quality is an optional argument. It needs to be an integer. 1 is the highest quality settings. - * 10 is the default. There is a trade-off between quality and speed. The bigger the number, the - * faster a color will be returned but the greater the likelihood that it will not be the visually - * most dominant color. - * - * */ -ColorThief.prototype.getColor = function(sourceImage, quality) { - var palette = this.getPalette(sourceImage, 5, quality); - var dominantColor = palette[0]; - return dominantColor; -}; - - -/* - * getPalette(sourceImage[, colorCount, quality]) - * returns array[ {r: num, g: num, b: num}, {r: num, g: num, b: num}, ...] - * - * Use the median cut algorithm provided by quantize.js to cluster similar colors. - * - * colorCount determines the size of the palette; the number of colors returned. If not set, it - * defaults to 10. - * - * BUGGY: Function does not always return the requested amount of colors. It can be +/- 2. - * - * quality is an optional argument. It needs to be an integer. 1 is the highest quality settings. - * 10 is the default. There is a trade-off between quality and speed. The bigger the number, the - * faster the palette generation but the greater the likelihood that colors will be missed. - * - * - */ -ColorThief.prototype.getPalette = function(sourceImage, colorCount, quality) { - - if (typeof colorCount === 'undefined' || colorCount < 2 || colorCount > 256) { - colorCount = 10; - } - if (typeof quality === 'undefined' || quality < 1) { - quality = 10; - } - - // Create custom CanvasImage object - var image = new CanvasImage(sourceImage); - var imageData = image.getImageData(); - var pixels = imageData.data; - var pixelCount = image.getPixelCount(); - - // Store the RGB values in an array format suitable for quantize function - var pixelArray = []; - for (var i = 0, offset, r, g, b, a; i < pixelCount; i = i + quality) { - offset = i * 4; - r = pixels[offset + 0]; - g = pixels[offset + 1]; - b = pixels[offset + 2]; - a = pixels[offset + 3]; - // If pixel is mostly opaque and not white - if (a >= 125) { - if (!(r > 250 && g > 250 && b > 250)) { - pixelArray.push([r, g, b]); - } - } - } - - // Send array to quantize function which clusters values - // using median cut algorithm - var cmap = MMCQ.quantize(pixelArray, colorCount); - var palette = cmap? cmap.palette() : null; - - // Clean up - image.removeCanvas(); - - return palette; -}; - -ColorThief.prototype.getColorFromUrl = function(imageUrl, callback, quality) { - sourceImage = document.createElement("img"); - var thief = this; - sourceImage.addEventListener('load' , function(){ - var palette = thief.getPalette(sourceImage, 5, quality); - var dominantColor = palette[0]; - callback(dominantColor, imageUrl); - }); - sourceImage.src = imageUrl -}; - - -ColorThief.prototype.getImageData = function(imageUrl, callback) { - xhr = new XMLHttpRequest(); - xhr.open('GET', imageUrl, true); - xhr.responseType = 'arraybuffer' - xhr.onload = function(e) { - if (this.status == 200) { - uInt8Array = new Uint8Array(this.response) - i = uInt8Array.length - binaryString = new Array(i); - for (var i = 0; i < uInt8Array.length; i++){ - binaryString[i] = String.fromCharCode(uInt8Array[i]) - } - data = binaryString.join('') - base64 = window.btoa(data) - callback ("data:image/png;base64,"+base64) - } - } - xhr.send(); -}; - -ColorThief.prototype.getColorAsync = function(imageUrl, callback, quality) { - var thief = this; - this.getImageData(imageUrl, function(imageData){ - sourceImage = document.createElement("img"); - sourceImage.addEventListener('load' , function(){ - var palette = thief.getPalette(sourceImage, 5, quality); - var dominantColor = palette[0]; - callback(dominantColor, this); - }); - sourceImage.src = imageData; - }); -}; - - - -/*! - * quantize.js Copyright 2008 Nick Rabinowitz. - * Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php - * @license - */ - -// fill out a couple protovis dependencies -/*! - * Block below copied from Protovis: http://mbostock.github.com/protovis/ - * Copyright 2010 Stanford Visualization Group - * Licensed under the BSD License: http://www.opensource.org/licenses/bsd-license.php - * @license - */ -if (!pv) { - var pv = { - map: function(array, f) { - var o = {}; - return f ? array.map(function(d, i) { o.index = i; return f.call(o, d); }) : array.slice(); - }, - naturalOrder: function(a, b) { - return (a < b) ? -1 : ((a > b) ? 1 : 0); - }, - sum: function(array, f) { - var o = {}; - return array.reduce(f ? function(p, d, i) { o.index = i; return p + f.call(o, d); } : function(p, d) { return p + d; }, 0); - }, - max: function(array, f) { - return Math.max.apply(null, f ? pv.map(array, f) : array); - } - }; -} - - - -/** - * Basic Javascript port of the MMCQ (modified median cut quantization) - * algorithm from the Leptonica library (http://www.leptonica.com/). - * Returns a color map you can use to map original pixels to the reduced - * palette. Still a work in progress. - * - * @author Nick Rabinowitz - * @example - -// array of pixels as [R,G,B] arrays -var myPixels = [[190,197,190], [202,204,200], [207,214,210], [211,214,211], [205,207,207] - // etc - ]; -var maxColors = 4; - -var cmap = MMCQ.quantize(myPixels, maxColors); -var newPalette = cmap.palette(); -var newPixels = myPixels.map(function(p) { - return cmap.map(p); -}); - - */ -var MMCQ = (function() { - // private constants - var sigbits = 5, - rshift = 8 - sigbits, - maxIterations = 1000, - fractByPopulations = 0.75; - - // get reduced-space color index for a pixel - function getColorIndex(r, g, b) { - return (r << (2 * sigbits)) + (g << sigbits) + b; - } - - // Simple priority queue - function PQueue(comparator) { - var contents = [], - sorted = false; - - function sort() { - contents.sort(comparator); - sorted = true; - } - - return { - push: function(o) { - contents.push(o); - sorted = false; - }, - peek: function(index) { - if (!sorted) sort(); - if (index===undefined) index = contents.length - 1; - return contents[index]; - }, - pop: function() { - if (!sorted) sort(); - return contents.pop(); - }, - size: function() { - return contents.length; - }, - map: function(f) { - return contents.map(f); - }, - debug: function() { - if (!sorted) sort(); - return contents; - } - }; - } - - // 3d color space box - function VBox(r1, r2, g1, g2, b1, b2, histo) { - var vbox = this; - vbox.r1 = r1; - vbox.r2 = r2; - vbox.g1 = g1; - vbox.g2 = g2; - vbox.b1 = b1; - vbox.b2 = b2; - vbox.histo = histo; - } - VBox.prototype = { - volume: function(force) { - var vbox = this; - if (!vbox._volume || force) { - vbox._volume = ((vbox.r2 - vbox.r1 + 1) * (vbox.g2 - vbox.g1 + 1) * (vbox.b2 - vbox.b1 + 1)); - } - return vbox._volume; - }, - count: function(force) { - var vbox = this, - histo = vbox.histo; - if (!vbox._count_set || force) { - var npix = 0, - index, i, j, k; - for (i = vbox.r1; i <= vbox.r2; i++) { - for (j = vbox.g1; j <= vbox.g2; j++) { - for (k = vbox.b1; k <= vbox.b2; k++) { - index = getColorIndex(i,j,k); - npix += (histo[index] || 0); - } - } - } - vbox._count = npix; - vbox._count_set = true; - } - return vbox._count; - }, - copy: function() { - var vbox = this; - return new VBox(vbox.r1, vbox.r2, vbox.g1, vbox.g2, vbox.b1, vbox.b2, vbox.histo); - }, - avg: function(force) { - var vbox = this, - histo = vbox.histo; - if (!vbox._avg || force) { - var ntot = 0, - mult = 1 << (8 - sigbits), - rsum = 0, - gsum = 0, - bsum = 0, - hval, - i, j, k, histoindex; - for (i = vbox.r1; i <= vbox.r2; i++) { - for (j = vbox.g1; j <= vbox.g2; j++) { - for (k = vbox.b1; k <= vbox.b2; k++) { - histoindex = getColorIndex(i,j,k); - hval = histo[histoindex] || 0; - ntot += hval; - rsum += (hval * (i + 0.5) * mult); - gsum += (hval * (j + 0.5) * mult); - bsum += (hval * (k + 0.5) * mult); - } - } - } - if (ntot) { - vbox._avg = [~~(rsum/ntot), ~~(gsum/ntot), ~~(bsum/ntot)]; - } else { -// console.log('empty box'); - vbox._avg = [ - ~~(mult * (vbox.r1 + vbox.r2 + 1) / 2), - ~~(mult * (vbox.g1 + vbox.g2 + 1) / 2), - ~~(mult * (vbox.b1 + vbox.b2 + 1) / 2) - ]; - } - } - return vbox._avg; - }, - contains: function(pixel) { - var vbox = this, - rval = pixel[0] >> rshift; - gval = pixel[1] >> rshift; - bval = pixel[2] >> rshift; - return (rval >= vbox.r1 && rval <= vbox.r2 && - gval >= vbox.g1 && gval <= vbox.g2 && - bval >= vbox.b1 && bval <= vbox.b2); - } - }; - - // Color map - function CMap() { - this.vboxes = new PQueue(function(a,b) { - return pv.naturalOrder( - a.vbox.count()*a.vbox.volume(), - b.vbox.count()*b.vbox.volume() - ); - }); - } - CMap.prototype = { - push: function(vbox) { - this.vboxes.push({ - vbox: vbox, - color: vbox.avg() - }); - }, - palette: function() { - return this.vboxes.map(function(vb) { return vb.color; }); - }, - size: function() { - return this.vboxes.size(); - }, - map: function(color) { - var vboxes = this.vboxes; - for (var i=0; i<vboxes.size(); i++) { - if (vboxes.peek(i).vbox.contains(color)) { - return vboxes.peek(i).color; - } - } - return this.nearest(color); - }, - nearest: function(color) { - var vboxes = this.vboxes, - d1, d2, pColor; - for (var i=0; i<vboxes.size(); i++) { - d2 = Math.sqrt( - Math.pow(color[0] - vboxes.peek(i).color[0], 2) + - Math.pow(color[1] - vboxes.peek(i).color[1], 2) + - Math.pow(color[2] - vboxes.peek(i).color[2], 2) - ); - if (d2 < d1 || d1 === undefined) { - d1 = d2; - pColor = vboxes.peek(i).color; - } - } - return pColor; - }, - forcebw: function() { - // XXX: won't work yet - var vboxes = this.vboxes; - vboxes.sort(function(a,b) { return pv.naturalOrder(pv.sum(a.color), pv.sum(b.color));}); - - // force darkest color to black if everything < 5 - var lowest = vboxes[0].color; - if (lowest[0] < 5 && lowest[1] < 5 && lowest[2] < 5) - vboxes[0].color = [0,0,0]; - - // force lightest color to white if everything > 251 - var idx = vboxes.length-1, - highest = vboxes[idx].color; - if (highest[0] > 251 && highest[1] > 251 && highest[2] > 251) - vboxes[idx].color = [255,255,255]; - } - }; - - // histo (1-d array, giving the number of pixels in - // each quantized region of color space), or null on error - function getHisto(pixels) { - var histosize = 1 << (3 * sigbits), - histo = new Array(histosize), - index, rval, gval, bval; - pixels.forEach(function(pixel) { - rval = pixel[0] >> rshift; - gval = pixel[1] >> rshift; - bval = pixel[2] >> rshift; - index = getColorIndex(rval, gval, bval); - histo[index] = (histo[index] || 0) + 1; - }); - return histo; - } - - function vboxFromPixels(pixels, histo) { - var rmin=1000000, rmax=0, - gmin=1000000, gmax=0, - bmin=1000000, bmax=0, - rval, gval, bval; - // find min/max - pixels.forEach(function(pixel) { - rval = pixel[0] >> rshift; - gval = pixel[1] >> rshift; - bval = pixel[2] >> rshift; - if (rval < rmin) rmin = rval; - else if (rval > rmax) rmax = rval; - if (gval < gmin) gmin = gval; - else if (gval > gmax) gmax = gval; - if (bval < bmin) bmin = bval; - else if (bval > bmax) bmax = bval; - }); - return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo); - } - - function medianCutApply(histo, vbox) { - if (!vbox.count()) return; - - var rw = vbox.r2 - vbox.r1 + 1, - gw = vbox.g2 - vbox.g1 + 1, - bw = vbox.b2 - vbox.b1 + 1, - maxw = pv.max([rw, gw, bw]); - // only one pixel, no split - if (vbox.count() == 1) { - return [vbox.copy()]; - } - /* Find the partial sum arrays along the selected axis. */ - var total = 0, - partialsum = [], - lookaheadsum = [], - i, j, k, sum, index; - if (maxw == rw) { - for (i = vbox.r1; i <= vbox.r2; i++) { - sum = 0; - for (j = vbox.g1; j <= vbox.g2; j++) { - for (k = vbox.b1; k <= vbox.b2; k++) { - index = getColorIndex(i,j,k); - sum += (histo[index] || 0); - } - } - total += sum; - partialsum[i] = total; - } - } - else if (maxw == gw) { - for (i = vbox.g1; i <= vbox.g2; i++) { - sum = 0; - for (j = vbox.r1; j <= vbox.r2; j++) { - for (k = vbox.b1; k <= vbox.b2; k++) { - index = getColorIndex(j,i,k); - sum += (histo[index] || 0); - } - } - total += sum; - partialsum[i] = total; - } - } - else { /* maxw == bw */ - for (i = vbox.b1; i <= vbox.b2; i++) { - sum = 0; - for (j = vbox.r1; j <= vbox.r2; j++) { - for (k = vbox.g1; k <= vbox.g2; k++) { - index = getColorIndex(j,k,i); - sum += (histo[index] || 0); - } - } - total += sum; - partialsum[i] = total; - } - } - partialsum.forEach(function(d,i) { - lookaheadsum[i] = total-d; - }); - function doCut(color) { - var dim1 = color + '1', - dim2 = color + '2', - left, right, vbox1, vbox2, d2, count2=0; - for (i = vbox[dim1]; i <= vbox[dim2]; i++) { - if (partialsum[i] > total / 2) { - vbox1 = vbox.copy(); - vbox2 = vbox.copy(); - left = i - vbox[dim1]; - right = vbox[dim2] - i; - if (left <= right) - d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2)); - else d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2)); - // avoid 0-count boxes - while (!partialsum[d2]) d2++; - count2 = lookaheadsum[d2]; - while (!count2 && partialsum[d2-1]) count2 = lookaheadsum[--d2]; - // set dimensions - vbox1[dim2] = d2; - vbox2[dim1] = vbox1[dim2] + 1; -// console.log('vbox counts:', vbox.count(), vbox1.count(), vbox2.count()); - return [vbox1, vbox2]; - } - } - - } - // determine the cut planes - return maxw == rw ? doCut('r') : - maxw == gw ? doCut('g') : - doCut('b'); - } - - function quantize(pixels, maxcolors) { - // short-circuit - if (!pixels.length || maxcolors < 2 || maxcolors > 256) { -// console.log('wrong number of maxcolors'); - return false; - } - - // XXX: check color content and convert to grayscale if insufficient - - var histo = getHisto(pixels), - histosize = 1 << (3 * sigbits); - - // check that we aren't below maxcolors already - var nColors = 0; - histo.forEach(function() { nColors++; }); - if (nColors <= maxcolors) { - // XXX: generate the new colors from the histo and return - } - - // get the beginning vbox from the colors - var vbox = vboxFromPixels(pixels, histo), - pq = new PQueue(function(a,b) { return pv.naturalOrder(a.count(), b.count()); }); - pq.push(vbox); - - // inner function to do the iteration - function iter(lh, target) { - var ncolors = 1, - niters = 0, - vbox; - while (niters < maxIterations) { - vbox = lh.pop(); - if (!vbox.count()) { /* just put it back */ - lh.push(vbox); - niters++; - continue; - } - // do the cut - var vboxes = medianCutApply(histo, vbox), - vbox1 = vboxes[0], - vbox2 = vboxes[1]; - - if (!vbox1) { -// console.log("vbox1 not defined; shouldn't happen!"); - return; - } - lh.push(vbox1); - if (vbox2) { /* vbox2 can be null */ - lh.push(vbox2); - ncolors++; - } - if (ncolors >= target) return; - if (niters++ > maxIterations) { -// console.log("infinite loop; perhaps too few pixels!"); - return; - } - } - } - - // first set of colors, sorted by population - iter(pq, fractByPopulations * maxcolors); - - // Re-sort by the product of pixel occupancy times the size in color space. - var pq2 = new PQueue(function(a,b) { - return pv.naturalOrder(a.count()*a.volume(), b.count()*b.volume()); - }); - while (pq.size()) { - pq2.push(pq.pop()); - } - - // next set - generate the median cuts using the (npix * vol) sorting. - iter(pq2, maxcolors - pq2.size()); - - // calculate the actual colors - var cmap = new CMap(); - while (pq2.size()) { - cmap.push(pq2.pop()); - } - - return cmap; - } - - return { - quantize: quantize - }; -})(); - -export default ColorThief diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index dad029cf43..b741750307 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -87,7 +87,8 @@ const FETCH_URL = "playlists/" export default { mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], props: { - defaultQuery: { type: String, required: false, default: "" } + defaultQuery: { type: String, required: false, default: "" }, + scope: { type: String, required: false, default: "all" }, }, components: { PlaylistCardList, @@ -141,6 +142,7 @@ export default { this.isLoading = true let url = FETCH_URL let params = { + scope: this.scope, page: this.page, page_size: this.paginateBy, q: this.query, diff --git a/front/vue.config.js b/front/vue.config.js index b4be291229..50668740b3 100644 --- a/front/vue.config.js +++ b/front/vue.config.js @@ -2,11 +2,16 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const webpack = require('webpack'); const PurgecssPlugin = require('purgecss-webpack-plugin') +const PreloadWebpackPlugin = require('preload-webpack-plugin'); const glob = require('glob-all') const path = require('path') let plugins = [ // do not include moment.js locales since it's quite heavy new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), + new PreloadWebpackPlugin({ + rel: 'preload', + include: ['audio', 'core', 'about'] + }), ] if (process.env.BUNDLE_ANALYZE === '1') { plugins.push(new BundleAnalyzerPlugin()) @@ -40,7 +45,6 @@ module.exports = { } }, chainWebpack: config => { - config.optimization.delete('splitChunks') config.plugins.delete('prefetch-embed') config.plugins.delete('prefetch-index') }, diff --git a/front/yarn.lock b/front/yarn.lock index 1bca8dd165..d21eb0b491 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -7131,6 +7131,11 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.5 source-map "^0.6.1" supports-color "^6.1.0" +preload-webpack-plugin@^3.0.0-beta.4: + version "3.0.0-beta.4" + resolved "https://registry.yarnpkg.com/preload-webpack-plugin/-/preload-webpack-plugin-3.0.0-beta.4.tgz#b8a36046df3b4a1b61db55d92f1a5aebdb99d246" + integrity sha512-6hhh0AswCbp/U4EPVN4fbK2wiDkXhmgjjgEYEmXa21UYwjYzCIgh3ZRMXM21ZPLfbQGpdFuSL3zFslU+edjpwg== + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -8217,10 +8222,10 @@ sort-keys@^2.0.0: dependencies: is-plain-obj "^1.0.0" -sortablejs@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.9.0.tgz#2d1e74ae6bac2cb4ad0622908f340848969eb88d" - integrity sha512-Ot6bYJ6PoqPmpsqQYXjn1+RKrY2NWQvQt/o4jfd/UYwVWndyO5EPO8YHbnm5HIykf8ENsm4JUrdAvolPT86yYA== +sortablejs@^1.10.1: + version "1.10.1" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.1.tgz#3d52b00f871be00f00f84d99a60d120bf3dfe52c" + integrity sha512-N6r7GrVmO8RW1rn0cTdvK3JR0BcqecAJ0PmYMCL3ZuqTH3pY+9QyqkmJSkkLyyDvd+AJnwaxTP22Ybr/83V9hQ== source-list-map@^2.0.0: version "2.0.1" @@ -9216,17 +9221,17 @@ vue-upload-component@^2.8.11: resolved "https://registry.yarnpkg.com/vue-upload-component/-/vue-upload-component-2.8.20.tgz#60824d3f20f3216dca90d8c86a5c980851b04ea0" integrity sha512-zrnJvULu4rnZe36Ib2/AZrI/h/mmNbUJZ+acZD652PyumzbvjCOQeYHe00sGifTdYjzzS66CwhTT+ubZ2D0Aow== -vue@^2.0.0, vue@^2.5.17: +vue@^2.0.0, vue@^2.6.10: version "2.6.10" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637" integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ== vuedraggable@^2.16.0: - version "2.21.0" - resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.21.0.tgz#30c485ed737a9a6a73ea8f21cc8e1ed59aaddc92" - integrity sha512-UDp0epjaZikuInoJA9rlEIJaSTQThabq0R9x7TqBdl0qGVFKKzo6glP6ubfzWBmV4iRIfbSOs2DV06s3B5h5tA== + version "2.23.2" + resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.23.2.tgz#0d95d7fdf4f02f56755a26b3c9dca5c7ca9cfa72" + integrity sha512-PgHCjUpxEAEZJq36ys49HfQmXglattf/7ofOzUrW2/rRdG7tu6fK84ir14t1jYv4kdXewTEa2ieKEAhhEMdwkQ== dependencies: - sortablejs "^1.9.0" + sortablejs "^1.10.1" vuex-persistedstate@^2.5.4: version "2.5.4" -- GitLab