소스 검색

Add Musicify equalizer and float window min size

Musicify: add a 10-band equalizer view with presets, pre-amp, a response curve and per-device persistence. It is built on Web Audio biquad filters ahead of the analyser and is hidden on iOS and Safari for macOS. The Now Playing overlay moves out of the scrolling area into a new .main-wrap, so it no longer needs scroll-offset handling or height compensation.

Desktop: float windows get an optional minimum size, set through the minWidth/minHeight config on newfw or at runtime with ao_module_setWindowMinSize(). Resizing respects the minimum and clamps it to the screen. Drive and mobile get no-op stubs for setFloatWindowMinSize. Musicify uses the new API to set a 400x480 minimum, and examples/README.md documents it.
Toby Chui 1 일 전
부모
커밋
da706a2496
7개의 변경된 파일과 584개의 추가작업 그리고 135개의 파일을 삭제
  1. 5 0
      examples/README.md
  2. 1 0
      src/web/Drive/js/apphost.js
  3. 269 125
      src/web/Musicify/index.html
  4. 217 6
      src/web/Musicify/musicify.js
  5. 79 4
      src/web/desktop.html
  6. 1 0
      src/web/mobile.html
  7. 12 0
      src/web/script/ao_module.js

+ 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"
     });