소스 검색

Added Musicify full buffer mode

Toby Chui 1 주 전
부모
커밋
eaa4d35b35
3개의 변경된 파일562개의 추가작업 그리고 155개의 파일을 삭제
  1. 70 0
      src/web/Musicify/backend/fullbuffer.js
  2. 251 92
      src/web/Musicify/index.html
  3. 241 63
      src/web/Musicify/musicify.js

+ 70 - 0
src/web/Musicify/backend/fullbuffer.js

@@ -0,0 +1,70 @@
+/*
+    Musicify - Full Buffer Transcode
+    Converts an audio file to MP3 using ffmpeg and caches the result in
+    tmp:/Musicify/buffer/ so subsequent requests for the same file+samplerate
+    are served instantly without re-encoding.
+
+    The buffer directory is part of tmp:/ which ArozOS clears every 24 hours,
+    so the cache is self-managing.
+
+    Parameters (GET or POST):
+        file        - virtual path of the source audio file (URL-encoded)
+        samplerate  - target sample rate in kHz: "16" | "24" | "48"  (default: "48")
+
+    Returns JSON:
+        { "path": "tmp:/Musicify/buffer/<hash>.mp3" }   on success
+        { "error": "<message>" }                         on failure
+*/
+
+requirelib("filelib");
+requirelib("ffmpeg");
+
+// ── djb2 hash – produces a stable hex string from a source string ─────────────
+function simpleHash(str) {
+    var hash = 5381;
+    for (var i = 0; i < str.length; i++) {
+        hash = ((hash << 5) + hash) + str.charCodeAt(i);
+        hash = hash & hash; // keep 32-bit
+    }
+    return (hash >>> 0).toString(16);
+}
+
+// ── Validate required "file" parameter ───────────────────────────────────────
+if (typeof(file) === "undefined" || file === "") {
+    sendJSONResp(JSON.stringify({ error: "file parameter required" }));
+} else {
+    var decodedFile = decodeURIComponent(file);
+
+    // Normalise sample-rate (kHz string → integer → back to string for key)
+    var sr = "48";
+    if (typeof(samplerate) !== "undefined" && samplerate !== "" && samplerate !== "undefined") {
+        var srInt = parseInt(samplerate);
+        if (srInt === 16 || srInt === 24 || srInt === 48) {
+            sr = String(srInt);
+        }
+    }
+
+    // Build a stable cache key from filepath + samplerate
+    var hashKey = simpleHash(decodedFile + "|" + sr);
+    var bufferDir  = "tmp:/Musicify/buffer";
+    var bufferPath = bufferDir + "/" + hashKey + ".mp3";
+
+    // Ensure the buffer directory exists
+    if (!filelib.fileExists(bufferDir)) {
+        filelib.mkdir(bufferDir);
+    }
+
+    // Return the cached file when it already exists
+    if (filelib.fileExists(bufferPath)) {
+        sendJSONResp(JSON.stringify({ path: bufferPath }));
+    } else {
+        // Convert: source → MP3 at the requested sample rate
+        var sampleRateHz = parseInt(sr) * 1000;
+        var ok = ffmpeg.audioConvert(decodedFile, bufferPath, sampleRateHz);
+        if (ok) {
+            sendJSONResp(JSON.stringify({ path: bufferPath }));
+        } else {
+            sendJSONResp(JSON.stringify({ error: "Transcoding failed" }));
+        }
+    }
+}

+ 251 - 92
src/web/Musicify/index.html

@@ -3,7 +3,7 @@
 <head>
     <meta charset="utf-8">
     <link rel="icon" type="image/png" href="img/module_icon.png" />
-    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
+    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
     <meta name="theme-color" content="#a855f7">
     <meta name="mobile-web-app-capable" content="yes">
     <meta name="apple-mobile-web-app-capable" content="yes">
@@ -399,7 +399,7 @@
         }
         .player-cover {
             width: 50px; height: 50px; border-radius: 6px; overflow: hidden;
-            flex-shrink: 0; background: var(--bg3);
+            flex-shrink: 0; background: var(--bg3); position: relative;
         }
         .player-cover img { width: 100%; height: 100%; object-fit: cover; }
         .player-track-info { min-width: 0; }
@@ -505,7 +505,10 @@
         }
 
         @media (max-width: 768px) {
-            .player-bar { height: 120px; padding: 8px 10px 0; flex-wrap: wrap; align-items: flex-start; gap: 0 10px; }
+            .player-bar { height: 120px; padding: 8px 16px 0; flex-wrap: wrap; align-items: flex-start; gap: 0 10px; }
+            .player-bar.android { height: 140px; padding-bottom: 46px; }
+            .np-open .player-bar { height: 100px; padding-bottom: 54px; }
+            .np-open .player-bar.android { height: 120px; padding-bottom: 64px; }
             .player-track { width: auto; flex: 1; align-self: center; }
             .player-controls { display: none; }
             .player-extras { width: auto; gap: 2px; align-self: center; }
@@ -517,9 +520,12 @@
                 display: flex !important; align-items: center; gap: 4px;
             }
             .content-body { padding: 14px 12px 12px}
-            .track-info-overlay {
+            .now-playing-overlay {
                 height: calc(100vh - var(--player-h) - 52px) !important;  /* compensate for mobile bar */
             }
+            .np-content { padding: 12px 16px 18px; gap: 10px; }
+            .np-art-wrap { width: min(68vw, 300px, 38vh); }
+            .np-title { font-size: 18px; }
         }
         .mobile-player-controls { display: none; }
         .mobile-seek-row { display: none; width: 100%; align-items: center; gap: 6px; padding: 2px 0 8px; }
@@ -629,42 +635,76 @@
         /* ── Drag‑style progress bar update (JS-driven CSS var) ─────────── */
         input[type=range] { cursor: pointer; }
 
-        /* ── Track Info Overlay ──────────────────────────────────────────── */
+        /* ── Overlay base (shared by the Now Playing overlay) ────────────── */
         .main-content { position: relative; }
-        .track-info-overlay {
+
+        /* ── Now Playing full-screen overlay ─────────────────────────────── */
+        .now-playing-overlay {
             position: absolute; inset: 0;
-            background: var(--bg); z-index: 80;
-            overflow-y: auto; display: flex; flex-direction: column;
-            height: calc(100vh - var(--player-h)); /* prevent overlay from covering player bar */
+            z-index: 85; overflow: hidden;
+            background: var(--bg);
+            height: calc(100vh - var(--player-h));
+        }
+        .now-playing-overlay .np-bg {
+            position: absolute; inset: 0; z-index: 0;
+            background-size: cover; background-position: center;
+            filter: blur(48px) brightness(.45) saturate(1.3);
+            transform: scale(1.35); opacity: .6; pointer-events: none;
+        }
+        .now-playing-overlay::before {
+            content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
+            background: linear-gradient(180deg, rgba(17,17,20,.45) 0%, rgba(17,17,20,.82) 100%);
+        }
+        .np-content {
+            position: relative; z-index: 2;
+            height: 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; }
+        .np-slide-right { animation: npSlideR .28s ease; }
+        @keyframes npSlideL { from { transform: translateX(60px); opacity: .25; } to { transform: translateX(0); opacity: 1; } }
+        @keyframes npSlideR { from { transform: translateX(-60px); opacity: .25; } to { transform: translateX(0); opacity: 1; } }
+        .np-header {
+            width: 100%; display: flex; align-items: center; justify-content: space-between;
+            flex-shrink: 0;
         }
-        .track-info-overlay::-webkit-scrollbar { width: 6px; }
-        .track-info-overlay::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
-        .track-info-header {
-            display: flex; align-items: center; gap: 12px;
-            padding: 14px 20px; border-bottom: 1px solid var(--border);
-            flex-shrink: 0; position: sticky; top: 0;
-            background: var(--bg); z-index: 1;
-        }
-        .track-info-header-title { font-size: 15px; font-weight: 700; color: var(--text); }
-        .track-info-body {
-            padding: 28px 24px 48px; display: flex;
-            flex-direction: column; align-items: center;
-            max-width: 520px; margin: 0 auto; width: 100%;
-        }
-        .track-info-cover-wrap {
-            width: min(240px, 70vw); height: min(240px, 70vw);
-            border-radius: 16px; overflow: hidden;
-            box-shadow: 0 8px 40px rgba(0,0,0,.55);
-            background: var(--bg3); margin-bottom: 22px; flex-shrink: 0;
-        }
-        .track-info-cover { width: 100%; height: 100%; object-fit: cover; display: block; }
-        .track-info-name {
-            font-size: 20px; font-weight: 800; color: var(--text);
-            text-align: center; margin-bottom: 4px; line-height: 1.3;
-        }
-        .track-info-artist {
-            font-size: 14px; color: var(--text2); text-align: center; margin-bottom: 22px;
+        .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;
+            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-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;
+        }
+        .np-art-loading i.icon { margin: 0; font-size: 34px; }
+        .np-title {
+            width: 100%; text-align: center; font-size: 20px; font-weight: 700; color: var(--text);
+            line-height: 1.3; max-height: 2.6em; overflow: hidden; flex-shrink: 0;
+        }
+        .np-artist { width: 100%; text-align: center; font-size: 14px; color: var(--text2); margin-top: -8px; flex-shrink: 0; }
+        .np-seek { width: 100%; max-width: 460px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
+        .np-seek .seek-bar-wrap { flex: 1; }
+        .np-controls {
+            width: 100%; max-width: 460px; display: flex; align-items: center; justify-content: center;
+            gap: 18px; margin-bottom: auto; flex-shrink: 0;
+        }
+        .np-controls .ctrl-btn { width: 46px; height: 46px; }
+        .np-controls .ctrl-btn i.icon { font-size: 18px; }
+        .np-controls .np-play { width: 60px; height: 60px; }
+        .np-controls .np-play i.icon { font-size: 24px; }
+        .np-hint { font-size: 11px; color: var(--text3); text-align: center; flex-shrink: 0; }
+        /* Docked song-info section at the bottom of the overlay */
+        .np-info { width: 100%; max-width: 460px; flex-shrink: 0; padding-bottom: 8px; }
+        .np-info .track-info-divider { margin: 6px 0 16px; }
+        /* When Now Playing is open, hide the duplicated transport/seek controls elsewhere */
+        .np-open .player-controls,
+        .np-open .mobile-seek-row,
+        .np-open .mobile-player-controls { display: none !important; }
         .track-info-meta {
             width: 100%; background: var(--bg2); border: 1px solid var(--border);
             border-radius: 10px; overflow: hidden; margin-bottom: 20px;
@@ -746,6 +786,41 @@
         .transcode-option-label { flex: 1; }
         .transcode-option-label strong { font-size: 13.5px; color: var(--text); font-weight: 600; }
         .transcode-option-label span { display: block; font-size: 11px; color: var(--text3); margin-top: 1px; }
+
+        /* ── Settings toggle switch ──────────────────────────────────────── */
+        .settings-toggle-row {
+            display: flex; align-items: center; justify-content: space-between;
+            padding: 10px 0; border-bottom: 1px solid var(--border);
+        }
+        .settings-toggle-row:last-child { border-bottom: none; padding-bottom: 0; }
+        .settings-toggle-label { font-size: 13.5px; color: var(--text); font-weight: 600; }
+        .settings-toggle-desc  { font-size: 11px; color: var(--text3); margin-top: 2px; }
+        .toggle-switch {
+            position: relative; width: 40px; height: 22px; flex-shrink: 0;
+        }
+        .toggle-switch input { opacity: 0; width: 0; height: 0; }
+        .toggle-slider {
+            position: absolute; inset: 0; background: var(--bg3);
+            border: 1px solid var(--border); border-radius: 22px;
+            cursor: pointer; transition: background .2s, border-color .2s;
+        }
+        .toggle-slider::before {
+            content: ''; position: absolute;
+            width: 16px; height: 16px; left: 2px; top: 2px;
+            background: var(--text3); border-radius: 50%;
+            transition: transform .2s, background .2s;
+        }
+        .toggle-switch input:checked + .toggle-slider { background: var(--accent); border-color: var(--accent); }
+        .toggle-switch input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
+
+        /* ── Full-buffer loading overlay on album art ────────────────────── */
+        .player-cover-loading {
+            position: absolute; inset: 0;
+            display: flex; align-items: center; justify-content: center;
+            background: rgba(0,0,0,.5); backdrop-filter: blur(1px);
+            color: #fff;
+        }
+        .player-cover-loading i.icon { margin: 0; font-size: 20px; }
     </style>
     <script>
         // ─── Global helpers (must be defined before Alpine evaluates templates) ───────
@@ -753,7 +828,7 @@
     </script>
 </head>
 <body>
-<div id="app" x-data="musicifyApp()" x-init="init()">
+<div id="app" x-data="musicifyApp()" x-init="init()" :class="{ 'np-open': showNowPlaying }">
 
     <!-- Hidden audio element -->
     <audio id="musicPlayer" preload="metadata"></audio>
@@ -1252,69 +1327,124 @@
                 </div>
             </div>
 
-            <!-- ── TRACK INFO OVERLAY ───────────────────────────────────────── -->
-            <div x-show="showTrackInfo && trackInfoSong" class="track-info-overlay">
-                <!-- Sticky back header -->
-                <div class="track-info-header">
-                    <button class="ctrl-btn" x-on:click="closeTrackInfo()" style="width:32px;height:32px;" title="Back">
-                        <i class="ui arrow left icon" style="margin:0;"></i>
-                    </button>
-                    <span class="track-info-header-title">Song Info</span>
-                </div>
-                <!-- Body -->
-                <div class="track-info-body">
+            <!-- ── NOW PLAYING OVERLAY ──────────────────────────────────────── -->
+            <div x-show="showNowPlaying" class="now-playing-overlay"
+                 x-on:touchstart="npTouchStart($event)"
+                 x-on:touchend="npTouchEnd($event)">
+                <!-- Blurred album art backdrop -->
+                <div class="np-bg"
+                     :style="currentTrack ? ('background-image:url(\'' + getCoverUrl(currentTrack) + '\')') : ''"></div>
+
+                <div class="np-content" :class="{'np-slide-left': _npSwipeDir==='left', 'np-slide-right': _npSwipeDir==='right'}">
+                    <!-- Header -->
+                    <div class="np-header">
+                        <button class="ctrl-btn" x-on:click="closeNowPlaying()" title="Close">
+                            <i class="ui chevron down icon" style="margin:0;"></i>
+                        </button>
+                        <span class="np-header-title">Now Playing</span>
+                        <button class="ctrl-btn" :class="{active: showTrackInfo}" x-on:click="toggleTrackInfo()" title="Song info">
+                            <i class="ui info circle icon" style="margin:0;"></i>
+                        </button>
+                    </div>
+
                     <!-- Large cover art -->
-                    <div class="track-info-cover-wrap">
-                        <img class="track-info-cover"
-                             :src="trackInfoSong ? getCoverUrl(trackInfoSong) : 'img/placeholder.png'"
+                    <div class="np-art-wrap">
+                        <img class="np-art"
+                             :src="currentTrack ? getCoverUrl(currentTrack) : 'img/placeholder.png'"
                              x-on:error="$event.target.src='img/placeholder.png'; $event.target.onerror=null"
                              alt="Cover">
-                    </div>
-                    <!-- Song title & artist -->
-                    <div class="track-info-name"   x-text="trackInfoSong ? trackInfoSong.name : ''"></div>
-                    <div class="track-info-artist" x-text="trackInfoSong ? getArtistLabel(trackInfoSong) : ''"></div>
-                    <!-- Metadata table -->
-                    <div class="track-info-meta">
-                        <div class="track-meta-row">
-                            <span class="track-meta-label">Format</span>
-                            <span class="track-meta-value" x-text="trackInfoSong && trackInfoSong.ext ? trackInfoSong.ext.toUpperCase() : '—'"></span>
-                        </div>
-                        <div class="track-meta-row">
-                            <span class="track-meta-label">Size</span>
-                            <span class="track-meta-value" x-text="trackInfoSong ? trackInfoSong.hsize : '—'"></span>
+                        <div class="np-art-loading" x-show="_fullBufferLoading">
+                            <i class="ui spinner loading icon"></i>
                         </div>
-                        <div class="track-meta-row">
-                            <span class="track-meta-label">Path</span>
-                            <span class="track-meta-value track-meta-path" x-text="trackInfoSong ? trackInfoSong.filepath : ''"></span>
+                    </div>
+
+                    <!-- Title + artist -->
+                    <div class="np-title"  x-text="currentTrack ? currentTrack.name : ''"></div>
+                    <div class="np-artist" x-text="currentTrack ? getArtistLabel(currentTrack) : ''"></div>
+
+                    <!-- Seek bar -->
+                    <div class="np-seek">
+                        <span class="seek-time" x-text="formatTime(currentTime)"></span>
+                        <div class="seek-bar-wrap">
+                            <input type="range" class="seek-bar" min="0" :max="Math.floor(duration) || 100"
+                                :value="Math.floor(currentTime)"
+                                :style="'--progress:' + progressPercent() + '%'"
+                                x-on:mousedown="beginSeek()"
+                                x-on:touchstart.stop="beginSeek()"
+                                x-on:touchmove.stop="currentTime = parseFloat($event.target.value)"
+                                x-on:touchend.stop="endSeek($event.target.value)"
+                                x-on:touchcancel.stop="endSeek($event.target.value)"
+                                x-on:change="endSeek($event.target.value)"
+                                x-on:input="currentTime = parseFloat($event.target.value)">
                         </div>
+                        <span class="seek-time right" x-text="(_currentTrackTranscoded && !duration) ? '--:--' : formatTime(duration)"></span>
                     </div>
-                    <!-- Divider -->
-                    <div class="track-info-divider"></div>
-                    <!-- Action buttons -->
-                    <div class="track-info-actions">
-                        <button class="track-action-btn" x-on:click="downloadSong(trackInfoSong)">
-                            <i class="ui download icon"></i>
-                            <span>Download to Local Device</span>
+
+                    <!-- Transport controls -->
+                    <div class="np-controls">
+                        <button class="ctrl-btn" :class="{active: shuffle}" x-on:click="toggleShuffle()" title="Shuffle">
+                            <i class="ui random icon" style="margin:0;"></i>
                         </button>
-                        <button class="track-action-btn" x-on:click="searchOnYoutube(trackInfoSong)">
-                            <i class="ui youtube icon"></i>
-                            <span>Search on YouTube</span>
+                        <button class="ctrl-btn" x-on:click="prevTrack()" title="Previous">
+                            <i class="ui step backward icon" style="margin:0;"></i>
                         </button>
-                        <button class="track-action-btn" x-on:click="copyTrackTitle(trackInfoSong)">
-                            <i class="ui copy outline icon"></i>
-                            <span>Copy Song Title</span>
+                        <button class="ctrl-btn play-btn np-play" x-on:click="togglePlay()" :title="isPlaying ? 'Pause' : 'Play'">
+                            <i :class="isPlaying ? 'pause' : 'play'" class="ui icon" style="margin:0;"></i>
                         </button>
-                        <button class="track-action-btn" x-on:click="openInFileManager(trackInfoSong)">
-                            <i class="ui folder open icon"></i>
-                            <span>Open in File Manager</span>
+                        <button class="ctrl-btn" x-on:click="nextTrack()" title="Next">
+                            <i class="ui step forward icon" style="margin:0;"></i>
                         </button>
-                        <button id="open-in-embedded" class="track-action-btn" x-on:click="openInEmbedded(trackInfoSong)">
-                            <i class="ui compress alternate icon"></i>
-                            <span>Open in Player View</span>
+                        <button class="ctrl-btn" :class="{active: repeat !== 'none'}" x-on:click="cycleRepeat()" :title="repeatTitle()">
+                            <i :class="repeatIcon()" class="ui icon" style="margin:0;"></i>
+                            <span x-show="repeat==='one'" style="position:absolute;font-size:9px;font-weight:900;line-height:1;bottom:3px;right:3px;color:var(--accent);">1</span>
                         </button>
                     </div>
+
+                    <div class="np-hint">Swipe left / right to change track</div>
+
+                    <!-- Song info (revealed by the info button; scrolls into view) -->
+                    <div class="np-info" x-show="showTrackInfo">
+                        <div class="track-info-divider"></div>
+                        <!-- Metadata table -->
+                        <div class="track-info-meta">
+                            <div class="track-meta-row">
+                                <span class="track-meta-label">Format</span>
+                                <span class="track-meta-value" x-text="trackInfoSong && trackInfoSong.ext ? trackInfoSong.ext.toUpperCase() : '—'"></span>
+                            </div>
+                            <div class="track-meta-row">
+                                <span class="track-meta-label">Size</span>
+                                <span class="track-meta-value" x-text="trackInfoSong ? trackInfoSong.hsize : '—'"></span>
+                            </div>
+                            <div class="track-meta-row">
+                                <span class="track-meta-label">Path</span>
+                                <span class="track-meta-value track-meta-path" x-text="trackInfoSong ? trackInfoSong.filepath : ''"></span>
+                            </div>
+                        </div>
+                        <!-- Action buttons -->
+                        <div class="track-info-actions">
+                            <button class="track-action-btn" x-on:click="downloadSong(trackInfoSong)">
+                                <i class="ui download icon"></i>
+                                <span>Download to Local Device</span>
+                            </button>
+                            <button class="track-action-btn" x-on:click="searchOnYoutube(trackInfoSong)">
+                                <i class="ui youtube icon"></i>
+                                <span>Search on YouTube</span>
+                            </button>
+                            <button class="track-action-btn" x-on:click="copyTrackTitle(trackInfoSong)">
+                                <i class="ui copy outline icon"></i>
+                                <span>Copy Song Title</span>
+                            </button>
+                            <button class="track-action-btn" x-on:click="openInFileManager(trackInfoSong)">
+                                <i class="ui folder open icon"></i>
+                                <span>Open in File Manager</span>
+                            </button>
+                            <button id="open-in-embedded" class="track-action-btn" x-on:click="openInEmbedded(trackInfoSong)">
+                                <i class="ui compress alternate icon"></i>
+                                <span>Open in Player View</span>
+                            </button>
+                        </div>
+                    </div>
                 </div>
-                <br><br><br>
             </div>
 
             <!-- ── SETTINGS ──────────────────────────────────────────────────── -->
@@ -1369,6 +1499,30 @@
                         </div>
                     </div>
 
+                    <!-- Full Buffer Mode subsection -->
+                    <div class="settings-section">
+                        <div class="settings-section-title">Full Buffer Mode</div>
+                        <div class="settings-section-desc">
+                            Buffer the entire track on the server before playback begins. This prevents iOS from resetting the audio stream mid-track, at the cost of a short wait before the song starts. Enabled automatically on iPhone &amp; iPad.
+                        </div>
+                        <div class="settings-toggle-row">
+                            <div>
+                                <div class="settings-toggle-label">Enable Full Buffer Mode</div>
+                                <div class="settings-toggle-desc" x-text="fullBufferMode ? 'Active – tracks will be fully transcoded before playback' : 'Inactive – using streaming transcode'"></div>
+                            </div>
+                            <label class="toggle-switch">
+                                <input type="checkbox" x-model="fullBufferMode" x-on:change="saveFullBufferMode()">
+                                <span class="toggle-slider"></span>
+                            </label>
+                        </div>
+                        <div style="margin-top:12px;padding:10px 12px;background:var(--bg3);border-radius:7px;border:1px solid var(--border);">
+                            <span style="font-size:12px;color:var(--text3);">
+                                <i class="ui info circle icon" style="color:var(--accent);"></i>
+                                Only applies when Transcode is enabled. The buffered file is cached for 24 hours so repeated listens are instant.
+                            </span>
+                        </div>
+                    </div>
+
                 </div>
             </div>
 
@@ -1411,15 +1565,20 @@
         <!-- Left: Track info -->
         <div class="player-track">
             <div class="player-cover"
-                 x-on:click="currentTrack && openTrackInfo(currentTrack, $event)"
-                 :style="currentTrack ? 'cursor:pointer;' : ''">
+                 x-on:click="currentTrack && toggleNowPlaying()"
+                 :style="currentTrack ? 'cursor:pointer;' : ''" title="Now Playing">
                 <img :src="currentTrack ? getCoverUrl(currentTrack) : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='"
                      x-on:error="handleCoverError($event)" alt="Cover">
+                <div class="player-cover-loading" x-show="_fullBufferLoading" title="Buffering…">
+                    <i class="ui spinner loading icon"></i>
+                </div>
             </div>
             <div class="player-track-info" x-show="currentTrack"
-                 x-on:click="openTrackInfo(currentTrack, $event)"
-                 style="cursor:pointer;" title="Song info">
-                <div class="player-track-name"   x-text="currentTrack ? currentTrack.name : ''"></div>
+                 x-on:click="openNowPlaying()"
+                 style="cursor:pointer;" title="Now Playing">
+                <div class="player-track-name">
+                    <span x-text="currentTrack ? currentTrack.name : ''"></span>
+                </div>
                 <div class="player-track-artist" x-text="currentTrack ? getArtistLabel(currentTrack) : ''"></div>
             </div>
             <div x-show="!currentTrack" style="color:var(--text3);font-size:13px;">No track playing</div>

+ 241 - 63
src/web/Musicify/musicify.js

@@ -95,6 +95,13 @@ function musicifyApp() {
         showTrackInfo: false,
         trackInfoSong: null,
 
+        // ── Now Playing full-screen overlay ──────────────────────────────────
+        showNowPlaying: false,
+        _npTouchStartX: 0,
+        _npTouchStartY: 0,
+        _npSwiping: false,
+        _npSwipeDir: '',        // 'left' | 'right' | '' — drives the slide animation
+
         // ── Internal playback guard ──────────────────────────────────────────
         _suppressEnded: false,  // true while a new track is loading (prevents double-skip)
 
@@ -107,6 +114,10 @@ function musicifyApp() {
         _currentTrackTranscoded: false,// true when current track is served via transcode endpoint
         _transcodeEndFallbackTimer: null, // guards against 'ended' not firing on Safari
 
+        // ── Full Buffer Mode ─────────────────────────────────────────────────
+        fullBufferMode: false,         // true = buffer entire track before playback (iOS default)
+        _fullBufferLoading: false,     // true while waiting for server-side buffer to finish
+
         // ── Arozcast ─────────────────────────────────────────────────────────
         castMode: false,
         castConnected: false,
@@ -181,7 +192,7 @@ 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._updateMediaSession(); });
+            this._audio.addEventListener('play',  () => { self.isPlaying = true; self._suppressEnded = false; self._fullBufferLoading = false; self._updateMediaSession(); });
             this._audio.addEventListener('pause', () => { self.isPlaying = false; self._updateMediaSession(); });
 
             // Restore volume
@@ -211,6 +222,15 @@ function musicifyApp() {
                 this.transcodeMode = _savedTranscode;
             }
 
+            // Full Buffer Mode: auto-enable on iOS, otherwise restore from localStorage
+            var _savedFBM = localStorage.getItem('musicify_fullBufferMode');
+            if (_savedFBM !== null) {
+                this.fullBufferMode = (_savedFBM === 'true');
+            } else {
+                // Auto-detect iOS and enable by default
+                this.fullBufferMode = this._isIOS();
+            }
+
             // MediaSession
             this._setupMediaSession();
 
@@ -256,6 +276,11 @@ function musicifyApp() {
                 }
             });
 
+            // Android player-bar class
+            if (this._isAndroid()) {
+                document.querySelector('.player-bar').classList.add('android');
+            }
+
             // Responsive sidebar
             this.sidebarOpen = window.innerWidth > 768;
             var resizeT;
@@ -285,6 +310,9 @@ function musicifyApp() {
             } else if (v === 'recent' && this.recentSongs.length === 0) {
                 this._loadRecent();
             }
+
+            //Close playing overlay if open
+            if (this.showNowPlaying) this.closeNowPlaying();
         },
 
         openPlaylistView(name) {
@@ -855,6 +883,8 @@ function musicifyApp() {
             this.queueIndex = startIndex;
             if (this.shuffle) this._buildShuffledQueue(startIndex);
             this._loadTrack(this._effectiveQueue()[this._effectiveIndex(startIndex)]);
+            // Starting playback brings up the full-screen Now Playing view
+            this.openNowPlaying();
         },
 
         playSong(song, sourceList, event) {
@@ -938,19 +968,12 @@ function musicifyApp() {
                 this._audio.pause();
                 this.isPlaying = true;
             } else {
-                this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(song));
-                this._audio.src = this._getAudioSrc(song);
-                this._audio.load();
-                this._audio.play().catch(() => {});
-                if (this._currentTrackTranscoded) {
-                    var _prefetchSong = song;
-                    fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(song.filepath))
-                        .then(r => r.json())
-                        .then(data => {
-                            if (data.duration > 0 && this.currentTrack && this.currentTrack.filepath === _prefetchSong.filepath) {
-                                this.duration = data.duration;
-                            }
-                        }).catch(() => {});
+                var willTranscode = (this.transcodeMode !== 'disabled' && this._needsTranscode(song));
+                this._currentTrackTranscoded = willTranscode;
+                if (willTranscode && this.fullBufferMode) {
+                    this._playViaFullBuffer(song, 0, true);
+                } else {
+                    this._playViaStream(song, 0, true);
                 }
             }
             this._saveRecentlyPlayed(song);
@@ -1084,6 +1107,14 @@ function musicifyApp() {
             return nonNative.indexOf(song.ext.toLowerCase()) !== -1;
         },
 
+        _isIOS() {
+            return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+        },
+
+        _isAndroid() {
+            return /Android/.test(navigator.userAgent);
+        },
+
         // 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) {
@@ -1097,11 +1128,128 @@ function musicifyApp() {
             return ao_root + 'media?file=' + encodeURIComponent(song.filepath);
         },
 
+        // Full Buffer Mode: ask the server to transcode the WHOLE track to a static
+        // MP3 first, then play that completed file. A complete file (served via /media
+        // with byte-range support) behaves like a native source, so iOS can seek it and
+        // never resets the stream to t=0 mid-playback.
+        //   resumeAt   – seconds to seek to once loaded (0 = from start)
+        //   wasPlaying – auto-play after the source is ready
+        _playViaFullBuffer(song, resumeAt, wasPlaying) {
+            var self = this;
+            var _bufSong = song;
+            resumeAt = resumeAt || 0;
+            this._fullBufferLoading = true;
+            if (MUSICIFY_DEBUG) console.log('[Musicify FBM] requesting server buffer:', song.filepath, '@', this.transcodeMode + 'kHz', 'resumeAt:', resumeAt);
+            fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/fullbuffer.js', {
+                method: 'POST', cache: 'no-cache',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ file: song.filepath, samplerate: self.transcodeMode })
+            }).then(r => r.json()).then(data => {
+                self._fullBufferLoading = false;
+                // Abort if the user already switched to a different track
+                if (!self.currentTrack || self.currentTrack.filepath !== _bufSong.filepath) return;
+                if (data.error || !data.path) {
+                    if (MUSICIFY_DEBUG) console.warn('[Musicify FBM] server buffer failed:', data.error || '(no path returned)');
+                    self._showToast('Full buffer failed – streaming instead', 'error');
+                    self._playViaStream(_bufSong, resumeAt, wasPlaying);
+                    return;
+                }
+                if (MUSICIFY_DEBUG) console.log('[Musicify FBM] buffered file ready:', data.path);
+                // Completed static MP3 — behaves like a native file (iOS-safe, seekable)
+                self._currentTrackTranscoded = false;
+                self._transcodeSeekOffset = 0;
+                self.duration = 0;
+                self._audio.src = ao_root + 'media?file=' + encodeURIComponent(data.path);
+                self._audio.load();
+                if (resumeAt > 0) {
+                    self._audio.addEventListener('loadedmetadata', function() {
+                        try { self._audio.currentTime = resumeAt; } catch (e) {}
+                    }, { once: true });
+                }
+                if (wasPlaying) {
+                    // On iOS the audio element must already be unlocked by a prior
+                    // gesture-initiated play; if not, play() rejects and we reflect the
+                    // paused state so the user can tap play.
+                    self._audio.play().catch(function() { self.isPlaying = false; });
+                }
+            }).catch(() => {
+                self._fullBufferLoading = false;
+                if (!self.currentTrack || self.currentTrack.filepath !== _bufSong.filepath) return;
+                if (MUSICIFY_DEBUG) console.warn('[Musicify FBM] network error contacting fullbuffer.js');
+                self._showToast('Full buffer error – streaming instead', 'error');
+                self._playViaStream(_bufSong, resumeAt, wasPlaying);
+            });
+        },
+
+        // Stream a track through the realtime transcode endpoint (or directly via /media
+        // for web-native formats). Used for non-FBM playback and as the FBM fallback.
+        //   resumeAt   – seconds to seek to once loaded (0 = from start)
+        //   wasPlaying – auto-play after the source is ready (default true)
+        _playViaStream(song, resumeAt, wasPlaying) {
+            var self = this;
+            resumeAt = resumeAt || 0;
+            if (wasPlaying === undefined) wasPlaying = true;
+            this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(song));
+            this._transcodeSeekOffset = 0;
+            this.duration = 0;
+            if (this._currentTrackTranscoded && resumeAt > 0.001) {
+                this._transcodeSeekOffset = resumeAt;
+                this._audio.src = this._getAudioSrc(song, resumeAt);
+            } else {
+                this._audio.src = this._getAudioSrc(song);
+            }
+            this._audio.load();
+            if (this._currentTrackTranscoded) {
+                // Transcoded streams have no Content-Length — pre-fetch duration for the seek bar
+                var _song = song;
+                fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(_song.filepath))
+                    .then(r => r.json())
+                    .then(data => {
+                        if (data.duration > 0 && self.currentTrack && self.currentTrack.filepath === _song.filepath) {
+                            self.duration = data.duration;
+                        }
+                    }).catch(() => {});
+            } else if (resumeAt > 0) {
+                // Native source: seek once metadata is ready
+                this._audio.addEventListener('loadedmetadata', function() {
+                    try { self._audio.currentTime = resumeAt; } catch (e) {}
+                }, { once: true });
+            }
+            if (wasPlaying) {
+                this._audio.play().catch(() => {});
+            }
+        },
+
+        saveFullBufferMode() {
+            localStorage.setItem('musicify_fullBufferMode', String(this.fullBufferMode));
+            this._showToast('Full Buffer Mode: ' + (this.fullBufferMode ? 'enabled' : 'disabled'));
+
+            // Reload the current track immediately so the change takes effect now
+            // (not just on the next track). Preserves the current playback position.
+            if (this.currentTrack && this._needsTranscode(this.currentTrack) &&
+                    this.transcodeMode !== 'disabled' && !this.castMode) {
+                if (this._transcodeEndFallbackTimer) {
+                    clearTimeout(this._transcodeEndFallbackTimer);
+                    this._transcodeEndFallbackTimer = null;
+                }
+                var resumeAt = this.currentTime;
+                var wasPlaying = this.isPlaying;
+                this._suppressEnded = true;
+                if (MUSICIFY_DEBUG) console.log('[Musicify FBM] toggle ->', this.fullBufferMode ? 'buffer' : 'stream', 'reloading at', resumeAt);
+                if (this.fullBufferMode) {
+                    this._playViaFullBuffer(this.currentTrack, resumeAt, wasPlaying);
+                } else {
+                    this._playViaStream(this.currentTrack, resumeAt, wasPlaying);
+                }
+            }
+        },
+
         saveTranscodeMode() {
             localStorage.setItem('musicify_transcodeMode', this.transcodeMode);
 
             // If a non-native track is currently loaded, reload it immediately at the
-            // current position so seeks work correctly under the new mode.
+            // current position so the new sample rate (and seeking) take effect now.
+            // Honour Full Buffer Mode: re-buffer at the new rate when it is enabled.
             if (this.currentTrack && this._needsTranscode(this.currentTrack) && !this.castMode) {
                 if (this._transcodeEndFallbackTimer) {
                     clearTimeout(this._transcodeEndFallbackTimer);
@@ -1109,42 +1257,11 @@ function musicifyApp() {
                 }
                 var resumeAt = this.currentTime; // already includes _transcodeSeekOffset
                 var wasPlaying = this.isPlaying;
-                var willTranscode = (this.transcodeMode !== 'disabled');
-
                 this._suppressEnded = true;
-                this._transcodeSeekOffset = 0;
-                this._currentTrackTranscoded = willTranscode;
-                this.duration = 0;
-
-                if (willTranscode && resumeAt > 0.001) {
-                    // Transcoded seek: bake the position into the stream URL
-                    this._transcodeSeekOffset = resumeAt;
-                    this._audio.src = this._getAudioSrc(this.currentTrack, resumeAt);
+                if (this.transcodeMode !== 'disabled' && this.fullBufferMode) {
+                    this._playViaFullBuffer(this.currentTrack, resumeAt, wasPlaying);
                 } else {
-                    this._audio.src = this._getAudioSrc(this.currentTrack);
-                }
-                this._audio.load();
-
-                if (willTranscode) {
-                    // Re-fetch duration for the transcoded stream
-                    var _song = this.currentTrack;
-                    fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(_song.filepath))
-                        .then(r => r.json())
-                        .then(data => {
-                            if (data.duration > 0 && this.currentTrack && this.currentTrack.filepath === _song.filepath) {
-                                this.duration = data.duration;
-                            }
-                        }).catch(() => {});
-                } else if (resumeAt > 0) {
-                    // Native audio: seek to position after metadata is ready
-                    var self = this;
-                    this._audio.addEventListener('loadedmetadata', function() {
-                        self._audio.currentTime = resumeAt;
-                    }, { once: true });
-                }
-
-                if (wasPlaying) {
-                    this._audio.play().catch(() => {});
+                    this._playViaStream(this.currentTrack, resumeAt, wasPlaying);
                 }
             }
 
@@ -1345,31 +1462,92 @@ function musicifyApp() {
         // ════════════════════════════════════════════════════════════════════
         //  TRACK INFO PANEL
         // ════════════════════════════════════════════════════════════════════
-        openTrackInfo(song, event) {
-            if (event) event.stopPropagation();
-            if (!song) return;
+        // Toggle the song-info section docked at the bottom of the Now Playing
+        // overlay. Revealing it scrolls the overlay down to bring it into view.
+        toggleTrackInfo() {
+            if (!this.currentTrack) return;
+            this.trackInfoSong = this.currentTrack;
+            this.showTrackInfo = !this.showTrackInfo;
+            if (this.showTrackInfo) {
+                if (!ao_module_virtualDesktop){
+                    // Not in webdesktop mode, so "Open in Player View" doesn't make sense – hide it
+                    $("#open-in-embedded").hide();
+                }else{
+                    $("#open-in-embedded").show();
+                }
+                this.$nextTick(() => {
+                    var c = document.querySelector('.now-playing-overlay .np-content');
+                    if (c) c.scrollTo({ top: c.scrollHeight, behavior: 'smooth' });
+                });
+            }
+        },
+
+        // ════════════════════════════════════════════════════════════════════
+        //  NOW PLAYING FULL-SCREEN OVERLAY
+        // ════════════════════════════════════════════════════════════════════
+        openNowPlaying() {
+            if (!this.currentTrack) return;
             var mc = document.getElementById('mainContent');
-            // Pin overlay to the current visible top before Alpine shows it
-            var overlay = mc ? mc.querySelector('.track-info-overlay') : null;
+            // Pin the overlay to the current scroll position before Alpine shows it,
+            // then freeze scrolling underneath.
+            var overlay = mc ? mc.querySelector('.now-playing-overlay') : null;
             if (overlay) overlay.style.top = (mc.scrollTop) + 'px';
             if (mc) mc.style.overflow = 'hidden';
-            this.trackInfoSong = song;
-            this.showTrackInfo = true;
-            if (!ao_module_virtualDesktop){
-                // Not in webdesktop mode, so "Open in Embedded Player" option doesn't make sense – hide it
-                $("#open-in-embedded").hide();
-            }else{
-                $("#open-in-embedded").show();
+            this.showTrackInfo = false;   // start collapsed
+            this.trackInfoSong = this.currentTrack;
+            this.showNowPlaying = true;
+            this.$nextTick(() => {
+                var c = overlay ? overlay.querySelector('.np-content') : null;
+                if (c) c.scrollTop = 0;
+                if (overlay) overlay.style.top = (mc.scrollTop) + 'px'; // readjust after content is revealed
+            });
+        },
+
+        toggleNowPlaying() {
+            if (this.showNowPlaying) {
+                this.closeNowPlaying();
+            } else {
+                this.openNowPlaying();
             }
         },
 
-        closeTrackInfo() {
+        closeNowPlaying() {
+            this.showNowPlaying = false;
             this.showTrackInfo = false;
-            this.trackInfoSong = null;
+            this._npSwipeDir = '';
             var mc = document.getElementById('mainContent');
             if (mc) mc.style.overflow = '';
         },
 
+        npTouchStart(e) {
+            var t = e.changedTouches ? e.changedTouches[0] : e;
+            this._npTouchStartX = t.clientX;
+            this._npTouchStartY = t.clientY;
+            this._npSwiping = true;
+        },
+
+        npTouchEnd(e) {
+            if (!this._npSwiping) return;
+            this._npSwiping = false;
+            var t = e.changedTouches ? e.changedTouches[0] : e;
+            var dx = t.clientX - this._npTouchStartX;
+            var dy = t.clientY - this._npTouchStartY;
+            // Horizontal swipe → change track; vertical swipe down → close
+            if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) {
+                if (dx < 0) {
+                    this._npSwipeDir = 'left';
+                    this.nextTrack();
+                } else {
+                    this._npSwipeDir = 'right';
+                    this.prevTrack();
+                }
+                var self = this;
+                setTimeout(function() { self._npSwipeDir = ''; }, 280);
+            } else if (dy > 90 && Math.abs(dy) > Math.abs(dx)) {
+                //this.closeNowPlaying();
+            }
+        },
+
         copyTrackTitle(song) {
             if (!song) return;
             var text = song.name;