From e15d806634443d0832d228426e383a3355ac247f Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Thu, 26 Dec 2019 11:38:26 +0100
Subject: [PATCH] Resolve "Redesign the sidebar/navigation to simplify the UI"

---
 changes/changelog.d/594.feature               |   1 +
 changes/notes.rst                             |   7 +
 front/package.json                            |   3 +-
 front/public/index.html                       |  80 ++-
 front/src/App.vue                             | 255 ++++++-
 front/src/assets/logo/text-white.svg          | 117 +++
 front/src/components/Queue.vue                | 576 +++++++++++++++
 front/src/components/ShortcutsModal.vue       |   7 +-
 front/src/components/Sidebar.vue              | 583 ++++++++-------
 front/src/components/audio/PlayButton.vue     |  38 +-
 front/src/components/audio/Player.vue         | 679 +++++++-----------
 front/src/components/audio/SearchBar.vue      |   2 +-
 front/src/components/audio/VolumeControl.vue  | 118 +++
 front/src/components/common/ActorAvatar.vue   |   7 +
 front/src/components/common/ExpandableDiv.vue |   2 +-
 .../favorites/TrackFavoriteIcon.vue           |   4 +-
 front/src/components/globals.js               |  74 +-
 front/src/components/library/Albums.vue       |   2 +
 front/src/components/library/Artists.vue      |   2 +
 front/src/components/library/Home.vue         |  13 +-
 front/src/components/library/Library.vue      |  17 -
 front/src/components/library/Radios.vue       |   6 +-
 .../manage/moderation/InstancePolicyCard.vue  |   2 +-
 .../src/components/moderation/ReportModal.vue |   6 +-
 .../playlists/TrackPlaylistIcon.vue           |   4 +-
 front/src/components/semantic/Modal.vue       |   2 +-
 front/src/lodash.js                           |   1 +
 front/src/router/index.js                     | 160 +++--
 front/src/store/player.js                     |   4 +-
 front/src/store/queue.js                      |   5 +-
 front/src/store/ui.js                         |  25 +
 front/src/style/_main.scss                    | 110 ++-
 front/src/style/themes/_dark.scss             |  14 +
 front/src/style/themes/_light.scss            |  16 +-
 front/src/vendor/color-thief.js               | 661 -----------------
 front/src/views/playlists/List.vue            |   4 +-
 front/vue.config.js                           |   6 +-
 front/yarn.lock                               |  23 +-
 38 files changed, 2065 insertions(+), 1571 deletions(-)
 create mode 100644 changes/changelog.d/594.feature
 create mode 100644 front/src/assets/logo/text-white.svg
 create mode 100644 front/src/components/Queue.vue
 create mode 100644 front/src/components/audio/VolumeControl.vue
 delete mode 100644 front/src/vendor/color-thief.js

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