2 Commits 38e2b6eeda ... f14a9ab3ad

Autor SHA1 Mensagem Data
  Toby Chui f14a9ab3ad Merge branch 'v3.0.4' of https://github.com/tobychui/arozos into v3.0.4 há 1 dia atrás
  Toby Chui da706a2496 Add Musicify equalizer and float window min size há 1 dia atrás

+ 5 - 0
examples/README.md

@@ -206,6 +206,7 @@ These functions are in global scope with your JavaScript. These are use to inter
 | ao_module_setFixedWindowSize()            | Set the float window to non-resizable                        |                                                    |
 | ao_module_setResizableWindowSize()        | Set the float window to be resizable                         |                                                    |
 | ao_module_setWindowSize(width, height)    | Change the size of the float window                          | ao_module_setWindowSize(480, 240)                  |
+| ao_module_setWindowMinSize(width, height) | Set the smallest size the user can resize the float window to (0 = default) | ao_module_setWindowMinSize(360, 480)               |
 | ao_module_setWindowTitle(newTitle)        | Update the title of the float window                         | ao_module_setWindowTitle("Loading File")           |
 | ao_module_makeSingleInstance()            | Load the current path into another instance's windows if exists |                                                    |
 | ao_module_getInstanceByPath(matchingPath) | Get other float window instance that have the same starting path | ao_module_getInstanceByPath("NotepadA/index.html") |
@@ -238,11 +239,15 @@ ao_module_newfw({
     title: "Dummy Module",
     left: 100,
     top: 100,
+    minWidth: 400,      //optional, smallest width the user can resize to
+    minHeight: 300,     //optional, smallest height the user can resize to
     parent: ao_module_windowID,
     callback: "childCallbackHandler"
 });
 ```
 
+`minWidth` / `minHeight` are optional; without them a window keeps the default minimum. A window can also set its own minimum at runtime with `ao_module_setWindowMinSize(width, height)`.
+
 ### Handling Embedded Mode Input File / File Open
 
 You can check if ArozOS File Manager is passing in any file for you to open using ```ao_module_loadInputFiles``` function. This function will return null if there are no files being passed in or return an array if there are any.

+ 1 - 0
src/web/Drive/js/apphost.js

@@ -410,6 +410,7 @@
             }
         };
         global.setFloatWindowSize = function () { };
+        global.setFloatWindowMinSize = function () { };
         global.setFloatWindowResizePolicy = function () { };
         global.setFloatWindowMinimizePolicy = function () { };
         global.setFloatWindowTheme = function () { };

+ 269 - 125
src/web/Musicify/index.html

@@ -183,9 +183,15 @@
         #app.mobile-landscape .sidebar-overlay { display: block; }
 
         /* ── Main Content ────────────────────────────────────────────────── */
+        /* Positioning box for the Now Playing overlay: it does not scroll, so
+           the overlay never depends on the content's scroll position */
+        .main-wrap {
+            flex: 1; min-width: 0; min-height: 0; position: relative;
+            display: flex; flex-direction: column;
+        }
         .main-content {
             flex: 1; overflow-y: auto; background: var(--bg);
-            min-width: 0; display: flex; flex-direction: column;
+            min-width: 0; min-height: 0; display: flex; flex-direction: column;
         }
         .main-content::-webkit-scrollbar { width: 6px; }
         .main-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
@@ -544,6 +550,9 @@
         .player-extras {
             display: flex; align-items: center; gap: 4px;
             width: 220px; justify-content: flex-end; flex-shrink: 0;
+            /* Keeps the group at the right edge when Now Playing hides the
+               centre controls (no effect while they fill the middle) */
+            margin-left: auto;
         }
         .sleep-indicator {
             font-size: 11px; color: var(--accent); background: var(--accent-glow);
@@ -566,9 +575,6 @@
                 display: flex !important; align-items: center; gap: 4px;
             }
             .content-body { padding: 14px 12px 12px}
-            .now-playing-overlay {
-                height: calc(100vh - var(--player-h) - 52px) !important;  /* compensate for mobile bar */
-            }
         }
         .mobile-player-controls { display: none; }
         .mobile-seek-row { display: none; width: 100%; align-items: center; gap: 6px; padding: 2px 0 8px; }
@@ -679,9 +685,6 @@
         /* ── Drag‑style progress bar update (JS-driven CSS var) ─────────── */
         input[type=range] { cursor: pointer; }
 
-        /* ── Overlay base (shared by the Now Playing overlay) ────────────── */
-        .main-content { position: relative; }
-
         /* ── Now Playing full-screen overlay ─────────────────────────────── */
         /* The centre of the overlay follows the Drive music player: a dark
            stage under the blurred cover art, thin stroked icons, a white play
@@ -700,7 +703,6 @@
             z-index: 85; overflow: hidden; overflow: clip;
             background: var(--np-stage);
             color: var(--np-text);
-            height: calc(100vh - var(--player-h));
         }
         .now-playing-overlay .np-bg {
             position: absolute; inset: -60px; z-index: 0;
@@ -999,6 +1001,62 @@
         .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; }
 
+        /* ── Equalizer View ──────────────────────────────────────────────── */
+        .nav-item svg.nav-svg { width: 16px; height: 16px; flex-shrink: 0; }
+        .eq-presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
+        .eq-preset {
+            padding: 5px 12px; border-radius: 14px; cursor: pointer;
+            border: 1px solid var(--border); background: var(--bg3);
+            color: var(--text2); font-size: 12px; font-weight: 600;
+            font-family: inherit; transition: border-color .15s, background .15s, color .15s;
+        }
+        .eq-preset:hover { border-color: var(--accent); color: var(--text); }
+        .eq-preset.selected { border-color: var(--accent); background: var(--active-bg); color: var(--accent); }
+        .eq-preset.custom { cursor: default; }
+        .eq-curve {
+            display: block; width: 100%; height: 90px; margin-bottom: 14px;
+            background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
+        }
+        .eq-curve .eq-grid { stroke: var(--border); stroke-width: 1; }
+        .eq-curve .eq-zero { stroke: var(--text3); stroke-width: 1; stroke-dasharray: 3 3; }
+        .eq-curve .eq-line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; }
+        .eq-curve .eq-fill { fill: var(--accent-glow); stroke: none; }
+        .eq-board { display: flex; align-items: stretch; gap: 4px; overflow-x: auto; padding-bottom: 4px; }
+        .eq-board.off { opacity: .5; }
+        .eq-band {
+            flex: 1; min-width: 38px; display: flex; flex-direction: column;
+            align-items: center; gap: 8px;
+        }
+        .eq-band.preamp { border-right: 1px solid var(--border); padding-right: 8px; margin-right: 6px; }
+        .eq-db   { font-size: 11px; color: var(--text2); font-variant-numeric: tabular-nums; min-height: 14px; }
+        .eq-freq { font-size: 11px; color: var(--text3); font-weight: 600; }
+        .eq-slider {
+            -webkit-appearance: none; appearance: none;
+            writing-mode: vertical-lr; direction: rtl;
+            width: 4px; height: 150px; margin: 0; padding: 0;
+            border-radius: 2px; outline: none; cursor: pointer;
+            background: linear-gradient(to top, var(--border) 0%, var(--border) var(--lo, 50%), var(--accent) var(--lo, 50%), var(--accent) var(--hi, 50%), var(--border) var(--hi, 50%), var(--border) 100%);
+        }
+        .eq-slider::-webkit-slider-thumb {
+            -webkit-appearance: none; width: 14px; height: 14px;
+            border-radius: 50%; background: var(--accent); cursor: pointer;
+            transition: transform .1s;
+        }
+        .eq-slider::-moz-range-thumb {
+            width: 14px; height: 14px; border: none;
+            border-radius: 50%; background: var(--accent); cursor: pointer;
+        }
+        .eq-slider:hover::-webkit-slider-thumb { transform: scale(1.2); }
+        .eq-actions { display: flex; justify-content: flex-end; margin-top: 14px; }
+        .eq-reset {
+            display: inline-flex; align-items: center; gap: 6px;
+            padding: 6px 14px; border-radius: 7px; cursor: pointer;
+            border: 1px solid var(--border); background: var(--bg3);
+            color: var(--text2); font-size: 12.5px; font-family: inherit;
+        }
+        .eq-reset:hover { border-color: var(--accent); color: var(--text); }
+        .eq-reset i.icon { margin: 0; }
+
         /* ── Full-buffer loading overlay on album art ────────────────────── */
         .player-cover-loading {
             position: absolute; inset: 0;
@@ -1105,6 +1163,15 @@
                 </div>
 
                 <div class="sidebar-section-label" style="margin-top:8px;">App</div>
+                <a class="nav-item" :class="{active: view==='equalizer'}" x-show="eqSupported" x-on:click="navigateTo('equalizer')">
+                    <svg class="nav-svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
+                        <path d="M3 2v12M8 2v12M13 2v12"/>
+                        <rect x="1.2" y="8.2" width="3.6" height="2.6" rx="1" fill="currentColor"/>
+                        <rect x="6.2" y="3.7" width="3.6" height="2.6" rx="1" fill="currentColor"/>
+                        <rect x="11.2" y="10.2" width="3.6" height="2.6" rx="1" fill="currentColor"/>
+                    </svg>
+                    Equalizer
+                </a>
                 <a class="nav-item" :class="{active: view==='settings'}" x-on:click="navigateTo('settings')">
                     <i class="ui sliders horizontal icon"></i> Settings
                 </a>
@@ -1112,6 +1179,9 @@
         </nav>
 
         <!-- ═══ MAIN CONTENT ═══════════════════════════════════════════════ -->
+        <!-- The Now Playing overlay sits beside the scrolling area, not in it,
+             so it always covers exactly the visible main area -->
+        <div class="main-wrap">
         <main class="main-content" id="mainContent">
 
             <!-- Loading overlay -->
@@ -1549,133 +1619,76 @@
                 </div>
             </div>
 
-            <!-- ── NOW PLAYING OVERLAY ──────────────────────────────────────── -->
-            <div x-show="showNowPlaying" class="now-playing-overlay" :class="{'np-noart': npNoArt}"
-                 x-on:touchstart="npTouchStart($event)"
-                 x-on:touchend="npTouchEnd($event)">
-                <!-- Blurred album art backdrop -->
-                <!-- Without cover art it falls back to a purple glow (.np-noart) -->
-                <div class="np-bg"
-                     :style="currentTrack && !npNoArt ? ('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="np-art-wrap">
-                        <img class="np-art" x-show="!privacyVisualizerMode"
-                             :src="currentTrack ? getCoverUrl(currentTrack) : 'img/placeholder.png'"
-                             x-on:error="npNoArt = true; $event.target.src='img/placeholder.png'; $event.target.onerror=null"
-                             x-on:load="npNoArt = $event.target.src.indexOf('img/placeholder.png') !== -1"
-                             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>
+            <!-- ── EQUALIZER ─────────────────────────────────────────────────── -->
+            <div x-show="view === 'equalizer' && eqSupported && !loading">
+                <div class="content-header"><h1>Equalizer</h1></div>
+                <div class="content-body">
+                    <div class="settings-section">
+                        <div class="settings-toggle-row">
+                            <div>
+                                <div class="settings-toggle-label">Enable Equalizer</div>
+                                <div class="settings-toggle-desc" x-text="eqEnabled ? 'Active – playback on this device is shaped by the bands below' : 'Inactive – playback is not altered'"></div>
+                            </div>
+                            <label class="toggle-switch">
+                                <input type="checkbox" x-model="eqEnabled" x-on:change="saveEq()">
+                                <span class="toggle-slider"></span>
+                            </label>
                         </div>
                     </div>
 
-                    <!-- Title + artist -->
-                    <div class="np-meta">
-                        <!-- A title too long for one line scrolls (two copies loop seamlessly) -->
-                        <div class="np-title" :title="currentTrack ? currentTrack.name : ''"><span class="np-mq"><span class="np-mq-text" x-text="currentTrack ? currentTrack.name : ''"></span><span class="np-mq-text np-mq-copy" aria-hidden="true" x-text="currentTrack ? currentTrack.name : ''"></span></span></div>
-                        <div class="np-artist" x-text="currentTrack ? getArtistLabel(currentTrack) : ''"></div>
-                    </div>
-
-                    <!-- Spectrum (needs Web Audio, so never on iOS / iPadOS) -->
-                    <canvas id="np-spectrum" class="np-vis" x-show="spectrumSupported"></canvas>
-
-                    <!-- Seek bar -->
-                    <div class="np-seek">
-                        <input type="range" class="np-range" min="0" :max="Math.floor(duration) || 100"
-                            aria-label="Seek"
-                            :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 class="np-times">
-                            <span x-text="formatTime(currentTime)"></span>
-                            <span x-text="(_currentTrackTranscoded && !duration) ? '--:--' : formatTime(duration)"></span>
+                    <div class="settings-section">
+                        <div class="settings-section-title">Presets</div>
+                        <div class="settings-section-desc">Pick a starting point, then fine-tune the bands. Settings are kept on this device only.</div>
+                        <div class="eq-presets">
+                            <template x-for="p in eqPresetList()" :key="p.id">
+                                <button class="eq-preset" :class="{selected: eqPreset === p.id}"
+                                        x-on:click="applyEqPreset(p.id)" x-text="p.name"></button>
+                            </template>
+                            <span class="eq-preset custom selected" x-show="eqPreset === 'custom'">Custom</span>
                         </div>
-                    </div>
 
-                    <!-- Transport controls -->
-                    <div class="np-controls">
-                        <button class="np-btn" :class="{on: shuffle}" x-on:click="toggleShuffle()" title="Shuffle">
-                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7h3.5c2 0 3.2 1 4.3 2.7l2.4 4.6c1.1 1.7 2.3 2.7 4.3 2.7H21"/><path d="M3 17h3.5c1.4 0 2.4-.5 3.2-1.4M14.3 8.4c.8-.9 1.8-1.4 3.2-1.4H21"/><path d="M18.5 4.5 21 7l-2.5 2.5M18.5 14.5 21 17l-2.5 2.5"/></svg>
-                        </button>
-                        <button class="np-btn" x-on:click="prevTrack()" title="Previous">
-                            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM19 5.5v13l-9.5-6.5z"/></svg>
-                        </button>
-                        <button class="np-btn np-play" x-on:click="togglePlay()" :title="isPlaying ? 'Pause' : 'Play'">
-                            <svg x-show="!isPlaying" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 5.5v13l10-6.5z"/></svg>
-                            <svg x-show="isPlaying" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6v12M15 6v12"/></svg>
-                        </button>
-                        <button class="np-btn" x-on:click="nextTrack()" title="Next">
-                            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM5 5.5v13l9.5-6.5z"/></svg>
-                        </button>
-                        <button class="np-btn" :class="{on: repeat !== 'none'}" x-on:click="cycleRepeat()" :title="repeatTitle()">
-                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11V9a3 3 0 0 1 3-3h12"/><path d="M16 3l3 3-3 3"/><path d="M20 13v2a3 3 0 0 1-3 3H5"/><path d="M8 21l-3-3 3-3"/><text x-show="repeat === 'one'" x="12" y="14.6" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none" font-family="Segoe UI,Arial,sans-serif">1</text></svg>
-                        </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>
+                        <svg class="eq-curve" viewBox="0 0 600 90" preserveAspectRatio="none" aria-hidden="true">
+                            <line class="eq-grid" x1="0" y1="15" x2="600" y2="15"/>
+                            <line class="eq-grid" x1="0" y1="75" x2="600" y2="75"/>
+                            <line class="eq-zero" x1="0" y1="45" x2="600" y2="45"/>
+                            <path class="eq-fill" :d="eqCurvePath(true)"/>
+                            <path class="eq-line" :d="eqCurvePath(false)" vector-effect="non-scaling-stroke"/>
+                        </svg>
+
+                        <div class="eq-board" :class="{off: !eqEnabled}">
+                            <div class="eq-band preamp">
+                                <span class="eq-db" x-text="formatDb(eqPreamp)"></span>
+                                <input type="range" class="eq-slider" min="-12" max="12" step="0.5"
+                                       :value="eqPreamp" :style="eqSliderStyle(eqPreamp)"
+                                       x-on:input="setEqPreamp($event.target.value)"
+                                       x-on:dblclick="setEqPreamp(0)" title="Pre-amp (double-click to reset)">
+                                <span class="eq-freq">Pre</span>
                             </div>
+                            <template x-for="(band, i) in eqBands" :key="band.freq">
+                                <div class="eq-band">
+                                    <span class="eq-db" x-text="formatDb(eqGains[i])"></span>
+                                    <input type="range" class="eq-slider" min="-12" max="12" step="0.5"
+                                           :value="eqGains[i]" :style="eqSliderStyle(eqGains[i])"
+                                           x-on:input="setEqGain(i, $event.target.value)"
+                                           x-on:dblclick="setEqGain(i, 0)" :title="band.label + ' Hz (double-click to reset)'">
+                                    <span class="eq-freq" x-text="band.label"></span>
+                                </div>
+                            </template>
                         </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>
+
+                        <div class="eq-actions">
+                            <button class="eq-reset" x-on:click="applyEqPreset('flat')">
+                                <i class="ui undo icon"></i> Reset to Flat
                             </button>
                         </div>
                     </div>
+
+                    <div style="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>
+                            The equalizer only shapes playback in this browser. It does not apply while casting to Arozcast. Lower the pre-amp when boosting bands to avoid distortion.
+                        </span>
+                    </div>
                 </div>
             </div>
 
@@ -1820,6 +1833,137 @@
 
         </main><!-- /.main-content -->
 
+            <!-- ── NOW PLAYING OVERLAY ──────────────────────────────────────── -->
+            <div x-show="showNowPlaying" class="now-playing-overlay" :class="{'np-noart': npNoArt}"
+                 x-on:touchstart="npTouchStart($event)"
+                 x-on:touchend="npTouchEnd($event)">
+                <!-- Blurred album art backdrop -->
+                <!-- Without cover art it falls back to a purple glow (.np-noart) -->
+                <div class="np-bg"
+                     :style="currentTrack && !npNoArt ? ('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="np-art-wrap">
+                        <img class="np-art" x-show="!privacyVisualizerMode"
+                             :src="currentTrack ? getCoverUrl(currentTrack) : 'img/placeholder.png'"
+                             x-on:error="npNoArt = true; $event.target.src='img/placeholder.png'; $event.target.onerror=null"
+                             x-on:load="npNoArt = $event.target.src.indexOf('img/placeholder.png') !== -1"
+                             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>
+                    </div>
+
+                    <!-- Title + artist -->
+                    <div class="np-meta">
+                        <!-- A title too long for one line scrolls (two copies loop seamlessly) -->
+                        <div class="np-title" :title="currentTrack ? currentTrack.name : ''"><span class="np-mq"><span class="np-mq-text" x-text="currentTrack ? currentTrack.name : ''"></span><span class="np-mq-text np-mq-copy" aria-hidden="true" x-text="currentTrack ? currentTrack.name : ''"></span></span></div>
+                        <div class="np-artist" x-text="currentTrack ? getArtistLabel(currentTrack) : ''"></div>
+                    </div>
+
+                    <!-- Spectrum (needs Web Audio, so never on iOS / iPadOS) -->
+                    <canvas id="np-spectrum" class="np-vis" x-show="spectrumSupported"></canvas>
+
+                    <!-- Seek bar -->
+                    <div class="np-seek">
+                        <input type="range" class="np-range" min="0" :max="Math.floor(duration) || 100"
+                            aria-label="Seek"
+                            :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 class="np-times">
+                            <span x-text="formatTime(currentTime)"></span>
+                            <span x-text="(_currentTrackTranscoded && !duration) ? '--:--' : formatTime(duration)"></span>
+                        </div>
+                    </div>
+
+                    <!-- Transport controls -->
+                    <div class="np-controls">
+                        <button class="np-btn" :class="{on: shuffle}" x-on:click="toggleShuffle()" title="Shuffle">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7h3.5c2 0 3.2 1 4.3 2.7l2.4 4.6c1.1 1.7 2.3 2.7 4.3 2.7H21"/><path d="M3 17h3.5c1.4 0 2.4-.5 3.2-1.4M14.3 8.4c.8-.9 1.8-1.4 3.2-1.4H21"/><path d="M18.5 4.5 21 7l-2.5 2.5M18.5 14.5 21 17l-2.5 2.5"/></svg>
+                        </button>
+                        <button class="np-btn" x-on:click="prevTrack()" title="Previous">
+                            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM19 5.5v13l-9.5-6.5z"/></svg>
+                        </button>
+                        <button class="np-btn np-play" x-on:click="togglePlay()" :title="isPlaying ? 'Pause' : 'Play'">
+                            <svg x-show="!isPlaying" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 5.5v13l10-6.5z"/></svg>
+                            <svg x-show="isPlaying" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6v12M15 6v12"/></svg>
+                        </button>
+                        <button class="np-btn" x-on:click="nextTrack()" title="Next">
+                            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM5 5.5v13l9.5-6.5z"/></svg>
+                        </button>
+                        <button class="np-btn" :class="{on: repeat !== 'none'}" x-on:click="cycleRepeat()" :title="repeatTitle()">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11V9a3 3 0 0 1 3-3h12"/><path d="M16 3l3 3-3 3"/><path d="M20 13v2a3 3 0 0 1-3 3H5"/><path d="M8 21l-3-3 3-3"/><text x-show="repeat === 'one'" x="12" y="14.6" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none" font-family="Segoe UI,Arial,sans-serif">1</text></svg>
+                        </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>
+            </div>
+        </div><!-- /.main-wrap -->
+
         <!-- ═══ QUEUE PANEL ════════════════════════════════════════════════ -->
         <aside id="queue-panel" class="queue-panel" x-show="showQueue">
             <div class="queue-header">

+ 217 - 6
src/web/Musicify/musicify.js

@@ -6,6 +6,38 @@
 // Set to true to enable verbose playback debug logging in the browser console.
 const MUSICIFY_DEBUG = false;
 
+// ─── Equalizer ───────────────────────────────────────────────────────────────
+// Ten octave bands; the outer two are shelves so they also cover the extremes.
+const MUSICIFY_EQ_BANDS = [
+    { freq: 31,    label: '31',  type: 'lowshelf' },
+    { freq: 62,    label: '62',  type: 'peaking' },
+    { freq: 125,   label: '125', type: 'peaking' },
+    { freq: 250,   label: '250', type: 'peaking' },
+    { freq: 500,   label: '500', type: 'peaking' },
+    { freq: 1000,  label: '1k',  type: 'peaking' },
+    { freq: 2000,  label: '2k',  type: 'peaking' },
+    { freq: 4000,  label: '4k',  type: 'peaking' },
+    { freq: 8000,  label: '8k',  type: 'peaking' },
+    { freq: 16000, label: '16k', type: 'highshelf' },
+];
+const MUSICIFY_EQ_Q = 1.41; // about one octave wide
+// Each preset lowers the pre-amp by roughly its largest boost so it does not clip.
+const MUSICIFY_EQ_PRESETS = [
+    { id: 'flat',       name: 'Flat',         preamp: 0,  gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
+    { id: 'bass',       name: 'Bass Boost',   preamp: -5, gains: [6, 5, 4, 2, 0, 0, 0, 0, 0, 0] },
+    { id: 'bassreduce', name: 'Bass Reduce',  preamp: 0,  gains: [-6, -5, -4, -2, 0, 0, 0, 0, 0, 0] },
+    { id: 'treble',     name: 'Treble Boost', preamp: -5, gains: [0, 0, 0, 0, 0, 1, 2, 4, 5, 6] },
+    { id: 'vocal',      name: 'Vocal',        preamp: -3, gains: [-2, -2, -1, 1, 3, 4, 3, 1, 0, -1] },
+    { id: 'rock',       name: 'Rock',         preamp: -3, gains: [4, 3, 2, 0, -1, -1, 1, 2, 3, 4] },
+    { id: 'pop',        name: 'Pop',          preamp: -3, gains: [-1, 0, 2, 3, 4, 3, 1, 0, -1, -1] },
+    { id: 'jazz',       name: 'Jazz',         preamp: -2, gains: [3, 2, 1, 2, -1, -1, 0, 1, 2, 3] },
+    { id: 'classical',  name: 'Classical',    preamp: -2, gains: [3, 2, 1, 0, 0, 0, -1, -1, 1, 2] },
+    { id: 'electronic', name: 'Electronic',   preamp: -4, gains: [5, 4, 1, 0, -2, 1, 0, 1, 4, 5] },
+    { id: 'acoustic',   name: 'Acoustic',     preamp: -2, gains: [3, 3, 2, 1, 1, 1, 2, 2, 2, 1] },
+    { id: 'loudness',   name: 'Loudness',     preamp: -4, gains: [5, 3, 0, 0, -1, 0, -1, 1, 4, 3] },
+    { id: 'spoken',     name: 'Spoken Word',  preamp: -3, gains: [-4, -3, -1, 1, 3, 4, 4, 3, 1, -2] },
+];
+
 
 
 // ─── Default cover art SVG (music note) ──────────────────────────────────────
@@ -15,7 +47,7 @@ function musicifyApp() {
     return {
 
         // ── Navigation ──────────────────────────────────────────────────────
-        view: 'home',           // 'home' | 'folders' | 'artists' | 'recent' | 'playlist'
+        view: 'home',           // 'home' | 'folders' | 'artists' | 'recent' | 'playlist' | 'equalizer' | 'settings'
         sidebarOpen: false,
         isMobileLandscape: false, // true when a mobile browser is rotated into landscape
         loading: false,
@@ -141,6 +173,16 @@ function musicifyApp() {
         spectrumSupported: false,      // Now Playing spectrum bars (hidden where Web Audio can't be used)
         _spectrumIdleKey: '',          // size of the last idle spectrum frame, so a paused one isn't repainted
 
+        // ── Equalizer ────────────────────────────────────────────────────────
+        eqSupported: false,            // hidden on iOS / iPadOS and on Safari for macOS
+        eqEnabled: false,
+        eqPreset: 'flat',              // a MUSICIFY_EQ_PRESETS id, or 'custom'
+        eqPreamp: 0,                   // dB
+        eqGains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // dB per band
+        eqBands: MUSICIFY_EQ_BANDS,
+        _eqPreampNode: null,
+        _eqFilters: null,
+
         // ── Arozcast ─────────────────────────────────────────────────────────
         castMode: false,
         castConnected: false,
@@ -292,6 +334,17 @@ function musicifyApp() {
             // out wherever routing playback through Web Audio is not safe (iOS).
             this.spectrumSupported = !this._webAudioUnsafe() && !!(window.AudioContext || window.webkitAudioContext);
 
+            // Equalizer: restore from localStorage (per device, like volume)
+            this.eqSupported = this.spectrumSupported && !this._isMacSafari();
+            if (this.eqSupported) {
+                this._loadEq();
+                if (this.eqEnabled) this._ensureAnalyser();
+                this._applyEq(); // the graph may already exist for Privacy Mode
+            }
+
+            // Float window: below this the player bar runs out of room
+            if (typeof ao_module_setWindowMinSize === 'function') ao_module_setWindowMinSize(400, 480);
+
             // MediaSession
             this._setupMediaSession();
 
@@ -1324,6 +1377,15 @@ function musicifyApp() {
             return this._isIOS() || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
         },
 
+        // Safari on macOS: its Web Audio handling of media elements is less
+        // dependable than Chrome's or Firefox's on the same Mac, so the
+        // equalizer is not offered there. Other Mac browsers keep it.
+        _isMacSafari() {
+            var ua = navigator.userAgent;
+            return /Macintosh|Mac OS X/.test(ua) && /Safari\//.test(ua) &&
+                !/Chrome|Chromium|CriOS|FxiOS|Firefox|Edg|OPR|Opera|Vivaldi|YaBrowser/.test(ua);
+        },
+
         _isMobile() {
             return this._isIOS() || this._isAndroid();
         },
@@ -1747,6 +1809,26 @@ function musicifyApp() {
                 // them back into the 32 bands they were tuned on.
                 this._analyser.fftSize = 256;
                 this._analyser.smoothingTimeConstant = 0.78;
+                // The equalizer sits ahead of the analyser so the bars show
+                // what is heard. A disabled equalizer is all 0 dB, which is
+                // transparent, so the chain is built whenever it may be used.
+                if (!this._isMacSafari()) {
+                    this._eqPreampNode = this._audioCtx.createGain();
+                    this._eqFilters = MUSICIFY_EQ_BANDS.map((band) => {
+                        var f = this._audioCtx.createBiquadFilter();
+                        f.type = band.type;
+                        f.frequency.value = band.freq;
+                        if (band.type === 'peaking') f.Q.value = MUSICIFY_EQ_Q;
+                        return f;
+                    });
+                    var node = source;
+                    [this._eqPreampNode].concat(this._eqFilters).forEach((next) => {
+                        node.connect(next);
+                        node = next;
+                    });
+                    source = node;
+                    this._applyEq();
+                }
                 source.connect(this._analyser);
                 this._analyser.connect(this._audioCtx.destination);
                 this._vizData = new Uint8Array(this._analyser.frequencyBinCount);
@@ -1754,10 +1836,141 @@ function musicifyApp() {
             } catch (e) {
                 if (MUSICIFY_DEBUG) console.warn('[Musicify] visualizer unavailable', e);
                 this._analyser = null;
+                this._eqPreampNode = null;
+                this._eqFilters = null;
                 this.spectrumSupported = false;
             }
         },
 
+        // ── Equalizer ────────────────────────────────────────────────────────
+        eqPresetList() { return MUSICIFY_EQ_PRESETS; },
+
+        _loadEq() {
+            try {
+                var saved = JSON.parse(localStorage.getItem('musicify_eq') || 'null');
+                if (!saved) return;
+                var clamp = (v) => Math.max(-12, Math.min(12, Number(v) || 0));
+                this.eqEnabled = !!saved.enabled;
+                this.eqPreset = typeof saved.preset === 'string' ? saved.preset : 'custom';
+                this.eqPreamp = clamp(saved.preamp);
+                if (Array.isArray(saved.gains) && saved.gains.length === MUSICIFY_EQ_BANDS.length) {
+                    this.eqGains = saved.gains.map(clamp);
+                }
+            } catch (e) {}
+        },
+
+        // Persists the settings and applies them. Turning the equalizer on is a
+        // user gesture, so the AudioContext can be created and resumed here.
+        saveEq() {
+            try {
+                localStorage.setItem('musicify_eq', JSON.stringify({
+                    enabled: this.eqEnabled, preset: this.eqPreset,
+                    preamp: this.eqPreamp, gains: this.eqGains,
+                }));
+            } catch (e) {}
+            if (this.eqEnabled) {
+                this._ensureAnalyser();
+                if (this._audioCtx && this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
+            }
+            this._applyEq();
+        },
+
+        _applyEq() {
+            if (!this._eqFilters || !this._audioCtx) return;
+            var t = this._audioCtx.currentTime;
+            var on = this.eqEnabled;
+            // A short time constant avoids zipper noise while a slider is dragged
+            this._eqFilters.forEach((f, i) => f.gain.setTargetAtTime(on ? this.eqGains[i] : 0, t, 0.02));
+            this._eqPreampNode.gain.setTargetAtTime(on ? Math.pow(10, this.eqPreamp / 20) : 1, t, 0.02);
+        },
+
+        applyEqPreset(id) {
+            var p = MUSICIFY_EQ_PRESETS.find((x) => x.id === id);
+            if (!p) return;
+            this.eqPreset = id;
+            this.eqPreamp = p.preamp;
+            this.eqGains = p.gains.slice();
+            // Picking a shape means the user wants to hear it
+            if (id !== 'flat') this.eqEnabled = true;
+            this.saveEq();
+        },
+
+        setEqGain(i, v) {
+            var gains = this.eqGains.slice();
+            gains[i] = Number(v);
+            this.eqGains = gains;
+            this.eqPreset = 'custom';
+            this.eqEnabled = true;
+            this.saveEq();
+        },
+
+        setEqPreamp(v) {
+            this.eqPreamp = Number(v);
+            this.eqEnabled = true;
+            this.saveEq();
+        },
+
+        formatDb(v) {
+            v = Number(v) || 0;
+            return (v > 0 ? '+' : '') + (Number.isInteger(v) ? v : v.toFixed(1)) + ' dB';
+        },
+
+        // Fills a vertical slider from the 0 dB centre to its value
+        eqSliderStyle(v) {
+            var pct = (Number(v) + 12) / 24 * 100;
+            return '--lo:' + Math.min(50, pct) + '%;--hi:' + Math.max(50, pct) + '%';
+        },
+
+        // Combined response of the filter chain (RBJ biquad formulas, the same
+        // ones BiquadFilterNode uses), drawn over 20 Hz - 20 kHz into the
+        // 600 x 90 curve, +/-15 dB full scale. Computed here rather than read
+        // from the nodes so it also works before the audio graph exists.
+        eqCurvePath(fill) {
+            var fs = 48000, W = 600, H = 90, N = 120, range = 15;
+            var coeffs = MUSICIFY_EQ_BANDS.map((band, i) => this._biquadCoeffs(band, this.eqGains[i], fs));
+            var pts = [];
+            for (var k = 0; k <= N; k++) {
+                var freq = 20 * Math.pow(1000, k / N);
+                var w = 2 * Math.PI * freq / fs;
+                var db = this.eqPreamp;
+                coeffs.forEach((c) => { db += this._biquadDb(c, w); });
+                db = Math.max(-range, Math.min(range, db));
+                pts.push((k / N * W).toFixed(1) + ',' + (H / 2 - db / range * (H / 2)).toFixed(1));
+            }
+            var d = 'M' + pts.join('L');
+            return fill ? d + 'L' + W + ',' + (H / 2) + 'L0,' + (H / 2) + 'Z' : d;
+        },
+
+        _biquadCoeffs(band, gain, fs) {
+            var A = Math.pow(10, gain / 40);
+            var w0 = 2 * Math.PI * band.freq / fs;
+            var cs = Math.cos(w0), sn = Math.sin(w0);
+            if (band.type === 'peaking') {
+                var alpha = sn / (2 * MUSICIFY_EQ_Q);
+                return [1 + alpha * A, -2 * cs, 1 - alpha * A, 1 + alpha / A, -2 * cs, 1 - alpha / A];
+            }
+            // Shelves with slope S = 1, as Web Audio defines them
+            var s2 = 2 * Math.sqrt(A) * (sn / 2 * Math.SQRT2);
+            if (band.type === 'lowshelf') {
+                return [
+                    A * ((A + 1) - (A - 1) * cs + s2), 2 * A * ((A - 1) - (A + 1) * cs), A * ((A + 1) - (A - 1) * cs - s2),
+                    (A + 1) + (A - 1) * cs + s2, -2 * ((A - 1) + (A + 1) * cs), (A + 1) + (A - 1) * cs - s2,
+                ];
+            }
+            return [
+                A * ((A + 1) + (A - 1) * cs + s2), -2 * A * ((A - 1) + (A + 1) * cs), A * ((A + 1) + (A - 1) * cs - s2),
+                (A + 1) - (A - 1) * cs + s2, 2 * ((A - 1) - (A + 1) * cs), (A + 1) - (A - 1) * cs - s2,
+            ];
+        },
+
+        // Gain in dB of one biquad at angular frequency w
+        _biquadDb(c, w) {
+            var c1 = Math.cos(w), s1 = Math.sin(w), c2 = Math.cos(2 * w), s2 = Math.sin(2 * w);
+            var nr = c[0] + c[1] * c1 + c[2] * c2, ni = -(c[1] * s1 + c[2] * s2);
+            var dr = c[3] + c[4] * c1 + c[5] * c2, di = -(c[4] * s1 + c[5] * s2);
+            return 10 * Math.log10((nr * nr + ni * ni) / (dr * dr + di * di));
+        },
+
         // Runs the animation loop only while something on screen draws from it:
         // the privacy bars, or the spectrum of an open Now Playing overlay.
         _syncVizLoop() {
@@ -2046,10 +2259,9 @@ function musicifyApp() {
         openNowPlaying() {
             if (!this.currentTrack) return;
             var mc = document.getElementById('mainContent');
-            // 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';
+            // The overlay covers the main area from outside it (.main-wrap), so
+            // it needs no scroll offset; just freeze scrolling underneath.
+            var overlay = document.querySelector('.now-playing-overlay');
             if (mc) mc.style.overflow = 'hidden';
             this.showTrackInfo = false;   // start collapsed
             this.trackInfoSong = this.currentTrack;
@@ -2061,7 +2273,6 @@ function musicifyApp() {
             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
                 this.updateQueuePanelPosition();
                 // x-show reveals the overlay on the next frame: measure the title then
                 requestAnimationFrame(() => this._updateTitleMarquee());

+ 79 - 4
src/web/desktop.html

@@ -2762,6 +2762,19 @@
         let resizingMinX = 0;
         let minxWidth = 120;
         let minHeight = 100;
+
+        //Minimum size of a floatWindow: the legacy floor above, raised by the
+        //window's optional minWidth / minHeight (newFloatWindow config or
+        //ao_module_setWindowMinSize) but never beyond the screen size
+        function getFloatWindowMinSize(object){
+            var w = parseInt($(object).attr("minwidth")) || 0;
+            var h = parseInt($(object).attr("minheight")) || 0;
+            return [
+                Math.max(minxWidth, Math.min(w, window.innerWidth)),
+                Math.max(minHeight, Math.min(h, window.innerHeight))
+            ];
+        }
+
         function resizeDown(object, event) {
             if (object.length > 1){
                 object = object[0];
@@ -2773,7 +2786,7 @@
             //Mouse down, start resizing
             resizingWindow = true;
             resizingWindowTarget = object;
-            resizingMinX = $(object).offset().left + $(object).width() - minxWidth;
+            resizingMinX = $(object).offset().left + $(object).width() - getFloatWindowMinSize(object)[0];
             MoveFloatWindowToTop(object);
             if ($(object).hasClass("topmost")){
                 //Top most windows
@@ -2820,6 +2833,16 @@
         function resizeMove(object, event) {
             var posX = event.pageX;
             var posY = event.pageY;
+            var minSize = getFloatWindowMinSize(object);
+            var minxWidth = minSize[0];
+            var minHeight = minSize[1];
+            //Dragging the top edge moves the top, so stop it where a window
+            //with a minimum height reaches it instead of sliding the window
+            //(windows without one keep the legacy behavior)
+            var maxTop = Infinity;
+            if ($(object).attr("minheight") !== undefined) {
+                maxTop = $(object).offset().top + $(object).height() - minHeight;
+            }
             
             if (resizingWindow == true && resizingEdgeID != 0) {
                 if (resizingEdgeID == 1) {
@@ -2862,7 +2885,7 @@
                 } else if (resizingEdgeID == 6){
                     var newHeight = $(object).height() - (posY - $(object).offset().top) + 2;
                     $(object).css({
-                        "top": posY - 2,
+                        "top": Math.min(posY - 2, maxTop),
                         "height": Math.max(newHeight,minHeight),
                     });
                 } else if (resizingEdgeID == 7){
@@ -2875,7 +2898,7 @@
                         newWidth = minxWidth;
                     }
                     $(object).css({
-                        "top": posY - 2,
+                        "top": Math.min(posY - 2, maxTop),
                         "height": Math.max(newHeight, minHeight),
                         "left": newLeft,
                         "width": newWidth
@@ -2885,7 +2908,7 @@
                     var newHeight = $(object).height() - (posY - $(object).offset().top) + 2;
                     var newWidth = posX - $(object).offset().left + 2;
                     $(object).css({
-                        "top": posY - 2,
+                        "top": Math.min(posY - 2, maxTop),
                         "height": Math.max(newHeight, minHeight),
                         "width": Math.max(newWidth, minxWidth)
                     });
@@ -3437,6 +3460,15 @@
             var callbackFunctionName = defaultOrKey(config, "", "callback");
             var bgcolor = defaultOrKey(config, "", "background-color");
             var needFocusCover = defaultOrKey(config, false, "focuscover") || isContainerAppURL(url);
+            //Optional minimum size. Unset keeps the legacy 120 x 100 floor
+            var fwMinWidth = parseInt(defaultOrKey(config, 0, "minWidth")) || 0;
+            var fwMinHeight = parseInt(defaultOrKey(config, 0, "minHeight")) || 0;
+            if (width < fwMinWidth) {
+                width = fwMinWidth;
+            }
+            if (height < fwMinHeight) {
+                height = fwMinHeight;
+            }
 
             //Encode function name
             callbackFunctionName = encodeURIComponent(callbackFunctionName);
@@ -3514,6 +3546,7 @@
                 </div>
             </div>`);
             var newWindowObject = getFloatWindowByID(uuid);
+            applyFloatWindowMinSize(newWindowObject, fwMinWidth, fwMinHeight);
             //console.log(newWindowObject);
             MoveFloatWindowToTop(newWindowObject);
 
@@ -3671,6 +3704,48 @@
             });
         }
 
+        //Set (or clear with 0) the minimum size of a floatWindow
+        function setFloatWindowMinSize(targetFWId, minWidth, minHeight) {
+            var targetfw = getFloatWindowByID(targetFWId);
+            if (targetfw == undefined || targetfw.length == 0) {
+                return;
+            }
+            applyFloatWindowMinSize(targetfw, minWidth, minHeight);
+            if ($(targetfw).attr("max") == "true") {
+                //Maximized or docked: the layout already fills the screen
+                return;
+            }
+            //Grow a window that is currently smaller than its new minimum,
+            //keeping it on screen
+            var minSize = getFloatWindowMinSize(targetfw);
+            var newWidth = Math.max($(targetfw).width(), minSize[0]);
+            var newHeight = Math.max($(targetfw).height(), minSize[1]);
+            var offset = $(targetfw).offset();
+            $(targetfw).css({
+                width: newWidth,
+                height: newHeight,
+                left: Math.max(0, Math.min(offset.left, window.innerWidth - newWidth)),
+                top: Math.max(0, Math.min(offset.top, window.innerHeight - newHeight))
+            });
+        }
+
+        //Store the minimum size and enforce it in CSS as well, so every other
+        //way of sizing the window (setFloatWindowSize, docking) respects it
+        function applyFloatWindowMinSize(targetfw, minWidth, minHeight) {
+            minWidth = parseInt(minWidth) || 0;
+            minHeight = parseInt(minHeight) || 0;
+            if (minWidth > 0) {
+                $(targetfw).attr("minwidth", minWidth).css("min-width", "min(" + minWidth + "px, 100vw)");
+            } else {
+                $(targetfw).removeAttr("minwidth").css("min-width", "");
+            }
+            if (minHeight > 0) {
+                $(targetfw).attr("minheight", minHeight).css("min-height", "min(" + minHeight + "px, 100vh)");
+            } else {
+                $(targetfw).removeAttr("minheight").css("min-height", "");
+            }
+        }
+
         function setFloatWindowTitle(targetFWId, newTitle) {
             var targetfw = getFloatWindowByID(targetFWId);
             $(targetfw).find(".title").text(newTitle);

+ 1 - 0
src/web/mobile.html

@@ -1102,6 +1102,7 @@ function MoveFloatWindowToTop(fw){
 function setFloatWindowTitle(id,t){$(".fw-tab[data-wid='"+id+"'] .fw-tab-title").text(t);}
 function setFloatWindowResizePolicy(){}
 function setFloatWindowSize(){}
+function setFloatWindowMinSize(){}
 function setFloatWindowMinimizePolicy(){}
 function PinFloatWindowToTopMostMode(){}
 function UnpinFloatWindowFromTopMostMode(){}

+ 12 - 0
src/web/script/ao_module.js

@@ -160,6 +160,16 @@ function ao_module_setWindowSize(width, height){
     parent.setFloatWindowSize(ao_module_windowID, width, height)
 }
 
+//Set the minimum size the user can resize this float window to, in px.
+//Pass 0 (or leave out) a dimension to drop back to the default minimum.
+//A window smaller than the new minimum grows to fit it.
+function ao_module_setWindowMinSize(width=0, height=0){
+    if (!ao_module_virtualDesktop || typeof parent.setFloatWindowMinSize != "function"){
+        return;
+    }
+    parent.setFloatWindowMinSize(ao_module_windowID, width, height);
+}
+
 //Update the floatWindow title
 function ao_module_setWindowTitle(newTitle){
     if (!ao_module_virtualDesktop){
@@ -451,6 +461,8 @@ function ao_module_openSetting(group, name){
         title: "Dummy Module",
         left: 100,
         top: 100,
+        minWidth: 400,      //optional, smallest width the user can resize to
+        minHeight: 300,     //optional, smallest height the user can resize to
         parent: ao_module_windowID,
         callback: "childCallbackHandler"
     });