diff --git a/changes/changelog.d/594.feature b/changes/changelog.d/594.feature
new file mode 100644
index 0000000000000000000000000000000000000000..3ab7ccdd9a63cda29e57d2302307149a9f0875c3
--- /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 1323a70eea69cbc57a5e66d3c14e07b15bf443f7..3ffbcfd25c864c095e5f12215b260574ec22dd44 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 c6b71944e8e59ebad4df087562dbd368c17fe64c..2d24fb952800974cdd5fb7818ff4b8aaf987e307 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 142419ca63f3295dadbf8773931f2cbe7e37e1cc..42adc6d41407a1a14be70387479ceda2401bdde7 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 5be97dfb5655f43f5093353fcfffd55e034aa310..c1fee631bfca7a52afec922b02ad5b609d6053c3 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 0000000000000000000000000000000000000000..f812c7c9926a052cf2ff30a69eb2499cd270fa16
--- /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 0000000000000000000000000000000000000000..f03b12c6273793a9b596cebd628b5f5c3fb4ef27
--- /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 999d24dd389e2765e2b08e7d52617043e6413442..097672f2caadce5073a694412ff27a58c9ddb051 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 ddeba0bb8bd9c742f9dd7a3f205bd4d17097a3b1..da693b257c7e7b543893304c30561cefbf98b579 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 77fffddd3d85c1323062924bb228f939bdb13995..970ca5567937209e05714ef236d925492db14edd 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 c5467e416e14ac3e20792b32633ddca83c1f8f15..71365a893373865e3de4bdeefac7bf20cb0f7f03 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 ae2ae08fb3c348a9e8b0288ad4d672678b1c430c..ed18805aa83972406f73d04c909ea1db644db031 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 0000000000000000000000000000000000000000..2ed68748386c53f90e5b6b75cba11aedb3cc0e8a
--- /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 be21eb5816ab6eec106503d1c16c2ce92040b4a2..d88dd6321c14bbb82ac8d01da3ea691e1a2331c5 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 653286ad21019afcb0a17e6264b970ef730b12cb..2a95a11a27c98ad853148a82ecd9e6c260ae1c0f 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 e6cde2265390f99a9b8553951a3e896cd3e186f8..e2b90460ca5ed7ea64d1b5a8a85a1376d2464724 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 01e33b00fa76c873da1cd21c7baede59ccd4e275..2644324696b46570a3e60a024ba79620fc8f86f4 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 3964cbad3b80042d4883e82d556633e54c3ced9f..2fd3ec783e95cb5aab2e0a828263bf2500fb3a8f 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 905c33d373a15c32daf98e6bd6102db1dce23118..7f83fb0a0d362d294f6f58d249d2e14ab1210469 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 60c3d539427271cf4ad2e3433ad26882f8cd876d..9cb8119825f4a3e00b92b7366713e469743e2540 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 ea4d98e6543e7382ead061577fc660d01e8a341d..ed528cd9656a50737eac7042f66d8f7c0de767bd 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 fcc14b807efae7fc764870cc525152f76be6f30f..2c579c1709bd094d12fffa8d02a76182bea7a41d 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 994003e3a1cbae469b9902116dde43b6425fa4e0..5ece6f1f9987e0c1d7615ac52dfc6c6af99cea95 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 da2c1bfcb57c283272bd3726074b87dd3a91b7ca..a0ca73e553f3b1982a074fb54c2f9d3d533fa986 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 2f57eb163ab3c698bc4695ed69a1d2644a930e8b..04787406d3d57abdd01db1b03cac24ca4620eb0c 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 c3af1524b050c06253c9c1e763a65ad4df16d9c6..828e842ab8a71a49543a6ac78fa569a73c54448d 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 213711bd345db246051081b57bb9125ba2269ed7..f633e9a94f503d836e0f2d36cc64105582a95889 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 1a99b6072019383ef2107fd096213b12e95d5826..3871f7fe7103ddb17bb6931a0eebf5fc8058fe7e 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 6757f0beef1e04655c60a366a844d3f10d179638..14affce90a6b56ea11693d6479991eb82c5848a0 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 9a0d55c0bf47f0f80ee02c18e33253dc1626d5be..a750dd7f01d78e8a3ea59a314c7b308076fb8afb 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 0ddd94555cc873118f9be9d36895bd6c16bc613b..09344b2cc444fc682b9eec70537ef7b2bf876e07 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 b98584f7d21e510a8ec9476fc1d25facbef2ab26..06124c8f4a679b12b8306db38e36be7058f1d7a2 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 a34b60cb5399b72ef5e55518d92609ef2b23f13f..93080325983c40e79bb1f94e70ab28f9aab5fcf3 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 a6e1a0cdf621edaf3b7ed5dd57d0a6e8c4727b02..b609075dc5161a48b7eeac6d64bfa2852a9e8ee2 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 ed0612f847a5f8f7e3d4ec14b46ed649a3c9dba4..0000000000000000000000000000000000000000
--- 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 dad029cf43ce9ee5cd40bc5eaabce545b597ce8e..b741750307d9544442e769bfd5a1c1dbafe256eb 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 b4be291229cba0dacf7d2cbcbe2505ed6835af3f..50668740b37b143df7767d28e17c728853a93a1c 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 1bca8dd16527c6c7611daa617b8fbf8de73a4873..d21eb0b4914db260238661c0c2ca198992b5bfcd 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"