Toby Chui 1 месяц назад
Родитель
Сommit
b026bc5ba7
2 измененных файлов с 354 добавлено и 12 удалено
  1. 95 7
      src/web/Musicify/index.html
  2. 259 5
      src/web/Musicify/musicify.js

+ 95 - 7
src/web/Musicify/index.html

@@ -166,6 +166,17 @@
             .sidebar-overlay { display: block; }
         }
 
+        /* Mobile browsers rotated to landscape are wide enough to miss the
+           768px breakpoint above, but still need the same collapsible,
+           toggle-button-driven sidebar rather than a static desktop column. */
+        #app.mobile-landscape .mobile-bar { display: flex; }
+        #app.mobile-landscape .sidebar {
+            position: fixed; top: 0; left: 0; height: 100%;
+            transform: translateX(-100%); z-index: 200;
+        }
+        #app.mobile-landscape .sidebar.open { transform: translateX(0); }
+        #app.mobile-landscape .sidebar-overlay { display: block; }
+
         /* ── Main Content ────────────────────────────────────────────────── */
         .main-content {
             flex: 1; overflow-y: auto; background: var(--bg);
@@ -370,9 +381,8 @@
         @media (max-width: 900px) {
             .queue-panel {
                 position: fixed;
-                right: 0; bottom: var(--player-h);
+                top: 0; right: 0; bottom: var(--player-h);
                 width: min(320px, 100vw);
-                height: 60vh;
                 z-index: 150;
                 border-left: none;
                 border-top: 1px solid var(--border);
@@ -381,6 +391,20 @@
             }
         }
 
+        /* Same fix for mobile landscape, which is often wider than the 900px
+           breakpoint above (e.g. large phones) but still needs the full-height
+           overlay treatment rather than the desktop inline column. */
+        #app.mobile-landscape .queue-panel {
+            position: fixed;
+            top: 0; right: 0; bottom: var(--player-h);
+            width: min(320px, 100vw);
+            z-index: 150;
+            border-left: none;
+            border-top: 1px solid var(--border);
+            border-radius: 12px 12px 0 0;
+            box-shadow: 0 -4px 32px rgba(0,0,0,.6);
+        }
+
         /* ── Player Bar ─────────────────────────────────────────────────── */
         .player-bar {
             height: var(--player-h); background: var(--player-bg);
@@ -399,6 +423,7 @@
             flex-shrink: 0; background: var(--bg3); position: relative;
         }
         .player-cover img { width: 100%; height: 100%; object-fit: cover; }
+        .player-cover-viz { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
         .player-track-info { min-width: 0; }
         .player-track-name   { font-size: 13px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
         .player-track-artist { font-size: 11.5px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -654,7 +679,7 @@
         }
         .np-content {
             position: relative; z-index: 2;
-            height: 100%; display: flex; flex-direction: column; align-items: center;
+            height: calc(100% ); display: flex; flex-direction: column; align-items: center;
             padding: 14px 22px 42px; gap: 14px; overflow-y: auto;
         }
         .np-slide-left  { animation: npSlideL .28s ease; }
@@ -668,12 +693,13 @@
         .np-header-title { font-size: 13px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text2); }
         .np-art-wrap {
             position: relative; margin-top: auto;
-            width: min(74vw, 360px, 40vh); aspect-ratio: 1 / 1;
+            width: min(74vw, 360px, 40vh); max-width: 100%; aspect-ratio: 1 / 1;
             border-radius: 14px; overflow: hidden;
             background: var(--bg3); box-shadow: 0 14px 50px rgba(0,0,0,.55);
             flex-shrink: 0;
         }
         .np-art { width: 100%; height: 100%; object-fit: cover; display: block; }
+        .np-art-viz { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
         .np-art-loading {
             position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
             background: rgba(0,0,0,.5); backdrop-filter: blur(2px); color: #fff;
@@ -702,6 +728,28 @@
         .np-open .player-controls,
         .np-open .mobile-seek-row,
         .np-open .mobile-player-controls { display: none !important; }
+        /* Mobile landscape: the Now Playing overlay is too short to fit its own
+           transport controls, so keep them in the player-bar instead and drop
+           the overlay's now-redundant (and now-hidden-anyway) copies. */
+        #app.mobile-landscape.np-open .player-controls { display: flex !important; }
+        #app.mobile-landscape .np-seek,
+        #app.mobile-landscape .np-controls,
+        #app.mobile-landscape .np-hint { display: none; }
+        /* Give the now-visible controls room on narrower landscape phones by
+           letting the fixed-width track/extras columns shrink to content. */
+        #app.mobile-landscape .player-track { width: auto; max-width: 42vw; }
+        #app.mobile-landscape .player-extras { width: auto; }
+        /* Now that the overlay no longer needs room for its own controls, tighten
+           it up so art + title + artist comfortably fit a short landscape viewport. */
+        #app.mobile-landscape .np-content { padding: 8px 16px 10px; gap: 6px; }
+        #app.mobile-landscape .np-art-wrap { width: min(50vw, 220px, 30vh); margin-top: 0; }
+        #app.mobile-landscape .np-title { font-size: 16px; }
+        /* In mobile landscape the queue panel floats as a side column over the
+           Now Playing overlay, so re-center the header/art/title within the
+           remaining width to keep the album art and info button clear of it.
+           Portrait is intentionally excluded: there the queue is a near-full-width
+           overlay that should sit on top of np-content rather than shrink it. */
+        #app.mobile-landscape.queue-open .np-content { padding-right: min(320px, 100vw); }
         .track-info-meta {
             width: 100%; background: var(--bg2); border: 1px solid var(--border);
             border-radius: 10px; overflow: hidden; margin-bottom: 20px;
@@ -825,7 +873,7 @@
     </script>
 </head>
 <body>
-<div id="app" x-data="musicifyApp()" x-init="init()" :class="{ 'np-open': showNowPlaying }">
+<div id="app" x-data="musicifyApp()" x-init="init()" :class="{ 'np-open': showNowPlaying, 'mobile-landscape': isMobileLandscape, 'queue-open': showQueue }">
 
     <!-- Hidden audio element -->
     <audio id="musicPlayer" preload="metadata"></audio>
@@ -1346,10 +1394,11 @@
 
                     <!-- Large cover art -->
                     <div class="np-art-wrap">
-                        <img class="np-art"
+                        <img class="np-art" x-show="!privacyVisualizerMode"
                              :src="currentTrack ? getCoverUrl(currentTrack) : 'img/placeholder.png'"
                              x-on:error="$event.target.src='img/placeholder.png'; $event.target.onerror=null"
                              alt="Cover">
+                        <canvas id="np-viz-canvas" class="np-art-viz" x-show="privacyVisualizerMode" title="Album art hidden — Privacy Mode"></canvas>
                         <div class="np-art-loading" x-show="_fullBufferLoading">
                             <i class="ui spinner loading icon"></i>
                         </div>
@@ -1520,6 +1569,42 @@
                         </div>
                     </div>
 
+                    <!-- Privacy subsection -->
+                    <div class="settings-section">
+                        <div class="settings-section-title">Privacy</div>
+                        <div class="settings-section-desc">
+                            Hide album art from onlookers by replacing the cover thumbnail with a live audio visualizer that reacts to the music instead.
+                        </div>
+                        <div class="settings-toggle-row">
+                            <div>
+                                <div class="settings-toggle-label">Audio Visualizer Mode</div>
+                                <div class="settings-toggle-desc" x-text="privacyVisualizerMode ? 'Active – album art is replaced with a visualizer' : 'Inactive – album art is shown as usual'"></div>
+                            </div>
+                            <label class="toggle-switch">
+                                <input type="checkbox" x-model="privacyVisualizerMode" x-on:change="savePrivacyVisualizerMode()">
+                                <span class="toggle-slider"></span>
+                            </label>
+                        </div>
+                    </div>
+
+                    <!-- Mobile Volume subsection -->
+                    <div class="settings-section">
+                        <div class="settings-section-title">Mobile Volume</div>
+                        <div class="settings-section-desc">
+                            Safari/iOS and most Android browsers expect volume to be controlled with the device's physical buttons rather than an in-app slider. By default, Musicify hides the in-app volume bar and sets playback to full volume on mobile devices so the hardware buttons have full range.
+                        </div>
+                        <div class="settings-toggle-row">
+                            <div>
+                                <div class="settings-toggle-label">Show In-App Volume Slider on Mobile</div>
+                                <div class="settings-toggle-desc" x-text="!_isMobile() ? 'This device is not detected as mobile – this setting has no effect here' : (forceShowVolumeControl ? 'Active – volume slider is shown on this device' : 'Inactive – use your device volume buttons; volume is fixed at max')"></div>
+                            </div>
+                            <label class="toggle-switch">
+                                <input type="checkbox" x-model="forceShowVolumeControl" x-on:change="saveForceShowVolumeControl()">
+                                <span class="toggle-slider"></span>
+                            </label>
+                        </div>
+                    </div>
+
                 </div>
             </div>
 
@@ -1564,8 +1649,10 @@
             <div class="player-cover"
                  x-on:click="currentTrack && toggleNowPlaying()"
                  :style="currentTrack ? 'cursor:pointer;' : ''" title="Now Playing">
-                <img :src="currentTrack ? getCoverUrl(currentTrack) : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='"
+                <img x-show="!privacyVisualizerMode"
+                     :src="currentTrack ? getCoverUrl(currentTrack) : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='"
                      x-on:error="handleCoverError($event)" alt="Cover">
+                <canvas id="mini-viz-canvas" class="player-cover-viz" x-show="privacyVisualizerMode" title="Album art hidden — Privacy Mode"></canvas>
                 <div class="player-cover-loading" x-show="_fullBufferLoading" title="Buffering…">
                     <i class="ui spinner loading icon"></i>
                 </div>
@@ -1642,6 +1729,7 @@
                 <i :class="volumeIcon()" class="ui icon" style="margin:0;"></i>
             </button>
             <input type="range" class="volume-bar" min="0" max="100"
+                x-show="shouldShowVolumeSlider()"
                 :value="isMuted ? 0 : volume"
                 :style="'--vol-pct:' + (isMuted ? 0 : volume) + '%'"
                 x-on:input="setVolume($event.target.value)">

+ 259 - 5
src/web/Musicify/musicify.js

@@ -17,6 +17,7 @@ function musicifyApp() {
         // ── Navigation ──────────────────────────────────────────────────────
         view: 'home',           // 'home' | 'folders' | 'artists' | 'recent' | 'playlist'
         sidebarOpen: false,
+        isMobileLandscape: false, // true when a mobile browser is rotated into landscape
         loading: false,
         loadingMsg: '',
 
@@ -75,6 +76,7 @@ function musicifyApp() {
         isSeeking: false,
         volume: 80,
         isMuted: false,
+        forceShowVolumeControl: false, // user override: force-show the in-app volume slider on mobile devices
         shuffle: false,
         repeat: 'none',         // 'none' | 'all' | 'one'
         showQueue: false,
@@ -106,7 +108,10 @@ function musicifyApp() {
         _suppressEnded: false,  // true while a new track is loading (prevents double-skip)
 
         // ── Helpers (accessible from Alpine template expressions) ─────────────
-        isSidebarDesktop() { return window.innerWidth > 768; },
+        // A "desktop" sidebar is a static, always-visible column. Mobile browsers
+        // rotated to landscape are wide enough to pass the 768px check but should
+        // still get the collapsible/overlay sidebar, so they're excluded here.
+        isSidebarDesktop() { return window.innerWidth > 768 && !this._isMobile(); },
 
         // ── Transcode ────────────────────────────────────────────────────────
         transcodeMode: '48',           // 'disabled' | '16' | '24' | '48' (kHz)
@@ -118,6 +123,14 @@ function musicifyApp() {
         fullBufferMode: false,         // true = buffer entire track before playback (iOS default)
         _fullBufferLoading: false,     // true while waiting for server-side buffer to finish
 
+        // ── Privacy / Audio Visualizer Mode ──────────────────────────────────
+        privacyVisualizerMode: false,  // true = replace album art with an audio-reactive visualizer
+        _vizColor: '168,85,247',       // 'r,g,b' infill color sampled from the current cover art
+        _audioCtx: null,
+        _analyser: null,
+        _vizData: null,
+        _vizAnimId: null,
+
         // ── Arozcast ─────────────────────────────────────────────────────────
         castMode: false,
         castConnected: false,
@@ -192,7 +205,10 @@ function musicifyApp() {
             this._audio.addEventListener('waiting', () => {
                 if (MUSICIFY_DEBUG) console.log('[Musicify] audio waiting – pos:', (self._audio.currentTime + self._transcodeSeekOffset).toFixed(2), '/ dur:', self.duration.toFixed(2), '| transcoded:', self._currentTrackTranscoded);
             });
-            this._audio.addEventListener('play',  () => { self.isPlaying = true; self._suppressEnded = false; self._fullBufferLoading = false; self._updateMediaSession(); });
+            this._audio.addEventListener('play',  () => {
+                self.isPlaying = true; self._suppressEnded = false; self._fullBufferLoading = false; self._updateMediaSession();
+                if (self._audioCtx && self._audioCtx.state === 'suspended') self._audioCtx.resume().catch(() => {});
+            });
             this._audio.addEventListener('pause', () => { self.isPlaying = false; self._updateMediaSession(); });
 
             // Restore volume
@@ -204,6 +220,19 @@ function musicifyApp() {
                 this._audio.volume = this.volume / 100;
             }
 
+            // Mobile Volume Control: Safari/iOS and most Android browsers expect the
+            // device's hardware buttons to control volume, so default to full volume
+            // and hide the in-app slider on mobile unless the user overrides it.
+            var _savedForceVol = localStorage.getItem('musicify_forceShowVolumeControl');
+            if (_savedForceVol !== null) {
+                this.forceShowVolumeControl = (_savedForceVol === 'true');
+            }
+            if (this._isMobile() && !this.forceShowVolumeControl) {
+                this.volume = 100;
+                this.isMuted = false;
+                this._audio.volume = 1;
+            }
+
             // Restore shuffle / repeat / recently-played from server-side prefs
             // (cross-device: stored per user, not per browser)
             ao_module_storage.loadStorage("Musicify", "shuffle", function(val) {
@@ -231,6 +260,17 @@ function musicifyApp() {
                 this.fullBufferMode = this._isIOS();
             }
 
+            // Privacy / Audio Visualizer Mode: restore from localStorage
+            var _savedPrivacyViz = localStorage.getItem('musicify_privacyVisualizer');
+            if (_savedPrivacyViz !== null) {
+                this.privacyVisualizerMode = (_savedPrivacyViz === 'true');
+            }
+            if (this.privacyVisualizerMode) {
+                this._ensureAnalyser();
+                this._updateVizColor(this.currentTrack);
+                this._startVizLoop();
+            }
+
             // MediaSession
             this._setupMediaSession();
 
@@ -282,14 +322,17 @@ function musicifyApp() {
             }
 
             // Responsive sidebar
-            this.sidebarOpen = window.innerWidth > 768;
+            this._updateLandscapeState();
+            this.sidebarOpen = window.innerWidth > 768 && !this.isMobileLandscape;
             var resizeT;
             window.addEventListener('resize', () => {
                 clearTimeout(resizeT);
                 resizeT = setTimeout(() => {
-                    if (window.innerWidth <= 768) this.sidebarOpen = false;
+                    this._updateLandscapeState();
+                    if (window.innerWidth <= 768 || this.isMobileLandscape) this.sidebarOpen = false;
                 }, 150);
             });
+            window.addEventListener('orientationchange', () => this._updateLandscapeState());
         },
 
         // ════════════════════════════════════════════════════════════════════
@@ -953,6 +996,7 @@ function musicifyApp() {
                 this._transcodeEndFallbackTimer = null;
             }
             this.currentTrack = song;
+            this._updateVizColor(song);
             this.coverError = false;
             this.currentTime = 0;
             this.duration = 0;
@@ -998,7 +1042,10 @@ function musicifyApp() {
                 }
                 return;
             }
-            if (this._audio.paused) { this._audio.play().catch(() => {}); }
+            if (this._audio.paused) {
+                if (this._audioCtx && this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
+                this._audio.play().catch(() => {});
+            }
             else { this._audio.pause(); }
         },
 
@@ -1083,6 +1130,25 @@ function musicifyApp() {
             this._audio.muted = this.isMuted;
         },
 
+        // True when the in-app volume slider should be shown: always on desktop,
+        // and on mobile only if the user has explicitly turned the override on.
+        shouldShowVolumeSlider() {
+            return !this._isMobile() || this.forceShowVolumeControl;
+        },
+
+        saveForceShowVolumeControl() {
+            localStorage.setItem('musicify_forceShowVolumeControl', String(this.forceShowVolumeControl));
+            if (this.forceShowVolumeControl) {
+                this._showToast('Volume slider shown on this device');
+            } else if (this._isMobile()) {
+                this.volume = 100;
+                this.isMuted = false;
+                this._audio.volume = 1;
+                localStorage.setItem('musicify_volume', this.volume);
+                this._showToast('Using native volume control – volume set to max');
+            }
+        },
+
         toggleQueue(){
             this.showQueue = !this.showQueue;
             this.updateQueuePanelPosition();
@@ -1131,6 +1197,14 @@ function musicifyApp() {
             return /Android/.test(navigator.userAgent);
         },
 
+        _isMobile() {
+            return this._isIOS() || this._isAndroid();
+        },
+
+        _updateLandscapeState() {
+            this.isMobileLandscape = this._isMobile() && window.innerWidth > window.innerHeight;
+        },
+
         // Returns the playback URL for a song, using the transcode endpoint when needed.
         // startTime (seconds) is only appended when seeking a transcoded stream.
         _getAudioSrc(song, startTime) {
@@ -1446,6 +1520,186 @@ function musicifyApp() {
             event.target.onerror = null;
         },
 
+        // ── Privacy / Audio Visualizer Mode ──────────────────────────────────
+        savePrivacyVisualizerMode() {
+            localStorage.setItem('musicify_privacyVisualizer', String(this.privacyVisualizerMode));
+            if (this.privacyVisualizerMode) {
+                this._ensureAnalyser();
+                if (this._audioCtx && this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
+                this._updateVizColor(this.currentTrack);
+                this._startVizLoop();
+                this._showToast('Privacy Mode: album art hidden');
+            } else {
+                this._stopVizLoop();
+                this._showToast('Privacy Mode: album art shown');
+            }
+        },
+
+        // Lazily wires the <audio> element into a Web Audio graph. This can only be
+        // done once per element (createMediaElementSource throws on a second call),
+        // so the analyser is created once and simply left connected afterwards.
+        _ensureAnalyser() {
+            if (this._analyser || !this._audio) return;
+            try {
+                var AudioCtx = window.AudioContext || window.webkitAudioContext;
+                this._audioCtx = new AudioCtx();
+                var source = this._audioCtx.createMediaElementSource(this._audio);
+                this._analyser = this._audioCtx.createAnalyser();
+                this._analyser.fftSize = 64;
+                this._analyser.smoothingTimeConstant = 0.8;
+                source.connect(this._analyser);
+                this._analyser.connect(this._audioCtx.destination);
+                this._vizData = new Uint8Array(this._analyser.frequencyBinCount);
+            } catch (e) {
+                if (MUSICIFY_DEBUG) console.warn('[Musicify] visualizer unavailable', e);
+                this._analyser = null;
+            }
+        },
+
+        _startVizLoop() {
+            if (this._vizAnimId) return;
+            var self = this;
+            (function frame() {
+                self._vizAnimId = requestAnimationFrame(frame);
+                self._renderViz();
+            })();
+        },
+
+        _stopVizLoop() {
+            if (this._vizAnimId) { cancelAnimationFrame(this._vizAnimId); this._vizAnimId = null; }
+        },
+
+        _renderViz() {
+            var bars = 27;
+            var mid = Math.floor(bars / 2);
+            var heights = new Array(bars);
+            if (this._analyser && this._vizData) {
+                this._analyser.getByteFrequencyData(this._vizData);
+                var binCount = this._vizData.length;
+                var step = Math.max(1, Math.floor(binCount / (mid + 2)));
+                for (var i = 0; i < bars; i++) {
+                    var dist = Math.abs(i - mid);
+                    var idx = Math.min(binCount - 1, dist * step);
+                    heights[i] = Math.max(0.06, this._vizData[idx] / 255);
+                }
+            } else {
+                for (var j = 0; j < bars; j++) heights[j] = 0.06;
+            }
+            this._paintVizCanvas(document.getElementById('np-viz-canvas'), heights);
+            this._paintVizCanvas(document.getElementById('mini-viz-canvas'), heights);
+        },
+
+        _paintVizCanvas(canvas, heights) {
+            if (!canvas || !canvas.offsetParent) return;
+            var dpr = window.devicePixelRatio || 1;
+            var w = canvas.clientWidth, h = canvas.clientHeight;
+            if (!w || !h) return;
+            if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
+                canvas.width = Math.round(w * dpr);
+                canvas.height = Math.round(h * dpr);
+            }
+            var ctx = canvas.getContext('2d');
+            ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+            ctx.clearRect(0, 0, w, h);
+            var n = heights.length;
+            var gap = w * 0.02;
+            var barW = (w - gap * (n - 1)) / n;
+            ctx.fillStyle = 'rgb(' + this._vizColor + ')';
+            for (var i = 0; i < n; i++) {
+                var bh = Math.max(2, heights[i] * h);
+                var x = i * (barW + gap);
+                var y = (h - bh) / 2;
+                this._roundRectPath(ctx, x, y, barW, bh, barW / 2);
+                ctx.fill();
+            }
+        },
+
+        _roundRectPath(ctx, x, y, w, h, r) {
+            r = Math.max(0, Math.min(r, w / 2, h / 2));
+            ctx.beginPath();
+            ctx.moveTo(x + r, y);
+            ctx.arcTo(x + w, y, x + w, y + h, r);
+            ctx.arcTo(x + w, y + h, x, y + h, r);
+            ctx.arcTo(x, y + h, x, y, r);
+            ctx.arcTo(x, y, x + w, y, r);
+            ctx.closePath();
+        },
+
+        // Samples the current cover art's average color (boosted for contrast against
+        // the blurred backdrop) and uses it as the visualizer bar color.
+        _updateVizColor(song) {
+            var self = this;
+            if (!song) { self._vizColor = '168,85,247'; return; }
+            var img = new Image();
+            img.onload = function() {
+                try {
+                    var c = document.createElement('canvas');
+                    c.width = 24; c.height = 24;
+                    var cctx = c.getContext('2d');
+                    cctx.drawImage(img, 0, 0, 24, 24);
+                    var data = cctx.getImageData(0, 0, 24, 24).data;
+                    var r = 0, g = 0, b = 0, n = 0;
+                    for (var i = 0; i < data.length; i += 4) {
+                        if (data[i + 3] < 10) continue;
+                        r += data[i]; g += data[i + 1]; b += data[i + 2]; n++;
+                    }
+                    if (n === 0) { self._vizColor = '168,85,247'; return; }
+                    r = Math.round(r / n); g = Math.round(g / n); b = Math.round(b / n);
+                    self._vizColor = self._boostColorRGB(r, g, b);
+                } catch (e) {
+                    self._vizColor = '168,85,247';
+                }
+            };
+            img.onerror = function() { self._vizColor = '168,85,247'; };
+            img.src = this.getCoverUrl(song);
+        },
+
+        _boostColorRGB(r, g, b) {
+            var hsl = this._rgbToHsl(r, g, b);
+            var s = Math.max(hsl[1], 0.65);
+            var l = Math.min(Math.max(hsl[2], 0.62), 0.82);
+            var rgb = this._hslToRgb(hsl[0], s, l);
+            return rgb[0] + ',' + rgb[1] + ',' + rgb[2];
+        },
+
+        _rgbToHsl(r, g, b) {
+            r /= 255; g /= 255; b /= 255;
+            var max = Math.max(r, g, b), min = Math.min(r, g, b);
+            var h = 0, s = 0, l = (max + min) / 2;
+            if (max !== min) {
+                var d = max - min;
+                s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+                switch (max) {
+                    case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+                    case g: h = (b - r) / d + 2; break;
+                    case b: h = (r - g) / d + 4; break;
+                }
+                h /= 6;
+            }
+            return [h, s, l];
+        },
+
+        _hslToRgb(h, s, l) {
+            var r, g, b;
+            if (s === 0) { r = g = b = l; }
+            else {
+                var hue2rgb = function(p, q, t) {
+                    if (t < 0) t += 1;
+                    if (t > 1) t -= 1;
+                    if (t < 1 / 6) return p + (q - p) * 6 * t;
+                    if (t < 1 / 2) return q;
+                    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
+                    return p;
+                };
+                var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+                var p = 2 * l - q;
+                r = hue2rgb(p, q, h + 1 / 3);
+                g = hue2rgb(p, q, h);
+                b = hue2rgb(p, q, h - 1 / 3);
+            }
+            return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
+        },
+
         _getArtistName(song) {
             if (!song) return '';
             var parts = song.filepath.split('/');