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

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

Closes #594

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="206.66678mm"
height="28.491329mm"
viewBox="0 0 206.66678 28.491329"
version="1.1"
id="svg4600"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="text-white.svg">
<defs
id="defs4594" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.7"
inkscape:cx="135.70772"
inkscape:cy="-23.988564"
inkscape:document-units="mm"
inkscape:current-layer="g5240"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1044"
inkscape:window-x="1920"
inkscape:window-y="36"
inkscape:window-maximized="1" />
<metadata
id="metadata4597">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(34.652951,-134.48185)">
<g
id="g5240">
<g
transform="translate(-66.52381,12.019644)"
id="g5221"
style="fill:#ffffff;fill-opacity:0.95454544">
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 32.845914,132.89252 c 0,-6.69443 2.603389,-9.29781 10.413554,-9.29781 1.636415,0 3.719126,0.14876 4.834864,0.37191 0.59506,0.14876 1.115738,0.59506 1.115738,1.11574 v 2.00832 c 0,0.59506 -0.446295,1.11574 -1.115738,1.11574 h -0.669443 c -0.818208,0 -1.48765,-0.29753 -2.529006,-0.29753 -4.834864,0 -5.801837,0.96698 -5.801837,4.98363 v 0.29753 h 6.620045 c 0.59506,0 1.115738,0.4463 1.115738,1.11574 v 2.15709 c 0,0.66945 -0.446295,1.11574 -1.115738,1.11574 h -6.620045 v 11.30614 c 0,0.59506 -0.446295,1.11574 -1.115737,1.11574 h -4.016657 c -0.59506,0 -1.115738,-0.52068 -1.115738,-1.11574 z"
id="path5166" />
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 57.020235,141.59528 c 0,3.04968 1.413268,4.31418 3.495978,4.31418 1.785181,0 3.495979,-1.2645 4.834864,-2.60339 v -12.12435 c 0,-0.59506 0.520678,-1.11573 1.115738,-1.11573 h 4.091039 c 0.59506,0 1.115738,0.52067 1.115738,1.11573 v 17.70304 c 0,0.59506 -0.446295,1.11574 -1.115738,1.11574 h -4.091039 c -0.59506,0 -1.115738,-0.52068 -1.115738,-1.11574 v -1.19012 c -1.710798,1.48765 -3.570361,2.67777 -6.322514,2.67777 -4.834864,0 -8.25646,-2.529 -8.25646,-8.70275 v -10.41355 c 0,-0.59506 0.446295,-1.11574 1.115738,-1.11574 h 4.091038 c 0.595061,0 1.115738,0.52068 1.115738,1.11574 v 10.33917 z"
id="path5168" />
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 90.715518,138.47121 c 0,-3.04968 -1.413268,-4.31419 -3.495979,-4.31419 -1.78518,0 -3.570361,1.26451 -4.909246,2.60339 v 12.19874 c 0,0.59506 -0.446295,1.11573 -1.115738,1.11573 h -4.091039 c -0.669442,0 -1.115738,-0.52067 -1.115738,-1.11573 v -17.77743 c 0,-0.59506 0.446296,-1.11573 1.115738,-1.11573 h 4.165422 c 0.59506,0 1.115737,0.52067 1.115737,1.11573 v 1.19012 c 1.710798,-1.48765 3.570362,-2.67777 6.396897,-2.67777 4.834865,0 8.256461,2.52901 8.256461,8.70276 v 10.41355 c 0,0.59506 -0.446295,1.11574 -1.115738,1.11574 h -4.091039 c -0.59506,0 -1.115738,-0.52068 -1.115738,-1.11574 z"
id="path5170" />
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 107.67473,137.95053 c 2.1571,0 3.57036,-0.89259 4.31419,-2.45462 l 1.78518,-3.71913 c 0.4463,-1.04135 1.56203,-1.71079 2.60339,-1.71079 h 3.42159 c 0.96698,0 1.11574,0.74382 0.59507,1.71079 l -2.38025,4.98363 c -0.74382,1.63642 -2.23147,2.90092 -3.86789,3.27283 1.48765,0.4463 2.75216,1.48765 3.86789,3.27283 l 3.12407,4.98363 c 0.59506,0.96698 0.29753,1.7108 -0.59506,1.7108 h -3.4216 c -1.19012,0 -2.15709,-0.74382 -2.75215,-1.7108 l -2.30586,-3.71912 c -0.96697,-1.63642 -2.67777,-2.38024 -4.31418,-2.38024 v 6.76881 c 0,0.59506 -0.4463,1.11573 -1.11574,1.11573 h -4.09104 c -0.59506,0 -1.11574,-0.52067 -1.11574,-1.11573 v -23.80241 c 0,-0.59506 0.4463,-1.11574 1.11574,-1.11574 h 4.09104 c 0.59506,0 1.11574,0.52068 1.11574,1.11574 v 12.79379 z"
id="path5172" />
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 140.1799,130.06599 c 1.04135,0 1.78518,0.59506 2.08271,1.56203 l 2.9753,9.81849 2.9753,-9.81849 c 0.29753,-1.04136 1.33888,-1.56203 2.45462,-1.56203 h 3.34722 c 0.89259,0 1.04135,0.66944 0.74382,1.56203 l -5.50431,16.73607 c -0.29753,0.96697 -1.33888,1.56203 -2.30585,1.56203 h -2.60339 c -0.89259,0 -2.00833,-0.59506 -2.30586,-1.56203 l -3.49598,-10.78547 -3.49598,10.85985 c -0.29753,0.96697 -1.41327,1.56203 -2.30586,1.56203 h -2.60338 c -0.96698,0 -1.93395,-0.59506 -2.30586,-1.56203 l -5.50431,-16.73607 c -0.29753,-0.89259 -0.0744,-1.56203 0.74383,-1.56203 h 3.34721 c 1.11574,0 2.08271,0.59506 2.45462,1.56203 l 2.9753,9.81849 2.9753,-9.81849 c 0.29753,-0.96697 1.04136,-1.56203 2.08272,-1.56203 h 3.27283 z"
id="path5174" />
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 172.09,138.47121 c 0,-3.04968 -1.41327,-4.31419 -3.4216,-4.31419 -1.78518,0 -3.57036,1.26451 -4.90924,2.60339 v 12.19874 c 0,0.59506 -0.4463,1.11573 -1.11574,1.11573 h -4.09104 c -0.59506,0 -1.11574,-0.52067 -1.11574,-1.11573 v -23.80241 c 0,-0.59506 0.52068,-1.11574 1.11574,-1.11574 h 4.09104 c 0.59506,0 1.11574,0.52068 1.11574,1.11574 v 7.2151 c 1.71079,-1.48765 3.57036,-2.67777 6.39689,-2.67777 4.83487,0 8.25646,2.52901 8.25646,8.70276 v 10.41355 c 0,0.59506 -0.52067,1.11574 -1.11573,1.11574 h -4.09104 c -0.59506,0 -1.11574,-0.52068 -1.11574,-1.11574 z"
id="path5176" />
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 189.04921,135.04961 c -0.44629,0.59506 -1.19012,0.96698 -2.08271,0.96698 h -2.67777 c -0.59506,0 -1.11573,-0.4463 -1.11573,-1.11574 0,-3.86789 3.86789,-5.20678 9.89287,-5.20678 5.35554,0 9.59535,2.23148 9.59535,7.88455 v 11.23176 c 0,0.59506 -0.52068,1.11574 -1.11574,1.11574 h -3.49598 c -0.59506,0 -1.11574,-0.52068 -1.11574,-1.11574 v -0.59506 c -1.7108,1.19012 -3.71912,2.08271 -6.62004,2.08271 -4.83487,0 -8.55399,-2.15709 -8.55399,-6.39689 0,-4.23981 3.71912,-6.32252 8.55399,-6.32252 h 6.02498 c 0,-2.90092 -1.19012,-3.79351 -3.71912,-3.79351 -1.56204,0.0744 -2.9753,0.52068 -3.57037,1.2645 z m 7.28949,9.52097 v -2.82654 h -5.57869 c -1.78518,0 -2.75215,0.96697 -2.75215,2.23148 0,1.2645 0.96697,2.23147 2.9753,2.23147 2.15709,0 4.01666,-0.8182 5.35554,-1.63641 z"
id="path5178" />
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 208.16552,150.0005 c -0.59506,0 -1.11573,-0.52068 -1.11573,-1.11574 v -23.8024 c 0,-0.59506 0.52067,-1.11574 1.11573,-1.11574 h 4.09104 c 0.59506,0 1.11574,0.52068 1.11574,1.11574 v 23.8024 c 0,0.59506 -0.44629,1.11574 -1.11574,1.11574 z"
id="path5180" />
<path
style="fill:#ffffff;fill-opacity:0.95454544;stroke-width:0.74382526"
inkscape:connector-curvature="0"
d="m 223.04203,141.96719 c 0.22315,2.9753 1.56203,4.2398 4.61171,4.2398 1.56204,0 2.97531,-0.44629 3.57037,-1.19012 0.52067,-0.59506 1.19012,-0.96697 2.08271,-0.96697 h 2.67777 c 0.59506,0 1.11574,0.52068 1.11574,1.11574 0,3.86789 -3.94228,5.20677 -9.89288,5.20677 -6.62004,0 -10.6367,-3.57036 -10.6367,-10.26478 0,-6.69443 4.01666,-10.33917 10.6367,-10.33917 6.62004,0 10.56232,3.57036 10.56232,10.11602 v 1.04135 c 0,0.59506 -0.4463,1.11574 -1.11574,1.11574 h -13.612 z m 0,-3.86789 h 8.47961 c -0.14877,-2.75216 -1.48765,-4.23981 -4.23981,-4.23981 -2.67777,0 -4.09104,1.48765 -4.2398,4.23981 z"
id="path5182" />
</g>
</g>
</g>
<style
id="style2"
type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#009FE3;}
.st2{fill:#3C3C3B;}
</style>
</svg>

Po

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

Wyświetl plik

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

Wyświetl plik

@ -42,12 +42,11 @@
</template>
<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')

Wyświetl plik

@ -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>&nbsp;
<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 {

Wyświetl plik

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

Wyświetl plik

@ -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">&infin;</span>
</i>
</span>
<span
role="button"
:disabled="queue.tracks.length === 0"
:title="labels.shuffle"
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()">
<div v-if="isShuffling" class="ui inline shuffling inverted tiny active loader"></div>
<i v-else :class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
</span>
</div>
<div class="group">
<div class="fake-dropdown">
<span class="position control desktop-and-up" role="button" @click.stop="toggleMobilePlayer">
<i class="stream icon"></i>
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}">
%{ index } of %{ length }
</translate>
</span>
<span class="position control tablet-and-below" role="button" @click.stop="switchTab">
<i class="stream icon"></i>
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}">
%{ index } of %{ length }
</translate>
</span>
<span
class="control close-control desktop-and-up"
v-if="$store.state.ui.queueFocused"
@click.stop="toggleMobilePlayer">
<i class="large down angle icon"></i>
</span>
<span
class="control desktop-and-up"
v-else
@click.stop="toggleMobilePlayer">
<i class="large up angle icon"></i>
</span>
<span
class="control close-control tablet-and-below"
v-if="$store.state.ui.queueFocused === 'player'"
@click.stop="switchTab">
<i class="large up angle icon"></i>
</span>
<span
class="control tablet-and-below"
v-if="$store.state.ui.queueFocused === 'queue'"
@click.stop="switchTab">
<i class="large down angle icon"></i>
</span>
</div>
<span
class="control close-control tablet-and-below"
@click.stop="$store.commit('ui/queueFocused', null)">
<i class="x icon"></i>
</span>
</div>
</div>
</div>
<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>

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,118 @@
<template>
<span :class="['volume-control', {'expanded': expanded}]" @click.prevent.stop="" @mouseover="handleOver" @mouseleave="handleLeave">
<span
role="button"
v-if="sliderVolume === 0"
:title="labels.unmute"
:aria-label="labels.unmute"
@click.prevent.stop="unmute">
<i class="volume off icon"></i>
</span>
<span
role="button"
v-else-if="sliderVolume < 0.5"
:title="labels.mute"
:aria-label="labels.mute"
@click.prevent.stop="mute">
<i class="volume down icon"></i>
</span>
<span
role="button"
v-else
:title="labels.mute"
:aria-label="labels.mute"
@click.prevent.stop="mute">
<i class="volume up icon"></i>
</span>
<div class="popup">
<input
type="range"
step="0.05"
min="0"
max="1"
v-model="sliderVolume" />
</div>
</span>
</template>
<script>
import { mapState, mapGetters, mapActions } from "vuex"
export default {
data () {
return {
expanded: false,
timeout: null,
}
},
computed: {
sliderVolume: {
get () {
return this.$store.state.player.volume
},
set (v) {
this.$store.commit("player/volume", v)
}
},
labels () {
return {
unmute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute"),
mute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute"),
}
}
},
methods: {
...mapActions({
mute: "player/mute",
unmute: "player/unmute",
toggleMute: "player/toggleMute",
}),
handleOver () {
if (this.timeout) {
clearTimeout(this.timeout)
}
this.expanded = true
},
handleLeave () {
if (this.timeout) {
clearTimeout(this.timeout)
}
this.timeout = setTimeout(() => {this.expanded = false}, 500)
}
}
}
</script>
<style lang="scss" scoped>
.volume-control {
display: flex;
line-height: inherit;
align-items: center;
position: relative;
overflow: visible;
input {
max-width: 5.5em;
height: 4px;
}
&.expandable {
.popup {
background-color: #1B1C1D;
position: absolute;
left: -4em;
top: -7em;
transform: rotate(-90deg);
display: flex;
align-items: center;
height: 2.5em;
padding: 0 0.5em;
box-shadow: 1px 1px 3px rgba(125, 125, 125, 0.5);
}
input {
max-width: 8.5em;
}
&:not(:hover):not(.expanded) .popup {
display: none;
}
}
}
</style>

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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() {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,661 +0,0 @@
/* eslint-disable */
/*
* Color Thief v2.0
* by Lokesh Dhakar - http://www.lokeshdhakar.com
*
* Thanks
* ------
* Nick Rabinowitz - For creating quantize.js.
* John Schulz - For clean up and optimization. @JFSIII
* Nathan Spady - For adding drag and drop support to the demo page.
*
* License
* -------
* Copyright 2011, 2015 Lokesh Dhakar
* Released under the MIT license
* https://raw.githubusercontent.com/lokesh/color-thief/master/LICENSE
*
* @license
*/
/*
CanvasImage Class
Class that wraps the html image element and canvas.
It also simplifies some of the canvas context manipulation
with a set of helper functions.
*/
var CanvasImage = function (image) {
this.canvas = document.createElement('canvas');
this.canvas.className = "color-thief hidden";
this.context = this.canvas.getContext('2d');
document.body.appendChild(this.canvas);
this.width = this.canvas.width = image.width;
this.height = this.canvas.height = image.height;
this.context.drawImage(image, 0, 0, this.width, this.height);
};
CanvasImage.prototype.clear = function () {
this.context.clearRect(0, 0, this.width, this.height);
};
CanvasImage.prototype.update = function (imageData) {
this.context.putImageData(imageData, 0, 0);
};
CanvasImage.prototype.getPixelCount = function () {
return this.width * this.height;
};
CanvasImage.prototype.getImageData = function () {
return this.context.getImageData(0, 0, this.width, this.height);
};
CanvasImage.prototype.removeCanvas = function () {
this.canvas.parentNode.removeChild(this.canvas);
};
var ColorThief = function () {};
/*
* getColor(sourceImage[, quality])
* returns {r: num, g: num, b: num}
*
* Use the median cut algorithm provided by quantize.js to cluster similar
* colors and return the base color from the largest cluster.
*
* Quality is an optional argument. It needs to be an integer. 1 is the highest quality settings.
* 10 is the default. There is a trade-off between quality and speed. The bigger the number, the
* faster a color will be returned but the greater the likelihood that it will not be the visually
* most dominant color.
*
* */
ColorThief.prototype.getColor = function(sourceImage, quality) {
var palette = this.getPalette(sourceImage, 5, quality);
var dominantColor = palette[0];
return dominantColor;
};
/*
* getPalette(sourceImage[, colorCount, quality])
* returns array[ {r: num, g: num, b: num}, {r: num, g: num, b: num}, ...]
*
* Use the median cut algorithm provided by quantize.js to cluster similar colors.
*
* colorCount determines the size of the palette; the number of colors returned. If not set, it
* defaults to 10.
*
* BUGGY: Function does not always return the requested amount of colors. It can be +/- 2.
*
* quality is an optional argument. It needs to be an integer. 1 is the highest quality settings.
* 10 is the default. There is a trade-off between quality and speed. The bigger the number, the
* faster the palette generation but the greater the likelihood that colors will be missed.
*
*
*/
ColorThief.prototype.getPalette = function(sourceImage, colorCount, quality) {
if (typeof colorCount === 'undefined' || colorCount < 2 || colorCount > 256) {
colorCount = 10;
}
if (typeof quality === 'undefined' || quality < 1) {
quality = 10;
}
// Create custom CanvasImage object
var image = new CanvasImage(sourceImage);
var imageData = image.getImageData();
var pixels = imageData.data;
var pixelCount = image.getPixelCount();
// Store the RGB values in an array format suitable for quantize function
var pixelArray = [];
for (var i = 0, offset, r, g, b, a; i < pixelCount; i = i + quality) {
offset = i * 4;
r = pixels[offset + 0];
g = pixels[offset + 1];
b = pixels[offset + 2];
a = pixels[offset + 3];
// If pixel is mostly opaque and not white
if (a >= 125) {
if (!(r > 250 && g > 250 && b > 250)) {
pixelArray.push([r, g, b]);
}
}
}
// Send array to quantize function which clusters values
// using median cut algorithm
var cmap = MMCQ.quantize(pixelArray, colorCount);
var palette = cmap? cmap.palette() : null;
// Clean up
image.removeCanvas();
return palette;
};
ColorThief.prototype.getColorFromUrl = function(imageUrl, callback, quality) {
sourceImage = document.createElement("img");
var thief = this;
sourceImage.addEventListener('load' , function(){
var palette = thief.getPalette(sourceImage, 5, quality);
var dominantColor = palette[0];
callback(dominantColor, imageUrl);
});
sourceImage.src = imageUrl
};
ColorThief.prototype.getImageData = function(imageUrl, callback) {
xhr = new XMLHttpRequest();
xhr.open('GET', imageUrl, true);
xhr.responseType = 'arraybuffer'
xhr.onload = function(e) {
if (this.status == 200) {
uInt8Array = new Uint8Array(this.response)
i = uInt8Array.length
binaryString = new Array(i);
for (var i = 0; i < uInt8Array.length; i++){
binaryString[i] = String.fromCharCode(uInt8Array[i])
}
data = binaryString.join('')
base64 = window.btoa(data)
callback ("data:image/png;base64,"+base64)
}
}
xhr.send();
};
ColorThief.prototype.getColorAsync = function(imageUrl, callback, quality) {
var thief = this;
this.getImageData(imageUrl, function(imageData){
sourceImage = document.createElement("img");
sourceImage.addEventListener('load' , function(){
var palette = thief.getPalette(sourceImage, 5, quality);
var dominantColor = palette[0];
callback(dominantColor, this);
});
sourceImage.src = imageData;
});
};
/*!
* quantize.js Copyright 2008 Nick Rabinowitz.
* Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
* @license
*/
// fill out a couple protovis dependencies
/*!
* Block below copied from Protovis: http://mbostock.github.com/protovis/
* Copyright 2010 Stanford Visualization Group
* Licensed under the BSD License: http://www.opensource.org/licenses/bsd-license.php
* @license
*/
if (!pv) {
var pv = {
map: function(array, f) {
var o = {};
return f ? array.map(function(d, i) { o.index = i; return f.call(o, d); }) : array.slice();
},
naturalOrder: function(a, b) {
return (a < b) ? -1 : ((a > b) ? 1 : 0);
},
sum: function(array, f) {
var o = {};
return array.reduce(f ? function(p, d, i) { o.index = i; return p + f.call(o, d); } : function(p, d) { return p + d; }, 0);
},
max: function(array, f) {
return Math.max.apply(null, f ? pv.map(array, f) : array);
}
};
}
/**
* Basic Javascript port of the MMCQ (modified median cut quantization)
* algorithm from the Leptonica library (http://www.leptonica.com/).
* Returns a color map you can use to map original pixels to the reduced
* palette. Still a work in progress.
*
* @author Nick Rabinowitz
* @example
// array of pixels as [R,G,B] arrays
var myPixels = [[190,197,190], [202,204,200], [207,214,210], [211,214,211], [205,207,207]
// etc
];
var maxColors = 4;
var cmap = MMCQ.quantize(myPixels, maxColors);
var newPalette = cmap.palette();
var newPixels = myPixels.map(function(p) {
return cmap.map(p);
});
*/
var MMCQ = (function() {
// private constants
var sigbits = 5,
rshift = 8 - sigbits,
maxIterations = 1000,
fractByPopulations = 0.75;
// get reduced-space color index for a pixel
function getColorIndex(r, g, b) {
return (r << (2 * sigbits)) + (g << sigbits) + b;
}
// Simple priority queue
function PQueue(comparator) {
var contents = [],
sorted = false;
function sort() {
contents.sort(comparator);
sorted = true;
}
return {
push: function(o) {
contents.push(o);
sorted = false;
},
peek: function(index) {
if (!sorted) sort();
if (index===undefined) index = contents.length - 1;
return contents[index];
},
pop: function() {
if (!sorted) sort();
return contents.pop();
},
size: function() {
return contents.length;
},
map: function(f) {
return contents.map(f);
},
debug: function() {
if (!sorted) sort();
return contents;
}
};
}
// 3d color space box
function VBox(r1, r2, g1, g2, b1, b2, histo) {
var vbox = this;
vbox.r1 = r1;
vbox.r2 = r2;
vbox.g1 = g1;
vbox.g2 = g2;
vbox.b1 = b1;
vbox.b2 = b2;
vbox.histo = histo;
}
VBox.prototype = {
volume: function(force) {
var vbox = this;
if (!vbox._volume || force) {
vbox._volume = ((vbox.r2 - vbox.r1 + 1) * (vbox.g2 - vbox.g1 + 1) * (vbox.b2 - vbox.b1 + 1));
}
return vbox._volume;
},
count: function(force) {
var vbox = this,
histo = vbox.histo;
if (!vbox._count_set || force) {
var npix = 0,
index, i, j, k;
for (i = vbox.r1; i <= vbox.r2; i++) {
for (j = vbox.g1; j <= vbox.g2; j++) {
for (k = vbox.b1; k <= vbox.b2; k++) {
index = getColorIndex(i,j,k);
npix += (histo[index] || 0);
}
}
}
vbox._count = npix;
vbox._count_set = true;
}
return vbox._count;
},
copy: function() {
var vbox = this;
return new VBox(vbox.r1, vbox.r2, vbox.g1, vbox.g2, vbox.b1, vbox.b2, vbox.histo);
},
avg: function(force) {
var vbox = this,
histo = vbox.histo;
if (!vbox._avg || force) {
var ntot = 0,
mult = 1 << (8 - sigbits),
rsum = 0,
gsum = 0,
bsum = 0,
hval,
i, j, k, histoindex;
for (i = vbox.r1; i <= vbox.r2; i++) {
for (j = vbox.g1; j <= vbox.g2; j++) {
for (k = vbox.b1; k <= vbox.b2; k++) {
histoindex = getColorIndex(i,j,k);
hval = histo[histoindex] || 0;
ntot += hval;
rsum += (hval * (i + 0.5) * mult);
gsum += (hval * (j + 0.5) * mult);
bsum += (hval * (k + 0.5) * mult);
}
}
}
if (ntot) {
vbox._avg = [~~(rsum/ntot), ~~(gsum/ntot), ~~(bsum/ntot)];
} else {
// console.log('empty box');
vbox._avg = [
~~(mult * (vbox.r1 + vbox.r2 + 1) / 2),
~~(mult * (vbox.g1 + vbox.g2 + 1) / 2),
~~(mult * (vbox.b1 + vbox.b2 + 1) / 2)
];
}
}
return vbox._avg;
},
contains: function(pixel) {
var vbox = this,
rval = pixel[0] >> rshift;
gval = pixel[1] >> rshift;
bval = pixel[2] >> rshift;
return (rval >= vbox.r1 && rval <= vbox.r2 &&
gval >= vbox.g1 && gval <= vbox.g2 &&
bval >= vbox.b1 && bval <= vbox.b2);
}
};
// Color map
function CMap() {
this.vboxes = new PQueue(function(a,b) {
return pv.naturalOrder(
a.vbox.count()*a.vbox.volume(),
b.vbox.count()*b.vbox.volume()
);
});
}
CMap.prototype = {
push: function(vbox) {
this.vboxes.push({
vbox: vbox,
color: vbox.avg()
});
},
palette: function() {
return this.vboxes.map(function(vb) { return vb.color; });
},
size: function() {
return this.vboxes.size();
},
map: function(color) {
var vboxes = this.vboxes;
for (var i=0; i<vboxes.size(); i++) {
if (vboxes.peek(i).vbox.contains(color)) {
return vboxes.peek(i).color;
}
}
return this.nearest(color);
},
nearest: function(color) {
var vboxes = this.vboxes,
d1, d2, pColor;
for (var i=0; i<vboxes.size(); i++) {
d2 = Math.sqrt(
Math.pow(color[0] - vboxes.peek(i).color[0], 2) +
Math.pow(color[1] - vboxes.peek(i).color[1], 2) +
Math.pow(color[2] - vboxes.peek(i).color[2], 2)
);
if (d2 < d1 || d1 === undefined) {
d1 = d2;
pColor = vboxes.peek(i).color;
}
}
return pColor;
},
forcebw: function() {
// XXX: won't work yet
var vboxes = this.vboxes;
vboxes.sort(function(a,b) { return pv.naturalOrder(pv.sum(a.color), pv.sum(b.color));});
// force darkest color to black if everything < 5
var lowest = vboxes[0].color;
if (lowest[0] < 5 && lowest[1] < 5 && lowest[2] < 5)
vboxes[0].color = [0,0,0];
// force lightest color to white if everything > 251
var idx = vboxes.length-1,
highest = vboxes[idx].color;
if (highest[0] > 251 && highest[1] > 251 && highest[2] > 251)
vboxes[idx].color = [255,255,255];
}
};
// histo (1-d array, giving the number of pixels in
// each quantized region of color space), or null on error
function getHisto(pixels) {
var histosize = 1 << (3 * sigbits),
histo = new Array(histosize),
index, rval, gval, bval;
pixels.forEach(function(pixel) {
rval = pixel[0] >> rshift;
gval = pixel[1] >> rshift;
bval = pixel[2] >> rshift;
index = getColorIndex(rval, gval, bval);
histo[index] = (histo[index] || 0) + 1;
});
return histo;
}
function vboxFromPixels(pixels, histo) {
var rmin=1000000, rmax=0,
gmin=1000000, gmax=0,
bmin=1000000, bmax=0,
rval, gval, bval;
// find min/max
pixels.forEach(function(pixel) {
rval = pixel[0] >> rshift;
gval = pixel[1] >> rshift;
bval = pixel[2] >> rshift;
if (rval < rmin) rmin = rval;
else if (rval > rmax) rmax = rval;
if (gval < gmin) gmin = gval;
else if (gval > gmax) gmax = gval;
if (bval < bmin) bmin = bval;
else if (bval > bmax) bmax = bval;
});
return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo);
}
function medianCutApply(histo, vbox) {
if (!vbox.count()) return;
var rw = vbox.r2 - vbox.r1 + 1,
gw = vbox.g2 - vbox.g1 + 1,
bw = vbox.b2 - vbox.b1 + 1,
maxw = pv.max([rw, gw, bw]);
// only one pixel, no split
if (vbox.count() == 1) {
return [vbox.copy()];
}
/* Find the partial sum arrays along the selected axis. */
var total = 0,
partialsum = [],
lookaheadsum = [],
i, j, k, sum, index;
if (maxw == rw) {
for (i = vbox.r1; i <= vbox.r2; i++) {
sum = 0;
for (j = vbox.g1; j <= vbox.g2; j++) {
for (k = vbox.b1; k <= vbox.b2; k++) {
index = getColorIndex(i,j,k);
sum += (histo[index] || 0);
}
}
total += sum;
partialsum[i] = total;
}
}
else if (maxw == gw) {
for (i = vbox.g1; i <= vbox.g2; i++) {
sum = 0;
for (j = vbox.r1; j <= vbox.r2; j++) {
for (k = vbox.b1; k <= vbox.b2; k++) {
index = getColorIndex(j,i,k);
sum += (histo[index] || 0);
}
}
total += sum;
partialsum[i] = total;
}
}
else { /* maxw == bw */
for (i = vbox.b1; i <= vbox.b2; i++) {
sum = 0;
for (j = vbox.r1; j <= vbox.r2; j++) {
for (k = vbox.g1; k <= vbox.g2; k++) {
index = getColorIndex(j,k,i);
sum += (histo[index] || 0);
}
}
total += sum;
partialsum[i] = total;
}
}
partialsum.forEach(function(d,i) {
lookaheadsum[i] = total-d;
});
function doCut(color) {
var dim1 = color + '1',
dim2 = color + '2',
left, right, vbox1, vbox2, d2, count2=0;
for (i = vbox[dim1]; i <= vbox[dim2]; i++) {
if (partialsum[i] > total / 2) {
vbox1 = vbox.copy();
vbox2 = vbox.copy();
left = i - vbox[dim1];
right = vbox[dim2] - i;
if (left <= right)
d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2));
else d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2));
// avoid 0-count boxes
while (!partialsum[d2]) d2++;
count2 = lookaheadsum[d2];
while (!count2 && partialsum[d2-1]) count2 = lookaheadsum[--d2];
// set dimensions
vbox1[dim2] = d2;
vbox2[dim1] = vbox1[dim2] + 1;
// console.log('vbox counts:', vbox.count(), vbox1.count(), vbox2.count());
return [vbox1, vbox2];
}
}
}
// determine the cut planes
return maxw == rw ? doCut('r') :
maxw == gw ? doCut('g') :
doCut('b');
}
function quantize(pixels, maxcolors) {
// short-circuit
if (!pixels.length || maxcolors < 2 || maxcolors > 256) {
// console.log('wrong number of maxcolors');
return false;
}
// XXX: check color content and convert to grayscale if insufficient
var histo = getHisto(pixels),
histosize = 1 << (3 * sigbits);
// check that we aren't below maxcolors already
var nColors = 0;
histo.forEach(function() { nColors++; });
if (nColors <= maxcolors) {
// XXX: generate the new colors from the histo and return
}
// get the beginning vbox from the colors
var vbox = vboxFromPixels(pixels, histo),
pq = new PQueue(function(a,b) { return pv.naturalOrder(a.count(), b.count()); });
pq.push(vbox);
// inner function to do the iteration
function iter(lh, target) {
var ncolors = 1,
niters = 0,
vbox;
while (niters < maxIterations) {
vbox = lh.pop();
if (!vbox.count()) { /* just put it back */
lh.push(vbox);
niters++;
continue;
}
// do the cut
var vboxes = medianCutApply(histo, vbox),
vbox1 = vboxes[0],
vbox2 = vboxes[1];
if (!vbox1) {
// console.log("vbox1 not defined; shouldn't happen!");
return;
}
lh.push(vbox1);
if (vbox2) { /* vbox2 can be null */
lh.push(vbox2);
ncolors++;
}
if (ncolors >= target) return;
if (niters++ > maxIterations) {
// console.log("infinite loop; perhaps too few pixels!");
return;
}
}
}
// first set of colors, sorted by population
iter(pq, fractByPopulations * maxcolors);
// Re-sort by the product of pixel occupancy times the size in color space.
var pq2 = new PQueue(function(a,b) {
return pv.naturalOrder(a.count()*a.volume(), b.count()*b.volume());
});
while (pq.size()) {
pq2.push(pq.pop());
}
// next set - generate the median cuts using the (npix * vol) sorting.
iter(pq2, maxcolors - pq2.size());
// calculate the actual colors
var cmap = new CMap();
while (pq2.size()) {
cmap.push(pq2.pop());
}
return cmap;
}
return {
quantize: quantize
};
})();
export default ColorThief

Wyświetl plik

@ -87,7 +87,8 @@ const FETCH_URL = "playlists/"
export default {
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,

Wyświetl plik

@ -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')
},

Wyświetl plik

@ -7131,6 +7131,11 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.5
source-map "^0.6.1"
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"