Merge branch '594-navigation-redesign' into 'develop'

Resolve "Redesign the sidebar/navigation to simplify the UI"

Closes #594

See merge request funkwhale/funkwhale!923
environments/review-front-serv-f1ybnc/deployments/3672
Eliot Berriot 2019-12-26 11:38:26 +01:00
commit 7c8b592f61
38 zmienionych plików z 2073 dodań i 1579 usunięć

Wyświetl plik

@ -0,0 +1 @@
Brand new navigation, queue and player redesign (#594)

Wyświetl plik

@ -6,6 +6,13 @@ Next release notes
Those release notes refer to the current development branch and are reset Those release notes refer to the current development branch and are reset
after each release. 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 Improved search performance
^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^

Wyświetl plik

@ -25,7 +25,7 @@
"qs": "^6.7.0", "qs": "^6.7.0",
"sanitize-html": "^1.20.1", "sanitize-html": "^1.20.1",
"showdown": "^1.8.6", "showdown": "^1.8.6",
"vue": "^2.5.17", "vue": "^2.6.10",
"vue-gettext": "^2.1.0", "vue-gettext": "^2.1.0",
"vue-lazyload": "^1.2.6", "vue-lazyload": "^1.2.6",
"vue-masonry": "^0.11.5", "vue-masonry": "^0.11.5",
@ -50,6 +50,7 @@
"mocha": "^5.2.0", "mocha": "^5.2.0",
"moxios": "^0.4.0", "moxios": "^0.4.0",
"node-sass": "^4.9.3", "node-sass": "^4.9.3",
"preload-webpack-plugin": "^3.0.0-beta.4",
"purgecss-webpack-plugin": "^1.6.0", "purgecss-webpack-plugin": "^1.6.0",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"sinon": "^6.1.5", "sinon": "^6.1.5",

Wyświetl plik

@ -7,13 +7,85 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.png"> <link rel="icon" href="<%= BASE_URL %>favicon.png">
<title>Funkwhale</title> <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> </head>
<body class="theme-light" id="body"> <body class="theme-light" id="body">
<noscript> <div id="fake-app">
<strong>We're sorry but Funkwhale doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <div id="fake-sidebar">
</noscript> <div id="orange-square"></div>
<div id="app"></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 --> <!-- built files will be auto injected -->
</body> </body>

Wyświetl plik

@ -1,5 +1,5 @@
<template> <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 --> <!-- here, we display custom stylesheets, if any -->
<link <link
v-for="url in customStylesheets" v-for="url in customStylesheets"
@ -12,9 +12,13 @@
<sidebar></sidebar> <sidebar></sidebar>
<set-instance-modal @update:show="showSetInstanceModal = $event" :show="showSetInstanceModal"></set-instance-modal> <set-instance-modal @update:show="showSetInstanceModal = $event" :show="showSetInstanceModal"></set-instance-modal>
<service-messages v-if="messages.length > 0"/> <service-messages v-if="messages.length > 0"/>
<router-view :key="$route.fullPath"></router-view> <transition name="queue">
<div class="ui fitted divider"></div> <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 <app-footer
:class="{hidden: $store.state.ui.queueFocused}"
:version="version" :version="version"
@show:shortcuts-modal="showShortcutsModal = !showShortcutsModal" @show:shortcuts-modal="showShortcutsModal = !showShortcutsModal"
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal" @show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
@ -32,39 +36,33 @@
import Vue from 'vue' import Vue from 'vue'
import axios from 'axios' import axios from 'axios'
import _ from '@/lodash' import _ from '@/lodash'
import {mapState, mapGetters} from 'vuex' import {mapState, mapGetters, mapActions} from 'vuex'
import { WebSocketBridge } from 'django-channels' import { WebSocketBridge } from 'django-channels'
import GlobalEvents from '@/components/utils/global-events' 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 moment from 'moment'
import locales from './locales' 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 { export default {
name: 'app', name: 'app',
components: { components: {
Sidebar, Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"),
AppFooter, Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"),
FilterModal, PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"),
ReportModal, Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"),
PlaylistModal, AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"),
ShortcutsModal, 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, GlobalEvents,
ServiceMessages,
SetInstanceModal,
}, },
data () { data () {
return { return {
bridge: null, bridge: null,
instanceUrl: null, instanceUrl: null,
showShortcutsModal: false, showShortcutsModal: false,
showSetInstanceModal: false, showSetInstanceModal: false
} }
}, },
async created () { async created () {
@ -82,6 +80,10 @@ export default {
if (serverUrl) { if (serverUrl) {
this.$store.commit('instance/instanceUrl', 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) { else if (!this.$store.state.instance.instanceUrl) {
// we have several way to guess the API server url. By order of precedence: // we have several way to guess the API server url. By order of precedence:
// 1. use the url provided in settings.json, if any // 1. use the url provided in settings.json, if any
@ -127,6 +129,9 @@ export default {
self.$router.push(event.target.getAttribute('href')) self.$router.push(event.target.getAttribute('href'))
event.preventDefault(); event.preventDefault();
}, false); }, false);
this.$nextTick(() => {
document.getElementById('fake-content').classList.add('loaded')
})
}, },
destroyed () { destroyed () {
@ -238,10 +243,27 @@ export default {
...mapState({ ...mapState({
messages: state => state.ui.messages, messages: state => state.ui.messages,
nodeinfo: state => state.instance.nodeinfo, nodeinfo: state => state.instance.nodeinfo,
playing: state => state.player.playing,
bufferProgress: state => state.player.bufferProgress,
isLoadingAudio: state => state.player.isLoadingAudio,
}), }),
...mapGetters({ ...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 () { suggestedInstances () {
let instances = this.$store.state.instance.knownInstances.slice(0) let instances = this.$store.state.instance.knownInstances.slice(0)
if (this.$store.state.instance.frontSettings.defaultServerUrl) { if (this.$store.state.instance.frontSettings.defaultServerUrl) {
@ -264,7 +286,7 @@ export default {
if (this.$store.state.instance.frontSettings) { if (this.$store.state.instance.frontSettings) {
return this.$store.state.instance.frontSettings.additionalStylesheets || [] return this.$store.state.instance.frontSettings.additionalStylesheets || []
} }
} },
}, },
watch: { watch: {
'$store.state.instance.instanceUrl' () { '$store.state.instance.instanceUrl' () {
@ -290,7 +312,7 @@ export default {
immediate: true, immediate: true,
handler(newValue) { handler(newValue) {
let self = this let self = this
import(`./translations/${newValue}.json`).then((response) =>{ import(/* webpackChunkName: "locale-[request]" */ `./translations/${newValue}.json`).then((response) =>{
Vue.$translations[newValue] = response.default[newValue] Vue.$translations[newValue] = response.default[newValue]
}).finally(() => { }).finally(() => {
// set current language twice, otherwise we seem to have a cache somewhere // 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') return self.$store.commit('ui/momentLocale', 'en')
} }
let momentLocale = newValue.replace('_', '-').toLowerCase() 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) self.$store.commit('ui/momentLocale', momentLocale)
}).catch(() => { }).catch(() => {
console.log('No momentjs locale available for', momentLocale) console.log('No momentjs locale available for', momentLocale)
let shortLocale = momentLocale.split('-')[0] 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) self.$store.commit('ui/momentLocale', shortLocale)
}).catch(() => { }).catch(() => {
console.log('No momentjs locale available for', shortLocale) console.log('No momentjs locale available for', shortLocale)
@ -333,4 +355,185 @@ export default {
<style lang="scss"> <style lang="scss">
@import "style/_main"; @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> </style>

Wyświetl plik

@ -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>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 8.8 KiB

Wyświetl plik

@ -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>

Wyświetl plik

@ -42,12 +42,11 @@
</template> </template>
<script> <script>
import Modal from '@/components/semantic/Modal'
export default { export default {
props: ['show'], props: ['show'],
components: { components: {
Modal, Modal: () => import(/* webpackChunkName: "modal" */ "@/components/semantic/Modal"),
}, },
computed: { computed: {
general () { general () {
@ -131,6 +130,10 @@ export default {
key: 'm', key: 'm',
summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle mute') 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', key: 'l',
summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle queue looping') summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle queue looping')

Wyświetl plik

@ -1,216 +1,178 @@
<template> <template>
<aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]"> <aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]">
<header class="ui inverted segment header-wrapper"> <header class="ui basic segment header-wrapper">
<search-bar @search="isCollapsed = false"> <router-link :title="'Funkwhale'" :to="{name: logoUrl}">
<router-link :title="'Funkwhale'" :to="{name: logoUrl}"> <i class="logo bordered inverted orange big icon">
<i class="logo bordered inverted orange big icon"> <logo class="logo"></logo>
<logo class="logo"></logo> </i>
</i> </router-link>
</router-link><span <router-link v-if="!$store.state.auth.authenticated" class="logo-wrapper" :to="{name: logoUrl}">
slot="after" <img src="../assets/logo/text-white.svg" />
@click="isCollapsed = !isCollapsed" </router-link>
:class="['ui', 'basic', 'big', {'inverted': isCollapsed}, 'orange', 'icon', 'collapse', 'button']"> <nav class="top ui compact right aligned inverted text menu">
<i class="sidebar icon"></i></span> <template v-if="$store.state.auth.authenticated">
</search-bar>
</header>
<div class="menu-area"> <div class="right menu">
<div class="ui compact fluid two item inverted menu"> <div class="item" :title="labels.administration" v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']">
<a :class="[{active: selectedTab === 'library'}, 'item']" role="button" @click.prevent.stop="selectedTab = 'library'" data-tab="library"><translate translate-context="*/Library/*/Verb">Browse</translate></a> <div class="item ui inline admin-dropdown dropdown">
<a :class="[{active: selectedTab === 'queue'}, 'item']" role="button" @click.prevent.stop="selectedTab = 'queue'" data-tab="queue"> <i class="wrench icon"></i>
<translate translate-context="Sidebar/Queue/Tab.Title/Noun">Queue</translate>&nbsp; <div
<template v-if="queue.tracks.length === 0"> v-if="$store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports > 0"
<translate translate-context="Sidebar/Queue/Tab.Title">(empty)</translate> :class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ $store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports }}</div>
</template> <div class="menu">
<translate translate-context="Sidebar/Queue/Tab.Title" v-else :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"> <div class="header">
(%{ index } of %{ length }) <translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate>
</translate> </div>
</a> <div class="divider"></div>
</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>
<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>
<div class="tabs"> <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>
<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"> <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"> <nav class="ui vertical large fluid inverted menu" role="navigation" :aria-label="labels.mainMenu">
<div class="item"> <div :class="[{collapsed: !exploreExpanded}, 'collaspable item']">
<header class="header"><translate translate-context="Sidebar/Profile/Title">My account</translate></header> <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"> <div class="menu">
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"> <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>
<i class="user icon"></i> <router-link class="item" :to="{name: 'library.albums.browse'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link>
<translate translate-context="Sidebar/Profile/List item.Link" :translate-params="{username: $store.state.auth.username}"> <router-link class="item" :to="{name: 'library.artists.browse'}"><i class="user icon"></i><translate translate-context="*/*/*">Artists</translate></router-link>
Logged in as %{ username } <router-link class="item" :to="{name: 'library.playlists.browse'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link>
</translate> <router-link class="item" :to="{name: 'library.radios.browse'}"><i class="feed icon"></i><translate translate-context="*/*/*">Radios</translate></router-link>
<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)" /> </div>
</router-link> </div>
<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> <div :class="[{collapsed: !myLibraryExpanded}, 'collaspable item']" v-if="$store.state.auth.authenticated">
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'notifications'}"> <header class="header" @click="myLibraryExpanded = true" tabindex="0" @focus="myLibraryExpanded = true">
<i class="feed icon"></i> <translate translate-context="*/*/*/Noun">My Library</translate>
<translate translate-context="*/Notifications/*">Notifications</translate> <i class="angle right icon" v-if="!myLibraryExpanded"></i>
<div </header>
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0" <div class="menu">
:class="['ui', 'teal', 'label']"> <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>
{{ $store.state.ui.notifications.inbox + additionalNotifications }}</div> <router-link class="item" :to="{name: 'library.albums.me'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link>
</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" 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> <router-link class="item" :to="{name: 'library.playlists.me'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link>
<template v-else> <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: 'login'}"><i class="sign in icon"></i><translate translate-context="*/Login/*/Verb">Login</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>
<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>
</div> </div>
</div> </div>
<div class="item"> <div class="item">
<header class="header"><translate translate-context="*/*/*/Noun">Music</translate></header> <header class="header">
<translate translate-context="Footer/About/List item.Link">More</translate>
</header>
<div class="menu"> <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" to="/about">
<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> <i class="info icon"></i><translate translate-context="Sidebar/*/List item.Link">About this pod</translate>
<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>
</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="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> </router-link>
</div> </div>
</div> </div>
</nav> </nav>
</section> </section>
<div v-if="queue.previousQueue " class="ui black icon message"> </nav>
<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>
</aside> </aside>
</template> </template>
<script> <script>
import { mapState, mapActions, mapGetters } from "vuex" import { mapState, mapActions, mapGetters } from "vuex"
import Player from "@/components/audio/Player"
import Logo from "@/components/Logo" import Logo from "@/components/Logo"
import SearchBar from "@/components/audio/SearchBar" import SearchBar from "@/components/audio/SearchBar"
import backend from "@/audio/backend" import backend from "@/audio/backend"
import draggable from "vuedraggable"
import $ from "jquery" import $ from "jquery"
export default { export default {
name: "sidebar", name: "sidebar",
components: { components: {
Player,
SearchBar, SearchBar,
Logo, Logo
draggable
}, },
data() { data() {
return { return {
selectedTab: "library", selectedTab: "library",
backend: backend, backend: backend,
tracksChangeBuffer: null,
isCollapsed: true, isCollapsed: true,
fetchInterval: null fetchInterval: null,
exploreExpanded: false,
myLibraryExpanded: false,
} }
}, },
destroy() { destroy() {
@ -218,6 +180,11 @@ export default {
clearInterval(this.fetchInterval) clearInterval(this.fetchInterval)
} }
}, },
mounted () {
this.$nextTick(() => {
document.getElementById('fake-sidebar').classList.add('loaded')
})
},
computed: { computed: {
...mapGetters({ ...mapGetters({
additionalNotifications: "ui/additionalNotifications", additionalNotifications: "ui/additionalNotifications",
@ -235,15 +202,10 @@ export default {
pendingFollows, pendingFollows,
mainMenu, mainMenu,
selectTrack, selectTrack,
pendingReviewEdits pendingReviewEdits,
} addContent: this.$pgettext("*/Library/*/Verb", 'Add content'),
}, notifications: this.$pgettext("*/Notifications/*", 'Notifications'),
tracks: { administration: this.$pgettext("Sidebar/Admin/Title/Noun", 'Administration'),
get() {
return this.$store.state.queue.tracks
},
set(value) {
this.tracksChangeBuffer = value
} }
}, },
logoUrl() { logoUrl() {
@ -252,36 +214,42 @@ export default {
} else { } else {
return "index" 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: { methods: {
...mapActions({ ...mapActions({
cleanTrack: "queue/cleanTrack" 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 () { applyContentFilters () {
let artistIds = this.$store.getters['moderation/artistFilters']().map((f) => { let artistIds = this.$store.getters['moderation/artistFilters']().map((f) => {
return f.target.id return f.target.id
@ -303,26 +271,66 @@ export default {
return await self.cleanTrack(realIndex) 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: { watch: {
url: function() { url: function() {
this.isCollapsed = true this.isCollapsed = true
}, },
selectedTab: function(newValue) {
if (newValue === "queue") {
this.scrollToCurrent()
}
},
"$store.state.queue.currentIndex": function() {
if (this.selectedTab !== "queue") {
this.scrollToCurrent()
}
},
"$store.state.moderation.lastUpdate": function () { "$store.state.moderation.lastUpdate": function () {
this.applyContentFilters() 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
}
}
},
myLibraryExpanded (v) {
if (v) {
this.exploreExpanded = false
}
},
exploreExpanded (v) {
if (v) {
this.myLibraryExpanded = false
}
},
} }
} }
</script> </script>
@ -331,16 +339,24 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
@import "../style/vendor/media"; @import "../style/vendor/media";
$sidebar-color: #3d3e3f; $sidebar-color: #2D2F33;
.sidebar { .sidebar {
background: $sidebar-color; background: $sidebar-color;
@include media(">tablet") { @include media(">desktop") {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
padding-bottom: 4em;
}
> nav {
flex-grow: 1;
overflow-y: auto;
} }
@include media(">desktop") { @include media(">desktop") {
.menu .item.collapse-button-wrapper {
padding: 0;
}
.collapse.button { .collapse.button {
display: none !important; display: none !important;
} }
@ -349,9 +365,10 @@ $sidebar-color: #3d3e3f;
position: static !important; position: static !important;
width: 100% !important; width: 100% !important;
&.collapsed { &.collapsed {
.menu-area,
.player-wrapper, .player-wrapper,
.tabs { .search,
.signup.segment,
nav.secondary {
display: none; display: none;
} }
} }
@ -366,23 +383,7 @@ $sidebar-color: #3d3e3f;
} }
} }
.menu-area { .ui.vertical.menu {
.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 {
.item .item { .item .item {
font-size: 1em; font-size: 1em;
> i.icon { > i.icon {
@ -390,9 +391,29 @@ $sidebar-color: #3d3e3f;
margin: 0 0.5em 0 0; margin: 0 0.5em 0 0;
} }
&:not(.active) { &: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 { .tabs {
flex: 1; flex: 1;
@ -416,6 +437,10 @@ $sidebar-color: #3d3e3f;
width: 55px; width: 55px;
} }
} }
.item .header .angle.icon {
float: right;
margin: 0;
}
.tab[data-tab="library"] { .tab[data-tab="library"] {
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
@ -432,8 +457,30 @@ $sidebar-color: #3d3e3f;
border-radius: 0; 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; 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 { .logo {
@ -442,20 +489,14 @@ $sidebar-color: #3d3e3f;
margin: 0px; margin: 0px;
} }
.ui.search { .collapsed .search-wrapper {
display: flex; @include media("<desktop") {
padding: 0;
.collapse.button,
.collapse.button:hover,
.collapse.button:active {
box-shadow: none !important;
margin: 0px;
display: flex;
flex-direction: column;
justify-content: center;
} }
} }
.ui.search {
display: flex;
}
.ui.message.black { .ui.message.black {
background: $sidebar-color; background: $sidebar-color;
} }
@ -463,10 +504,48 @@ $sidebar-color: #3d3e3f;
.ui.mini.image { .ui.mini.image {
width: 100%; 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>
<style lang="scss"> <style lang="scss">
.sidebar { aside.ui.sidebar {
overflow-y: visible !important;
.ui.search .input { .ui.search .input {
flex: 1; flex: 1;
.prompt { .prompt {

Wyświetl plik

@ -9,9 +9,12 @@
<i :class="[playIconClass, 'icon']"></i> <i :class="[playIconClass, 'icon']"></i>
<template v-if="!discrete && !iconOnly"><slot><translate translate-context="*/Queue/Button.Label/Short, Verb">Play</translate></slot></template> <template v-if="!discrete && !iconOnly"><slot><translate translate-context="*/Queue/Button.Label/Short, Verb">Play</translate></slot></template>
</button> </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> <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"> <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> <i class="plus icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate>
</button> </button>
@ -70,20 +73,9 @@ export default {
data () { data () {
return { return {
isLoading: false, 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: { computed: {
labels () { labels () {
return { return {
@ -250,6 +242,24 @@ export default {
date: new Date() 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> </script>

Wyświetl plik

@ -1,261 +1,249 @@
<template> <template>
<section class="ui inverted segment player-wrapper" :aria-label="labels.audioPlayer" :style="style"> <section v-if="currentTrack" class="player-wrapper ui bottom-player">
<div class="player"> <div class="ui inverted segment fixed-controls" @click.prevent.stop="toggleMobilePlayer">
<div v-if="currentTrack" class="track-area ui unstackable items"> <div
<div class="ui inverted item"> :class="['ui', 'top attached', 'small', 'orange', 'inverted', {'indicating': isLoadingAudio}, 'progress']">
<div class="ui tiny image"> <div class="buffer bar" :data-percent="bufferProgress" :style="{ 'width': bufferProgress + '%' }"></div>
<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)"> <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"> <img v-else src="../../assets/audio/default-cover.png">
</div> </div>
<div class="middle aligned content"> <div @click.stop.prevent="" class="middle aligned content ellipsis">
<router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"> <strong>
{{ currentTrack.title }} <router-link @click.stop.prevent="" class="small header discrete link track" :title="currentTrack.title" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
</router-link> {{ currentTrack.title }}
</router-link>
</strong>
<div class="meta"> <div class="meta">
<router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.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 }} {{ 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 }}">
</router-link> /
<router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
{{ currentTrack.album.title }} {{ currentTrack.album.title }}
</router-link> </router-link>
</div> </div>
<div class="description"> </div>
<track-favorite-icon </div>
v-if="$store.state.auth.authenticated" <div class="controls track-controls queue-not-focused tablet-and-below">
:class="{'inverted': !$store.getters['favorites/isFavorite'](currentTrack.id)}" <div class="ui tiny image">
:track="currentTrack"></track-favorite-icon> <img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
<track-playlist-icon <img v-else src="../../assets/audio/default-cover.png">
v-if="$store.state.auth.authenticated" </div>
:class="['inverted']" <div class="middle aligned content ellipsis">
:track="currentTrack"></track-playlist-icon> <strong>
<button {{ currentTrack.title }}
v-if="$store.state.auth.authenticated" </strong>
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})" <div class="meta">
:class="['ui', 'really', 'basic', 'circular', 'inverted', 'icon', 'button']" {{ currentTrack.artist.name }} / {{ currentTrack.album.title }}
:aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter">
<i :class="['eye slash outline', 'basic', 'icon']"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="controls desktop-and-up fluid align-right" v-if="$store.state.auth.authenticated">
<div class="progress-area" v-if="currentTrack && !errored"> <track-favorite-icon
<div class="ui grid"> class="control white"
<div class="left floated four wide column"> :track="currentTrack"></track-favorite-icon>
<p class="timer start" @click="setCurrentTime(0)">{{currentTimeFormatted}}</p> <track-playlist-icon
</div> class="control white"
:track="currentTrack"></track-playlist-icon>
<div v-if="!isLoadingAudio" class="right floated four wide column"> <button
<p class="timer total">{{durationFormatted}}</p> @click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
</div> :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 <div class="player-controls controls queue-not-focused">
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>
</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 }">
<span <span
role="button" role="button"
v-if="volume === 0" :title="labels.previous"
:title="labels.unmute" :aria-label="labels.previous"
:aria-label="labels.unmute" class="control tablet-and-up"
@click.prevent.stop="unmute"> @click.prevent.stop="$store.dispatch('queue/previous')"
<i class="volume off icon"></i> :disabled="!hasPrevious">
<i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" ></i>
</span> </span>
<span <span
role="button" role="button"
v-else-if="volume < 0.5" v-if="!playing"
:title="labels.mute" :title="labels.play"
:aria-label="labels.mute" :aria-label="labels.play"
@click.prevent.stop="mute"> @click.prevent.stop="togglePlay"
<i class="volume down icon"></i> class="control">
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']"></i>
</span> </span>
<span <span
role="button" role="button"
v-else v-else
:title="labels.mute" :title="labels.pause"
:aria-label="labels.mute" :aria-label="labels.pause"
@click.prevent.stop="mute"> @click.prevent.stop="togglePlay"
<i class="volume up icon"></i> class="control">
</span> <i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']"></i>
<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>
</span> </span>
<span <span
role="button" role="button"
@click.prevent.stop="$store.commit('player/looping', 2)" :title="labels.next"
:title="labels.loopingSingle" :aria-label="labels.next"
:aria-label="labels.loopingSingle" class="control"
v-if="looping === 1" @click.prevent.stop="$store.dispatch('queue/next')"
:disabled="!currentTrack"> :disabled="!hasNext">
<i <i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" ></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>
</span> </span>
</div> </div>
<span
role="button" <div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
:disabled="queue.tracks.length === 0" <div class="timer">
:title="labels.shuffle" <template v-if="!isLoadingAudio">
:aria-label="labels.shuffle" <span role="button" class="start" @click.stop.prevent="setCurrentTime(0)">{{currentTimeFormatted}}</span>
v-if="!showVolume" | <span class="total">{{durationFormatted}}</span>
@click.prevent.stop="shuffle()" </template>
class="two wide column control"> <template v-else>
<div v-if="isShuffling" class="ui inline shuffling inverted tiny active loader"></div> 00:00 | 00:00
<i v-else :class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> </template>
</span> </div>
<div class="one wide column" v-if="!showVolume"></div> </div>
<span <div class="controls queue-controls when-queue-focused align-right">
role="button" <div class="group">
:disabled="queue.tracks.length === 0" <volume-control class="expandable" />
:title="labels.clear" <span
:aria-label="labels.clear" role="button"
v-if="!showVolume" v-if="looping === 0"
@click.prevent.stop="clean()" :title="labels.loopingDisabled"
class="two wide column control"> :aria-label="labels.loopingDisabled"
<i class="icons"> @click.prevent.stop="$store.commit('player/looping', 1)"
<i :class="['ui', 'trash', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> :disabled="!currentTrack">
<i :class="['ui corner inverted', 'list', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> <i :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'icon']"></i>
</i> </span>
</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">&infin;</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> </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> </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> </section>
</template> </template>
<script> <script>
import { mapState, mapGetters, mapActions } from "vuex" import { mapState, mapGetters, mapActions } from "vuex"
import GlobalEvents from "@/components/utils/global-events" import GlobalEvents from "@/components/utils/global-events"
import ColorThief from "@/vendor/color-thief"
import { Howl } from "howler" import { Howl } from "howler"
import $ from 'jquery' import $ from 'jquery'
import _ from '@/lodash' import _ from '@/lodash'
import url from '@/utils/url' import url from '@/utils/url'
import axios from 'axios' import axios from 'axios'
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
export default { export default {
components: { components: {
TrackFavoriteIcon, VolumeControl: () => import(/* webpackChunkName: "audio" */ "./VolumeControl"),
TrackPlaylistIcon, TrackFavoriteIcon: () => import(/* webpackChunkName: "auth-audio" */ "@/components/favorites/TrackFavoriteIcon"),
TrackPlaylistIcon: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/TrackPlaylistIcon"),
GlobalEvents, GlobalEvents,
}, },
data() { data() {
let defaultAmbiantColors = [
[46, 46, 46],
[46, 46, 46],
[46, 46, 46],
[46, 46, 46]
]
return { return {
isShuffling: false, isShuffling: false,
sliderVolume: this.volume, sliderVolume: this.volume,
defaultAmbiantColors: defaultAmbiantColors,
showVolume: false, showVolume: false,
ambiantColors: defaultAmbiantColors,
currentSound: null, currentSound: null,
dummyAudio: null, dummyAudio: null,
isUpdatingTime: false, isUpdatingTime: false,
@ -350,26 +338,6 @@ export default {
self.$emit("previous") 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 }) { handleError({ sound, error }) {
this.$store.commit("player/isLoadingAudio", false) this.$store.commit("player/isLoadingAudio", false)
this.$store.dispatch("player/trackErrored") this.$store.dispatch("player/trackErrored")
@ -621,7 +589,22 @@ export default {
this.observeProgress(true) 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: { computed: {
...mapState({ ...mapState({
@ -639,6 +622,7 @@ export default {
...mapGetters({ ...mapGetters({
currentTrack: "queue/currentTrack", currentTrack: "queue/currentTrack",
hasNext: "queue/hasNext", hasNext: "queue/hasNext",
hasPrevious: "queue/hasPrevious",
emptyQueue: "queue/isEmpty", emptyQueue: "queue/isEmpty",
durationFormatted: "player/durationFormatted", durationFormatted: "player/durationFormatted",
currentTimeFormatted: "player/currentTimeFormatted", currentTimeFormatted: "player/currentTimeFormatted",
@ -655,6 +639,7 @@ export default {
let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track") let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track")
let unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute") let unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute")
let mute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute") 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', let loopingDisabled = this.$pgettext('Sidebar/Player/Icon.Tooltip',
"Looping disabled. Click to switch to single-track looping." "Looping disabled. Click to switch to single-track looping."
) )
@ -680,35 +665,10 @@ export default {
loopingWhole, loopingWhole,
shuffle, shuffle,
clear, clear,
expandQueue,
addArtistContentFilter, 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: { watch: {
currentTrack: { currentTrack: {
@ -725,9 +685,6 @@ export default {
this.$store.commit("player/isLoadingAudio", true) this.$store.commit("player/isLoadingAudio", true)
this.playTimeout = setTimeout(async () => { this.playTimeout = setTimeout(async () => {
await self.loadSound(newValue, oldValue) await self.loadSound(newValue, oldValue)
if (!newValue || !newValue.album.cover) {
self.ambiantColors = self.defaultAmbiantColors
}
}, 500); }, 500);
}, },
immediate: false immediate: false
@ -771,43 +728,10 @@ export default {
<!-- Add "scoped" attribute to limit CSS to this component only --> <!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss"> <style scoped lang="scss">
.ui.progress { @import "../../style/vendor/media";
margin: 0.5rem 0 1rem; .controls {
} display: flex;
.progress { justify-content: space-between;
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;
} }
.controls .icon.big { .controls .icon.big {
@ -819,150 +743,55 @@ export default {
cursor: pointer; cursor: pointer;
vertical-align: middle; vertical-align: middle;
} }
.timer {
.control .icon { font-size: 1.2em;
font-size: 1.5em;
} }
.progress-area .actions { .looping {
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;
}
.looping.control {
i { i {
position: relative; position: relative;
} }
.label { .ui.circular.label {
font-family: sans-serif;
position: absolute; position: absolute;
font-size: 0.7rem; font-size: 0.5em !important;
bottom: -0.7rem; bottom: -0.7rem;
right: -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 { .shuffling.loader.inline {
margin: 0; margin: 0;
} }
.control.circular.button {
@keyframes MOVE-BG { padding: 0;
from { border: none;
transform: translateX(0px); background-color: transparent;
color: inherit;
&:focus {
box-shadow: none;
} }
to {
transform: translateX(46px); }
.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;
} }
}
.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;
} }
</style> </style>

Wyświetl plik

@ -1,7 +1,7 @@
<template> <template>
<div class="ui fluid category search"> <div class="ui fluid category search">
<slot></slot><div class="ui icon input"> <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> <i class="search icon"></i>
</div> </div>
<div class="results"></div> <div class="results"></div>

Wyświetl plik

@ -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>

Wyświetl plik

@ -19,3 +19,10 @@ export default {
} }
} }
</script> </script>
<style lang="scss">
.ui.circular.avatar.label {
width: 28px;
height: 28px;
font-size: 1em !important;
}
</style>

Wyświetl plik

@ -11,7 +11,7 @@
</div> </div>
</template> </template>
<script> <script>
import sanitize from "@/sanitize" // import sanitize from "@/sanitize"
export default { export default {
props: { props: {

Wyświetl plik

@ -1,12 +1,12 @@
<template> <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> <i class="heart icon"></i>
<translate v-if="isFavorite" translate-context="Content/Track/Button.Message">In favorites</translate> <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> <translate v-else translate-context="Content/Track/*/Verb">Add to favorites</translate>
</button> </button>
<button <button
v-else 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']" :class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', 'really', 'button']"
:aria-label="title" :aria-label="title"
:title="title"> :title="title">

Wyświetl plik

@ -1,63 +1,19 @@
import Vue from 'vue' import Vue from 'vue'
import HumanDate from '@/components/common/HumanDate' Vue.component('human-date', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDate"))
Vue.component('username', () => import(/* webpackChunkName: "common" */ "@/components/common/Username"))
Vue.component('human-date', HumanDate) Vue.component('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink"))
Vue.component('actor-link', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink"))
import Username from '@/components/common/Username' Vue.component('actor-avatar', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorAvatar"))
Vue.component('duration', () => import(/* webpackChunkName: "common" */ "@/components/common/Duration"))
Vue.component('username', Username) Vue.component('dangerous-button', () => import(/* webpackChunkName: "common" */ "@/components/common/DangerousButton"))
Vue.component('message', () => import(/* webpackChunkName: "common" */ "@/components/common/Message"))
import UserLink from '@/components/common/UserLink' Vue.component('copy-input', () => import(/* webpackChunkName: "common" */ "@/components/common/CopyInput"))
Vue.component('ajax-button', () => import(/* webpackChunkName: "common" */ "@/components/common/AjaxButton"))
Vue.component('user-link', UserLink) Vue.component('tooltip', () => import(/* webpackChunkName: "common" */ "@/components/common/Tooltip"))
Vue.component('empty-state', () => import(/* webpackChunkName: "common" */ "@/components/common/EmptyState"))
import ActorLink from '@/components/common/ActorLink' Vue.component('expandable-div', () => import(/* webpackChunkName: "common" */ "@/components/common/ExpandableDiv"))
Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink"))
Vue.component('actor-link', ActorLink) Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback"))
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)
export default {} export default {}

Wyświetl plik

@ -112,6 +112,7 @@ export default {
props: { props: {
defaultQuery: { type: String, required: false, default: "" }, defaultQuery: { type: String, required: false, default: "" },
defaultTags: { type: Array, required: false, default: () => { return [] } }, defaultTags: { type: Array, required: false, default: () => { return [] } },
scope: { type: String, required: false, default: "all" },
}, },
components: { components: {
AlbumCard, AlbumCard,
@ -164,6 +165,7 @@ export default {
this.isLoading = true this.isLoading = true
let url = FETCH_URL let url = FETCH_URL
let params = { let params = {
scope: this.scope,
page: this.page, page: this.page,
page_size: this.paginateBy, page_size: this.paginateBy,
q: this.query, q: this.query,

Wyświetl plik

@ -100,6 +100,7 @@ export default {
props: { props: {
defaultQuery: { type: String, required: false, default: "" }, defaultQuery: { type: String, required: false, default: "" },
defaultTags: { type: Array, required: false, default: () => { return [] } }, defaultTags: { type: Array, required: false, default: () => { return [] } },
scope: { type: String, required: false, default: "all" },
}, },
components: { components: {
ArtistCard, ArtistCard,
@ -152,6 +153,7 @@ export default {
this.isLoading = true this.isLoading = true
let url = FETCH_URL let url = FETCH_URL
let params = { let params = {
scope: this.scope,
page: this.page, page: this.page,
page_size: this.paginateBy, page_size: this.paginateBy,
q: this.query, q: this.query,

Wyświetl plik

@ -3,17 +3,17 @@
<section class="ui vertical stripe segment"> <section class="ui vertical stripe segment">
<div class="ui stackable three column grid"> <div class="ui stackable three column grid">
<div class="column"> <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> <template slot="title"><translate translate-context="Content/Home/Title">Recently listened</translate></template>
</track-widget> </track-widget>
</div> </div>
<div class="column"> <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> <template slot="title"><translate translate-context="Content/Home/Title">Recently favorited</translate></template>
</track-widget> </track-widget>
</div> </div>
<div class="column"> <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> <template slot="title"><translate translate-context="*/*/*">Playlists</translate></template>
</playlist-widget> </playlist-widget>
</div> </div>
@ -21,7 +21,7 @@
<div class="ui section hidden divider"></div> <div class="ui section hidden divider"></div>
<div class="ui stackable one column grid"> <div class="ui stackable one column grid">
<div class="column"> <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> <template slot="title"><translate translate-context="Content/Home/Title">Recently added</translate></template>
</album-widget> </album-widget>
</div> </div>
@ -43,6 +43,9 @@ const ARTISTS_URL = "artists/"
export default { export default {
name: "library", name: "library",
props: {
scope: {default: 'all'}
},
components: { components: {
Search, Search,
ArtistCard, ArtistCard,
@ -53,7 +56,7 @@ export default {
data() { data() {
return { return {
artists: [], artists: [],
isLoadingArtists: false isLoadingArtists: false,
} }
}, },
created() { created() {

Wyświetl plik

@ -1,22 +1,5 @@
<template> <template>
<div class="main library pusher"> <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> <router-view :key="$route.fullPath"></router-view>
</div> </div>
</template> </template>

Wyświetl plik

@ -127,7 +127,8 @@ const FETCH_URL = "radios/radios/"
export default { export default {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
props: { props: {
defaultQuery: { type: String, required: false, default: "" } defaultQuery: { type: String, required: false, default: "" },
scope: { type: String, required: false, default: "all" },
}, },
components: { components: {
RadioCard, RadioCard,
@ -183,10 +184,11 @@ export default {
this.isLoading = true this.isLoading = true
let url = FETCH_URL let url = FETCH_URL
let params = { let params = {
scope: this.scope,
page: this.page, page: this.page,
page_size: this.paginateBy, page_size: this.paginateBy,
name__icontains: this.query, name__icontains: this.query,
ordering: this.getOrderingAsString() ordering: this.getOrderingAsString(),
} }
logger.default.debug("Fetching radios") logger.default.debug("Fetching radios")
axios.get(url, { params: params }).then(response => { axios.get(url, { params: params }).then(response => {

Wyświetl plik

@ -61,7 +61,7 @@ export default {
}, },
created () { created () {
let self = this let self = this
import('showdown').then(module => { import(/* webpackChunkName: "showdown" */ 'showdown').then(module => {
self.markdown = new module.default.Converter({simplifiedAutoLink: true, openLinksInNewWindow: true}) self.markdown = new module.default.Converter({simplifiedAutoLink: true, openLinksInNewWindow: true})
}) })
} }

Wyświetl plik

@ -74,13 +74,11 @@ import axios from 'axios'
import {mapState} from 'vuex' import {mapState} from 'vuex'
import logger from '@/logging' import logger from '@/logging'
import Modal from '@/components/semantic/Modal'
import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown'
export default { export default {
components: { components: {
Modal, ReportCategoryDropdown: () => import(/* webpackChunkName: "reports" */ "@/components/moderation/ReportCategoryDropdown"),
ReportCategoryDropdown, Modal: () => import(/* webpackChunkName: "modal" */ "@/components/semantic/Modal"),
}, },
data () { data () {
return { return {

Wyświetl plik

@ -1,6 +1,6 @@
<template> <template>
<button <button
@click="$store.commit('playlists/chooseTrack', track)" @click.stop="$store.commit('playlists/chooseTrack', track)"
v-if="button" v-if="button"
:class="['ui', 'icon', 'labeled', 'button']"> :class="['ui', 'icon', 'labeled', 'button']">
<i class="list icon"></i> <i class="list icon"></i>
@ -8,7 +8,7 @@
</button> </button>
<button <button
v-else v-else
@click="$store.commit('playlists/chooseTrack', track)" @click.stop="$store.commit('playlists/chooseTrack', track)"
:class="['ui', 'basic', 'circular', 'icon', 'really', 'button']" :class="['ui', 'basic', 'circular', 'icon', 'really', 'button']"
:aria-label="labels.addToPlaylist" :aria-label="labels.addToPlaylist"
:title="labels.addToPlaylist"> :title="labels.addToPlaylist">

Wyświetl plik

@ -1,7 +1,7 @@
<template> <template>
<div :class="['ui', {'active': show}, 'modal']"> <div :class="['ui', {'active': show}, 'modal']">
<i class="close icon"></i> <i class="close icon"></i>
<slot> <slot v-if="show">
</slot> </slot>
</div> </div>

Wyświetl plik

@ -14,4 +14,5 @@ export default {
remove: require('lodash/remove'), remove: require('lodash/remove'),
reverse: require('lodash/reverse'), reverse: require('lodash/reverse'),
isEqual: require('lodash/isEqual'), isEqual: require('lodash/isEqual'),
sum: require('lodash/sum'),
} }

Wyświetl plik

@ -38,26 +38,26 @@ export default new Router({
path: "/about", path: "/about",
name: "about", name: "about",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/About") import(/* webpackChunkName: "about" */ "@/components/About")
}, },
{ {
path: "/login", path: "/login",
name: "login", name: "login",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/auth/Login"), import(/* webpackChunkName: "login" */ "@/views/auth/Login"),
props: route => ({ next: route.query.next || "/library" }) props: route => ({ next: route.query.next || "/library" })
}, },
{ {
path: "/notifications", path: "/notifications",
name: "notifications", name: "notifications",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/Notifications") import(/* webpackChunkName: "notifications" */ "@/views/Notifications")
}, },
{ {
path: "/auth/password/reset", path: "/auth/password/reset",
name: "auth.password-reset", name: "auth.password-reset",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/auth/PasswordReset"), import(/* webpackChunkName: "password-reset" */ "@/views/auth/PasswordReset"),
props: route => ({ props: route => ({
defaultEmail: route.query.email defaultEmail: route.query.email
}) })
@ -66,7 +66,7 @@ export default new Router({
path: "/auth/email/confirm", path: "/auth/email/confirm",
name: "auth.email-confirm", name: "auth.email-confirm",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/auth/EmailConfirm"), import(/* webpackChunkName: "signup" */ "@/views/auth/EmailConfirm"),
props: route => ({ props: route => ({
defaultKey: route.query.key defaultKey: route.query.key
}) })
@ -76,7 +76,7 @@ export default new Router({
name: "auth.password-reset-confirm", name: "auth.password-reset-confirm",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm" /* webpackChunkName: "password-reset" */ "@/views/auth/PasswordResetConfirm"
), ),
props: route => ({ props: route => ({
defaultUid: route.query.uid, defaultUid: route.query.uid,
@ -87,7 +87,7 @@ export default new Router({
path: "/authorize", path: "/authorize",
name: "authorize", name: "authorize",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Authorize"), import(/* webpackChunkName: "settings" */ "@/components/auth/Authorize"),
props: route => ({ props: route => ({
clientId: route.query.client_id, clientId: route.query.client_id,
redirectUri: route.query.redirect_uri, redirectUri: route.query.redirect_uri,
@ -101,7 +101,7 @@ export default new Router({
path: "/signup", path: "/signup",
name: "signup", name: "signup",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/auth/Signup"), import(/* webpackChunkName: "signup" */ "@/views/auth/Signup"),
props: route => ({ props: route => ({
defaultInvitation: route.query.invitation defaultInvitation: route.query.invitation
}) })
@ -110,13 +110,13 @@ export default new Router({
path: "/logout", path: "/logout",
name: "logout", name: "logout",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Logout") import(/* webpackChunkName: "login" */ "@/components/auth/Logout")
}, },
{ {
path: "/settings", path: "/settings",
name: "settings", name: "settings",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Settings") import(/* webpackChunkName: "settings" */ "@/components/auth/Settings")
}, },
{ {
path: "/settings/applications/new", path: "/settings/applications/new",
@ -128,7 +128,7 @@ export default new Router({
}), }),
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/auth/ApplicationNew" /* webpackChunkName: "settings" */ "@/components/auth/ApplicationNew"
) )
}, },
{ {
@ -136,7 +136,7 @@ export default new Router({
name: "settings.applications.edit", name: "settings.applications.edit",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit" /* webpackChunkName: "settings" */ "@/components/auth/ApplicationEdit"
), ),
props: true props: true
}, },
@ -144,13 +144,14 @@ export default new Router({
path: "/@:username", path: "/@:username",
name: "profile", name: "profile",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Profile"), import(/* webpackChunkName: "core" */ "@/components/auth/Profile"),
props: true props: true
}, },
{ {
path: "/favorites", path: "/favorites",
name: "favorites",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/favorites/List"), import(/* webpackChunkName: "favorites" */ "@/components/favorites/List"),
props: route => ({ props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
defaultPage: route.query.page, defaultPage: route.query.page,
@ -173,14 +174,14 @@ export default new Router({
{ {
path: "/content/libraries/tracks", path: "/content/libraries/tracks",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/Base"), import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"),
children: [ children: [
{ {
path: "", path: "",
name: "content.libraries.files", name: "content.libraries.files",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/views/content/libraries/Files" /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Files"
), ),
props: route => ({ props: route => ({
query: route.query.q query: route.query.q
@ -191,14 +192,14 @@ export default new Router({
{ {
path: "/content/libraries", path: "/content/libraries",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/Base"), import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"),
children: [ children: [
{ {
path: "", path: "",
name: "content.libraries.index", name: "content.libraries.index",
component: () => component: () =>
import( 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", name: "content.libraries.detail.upload",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/views/content/libraries/Upload" /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Upload"
), ),
props: route => ({ props: route => ({
id: route.params.id, id: route.params.id,
@ -218,7 +219,7 @@ export default new Router({
name: "content.libraries.detail", name: "content.libraries.detail",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/views/content/libraries/Detail" /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Detail"
), ),
props: true props: true
} }
@ -227,13 +228,13 @@ export default new Router({
{ {
path: "/content/remote", path: "/content/remote",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/Base"), import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"),
children: [ children: [
{ {
path: "", path: "",
name: "content.remote.index", name: "content.remote.index",
component: () => 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"), import(/* webpackChunkName: "core" */ "@/components/library/Home"),
name: "library.index" name: "library.index"
}, },
{
path: "me",
component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/Home"),
name: "library.me",
props: route => ({
scope: 'me',
})
},
{ {
path: "artists/", path: "artists/",
name: "library.artists.browse", name: "library.artists.browse",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/Artists" /* webpackChunkName: "artists" */ "@/components/library/Artists"
), ),
props: route => ({ props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
@ -515,12 +525,30 @@ export default new Router({
defaultPage: route.query.page 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/", path: "albums/",
name: "library.albums.browse", name: "library.albums.browse",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/Albums" /* webpackChunkName: "albums" */ "@/components/library/Albums"
), ),
props: route => ({ props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
@ -532,12 +560,30 @@ export default new Router({
defaultPage: route.query.page 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/", path: "radios/",
name: "library.radios.browse", name: "library.radios.browse",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/Radios" /* webpackChunkName: "radios" */ "@/components/library/Radios"
), ),
props: route => ({ props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
@ -546,12 +592,27 @@ export default new Router({
defaultPage: route.query.page 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,
defaultPage: route.query.page
})
},
{ {
path: "radios/build", path: "radios/build",
name: "library.radios.build", name: "library.radios.build",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/radios/Builder" /* webpackChunkName: "radios" */ "@/components/library/radios/Builder"
), ),
props: true props: true
}, },
@ -560,7 +621,7 @@ export default new Router({
name: "library.radios.edit", name: "library.radios.edit",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/radios/Builder" /* webpackChunkName: "radios" */ "@/components/library/radios/Builder"
), ),
props: true props: true
}, },
@ -568,14 +629,14 @@ export default new Router({
path: "radios/:id", path: "radios/:id",
name: "library.radios.detail", name: "library.radios.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/radios/Detail"), import(/* webpackChunkName: "radios" */ "@/views/radios/Detail"),
props: true props: true
}, },
{ {
path: "playlists/", path: "playlists/",
name: "library.playlists.browse", name: "library.playlists.browse",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/playlists/List"), import(/* webpackChunkName: "playlists" */ "@/views/playlists/List"),
props: route => ({ props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
defaultQuery: route.query.query, defaultQuery: route.query.query,
@ -583,11 +644,24 @@ export default new Router({
defaultPage: route.query.page 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,
defaultPage: route.query.page
})
},
{ {
path: "playlists/:id", path: "playlists/:id",
name: "library.playlists.detail", name: "library.playlists.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/playlists/Detail"), import(/* webpackChunkName: "playlists" */ "@/views/playlists/Detail"),
props: route => ({ props: route => ({
id: route.params.id, id: route.params.id,
defaultEdit: route.query.mode === "edit" defaultEdit: route.query.mode === "edit"
@ -598,7 +672,7 @@ export default new Router({
name: "library.tags.detail", name: "library.tags.detail",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/TagDetail" /* webpackChunkName: "tags" */ "@/components/library/TagDetail"
), ),
props: true props: true
}, },
@ -606,7 +680,7 @@ export default new Router({
path: "artists/:id", path: "artists/:id",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/ArtistBase" /* webpackChunkName: "artists" */ "@/components/library/ArtistBase"
), ),
props: true, props: true,
children: [ children: [
@ -615,7 +689,7 @@ export default new Router({
name: "library.artists.detail", name: "library.artists.detail",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/ArtistDetail" /* webpackChunkName: "artists" */ "@/components/library/ArtistDetail"
) )
}, },
{ {
@ -623,7 +697,7 @@ export default new Router({
name: "library.artists.edit", name: "library.artists.edit",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/ArtistEdit" /* webpackChunkName: "edits" */ "@/components/library/ArtistEdit"
) )
}, },
{ {
@ -631,7 +705,7 @@ export default new Router({
name: "library.artists.edit.detail", name: "library.artists.edit.detail",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/EditDetail" /* webpackChunkName: "edits" */ "@/components/library/EditDetail"
), ),
props: true props: true
} }
@ -641,7 +715,7 @@ export default new Router({
path: "albums/:id", path: "albums/:id",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/AlbumBase" /* webpackChunkName: "albums" */ "@/components/library/AlbumBase"
), ),
props: true, props: true,
children: [ children: [
@ -650,7 +724,7 @@ export default new Router({
name: "library.albums.detail", name: "library.albums.detail",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/AlbumDetail" /* webpackChunkName: "albums" */ "@/components/library/AlbumDetail"
) )
}, },
{ {
@ -658,7 +732,7 @@ export default new Router({
name: "library.albums.edit", name: "library.albums.edit",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/AlbumEdit" /* webpackChunkName: "edits" */ "@/components/library/AlbumEdit"
) )
}, },
{ {
@ -666,7 +740,7 @@ export default new Router({
name: "library.albums.edit.detail", name: "library.albums.edit.detail",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/EditDetail" /* webpackChunkName: "edits" */ "@/components/library/EditDetail"
), ),
props: true props: true
} }
@ -676,7 +750,7 @@ export default new Router({
path: "tracks/:id", path: "tracks/:id",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/TrackBase" /* webpackChunkName: "tracks" */ "@/components/library/TrackBase"
), ),
props: true, props: true,
children: [ children: [
@ -685,7 +759,7 @@ export default new Router({
name: "library.tracks.detail", name: "library.tracks.detail",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/TrackDetail" /* webpackChunkName: "tracks" */ "@/components/library/TrackDetail"
) )
}, },
{ {
@ -693,7 +767,7 @@ export default new Router({
name: "library.tracks.edit", name: "library.tracks.edit",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/TrackEdit" /* webpackChunkName: "edits" */ "@/components/library/TrackEdit"
) )
}, },
{ {
@ -701,7 +775,7 @@ export default new Router({
name: "library.tracks.edit.detail", name: "library.tracks.edit.detail",
component: () => component: () =>
import( import(
/* webpackChunkName: "core" */ "@/components/library/EditDetail" /* webpackChunkName: "edits" */ "@/components/library/EditDetail"
), ),
props: true props: true
} }

Wyświetl plik

@ -9,7 +9,7 @@ export default {
errorCount: 0, errorCount: 0,
playing: false, playing: false,
isLoadingAudio: false, isLoadingAudio: false,
volume: 0.5, volume: 1,
tempVolume: 0.5, tempVolume: 0.5,
duration: 0, duration: 0,
currentTime: 0, currentTime: 0,
@ -88,7 +88,7 @@ export default {
return time.parse(Math.round(state.currentTime)) return time.parse(Math.round(state.currentTime))
}, },
progress: state => { progress: state => {
return Math.round(state.currentTime / state.duration * 100) return Math.round((state.currentTime / state.duration * 100) * 10) / 10
} }
}, },
actions: { actions: {

Wyświetl plik

@ -7,14 +7,12 @@ export default {
tracks: [], tracks: [],
currentIndex: -1, currentIndex: -1,
ended: true, ended: true,
previousQueue: null
}, },
mutations: { mutations: {
reset (state) { reset (state) {
state.tracks = [] state.tracks = []
state.currentIndex = -1 state.currentIndex = -1
state.ended = true state.ended = true
state.previousQueue = null
}, },
currentIndex (state, value) { currentIndex (state, value) {
state.currentIndex = value state.currentIndex = value
@ -56,6 +54,9 @@ export default {
hasNext: state => { hasNext: state => {
return state.currentIndex < state.tracks.length - 1 return state.currentIndex < state.tracks.length - 1
}, },
hasPrevious: state => {
return state.currentIndex > 0 && state.tracks.length > 1
},
isEmpty: state => state.tracks.length === 0 isEmpty: state => state.tracks.length === 0
}, },
actions: { actions: {

Wyświetl plik

@ -6,6 +6,7 @@ export default {
state: { state: {
currentLanguage: 'en_US', currentLanguage: 'en_US',
selectedLanguage: false, selectedLanguage: false,
queueFocused: null,
momentLocale: 'en', momentLocale: 'en',
lastDate: new Date(), lastDate: new Date(),
maxMessages: 100, maxMessages: 100,
@ -46,6 +47,26 @@ export default {
orderingDirection: "-", orderingDirection: "-",
ordering: "creation_date", 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: { getters: {
@ -104,6 +125,10 @@ export default {
computeLastDate: (state) => { computeLastDate: (state) => {
state.lastDate = new Date() state.lastDate = new Date()
}, },
queueFocused: (state, value) => {
state.queueFocused = value
},
theme: (state, value) => { theme: (state, value) => {
state.theme = value state.theme = value
}, },

Wyświetl plik

@ -79,8 +79,9 @@
// see https://github.com/webpack/webpack/issues/215 // see https://github.com/webpack/webpack/issues/215
@import "./vendor/media"; @import "./vendor/media";
$desktop-sidebar-width: 300px; $desktop-sidebar-width: 275px;
$widedesktop-sidebar-width: 350px; $widedesktop-sidebar-width: 275px;
$bottom-player-height: 4rem;
html, html,
body { body {
@ -88,6 +89,15 @@ body {
font-size: 90%; font-size: 90%;
} }
} }
html {
scroll-behavior: smooth;
}
@media screen and (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
#app { #app {
font-family: "Avenir", Helvetica, Arial, sans-serif; font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@ -95,8 +105,19 @@ body {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
flex-direction: column; 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 { #app > main, #app > .main {
flex: 1; flex: 1;
} }
@ -114,19 +135,24 @@ body {
width: $widedesktop-sidebar-width; width: $widedesktop-sidebar-width;
} }
} }
.main.pusher,
.footer {
@include media(">desktop") {
margin-left: $desktop-sidebar-width !important;
margin-top: 50px;
}
@include media(">widedesktop") { #app {
margin-left: $widedesktop-sidebar-width !important;; > .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 { .main.pusher > .ui.secondary.menu {
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
@ -140,16 +166,6 @@ body {
@include media(">tablet") { @include media(">tablet") {
padding: 0 2.5rem; 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 { .item {
padding-top: 1.5em; padding-top: 1.5em;
padding-bottom: 1.5em; padding-bottom: 1.5em;
@ -159,13 +175,7 @@ body {
.service-messages { .service-messages {
position: fixed; position: fixed;
bottom: 1em; bottom: 1em;
left: 1em; right: 1em;
@include media(">desktop") {
left: $desktop-sidebar-width;
}
@include media(">widedesktop") {
left: $widedesktop-sidebar-width;
}
> .ui.message { > .ui.message {
box-shadow: 0px 0px 7px rgba(0, 0, 0, 0.7); box-shadow: 0px 0px 7px rgba(0, 0, 0, 0.7);
} }
@ -306,10 +316,6 @@ label .tooltip {
margin-left: 1em; margin-left: 1em;
} }
canvas.color-thief {
display: none;
}
.ui.list .list.icon { .ui.list .list.icon {
padding-left: 0; padding-left: 0;
} }
@ -392,5 +398,45 @@ input + .help {
max-width: 100% !important; 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/_light.scss";
@import "./themes/_dark.scss"; @import "./themes/_dark.scss";

Wyświetl plik

@ -31,6 +31,9 @@ $link-color: rgb(255, 144, 0);
color: $text-color; color: $text-color;
} }
} }
.main.with-background {
background-color: $background-color;
}
.ui.link.list.list .active.item, .ui.link.list.list .active.item,
.ui.link.list.list .active.item a:not(.ui) { .ui.link.list.list .active.item a:not(.ui) {
color: inherit; color: inherit;
@ -281,6 +284,17 @@ $link-color: rgb(255, 144, 0);
color: $text-color; 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 */ /* purgecss end ignore */

Wyświetl plik

@ -11,6 +11,9 @@
} }
} }
} }
.main.with-background {
background-color: white;
}
.discrete { .discrete {
color: rgba(0, 0, 0, 0.87); color: rgba(0, 0, 0, 0.87);
@ -31,5 +34,16 @@
footer#footer div.item:hover { footer#footer div.item:hover {
color: rgba(0, 0, 0, 0.87); 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;
}
}
} }

Wyświetl plik

@ -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

Wyświetl plik

@ -87,7 +87,8 @@ const FETCH_URL = "playlists/"
export default { export default {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
props: { props: {
defaultQuery: { type: String, required: false, default: "" } defaultQuery: { type: String, required: false, default: "" },
scope: { type: String, required: false, default: "all" },
}, },
components: { components: {
PlaylistCardList, PlaylistCardList,
@ -141,6 +142,7 @@ export default {
this.isLoading = true this.isLoading = true
let url = FETCH_URL let url = FETCH_URL
let params = { let params = {
scope: this.scope,
page: this.page, page: this.page,
page_size: this.paginateBy, page_size: this.paginateBy,
q: this.query, q: this.query,

Wyświetl plik

@ -2,11 +2,16 @@
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const webpack = require('webpack'); const webpack = require('webpack');
const PurgecssPlugin = require('purgecss-webpack-plugin') const PurgecssPlugin = require('purgecss-webpack-plugin')
const PreloadWebpackPlugin = require('preload-webpack-plugin');
const glob = require('glob-all') const glob = require('glob-all')
const path = require('path') const path = require('path')
let plugins = [ let plugins = [
// do not include moment.js locales since it's quite heavy // do not include moment.js locales since it's quite heavy
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
new PreloadWebpackPlugin({
rel: 'preload',
include: ['audio', 'core', 'about']
}),
] ]
if (process.env.BUNDLE_ANALYZE === '1') { if (process.env.BUNDLE_ANALYZE === '1') {
plugins.push(new BundleAnalyzerPlugin()) plugins.push(new BundleAnalyzerPlugin())
@ -40,7 +45,6 @@ module.exports = {
} }
}, },
chainWebpack: config => { chainWebpack: config => {
config.optimization.delete('splitChunks')
config.plugins.delete('prefetch-embed') config.plugins.delete('prefetch-embed')
config.plugins.delete('prefetch-index') config.plugins.delete('prefetch-index')
}, },

Wyświetl plik

@ -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" source-map "^0.6.1"
supports-color "^6.1.0" 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: prelude-ls@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@ -8217,10 +8222,10 @@ sort-keys@^2.0.0:
dependencies: dependencies:
is-plain-obj "^1.0.0" is-plain-obj "^1.0.0"
sortablejs@^1.9.0: sortablejs@^1.10.1:
version "1.9.0" version "1.10.1"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.9.0.tgz#2d1e74ae6bac2cb4ad0622908f340848969eb88d" resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.1.tgz#3d52b00f871be00f00f84d99a60d120bf3dfe52c"
integrity sha512-Ot6bYJ6PoqPmpsqQYXjn1+RKrY2NWQvQt/o4jfd/UYwVWndyO5EPO8YHbnm5HIykf8ENsm4JUrdAvolPT86yYA== integrity sha512-N6r7GrVmO8RW1rn0cTdvK3JR0BcqecAJ0PmYMCL3ZuqTH3pY+9QyqkmJSkkLyyDvd+AJnwaxTP22Ybr/83V9hQ==
source-list-map@^2.0.0: source-list-map@^2.0.0:
version "2.0.1" 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" resolved "https://registry.yarnpkg.com/vue-upload-component/-/vue-upload-component-2.8.20.tgz#60824d3f20f3216dca90d8c86a5c980851b04ea0"
integrity sha512-zrnJvULu4rnZe36Ib2/AZrI/h/mmNbUJZ+acZD652PyumzbvjCOQeYHe00sGifTdYjzzS66CwhTT+ubZ2D0Aow== 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" version "2.6.10"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637"
integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ== integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==
vuedraggable@^2.16.0: vuedraggable@^2.16.0:
version "2.21.0" version "2.23.2"
resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.21.0.tgz#30c485ed737a9a6a73ea8f21cc8e1ed59aaddc92" resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.23.2.tgz#0d95d7fdf4f02f56755a26b3c9dca5c7ca9cfa72"
integrity sha512-UDp0epjaZikuInoJA9rlEIJaSTQThabq0R9x7TqBdl0qGVFKKzo6glP6ubfzWBmV4iRIfbSOs2DV06s3B5h5tA== integrity sha512-PgHCjUpxEAEZJq36ys49HfQmXglattf/7ofOzUrW2/rRdG7tu6fK84ir14t1jYv4kdXewTEa2ieKEAhhEMdwkQ==
dependencies: dependencies:
sortablejs "^1.9.0" sortablejs "^1.10.1"
vuex-persistedstate@^2.5.4: vuex-persistedstate@^2.5.4:
version "2.5.4" version "2.5.4"