Merge branch '594-navigation-redesign' into 'develop'
Resolve "Redesign the sidebar/navigation to simplify the UI" Closes #594 See merge request funkwhale/funkwhale!923
This commit is contained in:
commit
7c8b592f61
|
@ -0,0 +1 @@
|
|||
Brand new navigation, queue and player redesign (#594)
|
|
@ -6,6 +6,13 @@ Next release notes
|
|||
Those release notes refer to the current development branch and are reset
|
||||
after each release.
|
||||
|
||||
Redesigned navigation, player and queue
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This release includes a full redesign of our navigation, player and queue. Overall, it should provide
|
||||
a better, less confusing experience, especially on mobile devices. This redesign was suggested
|
||||
14 months ago, and took a while, but thanks to the involvement and feedback of many people, we got it done!
|
||||
|
||||
Improved search performance
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
"qs": "^6.7.0",
|
||||
"sanitize-html": "^1.20.1",
|
||||
"showdown": "^1.8.6",
|
||||
"vue": "^2.5.17",
|
||||
"vue": "^2.6.10",
|
||||
"vue-gettext": "^2.1.0",
|
||||
"vue-lazyload": "^1.2.6",
|
||||
"vue-masonry": "^0.11.5",
|
||||
|
@ -50,6 +50,7 @@
|
|||
"mocha": "^5.2.0",
|
||||
"moxios": "^0.4.0",
|
||||
"node-sass": "^4.9.3",
|
||||
"preload-webpack-plugin": "^3.0.0-beta.4",
|
||||
"purgecss-webpack-plugin": "^1.6.0",
|
||||
"sass-loader": "^7.1.0",
|
||||
"sinon": "^6.1.5",
|
||||
|
|
|
@ -7,13 +7,85 @@
|
|||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.png">
|
||||
<title>Funkwhale</title>
|
||||
<style>
|
||||
#fake-app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -1;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
#fake-sidebar {
|
||||
width: 275px;
|
||||
height: 100vh;
|
||||
background-color: #2D2F33;
|
||||
}
|
||||
#fake-sidebar.loaded, #fake-content.loaded {
|
||||
display: none;
|
||||
}
|
||||
#orange-square {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background-color: #f2711c
|
||||
}
|
||||
#fake-content {
|
||||
height: 100vh;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#fake-content h1 {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
#fake-content .placeholder {
|
||||
width: 20em;
|
||||
max-width: 95%;
|
||||
}
|
||||
@media only screen and (max-width: 768px) {
|
||||
#fake-app {
|
||||
flex-direction: column;
|
||||
}
|
||||
#fake-sidebar {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="theme-light" id="body">
|
||||
<noscript>
|
||||
<strong>We're sorry but Funkwhale doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<div id="fake-app">
|
||||
<div id="fake-sidebar">
|
||||
<div id="orange-square"></div>
|
||||
</div>
|
||||
<div id="fake-content">
|
||||
<noscript>
|
||||
<strong>We're sorry but Funkwhale doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<h1>Loading Funkwhale…</h1>
|
||||
<div class="ui placeholder">
|
||||
<div class="image header">
|
||||
<div class="full line"></div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div class="image header">
|
||||
<div class="line"></div>
|
||||
<div class="full line"></div>
|
||||
</div>
|
||||
<div class="image header">
|
||||
<div class="medium line"></div>
|
||||
<div class="full line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app">
|
||||
</div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div id="app" :key="String($store.state.instance.instanceUrl)">
|
||||
<div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}]">
|
||||
<!-- here, we display custom stylesheets, if any -->
|
||||
<link
|
||||
v-for="url in customStylesheets"
|
||||
|
@ -12,9 +12,13 @@
|
|||
<sidebar></sidebar>
|
||||
<set-instance-modal @update:show="showSetInstanceModal = $event" :show="showSetInstanceModal"></set-instance-modal>
|
||||
<service-messages v-if="messages.length > 0"/>
|
||||
<router-view :key="$route.fullPath"></router-view>
|
||||
<div class="ui fitted divider"></div>
|
||||
<transition name="queue">
|
||||
<queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue>
|
||||
</transition>
|
||||
<router-view :class="{hidden: $store.state.ui.queueFocused}" :key="$route.fullPath"></router-view>
|
||||
<player ref="player"></player>
|
||||
<app-footer
|
||||
:class="{hidden: $store.state.ui.queueFocused}"
|
||||
:version="version"
|
||||
@show:shortcuts-modal="showShortcutsModal = !showShortcutsModal"
|
||||
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
|
||||
|
@ -32,39 +36,33 @@
|
|||
import Vue from 'vue'
|
||||
import axios from 'axios'
|
||||
import _ from '@/lodash'
|
||||
import {mapState, mapGetters} from 'vuex'
|
||||
import {mapState, mapGetters, mapActions} from 'vuex'
|
||||
import { WebSocketBridge } from 'django-channels'
|
||||
import GlobalEvents from '@/components/utils/global-events'
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import AppFooter from '@/components/Footer'
|
||||
import ServiceMessages from '@/components/ServiceMessages'
|
||||
import moment from 'moment'
|
||||
import locales from './locales'
|
||||
import PlaylistModal from '@/components/playlists/PlaylistModal'
|
||||
import FilterModal from '@/components/moderation/FilterModal'
|
||||
import ReportModal from '@/components/moderation/ReportModal'
|
||||
import ShortcutsModal from '@/components/ShortcutsModal'
|
||||
import SetInstanceModal from '@/components/SetInstanceModal'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: {
|
||||
Sidebar,
|
||||
AppFooter,
|
||||
FilterModal,
|
||||
ReportModal,
|
||||
PlaylistModal,
|
||||
ShortcutsModal,
|
||||
Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"),
|
||||
Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"),
|
||||
PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"),
|
||||
Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"),
|
||||
AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"),
|
||||
ServiceMessages: () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"),
|
||||
SetInstanceModal: () => import(/* webpackChunkName: "core" */ "@/components/SetInstanceModal"),
|
||||
ShortcutsModal: () => import(/* webpackChunkName: "core" */ "@/components/ShortcutsModal"),
|
||||
FilterModal: () => import(/* webpackChunkName: "moderation" */ "@/components/moderation/FilterModal"),
|
||||
ReportModal: () => import(/* webpackChunkName: "moderation" */ "@/components/moderation/ReportModal"),
|
||||
GlobalEvents,
|
||||
ServiceMessages,
|
||||
SetInstanceModal,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
bridge: null,
|
||||
instanceUrl: null,
|
||||
showShortcutsModal: false,
|
||||
showSetInstanceModal: false,
|
||||
showSetInstanceModal: false
|
||||
}
|
||||
},
|
||||
async created () {
|
||||
|
@ -82,6 +80,10 @@ export default {
|
|||
if (serverUrl) {
|
||||
this.$store.commit('instance/instanceUrl', serverUrl)
|
||||
}
|
||||
const url = urlParams.get('_url')
|
||||
if (url) {
|
||||
this.$router.replace(url)
|
||||
}
|
||||
else if (!this.$store.state.instance.instanceUrl) {
|
||||
// we have several way to guess the API server url. By order of precedence:
|
||||
// 1. use the url provided in settings.json, if any
|
||||
|
@ -127,6 +129,9 @@ export default {
|
|||
self.$router.push(event.target.getAttribute('href'))
|
||||
event.preventDefault();
|
||||
}, false);
|
||||
this.$nextTick(() => {
|
||||
document.getElementById('fake-content').classList.add('loaded')
|
||||
})
|
||||
|
||||
},
|
||||
destroyed () {
|
||||
|
@ -238,10 +243,27 @@ export default {
|
|||
...mapState({
|
||||
messages: state => state.ui.messages,
|
||||
nodeinfo: state => state.instance.nodeinfo,
|
||||
playing: state => state.player.playing,
|
||||
bufferProgress: state => state.player.bufferProgress,
|
||||
isLoadingAudio: state => state.player.isLoadingAudio,
|
||||
}),
|
||||
...mapGetters({
|
||||
currentTrack: 'queue/currentTrack'
|
||||
hasNext: "queue/hasNext",
|
||||
currentTrack: 'queue/currentTrack',
|
||||
progress: "player/progress",
|
||||
}),
|
||||
labels() {
|
||||
let play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Play track")
|
||||
let pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Pause track")
|
||||
let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track")
|
||||
let expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Expand queue")
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
next,
|
||||
expandQueue,
|
||||
}
|
||||
},
|
||||
suggestedInstances () {
|
||||
let instances = this.$store.state.instance.knownInstances.slice(0)
|
||||
if (this.$store.state.instance.frontSettings.defaultServerUrl) {
|
||||
|
@ -264,7 +286,7 @@ export default {
|
|||
if (this.$store.state.instance.frontSettings) {
|
||||
return this.$store.state.instance.frontSettings.additionalStylesheets || []
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'$store.state.instance.instanceUrl' () {
|
||||
|
@ -290,7 +312,7 @@ export default {
|
|||
immediate: true,
|
||||
handler(newValue) {
|
||||
let self = this
|
||||
import(`./translations/${newValue}.json`).then((response) =>{
|
||||
import(/* webpackChunkName: "locale-[request]" */ `./translations/${newValue}.json`).then((response) =>{
|
||||
Vue.$translations[newValue] = response.default[newValue]
|
||||
}).finally(() => {
|
||||
// set current language twice, otherwise we seem to have a cache somewhere
|
||||
|
@ -302,12 +324,12 @@ export default {
|
|||
return self.$store.commit('ui/momentLocale', 'en')
|
||||
}
|
||||
let momentLocale = newValue.replace('_', '-').toLowerCase()
|
||||
import(`moment/locale/${momentLocale}.js`).then(() => {
|
||||
import(/* webpackChunkName: "moment-locale-[request]" */ `moment/locale/${momentLocale}.js`).then(() => {
|
||||
self.$store.commit('ui/momentLocale', momentLocale)
|
||||
}).catch(() => {
|
||||
console.log('No momentjs locale available for', momentLocale)
|
||||
let shortLocale = momentLocale.split('-')[0]
|
||||
import(`moment/locale/${shortLocale}.js`).then(() => {
|
||||
import(/* webpackChunkName: "moment-locale-[request]" */ `moment/locale/${shortLocale}.js`).then(() => {
|
||||
self.$store.commit('ui/momentLocale', shortLocale)
|
||||
}).catch(() => {
|
||||
console.log('No momentjs locale available for', shortLocale)
|
||||
|
@ -333,4 +355,185 @@ export default {
|
|||
|
||||
<style lang="scss">
|
||||
@import "style/_main";
|
||||
|
||||
.ui.bottom-player {
|
||||
z-index: 999999;
|
||||
width: 100%;
|
||||
width: 100vw;
|
||||
}
|
||||
#app.queue-focused {
|
||||
.queue-not-focused {
|
||||
@include media("<desktop") {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.when-queue-focused {
|
||||
.group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 1.1em;
|
||||
> * {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
@include media("<desktop") {
|
||||
width: 100%;
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
}
|
||||
#app:not(.queue-focused) {
|
||||
.when-queue-focused {
|
||||
@include media("<desktop") {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ui.bottom-player > .segment.fixed-controls {
|
||||
width: 100%;
|
||||
width: 100vw;
|
||||
border-radius: 0;
|
||||
padding: 0em;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
z-index: 1001;
|
||||
height: $bottom-player-height;
|
||||
.controls-row {
|
||||
height: $bottom-player-height;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@include media(">desktop") {
|
||||
padding: 0 1em;
|
||||
justify-content: space-around;
|
||||
}
|
||||
}
|
||||
cursor: pointer;
|
||||
.indicating.progress {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ui.progress .bar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.ui.progress .buffer.bar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@keyframes MOVE-BG {
|
||||
from {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
to {
|
||||
transform: translateX(46px);
|
||||
}
|
||||
}
|
||||
.discrete.link {
|
||||
color: inherit;
|
||||
}
|
||||
.indicating.progress .bar {
|
||||
left: -46px;
|
||||
width: 200% !important;
|
||||
color: grey;
|
||||
background: repeating-linear-gradient(
|
||||
-55deg,
|
||||
grey 1px,
|
||||
grey 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
) !important;
|
||||
|
||||
animation-name: MOVE-BG;
|
||||
animation-duration: 2s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
.ui.progress:not([data-percent]):not(.indeterminate)
|
||||
.bar.position:not(.buffer) {
|
||||
background: #ff851b;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
flex-grow: 1;
|
||||
.image {
|
||||
padding: 0.5em;
|
||||
width: auto;
|
||||
margin-right: 0.5em;
|
||||
> img {
|
||||
max-height: 3.7em;
|
||||
max-width: 4.7em;
|
||||
}
|
||||
}
|
||||
}
|
||||
.controls {
|
||||
min-width: 8em;
|
||||
font-size: 1.1em;
|
||||
@include media(">desktop") {
|
||||
&:not(.fluid) {
|
||||
width: 20%;
|
||||
}
|
||||
&.queue-controls {
|
||||
width: 32.5%;
|
||||
}
|
||||
&.progress-controls {
|
||||
width: 10%;
|
||||
}
|
||||
&.player-controls {
|
||||
width: 15%;
|
||||
}
|
||||
}
|
||||
&.small, .small {
|
||||
@include media(">desktop") {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
.icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.icon.large {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
&:not(.track-controls) {
|
||||
@include media(">desktop") {
|
||||
line-height: 1em;
|
||||
}
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
&.align-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.align-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
> * {
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
}
|
||||
&.player-controls {
|
||||
.icon {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.queue-enter-active, .queue-leave-active {
|
||||
transition: all 0.2s ease-in-out;
|
||||
.current-track, .queue-column {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.queue-enter, .queue-leave-to {
|
||||
transform: translateY(100vh);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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>
|
After Width: | Height: | Size: 8.8 KiB |
|
@ -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>
|
|
@ -42,12 +42,11 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from '@/components/semantic/Modal'
|
||||
|
||||
export default {
|
||||
props: ['show'],
|
||||
components: {
|
||||
Modal,
|
||||
Modal: () => import(/* webpackChunkName: "modal" */ "@/components/semantic/Modal"),
|
||||
},
|
||||
computed: {
|
||||
general () {
|
||||
|
@ -131,6 +130,10 @@ export default {
|
|||
key: 'm',
|
||||
summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle mute')
|
||||
},
|
||||
{
|
||||
key: 'e',
|
||||
summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Expand queue/player view')
|
||||
},
|
||||
{
|
||||
key: 'l',
|
||||
summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle queue looping')
|
||||
|
|
|
@ -1,216 +1,178 @@
|
|||
<template>
|
||||
<aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]">
|
||||
<header class="ui inverted segment header-wrapper">
|
||||
<search-bar @search="isCollapsed = false">
|
||||
<router-link :title="'Funkwhale'" :to="{name: logoUrl}">
|
||||
<i class="logo bordered inverted orange big icon">
|
||||
<logo class="logo"></logo>
|
||||
</i>
|
||||
</router-link><span
|
||||
slot="after"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
:class="['ui', 'basic', 'big', {'inverted': isCollapsed}, 'orange', 'icon', 'collapse', 'button']">
|
||||
<i class="sidebar icon"></i></span>
|
||||
</search-bar>
|
||||
</header>
|
||||
<header class="ui basic segment header-wrapper">
|
||||
<router-link :title="'Funkwhale'" :to="{name: logoUrl}">
|
||||
<i class="logo bordered inverted orange big icon">
|
||||
<logo class="logo"></logo>
|
||||
</i>
|
||||
</router-link>
|
||||
<router-link v-if="!$store.state.auth.authenticated" class="logo-wrapper" :to="{name: logoUrl}">
|
||||
<img src="../assets/logo/text-white.svg" />
|
||||
</router-link>
|
||||
<nav class="top ui compact right aligned inverted text menu">
|
||||
<template v-if="$store.state.auth.authenticated">
|
||||
|
||||
<div class="menu-area">
|
||||
<div class="ui compact fluid two item inverted menu">
|
||||
<a :class="[{active: selectedTab === 'library'}, 'item']" role="button" @click.prevent.stop="selectedTab = 'library'" data-tab="library"><translate translate-context="*/Library/*/Verb">Browse</translate></a>
|
||||
<a :class="[{active: selectedTab === 'queue'}, 'item']" role="button" @click.prevent.stop="selectedTab = 'queue'" data-tab="queue">
|
||||
<translate translate-context="Sidebar/Queue/Tab.Title/Noun">Queue</translate>
|
||||
<template v-if="queue.tracks.length === 0">
|
||||
<translate translate-context="Sidebar/Queue/Tab.Title">(empty)</translate>
|
||||
</template>
|
||||
<translate translate-context="Sidebar/Queue/Tab.Title" v-else :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}">
|
||||
(%{ index } of %{ length })
|
||||
</translate>
|
||||
</a>
|
||||
</div>
|
||||
<div class="right menu">
|
||||
<div class="item" :title="labels.administration" v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']">
|
||||
<div class="item ui inline admin-dropdown dropdown">
|
||||
<i class="wrench icon"></i>
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports > 0"
|
||||
:class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ $store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports }}</div>
|
||||
<div class="menu">
|
||||
<div class="header">
|
||||
<translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['library']"
|
||||
class="item"
|
||||
:to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}">
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.pendingReviewEdits > 0"
|
||||
:title="labels.pendingReviewEdits"
|
||||
:class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">
|
||||
{{ $store.state.ui.notifications.pendingReviewEdits }}</div>
|
||||
<translate translate-context="*/*/*/Noun">Library</translate>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['moderation']"
|
||||
class="item"
|
||||
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}">
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.pendingReviewReports > 0"
|
||||
:title="labels.pendingReviewReports"
|
||||
:class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div>
|
||||
<translate translate-context="*/Moderation/*">Moderation</translate>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
class="item"
|
||||
:to="{name: 'manage.users.users.list'}">
|
||||
<translate translate-context="*/*/*/Noun">Users</translate>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
class="item"
|
||||
:to="{path: '/manage/settings'}">
|
||||
<translate translate-context="*/*/*/Noun">Settings</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:title="labels.addContent"
|
||||
:to="{name: 'content.index'}"><i class="upload icon"></i></router-link>
|
||||
|
||||
<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 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">
|
||||
<nav class="ui inverted vertical large fluid menu" role="navigation" :aria-label="labels.mainMenu">
|
||||
<div class="item">
|
||||
<header class="header"><translate translate-context="Sidebar/Profile/Title">My account</translate></header>
|
||||
<nav class="ui vertical large fluid inverted menu" role="navigation" :aria-label="labels.mainMenu">
|
||||
<div :class="[{collapsed: !exploreExpanded}, 'collaspable item']">
|
||||
<header class="header" @click="exploreExpanded = true" tabindex="0" @focus="exploreExpanded = true">
|
||||
<translate translate-context="*/*/*/Verb">Explore</translate>
|
||||
<i class="angle right icon" v-if="!exploreExpanded"></i>
|
||||
</header>
|
||||
<div class="menu">
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}">
|
||||
<i class="user icon"></i>
|
||||
<translate translate-context="Sidebar/Profile/List item.Link" :translate-params="{username: $store.state.auth.username}">
|
||||
Logged in as %{ username }
|
||||
</translate>
|
||||
<img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
|
||||
</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/settings'}"><i class="setting icon"></i><translate translate-context="*/*/*/Noun">Settings</translate></router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'notifications'}">
|
||||
<i class="feed icon"></i>
|
||||
<translate translate-context="*/Notifications/*">Notifications</translate>
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
|
||||
:class="['ui', 'teal', 'label']">
|
||||
{{ $store.state.ui.notifications.inbox + additionalNotifications }}</div>
|
||||
</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link>
|
||||
<template v-else>
|
||||
<router-link class="item" :to="{name: 'login'}"><i class="sign in icon"></i><translate translate-context="*/Login/*/Verb">Login</translate></router-link>
|
||||
<router-link class="item" :to="{path: '/signup'}">
|
||||
<i class="corner add icon"></i>
|
||||
<translate translate-context="*/Signup/Link/Verb">Create an account</translate>
|
||||
</router-link>
|
||||
</template>
|
||||
<router-link class="item" :exact="true" :to="{name: 'library.index'}"><i class="music icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Browse</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'library.albums.browse'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'library.artists.browse'}"><i class="user icon"></i><translate translate-context="*/*/*">Artists</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'library.playlists.browse'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'library.radios.browse'}"><i class="feed icon"></i><translate translate-context="*/*/*">Radios</translate></router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[{collapsed: !myLibraryExpanded}, 'collaspable item']" v-if="$store.state.auth.authenticated">
|
||||
<header class="header" @click="myLibraryExpanded = true" tabindex="0" @focus="myLibraryExpanded = true">
|
||||
<translate translate-context="*/*/*/Noun">My Library</translate>
|
||||
<i class="angle right icon" v-if="!myLibraryExpanded"></i>
|
||||
</header>
|
||||
<div class="menu">
|
||||
<router-link class="item" :exact="true" :to="{name: 'library.me'}"><i class="music icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Browse</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'library.albums.me'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'library.artists.me'}"><i class="user icon"></i><translate translate-context="*/*/*">Artists</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'library.playlists.me'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'library.radios.me'}"><i class="feed icon"></i><translate translate-context="*/*/*">Radios</translate></router-link>
|
||||
<router-link class="item" :to="{name: 'favorites'}"><i class="heart icon"></i><translate translate-context="Sidebar/Favorites/List item.Link/Noun">Favorites</translate></router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<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">
|
||||
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"></i><translate translate-context="Sidebar/Library/List item.Link/Verb">Browse library</translate></router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i><translate translate-context="Sidebar/Favorites/List item.Link/Noun">Favorites</translate></router-link>
|
||||
<a
|
||||
@click="$store.commit('playlists/chooseTrack', null)"
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item">
|
||||
<i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate>
|
||||
</a>
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item" :to="{name: 'content.index'}"><i class="upload icon"></i><translate translate-context="*/Library/*/Verb">Add content</translate></router-link>
|
||||
</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 class="item" to="/about">
|
||||
<i class="info icon"></i><translate translate-context="Sidebar/*/List item.Link">About this pod</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</section>
|
||||
<div v-if="queue.previousQueue " class="ui black icon message">
|
||||
<i class="history icon"></i>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<translate translate-context="Sidebar/Queue/Message">Do you want to restore your previous queue?</translate>
|
||||
</div>
|
||||
<p>
|
||||
<translate translate-context="*/*/*"
|
||||
translate-plural="%{ count } tracks"
|
||||
:translate-n="queue.previousQueue.tracks.length"
|
||||
:translate-params="{count: queue.previousQueue.tracks.length}">
|
||||
%{ count } track
|
||||
</translate>
|
||||
</p>
|
||||
<div class="ui two buttons">
|
||||
<div @click="queue.restore()" class="ui basic inverted green button"><translate translate-context="*/*/*">Yes</translate></div>
|
||||
<div @click="queue.removePrevious()" class="ui basic inverted red button"><translate translate-context="*/*/*">No</translate></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section :class="['ui', 'bottom', 'attached', {active: selectedTab === 'queue'}, 'tab']">
|
||||
<table class="ui compact inverted very basic fixed single line unstackable table">
|
||||
<draggable v-model="tracks" tag="tbody" @update="reorder">
|
||||
<tr
|
||||
@click="$store.dispatch('queue/currentIndex', index)"
|
||||
v-for="(track, index) in tracks"
|
||||
:key="index"
|
||||
:class="[{'active': index === queue.currentIndex}]">
|
||||
<td class="right aligned">{{ index + 1}}</td>
|
||||
<td class="center aligned">
|
||||
<img class="ui mini image" v-if="track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
|
||||
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="4">
|
||||
<button class="title reset ellipsis" :title="track.title" :aria-label="labels.selectTrack">
|
||||
<strong>{{ track.title }}</strong><br />
|
||||
<span>
|
||||
{{ track.artist.name }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
|
||||
<i class="pink heart icon"></i>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<button :title="labels.removeFromQueue" @click.stop="cleanTrack(index)" :class="['ui', {'inverted': index != queue.currentIndex}, 'really', 'tiny', 'basic', 'circular', 'icon', 'button']">
|
||||
<i class="trash icon"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</draggable>
|
||||
</table>
|
||||
<div v-if="$store.state.radios.running" class="ui black message">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<i class="feed icon"></i> <translate translate-context="Sidebar/Player/Title">You have a radio playing</translate>
|
||||
</div>
|
||||
<p><translate translate-context="Sidebar/Player/Paragraph">New tracks will be appended here automatically.</translate></p>
|
||||
<div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button"><translate translate-context="*/Player/Button.Label/Short, Verb">Stop radio</translate></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<player @next="scrollToCurrent" @previous="scrollToCurrent"></player>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapActions, mapGetters } from "vuex"
|
||||
|
||||
import Player from "@/components/audio/Player"
|
||||
import Logo from "@/components/Logo"
|
||||
import SearchBar from "@/components/audio/SearchBar"
|
||||
import backend from "@/audio/backend"
|
||||
import draggable from "vuedraggable"
|
||||
|
||||
import $ from "jquery"
|
||||
|
||||
export default {
|
||||
name: "sidebar",
|
||||
components: {
|
||||
Player,
|
||||
SearchBar,
|
||||
Logo,
|
||||
draggable
|
||||
Logo
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedTab: "library",
|
||||
backend: backend,
|
||||
tracksChangeBuffer: null,
|
||||
isCollapsed: true,
|
||||
fetchInterval: null
|
||||
fetchInterval: null,
|
||||
exploreExpanded: false,
|
||||
myLibraryExpanded: false,
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
|
@ -218,6 +180,11 @@ export default {
|
|||
clearInterval(this.fetchInterval)
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$nextTick(() => {
|
||||
document.getElementById('fake-sidebar').classList.add('loaded')
|
||||
})
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
additionalNotifications: "ui/additionalNotifications",
|
||||
|
@ -235,15 +202,10 @@ export default {
|
|||
pendingFollows,
|
||||
mainMenu,
|
||||
selectTrack,
|
||||
pendingReviewEdits
|
||||
}
|
||||
},
|
||||
tracks: {
|
||||
get() {
|
||||
return this.$store.state.queue.tracks
|
||||
},
|
||||
set(value) {
|
||||
this.tracksChangeBuffer = value
|
||||
pendingReviewEdits,
|
||||
addContent: this.$pgettext("*/Library/*/Verb", 'Add content'),
|
||||
notifications: this.$pgettext("*/Notifications/*", 'Notifications'),
|
||||
administration: this.$pgettext("Sidebar/Admin/Title/Noun", 'Administration'),
|
||||
}
|
||||
},
|
||||
logoUrl() {
|
||||
|
@ -252,36 +214,42 @@ export default {
|
|||
} else {
|
||||
return "index"
|
||||
}
|
||||
},
|
||||
focusedMenu () {
|
||||
let mapping = {
|
||||
"library.index": 'exploreExpanded',
|
||||
"library.albums.browse": 'exploreExpanded',
|
||||
"library.albums.detail": 'exploreExpanded',
|
||||
"library.artists.browse": 'exploreExpanded',
|
||||
"library.artists.detail": 'exploreExpanded',
|
||||
"library.tracks.detail": 'exploreExpanded',
|
||||
"library.playlists.browse": 'exploreExpanded',
|
||||
"library.playlists.detail": 'exploreExpanded',
|
||||
"library.radios.browse": 'exploreExpanded',
|
||||
"library.radios.detail": 'exploreExpanded',
|
||||
'library.me': "myLibraryExpanded",
|
||||
'library.albums.me': "myLibraryExpanded",
|
||||
'library.artists.me': "myLibraryExpanded",
|
||||
'library.playlists.me': "myLibraryExpanded",
|
||||
'library.radios.me': "myLibraryExpanded",
|
||||
'favorites': "myLibraryExpanded",
|
||||
}
|
||||
let m = mapping[this.$route.name]
|
||||
if (m) {
|
||||
return m
|
||||
}
|
||||
|
||||
if (this.$store.state.auth.authenticated) {
|
||||
return 'myLibraryExpanded'
|
||||
} else {
|
||||
return 'exploreExpanded'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
cleanTrack: "queue/cleanTrack"
|
||||
}),
|
||||
reorder: function(event) {
|
||||
this.$store.commit("queue/reorder", {
|
||||
tracks: this.tracksChangeBuffer,
|
||||
oldIndex: event.oldIndex,
|
||||
newIndex: event.newIndex
|
||||
})
|
||||
},
|
||||
scrollToCurrent() {
|
||||
let current = $(this.$el).find('[data-tab="queue"] .active')[0]
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
let container = $(this.$el).find(".tabs")[0]
|
||||
// Position container at the top line then scroll current into view
|
||||
container.scrollTop = 0
|
||||
current.scrollIntoView(true)
|
||||
// Scroll back nothing if element is at bottom of container else do it
|
||||
// for half the height of the containers display area
|
||||
var scrollBack =
|
||||
container.scrollHeight - container.scrollTop <= container.clientHeight
|
||||
? 0
|
||||
: container.clientHeight / 2
|
||||
container.scrollTop = container.scrollTop - scrollBack
|
||||
},
|
||||
applyContentFilters () {
|
||||
let artistIds = this.$store.getters['moderation/artistFilters']().map((f) => {
|
||||
return f.target.id
|
||||
|
@ -303,26 +271,66 @@ export default {
|
|||
return await self.cleanTrack(realIndex)
|
||||
}
|
||||
})
|
||||
|
||||
},
|
||||
setupDropdown (selector) {
|
||||
let self = this
|
||||
$(self.$el).find(selector).dropdown({
|
||||
selectOnKeydown: false,
|
||||
action: function (text, value, $el) {
|
||||
// used ton ensure focusing the dropdown and clicking via keyboard
|
||||
// works as expected
|
||||
let link = $($el).closest('a')
|
||||
let url = link.attr('href')
|
||||
self.$router.push(url)
|
||||
$(self.$el).find(selector).dropdown('hide')
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
url: function() {
|
||||
this.isCollapsed = true
|
||||
},
|
||||
selectedTab: function(newValue) {
|
||||
if (newValue === "queue") {
|
||||
this.scrollToCurrent()
|
||||
}
|
||||
},
|
||||
"$store.state.queue.currentIndex": function() {
|
||||
if (this.selectedTab !== "queue") {
|
||||
this.scrollToCurrent()
|
||||
}
|
||||
},
|
||||
"$store.state.moderation.lastUpdate": function () {
|
||||
this.applyContentFilters()
|
||||
}
|
||||
},
|
||||
"$store.state.auth.authenticated": {
|
||||
immediate: true,
|
||||
handler (v) {
|
||||
if (v) {
|
||||
this.$nextTick(() => {
|
||||
this.setupDropdown('.user-dropdown')
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
"$store.state.auth.availablePermissions": {
|
||||
immediate: true,
|
||||
handler (v) {
|
||||
this.$nextTick(() => {
|
||||
this.setupDropdown('.admin-dropdown')
|
||||
})
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
focusedMenu: {
|
||||
immediate: true,
|
||||
handler (n) {
|
||||
if (n) {
|
||||
this[n] = true
|
||||
}
|
||||
}
|
||||
},
|
||||
myLibraryExpanded (v) {
|
||||
if (v) {
|
||||
this.exploreExpanded = false
|
||||
}
|
||||
},
|
||||
exploreExpanded (v) {
|
||||
if (v) {
|
||||
this.myLibraryExpanded = false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -331,16 +339,24 @@ export default {
|
|||
<style scoped lang="scss">
|
||||
@import "../style/vendor/media";
|
||||
|
||||
$sidebar-color: #3d3e3f;
|
||||
$sidebar-color: #2D2F33;
|
||||
|
||||
.sidebar {
|
||||
background: $sidebar-color;
|
||||
@include media(">tablet") {
|
||||
@include media(">desktop") {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 4em;
|
||||
}
|
||||
> nav {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@include media(">desktop") {
|
||||
.menu .item.collapse-button-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.collapse.button {
|
||||
display: none !important;
|
||||
}
|
||||
|
@ -349,9 +365,10 @@ $sidebar-color: #3d3e3f;
|
|||
position: static !important;
|
||||
width: 100% !important;
|
||||
&.collapsed {
|
||||
.menu-area,
|
||||
.player-wrapper,
|
||||
.tabs {
|
||||
.search,
|
||||
.signup.segment,
|
||||
nav.secondary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -366,23 +383,7 @@ $sidebar-color: #3d3e3f;
|
|||
}
|
||||
}
|
||||
|
||||
.menu-area {
|
||||
.menu .item:not(.active):not(:hover) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.menu .item {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.menu .item.active {
|
||||
background-color: $sidebar-color;
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
.vertical.menu {
|
||||
.ui.vertical.menu {
|
||||
.item .item {
|
||||
font-size: 1em;
|
||||
> i.icon {
|
||||
|
@ -390,9 +391,29 @@ $sidebar-color: #3d3e3f;
|
|||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
&:not(.active) {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
// color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
}
|
||||
.item.active {
|
||||
border-right: 5px solid #F2711C;
|
||||
border-radius: 0 !important;
|
||||
background-color: rgba(255, 255, 255, 0.15) !important;
|
||||
}
|
||||
.item.collapsed {
|
||||
&:not(:focus) > .menu {
|
||||
display: none;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.collaspable.item .header {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.ui.secondary.menu {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
.tabs {
|
||||
flex: 1;
|
||||
|
@ -416,6 +437,10 @@ $sidebar-color: #3d3e3f;
|
|||
width: 55px;
|
||||
}
|
||||
}
|
||||
.item .header .angle.icon {
|
||||
float: right;
|
||||
margin: 0;
|
||||
}
|
||||
.tab[data-tab="library"] {
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
|
@ -432,8 +457,30 @@ $sidebar-color: #3d3e3f;
|
|||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ui.inverted.segment.header-wrapper {
|
||||
.ui.menu .item.inline.admin-dropdown.dropdown > .menu {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
.ui.segment.header-wrapper {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 4em;
|
||||
nav {
|
||||
> .item, > .menu > .item > .item {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav.top.title-menu {
|
||||
flex-grow: 1;
|
||||
.item {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
@ -442,20 +489,14 @@ $sidebar-color: #3d3e3f;
|
|||
margin: 0px;
|
||||
}
|
||||
|
||||
.ui.search {
|
||||
display: flex;
|
||||
|
||||
.collapse.button,
|
||||
.collapse.button:hover,
|
||||
.collapse.button:active {
|
||||
box-shadow: none !important;
|
||||
margin: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
.collapsed .search-wrapper {
|
||||
@include media("<desktop") {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.search {
|
||||
display: flex;
|
||||
}
|
||||
.ui.message.black {
|
||||
background: $sidebar-color;
|
||||
}
|
||||
|
@ -463,10 +504,48 @@ $sidebar-color: #3d3e3f;
|
|||
.ui.mini.image {
|
||||
width: 100%;
|
||||
}
|
||||
nav.top {
|
||||
align-items: self-end;
|
||||
padding: 0.5em 0;
|
||||
> .item, > .right.menu > .item {
|
||||
// color: rgba(255, 255, 255, 0.9) !important;
|
||||
font-size: 1.2em;
|
||||
&:hover, > .dropdown > .icon {
|
||||
// color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
> .label, > .dropdown > .label {
|
||||
font-size: 0.5em;
|
||||
right: 1.7em;
|
||||
bottom: -0.5em;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ui.user-dropdown > .text > .label {
|
||||
margin-right: 0;
|
||||
}
|
||||
.logo-wrapper {
|
||||
display: inline-block;
|
||||
margin: 0 auto;
|
||||
@include media("<desktop") {
|
||||
margin: 0;
|
||||
}
|
||||
img {
|
||||
height: 1em;
|
||||
display: inline-block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@include media(">tablet") {
|
||||
img {
|
||||
height: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.sidebar {
|
||||
aside.ui.sidebar {
|
||||
overflow-y: visible !important;
|
||||
.ui.search .input {
|
||||
flex: 1;
|
||||
.prompt {
|
||||
|
|
|
@ -9,9 +9,12 @@
|
|||
<i :class="[playIconClass, 'icon']"></i>
|
||||
<template v-if="!discrete && !iconOnly"><slot><translate translate-context="*/Queue/Button.Label/Short, Verb">Play</translate></slot></template>
|
||||
</button>
|
||||
<div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]">
|
||||
<div
|
||||
v-if="!discrete && !iconOnly"
|
||||
@click.prevent="clicked = true"
|
||||
:class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]">
|
||||
<i :class="dropdownIconClasses.concat(['icon'])" :title="title" ></i>
|
||||
<div class="menu">
|
||||
<div class="menu" v-if="clicked">
|
||||
<button class="item basic" ref="add" data-ref="add" :disabled="!playable" @click.stop.prevent="add" :title="labels.addToQueue">
|
||||
<i class="plus icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate>
|
||||
</button>
|
||||
|
@ -70,20 +73,9 @@ export default {
|
|||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
clicked: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
let self = this
|
||||
jQuery(this.$el).find('.ui.dropdown').dropdown({
|
||||
selectOnKeydown: false,
|
||||
action: function (text, value, $el) {
|
||||
// used ton ensure focusing the dropdown and clicking via keyboard
|
||||
// works as expected
|
||||
self.$refs[$el.data('ref')].click()
|
||||
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
|
||||
}
|
||||
})
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
return {
|
||||
|
@ -250,6 +242,24 @@ export default {
|
|||
date: new Date()
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
clicked () {
|
||||
|
||||
let self = this
|
||||
this.$nextTick(() => {
|
||||
jQuery(this.$el).find('.ui.dropdown').dropdown({
|
||||
selectOnKeydown: false,
|
||||
action: function (text, value, $el) {
|
||||
// used ton ensure focusing the dropdown and clicking via keyboard
|
||||
// works as expected
|
||||
self.$refs[$el.data('ref')].click()
|
||||
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
|
||||
}
|
||||
})
|
||||
jQuery(this.$el).find('.ui.dropdown').dropdown('show')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,261 +1,249 @@
|
|||
<template>
|
||||
<section class="ui inverted segment player-wrapper" :aria-label="labels.audioPlayer" :style="style">
|
||||
<div class="player">
|
||||
<div v-if="currentTrack" class="track-area ui unstackable items">
|
||||
<div class="ui inverted item">
|
||||
<div class="ui tiny image">
|
||||
<img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
|
||||
<section v-if="currentTrack" class="player-wrapper ui bottom-player">
|
||||
<div class="ui inverted segment fixed-controls" @click.prevent.stop="toggleMobilePlayer">
|
||||
<div
|
||||
:class="['ui', 'top attached', 'small', 'orange', 'inverted', {'indicating': isLoadingAudio}, 'progress']">
|
||||
<div class="buffer bar" :data-percent="bufferProgress" :style="{ 'width': bufferProgress + '%' }"></div>
|
||||
<div class="position bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
|
||||
</div>
|
||||
<div class="controls-row">
|
||||
|
||||
<div class="controls track-controls queue-not-focused desktop-and-up">
|
||||
<div @click.stop.prevent="" class="ui tiny image" @click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})">
|
||||
<img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
|
||||
<img v-else src="../../assets/audio/default-cover.png">
|
||||
</div>
|
||||
<div class="middle aligned content">
|
||||
<router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
|
||||
{{ currentTrack.title }}
|
||||
</router-link>
|
||||
<div @click.stop.prevent="" class="middle aligned content ellipsis">
|
||||
<strong>
|
||||
<router-link @click.stop.prevent="" class="small header discrete link track" :title="currentTrack.title" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
|
||||
{{ currentTrack.title }}
|
||||
</router-link>
|
||||
</strong>
|
||||
<div class="meta">
|
||||
<router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
|
||||
{{ currentTrack.artist.name }}
|
||||
</router-link> /
|
||||
<router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
|
||||
<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
|
||||
{{ currentTrack.artist.name }}</router-link> /<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
|
||||
{{ currentTrack.album.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="description">
|
||||
<track-favorite-icon
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:class="{'inverted': !$store.getters['favorites/isFavorite'](currentTrack.id)}"
|
||||
:track="currentTrack"></track-favorite-icon>
|
||||
<track-playlist-icon
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:class="['inverted']"
|
||||
:track="currentTrack"></track-playlist-icon>
|
||||
<button
|
||||
v-if="$store.state.auth.authenticated"
|
||||
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
|
||||
:class="['ui', 'really', 'basic', 'circular', 'inverted', 'icon', 'button']"
|
||||
:aria-label="labels.addArtistContentFilter"
|
||||
:title="labels.addArtistContentFilter">
|
||||
<i :class="['eye slash outline', 'basic', 'icon']"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls track-controls queue-not-focused tablet-and-below">
|
||||
<div class="ui tiny image">
|
||||
<img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
|
||||
<img v-else src="../../assets/audio/default-cover.png">
|
||||
</div>
|
||||
<div class="middle aligned content ellipsis">
|
||||
<strong>
|
||||
{{ currentTrack.title }}
|
||||
</strong>
|
||||
<div class="meta">
|
||||
{{ currentTrack.artist.name }} / {{ currentTrack.album.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-area" v-if="currentTrack && !errored">
|
||||
<div class="ui grid">
|
||||
<div class="left floated four wide column">
|
||||
<p class="timer start" @click="setCurrentTime(0)">{{currentTimeFormatted}}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isLoadingAudio" class="right floated four wide column">
|
||||
<p class="timer total">{{durationFormatted}}</p>
|
||||
</div>
|
||||
<div class="controls desktop-and-up fluid align-right" v-if="$store.state.auth.authenticated">
|
||||
<track-favorite-icon
|
||||
class="control white"
|
||||
:track="currentTrack"></track-favorite-icon>
|
||||
<track-playlist-icon
|
||||
class="control white"
|
||||
:track="currentTrack"></track-playlist-icon>
|
||||
<button
|
||||
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
|
||||
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']"
|
||||
:aria-label="labels.addArtistContentFilter"
|
||||
:title="labels.addArtistContentFilter">
|
||||
<i :class="['eye slash outline', 'basic', 'icon']"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
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 }">
|
||||
<div class="player-controls controls queue-not-focused">
|
||||
<span
|
||||
role="button"
|
||||
v-if="volume === 0"
|
||||
:title="labels.unmute"
|
||||
:aria-label="labels.unmute"
|
||||
@click.prevent.stop="unmute">
|
||||
<i class="volume off icon"></i>
|
||||
:title="labels.previous"
|
||||
:aria-label="labels.previous"
|
||||
class="control tablet-and-up"
|
||||
@click.prevent.stop="$store.dispatch('queue/previous')"
|
||||
:disabled="!hasPrevious">
|
||||
<i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" ></i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
v-else-if="volume < 0.5"
|
||||
:title="labels.mute"
|
||||
:aria-label="labels.mute"
|
||||
@click.prevent.stop="mute">
|
||||
<i class="volume down icon"></i>
|
||||
v-if="!playing"
|
||||
:title="labels.play"
|
||||
:aria-label="labels.play"
|
||||
@click.prevent.stop="togglePlay"
|
||||
class="control">
|
||||
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']"></i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
v-else
|
||||
:title="labels.mute"
|
||||
:aria-label="labels.mute"
|
||||
@click.prevent.stop="mute">
|
||||
<i class="volume up icon"></i>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
step="0.05"
|
||||
min="0"
|
||||
max="1"
|
||||
v-model="sliderVolume"
|
||||
v-if="showVolume" />
|
||||
</div>
|
||||
<div class="two wide column control looping" v-if="!showVolume">
|
||||
<span
|
||||
role="button"
|
||||
v-if="looping === 0"
|
||||
:title="labels.loopingDisabled"
|
||||
:aria-label="labels.loopingDisabled"
|
||||
@click.prevent.stop="$store.commit('player/looping', 1)"
|
||||
:disabled="!currentTrack">
|
||||
<i :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'icon']"></i>
|
||||
:title="labels.pause"
|
||||
:aria-label="labels.pause"
|
||||
@click.prevent.stop="togglePlay"
|
||||
class="control">
|
||||
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']"></i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
@click.prevent.stop="$store.commit('player/looping', 2)"
|
||||
:title="labels.loopingSingle"
|
||||
:aria-label="labels.loopingSingle"
|
||||
v-if="looping === 1"
|
||||
:disabled="!currentTrack">
|
||||
<i
|
||||
class="repeat icon">
|
||||
<span class="ui circular tiny orange label">1</span>
|
||||
</i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
:title="labels.loopingWhole"
|
||||
:aria-label="labels.loopingWhole"
|
||||
v-if="looping === 2"
|
||||
:disabled="!currentTrack"
|
||||
@click.prevent.stop="$store.commit('player/looping', 0)">
|
||||
<i
|
||||
class="repeat orange icon">
|
||||
</i>
|
||||
:title="labels.next"
|
||||
:aria-label="labels.next"
|
||||
class="control"
|
||||
@click.prevent.stop="$store.dispatch('queue/next')"
|
||||
:disabled="!hasNext">
|
||||
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" ></i>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
:disabled="queue.tracks.length === 0"
|
||||
:title="labels.shuffle"
|
||||
:aria-label="labels.shuffle"
|
||||
v-if="!showVolume"
|
||||
@click.prevent.stop="shuffle()"
|
||||
class="two wide column control">
|
||||
<div v-if="isShuffling" class="ui inline shuffling inverted tiny active loader"></div>
|
||||
<i v-else :class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
|
||||
</span>
|
||||
<div class="one wide column" v-if="!showVolume"></div>
|
||||
<span
|
||||
role="button"
|
||||
:disabled="queue.tracks.length === 0"
|
||||
:title="labels.clear"
|
||||
:aria-label="labels.clear"
|
||||
v-if="!showVolume"
|
||||
@click.prevent.stop="clean()"
|
||||
class="two wide column control">
|
||||
<i class="icons">
|
||||
<i :class="['ui', 'trash', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
|
||||
<i :class="['ui corner inverted', 'list', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
|
||||
</i>
|
||||
</span>
|
||||
|
||||
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
|
||||
<div class="timer">
|
||||
<template v-if="!isLoadingAudio">
|
||||
<span role="button" class="start" @click.stop.prevent="setCurrentTime(0)">{{currentTimeFormatted}}</span>
|
||||
| <span class="total">{{durationFormatted}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
00:00 | 00:00
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls queue-controls when-queue-focused align-right">
|
||||
<div class="group">
|
||||
<volume-control class="expandable" />
|
||||
<span
|
||||
role="button"
|
||||
v-if="looping === 0"
|
||||
:title="labels.loopingDisabled"
|
||||
:aria-label="labels.loopingDisabled"
|
||||
@click.prevent.stop="$store.commit('player/looping', 1)"
|
||||
:disabled="!currentTrack">
|
||||
<i :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'icon']"></i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
@click.prevent.stop="$store.commit('player/looping', 2)"
|
||||
:title="labels.loopingSingle"
|
||||
:aria-label="labels.loopingSingle"
|
||||
v-if="looping === 1"
|
||||
class="looping"
|
||||
:disabled="!currentTrack">
|
||||
<i
|
||||
class="repeat icon">
|
||||
<span class="ui circular tiny orange label">1</span>
|
||||
</i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
:title="labels.loopingWhole"
|
||||
:aria-label="labels.loopingWhole"
|
||||
v-if="looping === 2"
|
||||
:disabled="!currentTrack"
|
||||
class="looping"
|
||||
@click.prevent.stop="$store.commit('player/looping', 0)">
|
||||
<i
|
||||
class="repeat icon">
|
||||
<span class="ui circular tiny orange label">∞</span>
|
||||
</i>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
:disabled="queue.tracks.length === 0"
|
||||
:title="labels.shuffle"
|
||||
:aria-label="labels.shuffle"
|
||||
@click.prevent.stop="shuffle()">
|
||||
<div v-if="isShuffling" class="ui inline shuffling inverted tiny active loader"></div>
|
||||
<i v-else :class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="fake-dropdown">
|
||||
<span class="position control desktop-and-up" role="button" @click.stop="toggleMobilePlayer">
|
||||
<i class="stream icon"></i>
|
||||
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}">
|
||||
%{ index } of %{ length }
|
||||
</translate>
|
||||
</span>
|
||||
<span class="position control tablet-and-below" role="button" @click.stop="switchTab">
|
||||
<i class="stream icon"></i>
|
||||
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}">
|
||||
%{ index } of %{ length }
|
||||
</translate>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="control close-control desktop-and-up"
|
||||
v-if="$store.state.ui.queueFocused"
|
||||
@click.stop="toggleMobilePlayer">
|
||||
<i class="large down angle icon"></i>
|
||||
</span>
|
||||
<span
|
||||
class="control desktop-and-up"
|
||||
v-else
|
||||
@click.stop="toggleMobilePlayer">
|
||||
<i class="large up angle icon"></i>
|
||||
</span>
|
||||
<span
|
||||
class="control close-control tablet-and-below"
|
||||
v-if="$store.state.ui.queueFocused === 'player'"
|
||||
@click.stop="switchTab">
|
||||
<i class="large up angle icon"></i>
|
||||
</span>
|
||||
<span
|
||||
class="control tablet-and-below"
|
||||
v-if="$store.state.ui.queueFocused === 'queue'"
|
||||
@click.stop="switchTab">
|
||||
<i class="large down angle icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="control close-control tablet-and-below"
|
||||
@click.stop="$store.commit('ui/queueFocused', null)">
|
||||
<i class="x icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GlobalEvents
|
||||
@keydown.space.prevent.exact="togglePlay"
|
||||
@keydown.ctrl.shift.left.prevent.exact="previous"
|
||||
@keydown.ctrl.shift.right.prevent.exact="next"
|
||||
@keydown.shift.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)"
|
||||
@keydown.shift.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)"
|
||||
@keydown.right.prevent.exact="seek (5)"
|
||||
@keydown.left.prevent.exact="seek (-5)"
|
||||
@keydown.shift.right.prevent.exact="seek (30)"
|
||||
@keydown.shift.left.prevent.exact="seek (-30)"
|
||||
@keydown.m.prevent.exact="toggleMute"
|
||||
@keydown.l.exact="$store.commit('player/toggleLooping')"
|
||||
@keydown.s.exact="shuffle"
|
||||
@keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)"
|
||||
@keydown.q.exact="clean"
|
||||
/>
|
||||
</div>
|
||||
<GlobalEvents
|
||||
@keydown.space.prevent.exact="togglePlay"
|
||||
@keydown.ctrl.shift.left.prevent.exact="previous"
|
||||
@keydown.ctrl.shift.right.prevent.exact="next"
|
||||
@keydown.shift.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)"
|
||||
@keydown.shift.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)"
|
||||
@keydown.right.prevent.exact="seek (5)"
|
||||
@keydown.left.prevent.exact="seek (-5)"
|
||||
@keydown.shift.right.prevent.exact="seek (30)"
|
||||
@keydown.shift.left.prevent.exact="seek (-30)"
|
||||
@keydown.m.prevent.exact="toggleMute"
|
||||
@keydown.l.exact="$store.commit('player/toggleLooping')"
|
||||
@keydown.s.exact="shuffle"
|
||||
@keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)"
|
||||
@keydown.q.exact="clean"
|
||||
@keydown.e.exact="toggleMobilePlayer"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters, mapActions } from "vuex"
|
||||
import GlobalEvents from "@/components/utils/global-events"
|
||||
import ColorThief from "@/vendor/color-thief"
|
||||
import { Howl } from "howler"
|
||||
import $ from 'jquery'
|
||||
import _ from '@/lodash'
|
||||
import url from '@/utils/url'
|
||||
import axios from 'axios'
|
||||
|
||||
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
|
||||
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TrackFavoriteIcon,
|
||||
TrackPlaylistIcon,
|
||||
VolumeControl: () => import(/* webpackChunkName: "audio" */ "./VolumeControl"),
|
||||
TrackFavoriteIcon: () => import(/* webpackChunkName: "auth-audio" */ "@/components/favorites/TrackFavoriteIcon"),
|
||||
TrackPlaylistIcon: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/TrackPlaylistIcon"),
|
||||
GlobalEvents,
|
||||
},
|
||||
data() {
|
||||
let defaultAmbiantColors = [
|
||||
[46, 46, 46],
|
||||
[46, 46, 46],
|
||||
[46, 46, 46],
|
||||
[46, 46, 46]
|
||||
]
|
||||
return {
|
||||
isShuffling: false,
|
||||
sliderVolume: this.volume,
|
||||
defaultAmbiantColors: defaultAmbiantColors,
|
||||
showVolume: false,
|
||||
ambiantColors: defaultAmbiantColors,
|
||||
currentSound: null,
|
||||
dummyAudio: null,
|
||||
isUpdatingTime: false,
|
||||
|
@ -350,26 +338,6 @@ export default {
|
|||
self.$emit("previous")
|
||||
})
|
||||
},
|
||||
touchProgress(e) {
|
||||
let time
|
||||
let target = this.$refs.progress
|
||||
time = (e.layerX / target.offsetWidth) * this.duration
|
||||
this.setCurrentTime(time)
|
||||
},
|
||||
updateBackground() {
|
||||
// delete existing canvas, if any
|
||||
$('canvas.color-thief').remove()
|
||||
if (!this.currentTrack.album.cover) {
|
||||
this.ambiantColors = this.defaultAmbiantColors
|
||||
return
|
||||
}
|
||||
let image = this.$refs.cover
|
||||
try {
|
||||
this.ambiantColors = ColorThief.prototype.getPalette(image, 4).slice(0, 4)
|
||||
} catch (e) {
|
||||
console.log('Cannot generate player background from cover image, likely a cross-origin tainted canvas issue')
|
||||
}
|
||||
},
|
||||
handleError({ sound, error }) {
|
||||
this.$store.commit("player/isLoadingAudio", false)
|
||||
this.$store.dispatch("player/trackErrored")
|
||||
|
@ -621,7 +589,22 @@ export default {
|
|||
this.observeProgress(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleMobilePlayer () {
|
||||
if (['queue', 'player'].indexOf(this.$store.state.ui.queueFocused) > -1) {
|
||||
this.$store.commit('ui/queueFocused', null)
|
||||
} else {
|
||||
this.$store.commit('ui/queueFocused', 'player')
|
||||
}
|
||||
},
|
||||
switchTab () {
|
||||
if (this.$store.state.ui.queueFocused === 'player') {
|
||||
this.$store.commit('ui/queueFocused', 'queue')
|
||||
} else {
|
||||
this.$store.commit('ui/queueFocused', 'player')
|
||||
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
|
@ -639,6 +622,7 @@ export default {
|
|||
...mapGetters({
|
||||
currentTrack: "queue/currentTrack",
|
||||
hasNext: "queue/hasNext",
|
||||
hasPrevious: "queue/hasPrevious",
|
||||
emptyQueue: "queue/isEmpty",
|
||||
durationFormatted: "player/durationFormatted",
|
||||
currentTimeFormatted: "player/currentTimeFormatted",
|
||||
|
@ -655,6 +639,7 @@ export default {
|
|||
let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track")
|
||||
let unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute")
|
||||
let mute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute")
|
||||
let expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Expand queue")
|
||||
let loopingDisabled = this.$pgettext('Sidebar/Player/Icon.Tooltip',
|
||||
"Looping disabled. Click to switch to single-track looping."
|
||||
)
|
||||
|
@ -680,35 +665,10 @@ export default {
|
|||
loopingWhole,
|
||||
shuffle,
|
||||
clear,
|
||||
expandQueue,
|
||||
addArtistContentFilter,
|
||||
}
|
||||
},
|
||||
style: function() {
|
||||
let style = {
|
||||
background: this.ambiantGradiant
|
||||
}
|
||||
return style
|
||||
},
|
||||
ambiantGradiant: function() {
|
||||
let indexConf = [
|
||||
{ orientation: 330, percent: 100, opacity: 0.7 },
|
||||
{ orientation: 240, percent: 90, opacity: 0.7 },
|
||||
{ orientation: 150, percent: 80, opacity: 0.7 },
|
||||
{ orientation: 60, percent: 70, opacity: 0.7 }
|
||||
]
|
||||
let gradients = this.ambiantColors
|
||||
.map((e, i) => {
|
||||
let [r, g, b] = e
|
||||
let conf = indexConf[i]
|
||||
return `linear-gradient(${
|
||||
conf.orientation
|
||||
}deg, rgba(${r}, ${g}, ${b}, ${
|
||||
conf.opacity
|
||||
}) 10%, rgba(255, 255, 255, 0) ${conf.percent}%)`
|
||||
})
|
||||
.join(", ")
|
||||
return gradients
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentTrack: {
|
||||
|
@ -725,9 +685,6 @@ export default {
|
|||
this.$store.commit("player/isLoadingAudio", true)
|
||||
this.playTimeout = setTimeout(async () => {
|
||||
await self.loadSound(newValue, oldValue)
|
||||
if (!newValue || !newValue.album.cover) {
|
||||
self.ambiantColors = self.defaultAmbiantColors
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
immediate: false
|
||||
|
@ -771,43 +728,10 @@ export default {
|
|||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
.ui.progress {
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.progress {
|
||||
cursor: pointer;
|
||||
.bar {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.inverted.item > .content > .description {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.ui.item {
|
||||
.meta {
|
||||
font-size: 90%;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
.timer.total {
|
||||
text-align: right;
|
||||
}
|
||||
.timer.start {
|
||||
cursor: pointer;
|
||||
}
|
||||
.track-area {
|
||||
margin-top: 0;
|
||||
.header,
|
||||
.meta,
|
||||
.artist,
|
||||
.album {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
.controls a {
|
||||
color: white;
|
||||
@import "../../style/vendor/media";
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.controls .icon.big {
|
||||
|
@ -819,150 +743,55 @@ export default {
|
|||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.control .icon {
|
||||
font-size: 1.5em;
|
||||
.timer {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.progress-area .actions {
|
||||
text-align: center;
|
||||
}
|
||||
.ui.progress:not([data-percent]):not(.indeterminate)
|
||||
.bar.position:not(.buffer) {
|
||||
background: #ff851b;
|
||||
}
|
||||
.volume-control {
|
||||
position: relative;
|
||||
width: 12.5% !important;
|
||||
[type="range"] {
|
||||
max-width: 70%;
|
||||
position: absolute;
|
||||
bottom: 1.1rem;
|
||||
left: 25%;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
}
|
||||
input[type="range"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
border-radius: 3px;
|
||||
width: 10px;
|
||||
}
|
||||
input[type="range"]::-moz-range-track {
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
opacity: 0.3;
|
||||
}
|
||||
input[type="range"]::-moz-focus-outer {
|
||||
border: 0;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
width: 10px;
|
||||
}
|
||||
input[type="range"]::-ms-track {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
input[type="range"]::-ms-fill-lower {
|
||||
background: white;
|
||||
opacity: 0.3;
|
||||
}
|
||||
input[type="range"]::-ms-fill-upper {
|
||||
background: white;
|
||||
opacity: 0.3;
|
||||
}
|
||||
input[type="range"]::-ms-thumb {
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
width: 10px;
|
||||
}
|
||||
input[type="range"]:focus::-ms-fill-lower {
|
||||
background: white;
|
||||
}
|
||||
input[type="range"]:focus::-ms-fill-upper {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
.active.volume-control {
|
||||
width: 60% !important;
|
||||
}
|
||||
|
||||
.looping.control {
|
||||
.looping {
|
||||
i {
|
||||
position: relative;
|
||||
}
|
||||
.label {
|
||||
.ui.circular.label {
|
||||
font-family: sans-serif;
|
||||
position: absolute;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.5em !important;
|
||||
bottom: -0.7rem;
|
||||
right: -0.7rem;
|
||||
padding: 2px 0 !important;
|
||||
width: 15px !important;
|
||||
height: 15px !important;
|
||||
min-width: 15px !important;
|
||||
min-height: 15px !important;
|
||||
@include media(">desktop") {
|
||||
font-size: 0.6em !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ui.feed.icon {
|
||||
margin: 0;
|
||||
}
|
||||
.shuffling.loader.inline {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes MOVE-BG {
|
||||
from {
|
||||
transform: translateX(0px);
|
||||
.control.circular.button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
to {
|
||||
transform: translateX(46px);
|
||||
|
||||
}
|
||||
.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>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="ui fluid category search">
|
||||
<slot></slot><div class="ui icon input">
|
||||
<input class="prompt" ref="search" name="search" :placeholder="labels.placeholder" type="text" @keydown.esc="$event.target.blur()">
|
||||
<input ref="search" class="prompt" name="search" :placeholder="labels.placeholder" type="text" @keydown.esc="$event.target.blur()">
|
||||
<i class="search icon"></i>
|
||||
</div>
|
||||
<div class="results"></div>
|
||||
|
|
|
@ -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>
|
|
@ -19,3 +19,10 @@ export default {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.ui.circular.avatar.label {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 1em !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import sanitize from "@/sanitize"
|
||||
// import sanitize from "@/sanitize"
|
||||
|
||||
export default {
|
||||
props: {
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<button @click="$store.dispatch('favorites/toggle', track.id)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']">
|
||||
<button @click.stop="$store.dispatch('favorites/toggle', track.id)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']">
|
||||
<i class="heart icon"></i>
|
||||
<translate v-if="isFavorite" translate-context="Content/Track/Button.Message">In favorites</translate>
|
||||
<translate v-else translate-context="Content/Track/*/Verb">Add to favorites</translate>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="$store.dispatch('favorites/toggle', track.id)"
|
||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
||||
:class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', 'really', 'button']"
|
||||
:aria-label="title"
|
||||
:title="title">
|
||||
|
|
|
@ -1,63 +1,19 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
import HumanDate from '@/components/common/HumanDate'
|
||||
|
||||
Vue.component('human-date', HumanDate)
|
||||
|
||||
import Username from '@/components/common/Username'
|
||||
|
||||
Vue.component('username', Username)
|
||||
|
||||
import UserLink from '@/components/common/UserLink'
|
||||
|
||||
Vue.component('user-link', UserLink)
|
||||
|
||||
import ActorLink from '@/components/common/ActorLink'
|
||||
|
||||
Vue.component('actor-link', ActorLink)
|
||||
|
||||
import ActorAvatar from '@/components/common/ActorAvatar'
|
||||
|
||||
Vue.component('actor-avatar', ActorAvatar)
|
||||
|
||||
import Duration from '@/components/common/Duration'
|
||||
|
||||
Vue.component('duration', Duration)
|
||||
|
||||
import DangerousButton from '@/components/common/DangerousButton'
|
||||
|
||||
Vue.component('dangerous-button', DangerousButton)
|
||||
|
||||
import Message from '@/components/common/Message'
|
||||
|
||||
Vue.component('message', Message)
|
||||
|
||||
import CopyInput from '@/components/common/CopyInput'
|
||||
|
||||
Vue.component('copy-input', CopyInput)
|
||||
|
||||
import AjaxButton from '@/components/common/AjaxButton'
|
||||
|
||||
Vue.component('ajax-button', AjaxButton)
|
||||
|
||||
import Tooltip from '@/components/common/Tooltip'
|
||||
|
||||
Vue.component('tooltip', Tooltip)
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
|
||||
Vue.component('empty-state', EmptyState)
|
||||
|
||||
import ExpandableDiv from '@/components/common/ExpandableDiv'
|
||||
|
||||
Vue.component('expandable-div', ExpandableDiv)
|
||||
|
||||
import CollapseLink from '@/components/common/CollapseLink'
|
||||
|
||||
Vue.component('collapse-link', CollapseLink)
|
||||
|
||||
import ActionFeedback from '@/components/common/ActionFeedback'
|
||||
|
||||
Vue.component('action-feedback', ActionFeedback)
|
||||
Vue.component('human-date', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDate"))
|
||||
Vue.component('username', () => import(/* webpackChunkName: "common" */ "@/components/common/Username"))
|
||||
Vue.component('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink"))
|
||||
Vue.component('actor-link', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink"))
|
||||
Vue.component('actor-avatar', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorAvatar"))
|
||||
Vue.component('duration', () => import(/* webpackChunkName: "common" */ "@/components/common/Duration"))
|
||||
Vue.component('dangerous-button', () => import(/* webpackChunkName: "common" */ "@/components/common/DangerousButton"))
|
||||
Vue.component('message', () => import(/* webpackChunkName: "common" */ "@/components/common/Message"))
|
||||
Vue.component('copy-input', () => import(/* webpackChunkName: "common" */ "@/components/common/CopyInput"))
|
||||
Vue.component('ajax-button', () => import(/* webpackChunkName: "common" */ "@/components/common/AjaxButton"))
|
||||
Vue.component('tooltip', () => import(/* webpackChunkName: "common" */ "@/components/common/Tooltip"))
|
||||
Vue.component('empty-state', () => import(/* webpackChunkName: "common" */ "@/components/common/EmptyState"))
|
||||
Vue.component('expandable-div', () => import(/* webpackChunkName: "common" */ "@/components/common/ExpandableDiv"))
|
||||
Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink"))
|
||||
Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback"))
|
||||
|
||||
export default {}
|
||||
|
|
|
@ -112,6 +112,7 @@ export default {
|
|||
props: {
|
||||
defaultQuery: { type: String, required: false, default: "" },
|
||||
defaultTags: { type: Array, required: false, default: () => { return [] } },
|
||||
scope: { type: String, required: false, default: "all" },
|
||||
},
|
||||
components: {
|
||||
AlbumCard,
|
||||
|
@ -164,6 +165,7 @@ export default {
|
|||
this.isLoading = true
|
||||
let url = FETCH_URL
|
||||
let params = {
|
||||
scope: this.scope,
|
||||
page: this.page,
|
||||
page_size: this.paginateBy,
|
||||
q: this.query,
|
||||
|
|
|
@ -100,6 +100,7 @@ export default {
|
|||
props: {
|
||||
defaultQuery: { type: String, required: false, default: "" },
|
||||
defaultTags: { type: Array, required: false, default: () => { return [] } },
|
||||
scope: { type: String, required: false, default: "all" },
|
||||
},
|
||||
components: {
|
||||
ArtistCard,
|
||||
|
@ -152,6 +153,7 @@ export default {
|
|||
this.isLoading = true
|
||||
let url = FETCH_URL
|
||||
let params = {
|
||||
scope: this.scope,
|
||||
page: this.page,
|
||||
page_size: this.paginateBy,
|
||||
q: this.query,
|
||||
|
|
|
@ -3,17 +3,17 @@
|
|||
<section class="ui vertical stripe segment">
|
||||
<div class="ui stackable three column grid">
|
||||
<div class="column">
|
||||
<track-widget :url="'history/listenings/'" :filters="{scope: 'all', ordering: '-creation_date'}">
|
||||
<track-widget :url="'history/listenings/'" :filters="{scope: scope, ordering: '-creation_date'}">
|
||||
<template slot="title"><translate translate-context="Content/Home/Title">Recently listened</translate></template>
|
||||
</track-widget>
|
||||
</div>
|
||||
<div class="column">
|
||||
<track-widget :url="'favorites/tracks/'" :filters="{scope: 'all', ordering: '-creation_date'}">
|
||||
<track-widget :url="'favorites/tracks/'" :filters="{scope: scope, ordering: '-creation_date'}">
|
||||
<template slot="title"><translate translate-context="Content/Home/Title">Recently favorited</translate></template>
|
||||
</track-widget>
|
||||
</div>
|
||||
<div class="column">
|
||||
<playlist-widget :url="'playlists/'" :filters="{scope: 'all', playable: true, ordering: '-modification_date'}">
|
||||
<playlist-widget :url="'playlists/'" :filters="{scope: scope, playable: true, ordering: '-modification_date'}">
|
||||
<template slot="title"><translate translate-context="*/*/*">Playlists</translate></template>
|
||||
</playlist-widget>
|
||||
</div>
|
||||
|
@ -21,7 +21,7 @@
|
|||
<div class="ui section hidden divider"></div>
|
||||
<div class="ui stackable one column grid">
|
||||
<div class="column">
|
||||
<album-widget :filters="{playable: true, ordering: '-creation_date'}">
|
||||
<album-widget :filters="{scope: scope, playable: true, ordering: '-creation_date'}">
|
||||
<template slot="title"><translate translate-context="Content/Home/Title">Recently added</translate></template>
|
||||
</album-widget>
|
||||
</div>
|
||||
|
@ -43,6 +43,9 @@ const ARTISTS_URL = "artists/"
|
|||
|
||||
export default {
|
||||
name: "library",
|
||||
props: {
|
||||
scope: {default: 'all'}
|
||||
},
|
||||
components: {
|
||||
Search,
|
||||
ArtistCard,
|
||||
|
@ -53,7 +56,7 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
artists: [],
|
||||
isLoadingArtists: false
|
||||
isLoadingArtists: false,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
|
|
|
@ -1,22 +1,5 @@
|
|||
<template>
|
||||
<div class="main library pusher">
|
||||
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
|
||||
<router-link class="ui item" to="/library" exact>
|
||||
<translate translate-context="*/Library/*/Verb">Browse</translate>
|
||||
</router-link>
|
||||
<router-link class="ui item" to="/library/albums" exact>
|
||||
<translate translate-context="*/*/*">Albums</translate>
|
||||
</router-link>
|
||||
<router-link class="ui item" to="/library/artists" exact>
|
||||
<translate translate-context="*/*/*/Noun">Artists</translate>
|
||||
</router-link>
|
||||
<router-link class="ui item" to="/library/radios" exact>
|
||||
<translate translate-context="*/*/*">Radios</translate>
|
||||
</router-link>
|
||||
<router-link class="ui item" to="/library/playlists" exact>
|
||||
<translate translate-context="*/*/*">Playlists</translate>
|
||||
</router-link>
|
||||
</nav>
|
||||
<router-view :key="$route.fullPath"></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -127,7 +127,8 @@ const FETCH_URL = "radios/radios/"
|
|||
export default {
|
||||
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
|
||||
props: {
|
||||
defaultQuery: { type: String, required: false, default: "" }
|
||||
defaultQuery: { type: String, required: false, default: "" },
|
||||
scope: { type: String, required: false, default: "all" },
|
||||
},
|
||||
components: {
|
||||
RadioCard,
|
||||
|
@ -183,10 +184,11 @@ export default {
|
|||
this.isLoading = true
|
||||
let url = FETCH_URL
|
||||
let params = {
|
||||
scope: this.scope,
|
||||
page: this.page,
|
||||
page_size: this.paginateBy,
|
||||
name__icontains: this.query,
|
||||
ordering: this.getOrderingAsString()
|
||||
ordering: this.getOrderingAsString(),
|
||||
}
|
||||
logger.default.debug("Fetching radios")
|
||||
axios.get(url, { params: params }).then(response => {
|
||||
|
|
|
@ -61,7 +61,7 @@ export default {
|
|||
},
|
||||
created () {
|
||||
let self = this
|
||||
import('showdown').then(module => {
|
||||
import(/* webpackChunkName: "showdown" */ 'showdown').then(module => {
|
||||
self.markdown = new module.default.Converter({simplifiedAutoLink: true, openLinksInNewWindow: true})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -74,13 +74,11 @@ import axios from 'axios'
|
|||
import {mapState} from 'vuex'
|
||||
|
||||
import logger from '@/logging'
|
||||
import Modal from '@/components/semantic/Modal'
|
||||
import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Modal,
|
||||
ReportCategoryDropdown,
|
||||
ReportCategoryDropdown: () => import(/* webpackChunkName: "reports" */ "@/components/moderation/ReportCategoryDropdown"),
|
||||
Modal: () => import(/* webpackChunkName: "modal" */ "@/components/semantic/Modal"),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<button
|
||||
@click="$store.commit('playlists/chooseTrack', track)"
|
||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
||||
v-if="button"
|
||||
:class="['ui', 'icon', 'labeled', 'button']">
|
||||
<i class="list icon"></i>
|
||||
|
@ -8,7 +8,7 @@
|
|||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="$store.commit('playlists/chooseTrack', track)"
|
||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
||||
:class="['ui', 'basic', 'circular', 'icon', 'really', 'button']"
|
||||
:aria-label="labels.addToPlaylist"
|
||||
:title="labels.addToPlaylist">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div :class="['ui', {'active': show}, 'modal']">
|
||||
<i class="close icon"></i>
|
||||
<slot>
|
||||
<slot v-if="show">
|
||||
|
||||
</slot>
|
||||
</div>
|
||||
|
|
|
@ -14,4 +14,5 @@ export default {
|
|||
remove: require('lodash/remove'),
|
||||
reverse: require('lodash/reverse'),
|
||||
isEqual: require('lodash/isEqual'),
|
||||
sum: require('lodash/sum'),
|
||||
}
|
||||
|
|
|
@ -38,26 +38,26 @@ export default new Router({
|
|||
path: "/about",
|
||||
name: "about",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/components/About")
|
||||
import(/* webpackChunkName: "about" */ "@/components/About")
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/auth/Login"),
|
||||
import(/* webpackChunkName: "login" */ "@/views/auth/Login"),
|
||||
props: route => ({ next: route.query.next || "/library" })
|
||||
},
|
||||
{
|
||||
path: "/notifications",
|
||||
name: "notifications",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/Notifications")
|
||||
import(/* webpackChunkName: "notifications" */ "@/views/Notifications")
|
||||
},
|
||||
{
|
||||
path: "/auth/password/reset",
|
||||
name: "auth.password-reset",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/auth/PasswordReset"),
|
||||
import(/* webpackChunkName: "password-reset" */ "@/views/auth/PasswordReset"),
|
||||
props: route => ({
|
||||
defaultEmail: route.query.email
|
||||
})
|
||||
|
@ -66,7 +66,7 @@ export default new Router({
|
|||
path: "/auth/email/confirm",
|
||||
name: "auth.email-confirm",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/auth/EmailConfirm"),
|
||||
import(/* webpackChunkName: "signup" */ "@/views/auth/EmailConfirm"),
|
||||
props: route => ({
|
||||
defaultKey: route.query.key
|
||||
})
|
||||
|
@ -76,7 +76,7 @@ export default new Router({
|
|||
name: "auth.password-reset-confirm",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm"
|
||||
/* webpackChunkName: "password-reset" */ "@/views/auth/PasswordResetConfirm"
|
||||
),
|
||||
props: route => ({
|
||||
defaultUid: route.query.uid,
|
||||
|
@ -87,7 +87,7 @@ export default new Router({
|
|||
path: "/authorize",
|
||||
name: "authorize",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/components/auth/Authorize"),
|
||||
import(/* webpackChunkName: "settings" */ "@/components/auth/Authorize"),
|
||||
props: route => ({
|
||||
clientId: route.query.client_id,
|
||||
redirectUri: route.query.redirect_uri,
|
||||
|
@ -101,7 +101,7 @@ export default new Router({
|
|||
path: "/signup",
|
||||
name: "signup",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/auth/Signup"),
|
||||
import(/* webpackChunkName: "signup" */ "@/views/auth/Signup"),
|
||||
props: route => ({
|
||||
defaultInvitation: route.query.invitation
|
||||
})
|
||||
|
@ -110,13 +110,13 @@ export default new Router({
|
|||
path: "/logout",
|
||||
name: "logout",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/components/auth/Logout")
|
||||
import(/* webpackChunkName: "login" */ "@/components/auth/Logout")
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/components/auth/Settings")
|
||||
import(/* webpackChunkName: "settings" */ "@/components/auth/Settings")
|
||||
},
|
||||
{
|
||||
path: "/settings/applications/new",
|
||||
|
@ -128,7 +128,7 @@ export default new Router({
|
|||
}),
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/auth/ApplicationNew"
|
||||
/* webpackChunkName: "settings" */ "@/components/auth/ApplicationNew"
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -136,7 +136,7 @@ export default new Router({
|
|||
name: "settings.applications.edit",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit"
|
||||
/* webpackChunkName: "settings" */ "@/components/auth/ApplicationEdit"
|
||||
),
|
||||
props: true
|
||||
},
|
||||
|
@ -144,13 +144,14 @@ export default new Router({
|
|||
path: "/@:username",
|
||||
name: "profile",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/components/auth/Profile"),
|
||||
import(/* webpackChunkName: "core" */ "@/components/auth/Profile"),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: "/favorites",
|
||||
name: "favorites",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/components/favorites/List"),
|
||||
import(/* webpackChunkName: "favorites" */ "@/components/favorites/List"),
|
||||
props: route => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultPage: route.query.page,
|
||||
|
@ -173,14 +174,14 @@ export default new Router({
|
|||
{
|
||||
path: "/content/libraries/tracks",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/content/Base"),
|
||||
import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "content.libraries.files",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/views/content/libraries/Files"
|
||||
/* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Files"
|
||||
),
|
||||
props: route => ({
|
||||
query: route.query.q
|
||||
|
@ -191,14 +192,14 @@ export default new Router({
|
|||
{
|
||||
path: "/content/libraries",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/content/Base"),
|
||||
import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "content.libraries.index",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/views/content/libraries/Home"
|
||||
/* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Home"
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -206,7 +207,7 @@ export default new Router({
|
|||
name: "content.libraries.detail.upload",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/views/content/libraries/Upload"
|
||||
/* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Upload"
|
||||
),
|
||||
props: route => ({
|
||||
id: route.params.id,
|
||||
|
@ -218,7 +219,7 @@ export default new Router({
|
|||
name: "content.libraries.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/views/content/libraries/Detail"
|
||||
/* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Detail"
|
||||
),
|
||||
props: true
|
||||
}
|
||||
|
@ -227,13 +228,13 @@ export default new Router({
|
|||
{
|
||||
path: "/content/remote",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/content/Base"),
|
||||
import(/* webpackChunkName: "auth-libraries" */ "@/views/content/Base"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "content.remote.index",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/content/remote/Home")
|
||||
import(/* webpackChunkName: "auth-libraries" */ "@/views/content/remote/Home")
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -498,12 +499,21 @@ export default new Router({
|
|||
import(/* webpackChunkName: "core" */ "@/components/library/Home"),
|
||||
name: "library.index"
|
||||
},
|
||||
{
|
||||
path: "me",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/components/library/Home"),
|
||||
name: "library.me",
|
||||
props: route => ({
|
||||
scope: 'me',
|
||||
})
|
||||
},
|
||||
{
|
||||
path: "artists/",
|
||||
name: "library.artists.browse",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/Artists"
|
||||
/* webpackChunkName: "artists" */ "@/components/library/Artists"
|
||||
),
|
||||
props: route => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
|
@ -515,12 +525,30 @@ export default new Router({
|
|||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{
|
||||
path: "me/artists",
|
||||
name: "library.artists.me",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "artists" */ "@/components/library/Artists"
|
||||
),
|
||||
props: route => ({
|
||||
scope: 'me',
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
defaultTags: Array.isArray(route.query.tag || [])
|
||||
? route.query.tag
|
||||
: [route.query.tag],
|
||||
defaultPaginateBy: route.query.paginateBy,
|
||||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{
|
||||
path: "albums/",
|
||||
name: "library.albums.browse",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/Albums"
|
||||
/* webpackChunkName: "albums" */ "@/components/library/Albums"
|
||||
),
|
||||
props: route => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
|
@ -532,12 +560,30 @@ export default new Router({
|
|||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{
|
||||
path: "me/albums",
|
||||
name: "library.albums.me",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "albums" */ "@/components/library/Albums"
|
||||
),
|
||||
props: route => ({
|
||||
scope: 'me',
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
defaultTags: Array.isArray(route.query.tag || [])
|
||||
? route.query.tag
|
||||
: [route.query.tag],
|
||||
defaultPaginateBy: route.query.paginateBy,
|
||||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{
|
||||
path: "radios/",
|
||||
name: "library.radios.browse",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/Radios"
|
||||
/* webpackChunkName: "radios" */ "@/components/library/Radios"
|
||||
),
|
||||
props: route => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
|
@ -546,12 +592,27 @@ export default new Router({
|
|||
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",
|
||||
name: "library.radios.build",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/radios/Builder"
|
||||
/* webpackChunkName: "radios" */ "@/components/library/radios/Builder"
|
||||
),
|
||||
props: true
|
||||
},
|
||||
|
@ -560,7 +621,7 @@ export default new Router({
|
|||
name: "library.radios.edit",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/radios/Builder"
|
||||
/* webpackChunkName: "radios" */ "@/components/library/radios/Builder"
|
||||
),
|
||||
props: true
|
||||
},
|
||||
|
@ -568,14 +629,14 @@ export default new Router({
|
|||
path: "radios/:id",
|
||||
name: "library.radios.detail",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/radios/Detail"),
|
||||
import(/* webpackChunkName: "radios" */ "@/views/radios/Detail"),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: "playlists/",
|
||||
name: "library.playlists.browse",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/playlists/List"),
|
||||
import(/* webpackChunkName: "playlists" */ "@/views/playlists/List"),
|
||||
props: route => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
|
@ -583,11 +644,24 @@ export default new Router({
|
|||
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",
|
||||
name: "library.playlists.detail",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/views/playlists/Detail"),
|
||||
import(/* webpackChunkName: "playlists" */ "@/views/playlists/Detail"),
|
||||
props: route => ({
|
||||
id: route.params.id,
|
||||
defaultEdit: route.query.mode === "edit"
|
||||
|
@ -598,7 +672,7 @@ export default new Router({
|
|||
name: "library.tags.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/TagDetail"
|
||||
/* webpackChunkName: "tags" */ "@/components/library/TagDetail"
|
||||
),
|
||||
props: true
|
||||
},
|
||||
|
@ -606,7 +680,7 @@ export default new Router({
|
|||
path: "artists/:id",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/ArtistBase"
|
||||
/* webpackChunkName: "artists" */ "@/components/library/ArtistBase"
|
||||
),
|
||||
props: true,
|
||||
children: [
|
||||
|
@ -615,7 +689,7 @@ export default new Router({
|
|||
name: "library.artists.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/ArtistDetail"
|
||||
/* webpackChunkName: "artists" */ "@/components/library/ArtistDetail"
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -623,7 +697,7 @@ export default new Router({
|
|||
name: "library.artists.edit",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/ArtistEdit"
|
||||
/* webpackChunkName: "edits" */ "@/components/library/ArtistEdit"
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -631,7 +705,7 @@ export default new Router({
|
|||
name: "library.artists.edit.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/EditDetail"
|
||||
/* webpackChunkName: "edits" */ "@/components/library/EditDetail"
|
||||
),
|
||||
props: true
|
||||
}
|
||||
|
@ -641,7 +715,7 @@ export default new Router({
|
|||
path: "albums/:id",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/AlbumBase"
|
||||
/* webpackChunkName: "albums" */ "@/components/library/AlbumBase"
|
||||
),
|
||||
props: true,
|
||||
children: [
|
||||
|
@ -650,7 +724,7 @@ export default new Router({
|
|||
name: "library.albums.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/AlbumDetail"
|
||||
/* webpackChunkName: "albums" */ "@/components/library/AlbumDetail"
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -658,7 +732,7 @@ export default new Router({
|
|||
name: "library.albums.edit",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/AlbumEdit"
|
||||
/* webpackChunkName: "edits" */ "@/components/library/AlbumEdit"
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -666,7 +740,7 @@ export default new Router({
|
|||
name: "library.albums.edit.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/EditDetail"
|
||||
/* webpackChunkName: "edits" */ "@/components/library/EditDetail"
|
||||
),
|
||||
props: true
|
||||
}
|
||||
|
@ -676,7 +750,7 @@ export default new Router({
|
|||
path: "tracks/:id",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/TrackBase"
|
||||
/* webpackChunkName: "tracks" */ "@/components/library/TrackBase"
|
||||
),
|
||||
props: true,
|
||||
children: [
|
||||
|
@ -685,7 +759,7 @@ export default new Router({
|
|||
name: "library.tracks.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/TrackDetail"
|
||||
/* webpackChunkName: "tracks" */ "@/components/library/TrackDetail"
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -693,7 +767,7 @@ export default new Router({
|
|||
name: "library.tracks.edit",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/TrackEdit"
|
||||
/* webpackChunkName: "edits" */ "@/components/library/TrackEdit"
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -701,7 +775,7 @@ export default new Router({
|
|||
name: "library.tracks.edit.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "core" */ "@/components/library/EditDetail"
|
||||
/* webpackChunkName: "edits" */ "@/components/library/EditDetail"
|
||||
),
|
||||
props: true
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ export default {
|
|||
errorCount: 0,
|
||||
playing: false,
|
||||
isLoadingAudio: false,
|
||||
volume: 0.5,
|
||||
volume: 1,
|
||||
tempVolume: 0.5,
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
|
@ -88,7 +88,7 @@ export default {
|
|||
return time.parse(Math.round(state.currentTime))
|
||||
},
|
||||
progress: state => {
|
||||
return Math.round(state.currentTime / state.duration * 100)
|
||||
return Math.round((state.currentTime / state.duration * 100) * 10) / 10
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
|
|
@ -7,14 +7,12 @@ export default {
|
|||
tracks: [],
|
||||
currentIndex: -1,
|
||||
ended: true,
|
||||
previousQueue: null
|
||||
},
|
||||
mutations: {
|
||||
reset (state) {
|
||||
state.tracks = []
|
||||
state.currentIndex = -1
|
||||
state.ended = true
|
||||
state.previousQueue = null
|
||||
},
|
||||
currentIndex (state, value) {
|
||||
state.currentIndex = value
|
||||
|
@ -56,6 +54,9 @@ export default {
|
|||
hasNext: state => {
|
||||
return state.currentIndex < state.tracks.length - 1
|
||||
},
|
||||
hasPrevious: state => {
|
||||
return state.currentIndex > 0 && state.tracks.length > 1
|
||||
},
|
||||
isEmpty: state => state.tracks.length === 0
|
||||
},
|
||||
actions: {
|
||||
|
|
|
@ -6,6 +6,7 @@ export default {
|
|||
state: {
|
||||
currentLanguage: 'en_US',
|
||||
selectedLanguage: false,
|
||||
queueFocused: null,
|
||||
momentLocale: 'en',
|
||||
lastDate: new Date(),
|
||||
maxMessages: 100,
|
||||
|
@ -46,6 +47,26 @@ export default {
|
|||
orderingDirection: "-",
|
||||
ordering: "creation_date",
|
||||
},
|
||||
"library.albums.me": {
|
||||
paginateBy: 25,
|
||||
orderingDirection: "-",
|
||||
ordering: "creation_date",
|
||||
},
|
||||
"library.artists.me": {
|
||||
paginateBy: 30,
|
||||
orderingDirection: "-",
|
||||
ordering: "creation_date",
|
||||
},
|
||||
"library.radios.me": {
|
||||
paginateBy: 12,
|
||||
orderingDirection: "-",
|
||||
ordering: "creation_date",
|
||||
},
|
||||
"library.playlists.me": {
|
||||
paginateBy: 25,
|
||||
orderingDirection: "-",
|
||||
ordering: "creation_date",
|
||||
},
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
|
@ -104,6 +125,10 @@ export default {
|
|||
computeLastDate: (state) => {
|
||||
state.lastDate = new Date()
|
||||
},
|
||||
queueFocused: (state, value) => {
|
||||
state.queueFocused = value
|
||||
},
|
||||
|
||||
theme: (state, value) => {
|
||||
state.theme = value
|
||||
},
|
||||
|
|
|
@ -79,8 +79,9 @@
|
|||
// see https://github.com/webpack/webpack/issues/215
|
||||
@import "./vendor/media";
|
||||
|
||||
$desktop-sidebar-width: 300px;
|
||||
$widedesktop-sidebar-width: 350px;
|
||||
$desktop-sidebar-width: 275px;
|
||||
$widedesktop-sidebar-width: 275px;
|
||||
$bottom-player-height: 4rem;
|
||||
|
||||
html,
|
||||
body {
|
||||
|
@ -88,6 +89,15 @@ body {
|
|||
font-size: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
#app {
|
||||
font-family: "Avenir", Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
@ -95,8 +105,19 @@ body {
|
|||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
&.has-bottom-player {
|
||||
padding-bottom: $bottom-player-height;
|
||||
.service-messages {
|
||||
bottom: $bottom-player-height + 1rem;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#footer {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid rgba(34, 36, 38, 0.15);
|
||||
}
|
||||
#app > main, #app > .main {
|
||||
flex: 1;
|
||||
}
|
||||
|
@ -114,19 +135,24 @@ body {
|
|||
width: $widedesktop-sidebar-width;
|
||||
}
|
||||
}
|
||||
.main.pusher,
|
||||
.footer {
|
||||
@include media(">desktop") {
|
||||
margin-left: $desktop-sidebar-width !important;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
@include media(">widedesktop") {
|
||||
margin-left: $widedesktop-sidebar-width !important;;
|
||||
#app {
|
||||
> .main.pusher,
|
||||
> .footer {
|
||||
@include media(">desktop") {
|
||||
margin-left: $desktop-sidebar-width !important;
|
||||
}
|
||||
|
||||
@include media(">widedesktop") {
|
||||
margin-left: $widedesktop-sidebar-width !important;;
|
||||
}
|
||||
transform: none !important;
|
||||
}
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.main.pusher.hidden {
|
||||
display: none;
|
||||
}
|
||||
.main.pusher > .ui.secondary.menu {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
|
@ -140,16 +166,6 @@ body {
|
|||
@include media(">tablet") {
|
||||
padding: 0 2.5rem;
|
||||
}
|
||||
@include media(">desktop") {
|
||||
position: fixed;
|
||||
left: $desktop-sidebar-width;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
z-index: 99;
|
||||
}
|
||||
@include media(">widedesktop") {
|
||||
left: $widedesktop-sidebar-width;
|
||||
}
|
||||
.item {
|
||||
padding-top: 1.5em;
|
||||
padding-bottom: 1.5em;
|
||||
|
@ -159,13 +175,7 @@ body {
|
|||
.service-messages {
|
||||
position: fixed;
|
||||
bottom: 1em;
|
||||
left: 1em;
|
||||
@include media(">desktop") {
|
||||
left: $desktop-sidebar-width;
|
||||
}
|
||||
@include media(">widedesktop") {
|
||||
left: $widedesktop-sidebar-width;
|
||||
}
|
||||
right: 1em;
|
||||
> .ui.message {
|
||||
box-shadow: 0px 0px 7px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
@ -306,10 +316,6 @@ label .tooltip {
|
|||
margin-left: 1em;
|
||||
}
|
||||
|
||||
canvas.color-thief {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui.list .list.icon {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
@ -392,5 +398,45 @@ input + .help {
|
|||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.ui.small.divider {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.queue.segment.player-focused #queue-grid #player {
|
||||
@include media("<desktop") {
|
||||
padding-bottom: $bottom-player-height + 2rem;
|
||||
}
|
||||
}
|
||||
.queue-controls {
|
||||
|
||||
@include media("<desktop") {
|
||||
height: $bottom-player-height;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-and-up {
|
||||
@include media("<desktop") {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
.tablet-and-up {
|
||||
@include media("<tablet") {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
.tablet-and-below {
|
||||
@include media(">desktop") {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
:not(.menu) > {
|
||||
a, .link {
|
||||
&:not(.button):not(.list) {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@import "./themes/_light.scss";
|
||||
@import "./themes/_dark.scss";
|
||||
|
|
|
@ -31,6 +31,9 @@ $link-color: rgb(255, 144, 0);
|
|||
color: $text-color;
|
||||
}
|
||||
}
|
||||
.main.with-background {
|
||||
background-color: $background-color;
|
||||
}
|
||||
.ui.link.list.list .active.item,
|
||||
.ui.link.list.list .active.item a:not(.ui) {
|
||||
color: inherit;
|
||||
|
@ -281,6 +284,17 @@ $link-color: rgb(255, 144, 0);
|
|||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.fixed-header.segment {
|
||||
background-color: $background-color;
|
||||
box-shadow: inset 0px -1px 0px 0px rgba(34, 36, 38, 0.15);
|
||||
}
|
||||
.ui.fixed-footer.segment {
|
||||
box-shadow: inset 0px 1px 0px 0px rgba(34, 36, 38, 0.15);
|
||||
}
|
||||
@include media("<desktop") {
|
||||
background-color: $background-color;
|
||||
}
|
||||
}
|
||||
|
||||
/* purgecss end ignore */
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
.main.with-background {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.discrete {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
|
@ -31,5 +34,16 @@
|
|||
footer#footer div.item:hover {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
.ui.fixed-header.segment {
|
||||
background-color: white;
|
||||
box-shadow: inset 0px -1px 0px 0px rgba(34, 36, 38, 0.15);
|
||||
}
|
||||
.ui.fixed-footer.segment {
|
||||
box-shadow: inset 0px 1px 0px 0px rgba(34, 36, 38, 0.15);
|
||||
}
|
||||
.queue.segment .queue-controls {
|
||||
@include media("<desktop") {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -87,7 +87,8 @@ const FETCH_URL = "playlists/"
|
|||
export default {
|
||||
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
|
||||
props: {
|
||||
defaultQuery: { type: String, required: false, default: "" }
|
||||
defaultQuery: { type: String, required: false, default: "" },
|
||||
scope: { type: String, required: false, default: "all" },
|
||||
},
|
||||
components: {
|
||||
PlaylistCardList,
|
||||
|
@ -141,6 +142,7 @@ export default {
|
|||
this.isLoading = true
|
||||
let url = FETCH_URL
|
||||
let params = {
|
||||
scope: this.scope,
|
||||
page: this.page,
|
||||
page_size: this.paginateBy,
|
||||
q: this.query,
|
||||
|
|
|
@ -2,11 +2,16 @@
|
|||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
const webpack = require('webpack');
|
||||
const PurgecssPlugin = require('purgecss-webpack-plugin')
|
||||
const PreloadWebpackPlugin = require('preload-webpack-plugin');
|
||||
const glob = require('glob-all')
|
||||
const path = require('path')
|
||||
let plugins = [
|
||||
// do not include moment.js locales since it's quite heavy
|
||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
||||
new PreloadWebpackPlugin({
|
||||
rel: 'preload',
|
||||
include: ['audio', 'core', 'about']
|
||||
}),
|
||||
]
|
||||
if (process.env.BUNDLE_ANALYZE === '1') {
|
||||
plugins.push(new BundleAnalyzerPlugin())
|
||||
|
@ -40,7 +45,6 @@ module.exports = {
|
|||
}
|
||||
},
|
||||
chainWebpack: config => {
|
||||
config.optimization.delete('splitChunks')
|
||||
config.plugins.delete('prefetch-embed')
|
||||
config.plugins.delete('prefetch-index')
|
||||
},
|
||||
|
|
|
@ -7131,6 +7131,11 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.5
|
|||
source-map "^0.6.1"
|
||||
supports-color "^6.1.0"
|
||||
|
||||
preload-webpack-plugin@^3.0.0-beta.4:
|
||||
version "3.0.0-beta.4"
|
||||
resolved "https://registry.yarnpkg.com/preload-webpack-plugin/-/preload-webpack-plugin-3.0.0-beta.4.tgz#b8a36046df3b4a1b61db55d92f1a5aebdb99d246"
|
||||
integrity sha512-6hhh0AswCbp/U4EPVN4fbK2wiDkXhmgjjgEYEmXa21UYwjYzCIgh3ZRMXM21ZPLfbQGpdFuSL3zFslU+edjpwg==
|
||||
|
||||
prelude-ls@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
|
||||
|
@ -8217,10 +8222,10 @@ sort-keys@^2.0.0:
|
|||
dependencies:
|
||||
is-plain-obj "^1.0.0"
|
||||
|
||||
sortablejs@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.9.0.tgz#2d1e74ae6bac2cb4ad0622908f340848969eb88d"
|
||||
integrity sha512-Ot6bYJ6PoqPmpsqQYXjn1+RKrY2NWQvQt/o4jfd/UYwVWndyO5EPO8YHbnm5HIykf8ENsm4JUrdAvolPT86yYA==
|
||||
sortablejs@^1.10.1:
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.1.tgz#3d52b00f871be00f00f84d99a60d120bf3dfe52c"
|
||||
integrity sha512-N6r7GrVmO8RW1rn0cTdvK3JR0BcqecAJ0PmYMCL3ZuqTH3pY+9QyqkmJSkkLyyDvd+AJnwaxTP22Ybr/83V9hQ==
|
||||
|
||||
source-list-map@^2.0.0:
|
||||
version "2.0.1"
|
||||
|
@ -9216,17 +9221,17 @@ vue-upload-component@^2.8.11:
|
|||
resolved "https://registry.yarnpkg.com/vue-upload-component/-/vue-upload-component-2.8.20.tgz#60824d3f20f3216dca90d8c86a5c980851b04ea0"
|
||||
integrity sha512-zrnJvULu4rnZe36Ib2/AZrI/h/mmNbUJZ+acZD652PyumzbvjCOQeYHe00sGifTdYjzzS66CwhTT+ubZ2D0Aow==
|
||||
|
||||
vue@^2.0.0, vue@^2.5.17:
|
||||
vue@^2.0.0, vue@^2.6.10:
|
||||
version "2.6.10"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637"
|
||||
integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==
|
||||
|
||||
vuedraggable@^2.16.0:
|
||||
version "2.21.0"
|
||||
resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.21.0.tgz#30c485ed737a9a6a73ea8f21cc8e1ed59aaddc92"
|
||||
integrity sha512-UDp0epjaZikuInoJA9rlEIJaSTQThabq0R9x7TqBdl0qGVFKKzo6glP6ubfzWBmV4iRIfbSOs2DV06s3B5h5tA==
|
||||
version "2.23.2"
|
||||
resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.23.2.tgz#0d95d7fdf4f02f56755a26b3c9dca5c7ca9cfa72"
|
||||
integrity sha512-PgHCjUpxEAEZJq36ys49HfQmXglattf/7ofOzUrW2/rRdG7tu6fK84ir14t1jYv4kdXewTEa2ieKEAhhEMdwkQ==
|
||||
dependencies:
|
||||
sortablejs "^1.9.0"
|
||||
sortablejs "^1.10.1"
|
||||
|
||||
vuex-persistedstate@^2.5.4:
|
||||
version "2.5.4"
|
||||
|
|
Loading…
Reference in New Issue