Bläddra i källkod

Add Movie settings tab and playback controls

Adds a new Settings view in Movie with index management: auto-index toggle, index stats, manual rescan, and clear-index actions backed by new AGI scripts (`getIndexStats.js`, `clearIndex.js`) and common script constants.

Upgrades player UX in both full and embedded views with a gear-based playback popup (repeat-one, speed, volume bar visibility), persisted preferences, center play/pause flash feedback, improved repeat handling for transcoded streams, and keyboard shortcuts (`S`, `R`, refined `Esc`). Also replaces emoji/text glyphs with SVG icons and improves long-title/season tab overflow handling.
Toby Chui 3 veckor sedan
förälder
incheckning
17c225e1bf

+ 37 - 0
src/web/Movie/backend/clearIndex.js

@@ -0,0 +1,37 @@
+/*
+    Movie App - Clear Library Index
+
+    Deletes the cached scan result so the next scan starts from scratch.
+    Only removes the index file — no user media is touched.
+
+    Returns JSON: { ok: true, removed: bool } or { error: "..." }
+*/
+
+includes("common.js");
+requirelib("filelib");
+
+var CACHE_FILE = "user:/.appdata/Movie/library_cache.json";
+
+function main() {
+    if (!filelib.fileExists(CACHE_FILE)) {
+        // Nothing to remove is a success from the caller's point of view
+        sendJSONResp(JSON.stringify({ ok: true, removed: false }));
+        return;
+    }
+
+    try {
+        filelib.deleteFile(CACHE_FILE);
+    } catch (e) {
+        sendJSONResp(JSON.stringify({ error: "delete_failed" }));
+        return;
+    }
+
+    if (filelib.fileExists(CACHE_FILE)) {
+        sendJSONResp(JSON.stringify({ error: "delete_failed" }));
+        return;
+    }
+
+    sendJSONResp(JSON.stringify({ ok: true, removed: true }));
+}
+
+main();

+ 2 - 0
src/web/Movie/backend/common.js

@@ -23,6 +23,8 @@ var SCRIPT_GET_MOVIE_INFO     = BACKEND_PATH + "getMovieInfo.js";
 var SCRIPT_DISABLE_MOVIE_INFO = BACKEND_PATH + "disableMovieInfo.js";
 var SCRIPT_GET_WATCHTIME      = BACKEND_PATH + "getWatchTime.js";
 var SCRIPT_SET_WATCHTIME      = BACKEND_PATH + "setWatchTime.js";
+var SCRIPT_GET_INDEX_STATS    = BACKEND_PATH + "getIndexStats.js";
+var SCRIPT_CLEAR_INDEX        = BACKEND_PATH + "clearIndex.js";
 
 // ── Scanner settings ─────────────────────────────────────────────────────────
 var VALID_VIDEO_FORMATS = ["mp4", "webm", "ogg", "mkv", "avi", "mov", "m4v", "wmv", "flv", "rmvb", "ts"];

+ 50 - 0
src/web/Movie/backend/getIndexStats.js

@@ -0,0 +1,50 @@
+/*
+    Movie App - Index Statistics
+
+    Reports the on-disk footprint of the library index plus how many storage
+    roots a scan would visit. Deliberately does NOT parse the cache file — it
+    can be several MB (thumbnails are inlined as base64) and the front end has
+    already loaded that data via getLibraryCache.js, so album/video totals are
+    counted client-side instead.
+
+    Returns JSON:
+      { exists: bool, sizeBytes: int, roots: int, skippedRoots: int }
+*/
+
+includes("common.js");
+requirelib("filelib");
+
+var CACHE_FILE = "user:/.appdata/Movie/library_cache.json";
+
+function shouldSkipRoot(rootPath) {
+    var lower = rootPath.toLowerCase();
+    for (var i = 0; i < SKIP_ROOT_PREFIXES.length; i++) {
+        if (lower.indexOf(SKIP_ROOT_PREFIXES[i]) === 0) { return true; }
+    }
+    return false;
+}
+
+function main() {
+    var out = { exists: false, sizeBytes: 0, roots: 0, skippedRoots: 0 };
+
+    // How many storage roots the scanner would walk on the next run
+    var roots = filelib.glob("/");
+    if (roots) {
+        for (var i = 0; i < roots.length; i++) {
+            if (shouldSkipRoot(roots[i])) { out.skippedRoots++; }
+            else                          { out.roots++; }
+        }
+    }
+
+    if (filelib.fileExists(CACHE_FILE)) {
+        out.exists = true;
+        try {
+            var size = filelib.filesize(CACHE_FILE);
+            if (size && size > 0) { out.sizeBytes = size; }
+        } catch (e) {}
+    }
+
+    sendJSONResp(JSON.stringify(out));
+}
+
+main();

+ 284 - 28
src/web/Movie/embedded.html

@@ -87,6 +87,14 @@
             width: 12px; height: 12px; background: #fff; border-radius: 50%;
         }
 
+        /* Portrait phones/tablets drive playback volume with the hardware keys,
+           so the in-page slider only eats toolbar width. Users who want it back
+           can force it on from the settings popup ("Volume bar → Always show"). */
+        @media (max-width: 899px) and (orientation: portrait) {
+            #volume-slider { display: none; }
+        }
+        body.always-show-volume #volume-slider { display: block; }
+
         #time-display {
             font-size: 13px; color: rgba(255,255,255,0.8);
             font-family: -apple-system, BlinkMacSystemFont, sans-serif;
@@ -108,6 +116,27 @@
                 0    2px 6px rgba(0,0,0,0.85);
         }
 
+        /* ── Centre play/pause flash indicator ─────────────────────────────── */
+        #play-flash {
+            position: absolute;
+            top: 50%; left: 50%;
+            width: 88px; height: 88px;
+            margin: -44px 0 0 -44px;
+            border-radius: 50%;
+            background: rgba(0,0,0,0.55);
+            backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
+            display: flex; align-items: center; justify-content: center;
+            pointer-events: none;
+            opacity: 0; z-index: 18;
+        }
+        #play-flash img { width: 40px; height: 40px; display: block; }
+        #play-flash.flash { animation: playFlash 0.62s ease-out; }
+        @keyframes playFlash {
+            0%   { opacity: 0;    transform: scale(0.68); }
+            18%  { opacity: 0.95; transform: scale(0.92); }
+            100% { opacity: 0;    transform: scale(1.32); }
+        }
+
         /* ── Resume popup ──────────────────────────────────────────────────── */
         #resume-popup {
             display: none;
@@ -156,6 +185,7 @@
         .ctx-item.ctx-active   { color: var(--accent); }
         .ctx-item.ctx-disabled { opacity: 0.3; pointer-events: none; }
         .ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-style: normal; }
+        img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
         .ctx-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 3px 0; }
         .ctx-has-sub { justify-content: space-between; position: relative; }
         .ctx-sub-arrow { font-style: normal; opacity: 0.55; font-size: 15px; }
@@ -191,7 +221,8 @@
             background: none; border: none; cursor: pointer;
             color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
         }
-        #sset-close:hover { color: var(--text); }
+        #sset-close img { width: 14px; height: 14px; display: block; opacity: 0.55; transition: opacity var(--transition); }
+        #sset-close:hover img { opacity: 1; }
         .sset-row {
             display: flex; align-items: center; gap: 10px;
             padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
@@ -258,7 +289,8 @@
             background: none; border: none; cursor: pointer;
             color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
         }
-        #video-info-close:hover { color: var(--text); }
+        #video-info-close img { width: 14px; height: 14px; display: block; opacity: 0.55; transition: opacity var(--transition); }
+        #video-info-close:hover img { opacity: 1; }
         #video-info-tabs {
             display: flex; gap: 3px;
             background: var(--surface2); border-radius: calc(var(--radius) / 1.25); padding: 3px; margin-bottom: 12px;
@@ -279,6 +311,62 @@
         .info-label { color: var(--text-sub); flex-shrink: 0; width: 110px; }
         .info-value { color: var(--text); word-break: break-all; }
 
+        /* ── Playback settings popup (gear button) ─────────────────────────── */
+        #settings-popup {
+            display: none;
+            position: absolute;
+            right: 14px; bottom: 62px;
+            z-index: 35;
+            background: rgba(28,28,30,0.97);
+            backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
+            border-radius: calc(var(--radius) * 1.5);
+            padding: 6px 0; width: 236px;
+            box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
+            color: var(--text);
+            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+            user-select: none;
+        }
+        #settings-popup.active { display: block; }
+        .set-section { padding: 6px 14px 8px; }
+        .set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
+        .set-head {
+            display: flex; align-items: center; gap: 8px;
+            font-size: 11px; font-weight: 600; letter-spacing: 0.3px;
+            text-transform: uppercase; color: var(--text-sub);
+            margin-bottom: 7px;
+        }
+        .set-head img { width: 14px; height: 14px; opacity: 0.7; display: block; }
+        .set-seg {
+            display: flex; background: var(--surface2);
+            border-radius: calc(var(--radius) * 1.2); padding: 2px; gap: 2px;
+        }
+        .set-seg-btn {
+            flex: 1; padding: 5px 0; font-size: 12px; font-family: inherit;
+            border: none; border-radius: var(--radius); cursor: pointer;
+            background: transparent; color: var(--text-sub);
+            transition: background 0.15s, color 0.15s; outline: none;
+        }
+        .set-seg-btn:hover  { color: var(--text); }
+        .set-seg-btn.active { background: var(--surface); color: var(--text); }
+
+        .speed-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
+        .speed-btn {
+            padding: 5px 0; font-size: 12px; font-family: inherit;
+            border: none; border-radius: var(--radius); cursor: pointer;
+            background: var(--surface2); color: var(--text-sub);
+            transition: background 0.15s, color 0.15s; outline: none;
+        }
+        .speed-btn:hover  { color: var(--text); }
+        .speed-btn.active { background: var(--accent); color: #fff; font-weight: 600; }
+
+        .set-link {
+            display: flex; align-items: center; gap: 9px;
+            padding: 8px 14px; font-size: 13px; cursor: pointer;
+            color: var(--text); transition: background var(--transition);
+        }
+        .set-link:hover { background: rgba(255,255,255,0.08); }
+        .set-link img   { width: 15px; height: 15px; opacity: 0.8; display: block; }
+
         /* ── Toast ─────────────────────────────────────────────────────────── */
         #toast {
             position: fixed; bottom: 30px; left: 50%;
@@ -300,6 +388,9 @@
     <!-- Subtitle overlay -->
     <div id="subtitle-display"></div>
 
+    <!-- Centre play/pause flash indicator -->
+    <div id="play-flash"><img id="play-flash-icon" src="img/icons/play_white.svg" alt=""></div>
+
     <!-- Resume popup -->
     <div id="resume-popup">
         <div id="resume-popup-title">Resume playback?</div>
@@ -312,10 +403,10 @@
 
     <!-- Context menu -->
     <div id="player-ctx">
-        <div class="ctx-item" id="ctx-play"><i class="ctx-icon">▶</i>Play</div>
-        <div class="ctx-item" id="ctx-pause"><i class="ctx-icon">⏸</i>Pause</div>
+        <div class="ctx-item" id="ctx-play"><img class="ctx-icon" src="img/icons/play_white.svg" alt="">Play</div>
+        <div class="ctx-item" id="ctx-pause"><img class="ctx-icon" src="img/icons/pause_white.svg" alt="">Pause</div>
         <div class="ctx-divider"></div>
-        <div class="ctx-item" id="ctx-repeat"><i class="ctx-icon">↺</i>Repeat: Off</div>
+        <div class="ctx-item" id="ctx-repeat"><img class="ctx-icon" src="img/icons/repeat_white.svg" alt="">Repeat: Off</div>
         <div class="ctx-divider"></div>
         <div class="ctx-item ctx-has-sub" id="ctx-subtitle-parent">
             <span>Subtitles</span>
@@ -327,13 +418,13 @@
             </div>
         </div>
         <div class="ctx-divider"></div>
-        <div class="ctx-item" id="ctx-subtitle-settings"><i class="ctx-icon">⚙</i>Subtitle Settings</div>
+        <div class="ctx-item" id="ctx-subtitle-settings"><img class="ctx-icon" src="img/icons/settings_white.svg" alt="">Subtitle Settings</div>
         <div class="ctx-item" id="ctx-props">Video Properties</div>
     </div>
 
     <!-- Subtitle settings modal -->
     <div id="subtitle-settings-modal">
-        <h3>Subtitle Settings<button id="sset-close">✕</button></h3>
+        <h3>Subtitle Settings<button id="sset-close" title="Close"><img src="img/icons/close_white.svg" alt=""></button></h3>
         <div class="sset-row">
             <span class="sset-label">Position</span>
             <div class="sset-seg">
@@ -376,7 +467,7 @@
 
     <!-- Video info / stats modal -->
     <div id="video-info-modal">
-        <h3><span id="video-info-title">Video Properties</span><button id="video-info-close" onclick="closeVideoInfo()">✕</button></h3>
+        <h3><span id="video-info-title">Video Properties</span><button id="video-info-close" title="Close" onclick="closeVideoInfo()"><img src="img/icons/close_white.svg" alt=""></button></h3>
         <div id="video-info-tabs">
             <div class="info-tab active" onclick="showInfoTab('props')">Properties</div>
             <div class="info-tab"        onclick="showInfoTab('stats')">Stats</div>
@@ -384,6 +475,48 @@
         <div id="video-info-body"></div>
     </div>
 
+    <!-- Playback settings popup -->
+    <div id="settings-popup">
+        <div class="set-section">
+            <div class="set-head"><img src="img/icons/repeat_white.svg" alt="">Repeat</div>
+            <div class="set-seg" id="set-repeat-seg">
+                <button class="set-seg-btn active" data-repeat="off">Off</button>
+                <button class="set-seg-btn"        data-repeat="one">Repeat one</button>
+            </div>
+        </div>
+        <div class="set-section">
+            <div class="set-head"><img src="img/icons/play_white.svg" alt="">Playback speed</div>
+            <div class="speed-grid" id="set-speed-grid">
+                <button class="speed-btn" data-speed="0.25">0.25×</button>
+                <button class="speed-btn" data-speed="0.5">0.5×</button>
+                <button class="speed-btn" data-speed="0.75">0.75×</button>
+                <button class="speed-btn active" data-speed="1">Normal</button>
+                <button class="speed-btn" data-speed="1.25">1.25×</button>
+                <button class="speed-btn" data-speed="1.5">1.5×</button>
+                <button class="speed-btn" data-speed="1.75">1.75×</button>
+                <button class="speed-btn" data-speed="2">2×</button>
+            </div>
+        </div>
+        <div class="set-section">
+            <div class="set-head"><img src="img/icons/volume_white.svg" alt="">Volume bar</div>
+            <div class="set-seg" id="set-volbar-seg">
+                <button class="set-seg-btn active" data-volbar="auto">Auto</button>
+                <button class="set-seg-btn"        data-volbar="always">Always show</button>
+            </div>
+        </div>
+        <div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
+            <div class="set-link" id="set-load-subtitle">
+                <img src="img/icons/movie_white.svg" alt="">Load SRT subtitle…
+            </div>
+            <div class="set-link" id="set-subtitle-settings">
+                <img src="img/icons/settings_white.svg" alt="">Subtitle settings
+            </div>
+            <div class="set-link" id="set-video-props">
+                <img src="img/icons/menu_white.svg" alt="">Video properties
+            </div>
+        </div>
+    </div>
+
     <!-- Player controls -->
     <div id="video-controls">
         <div id="progress-wrap">
@@ -402,6 +535,9 @@
             </div>
             <span id="time-display">0:00 / 0:00</span>
             <span id="spacer"></span>
+            <button class="ctrl-btn" id="ctrl-settings" title="Playback settings">
+                <img src="img/icons/settings_white.svg" alt="">
+            </button>
             <button class="ctrl-btn" id="ctrl-fs" title="Fullscreen (F)">
                 <img src="img/icons/fullscreen_white.svg" alt="">
             </button>
@@ -423,7 +559,9 @@ var transcodeDuration   = 0;      // total duration for transcoded video (from /
 var pendingResumePos    = 0;
 var watchSaveInterval   = null;
 var controlsTimer       = null;
-var repeatSingle        = false;
+var repeatSingle        = (localStorage.getItem('movie_repeat_one') === '1');
+var playbackSpeed       = parseFloat(localStorage.getItem('movie_playback_speed') || '1') || 1;
+var alwaysShowVolumeBar = (localStorage.getItem('movie_always_volume_bar') === '1');
 var infoRefreshTimer    = null;
 var currentInfoTab      = 'props';
 
@@ -458,6 +596,17 @@ function escapeHtml(str) {
         .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
 }
 
+// Flash a large play/pause glyph in the centre of the video so the user gets
+// immediate feedback that their click / key press registered.
+function flashPlayState(isPlaying) {
+    var $f = $('#play-flash');
+    $('#play-flash-icon').attr('src',
+        isPlaying ? 'img/icons/play_white.svg' : 'img/icons/pause_white.svg');
+    $f.removeClass('flash');
+    void $f[0].offsetWidth;   // force reflow so the animation restarts
+    $f.addClass('flash');
+}
+
 var toastTimer;
 function showToast(msg) {
     clearTimeout(toastTimer);
@@ -598,7 +747,16 @@ function initVideoControls() {
     $(vid).on('ended', function () {
         clearWatchPosition();
         if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
-        if (repeatSingle) { vid.currentTime = 0; vid.play(); }
+        if (!repeatSingle) { return; }
+        if (isTranscodedVideo && transcodeSeekOffset > 0 && currentFile) {
+            // The stream started part-way in — restart it from the beginning
+            transcodeSeekOffset = 0;
+            vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath);
+            vid.load();
+        } else {
+            vid.currentTime = 0;
+        }
+        vid.play();
     });
 
     $(vid).on('volumechange', function () {
@@ -623,14 +781,19 @@ function updateMuteIcon() {
 
 function togglePlay() {
     $('#resume-popup').removeClass('active');
-    if (vid.paused) { vid.play(); } else { vid.pause(); }
+    var willPlay = vid.paused;
+    if (willPlay) { vid.play(); } else { vid.pause(); }
+    flashPlayState(willPlay);
 }
 
 function showControls() {
     $('#video-controls').removeClass('hidden');
     clearTimeout(controlsTimer);
     controlsTimer = setTimeout(function () {
-        if (!vid.paused) { $('#video-controls').addClass('hidden'); }
+        // Keep the bar up while the settings popup is open — it is anchored to it
+        if (!vid.paused && !$('#settings-popup').hasClass('active')) {
+            $('#video-controls').addClass('hidden');
+        }
     }, 3000);
 }
 
@@ -703,8 +866,11 @@ function initContextMenu() {
         $('#ctx-pause').toggleClass('ctx-disabled', vid.paused);
         $('#ctx-repeat')
             .toggleClass('ctx-active', repeatSingle)
-            .html('<i class="ctx-icon">' + (repeatSingle ? '✓' : '↺') + '</i>Repeat: ' + (repeatSingle ? 'On' : 'Off'));
+            .html('<img class="ctx-icon" src="img/icons/'
+                + (repeatSingle ? 'repeat_one_white.svg' : 'repeat_white.svg')
+                + '" alt="">Repeat: ' + (repeatSingle ? 'On' : 'Off'));
         $('#ctx-subtitle-sub').hide();
+        closeSettingsPopup();
 
         var rect = this.getBoundingClientRect();
         var x = e.clientX - rect.left;
@@ -720,13 +886,92 @@ function initContextMenu() {
         if (!$(e.target).closest('#player-ctx').length) { $ctx.hide(); }
     });
 
-    $('#ctx-play').on('click',   function () { vid.play();          $ctx.hide(); });
-    $('#ctx-pause').on('click',  function () { vid.pause();         $ctx.hide(); });
-    $('#ctx-repeat').on('click', function () { repeatSingle = !repeatSingle; $ctx.hide(); });
+    $('#ctx-play').on('click',   function () { vid.play();  flashPlayState(true);  $ctx.hide(); });
+    $('#ctx-pause').on('click',  function () { vid.pause(); flashPlayState(false); $ctx.hide(); });
+    $('#ctx-repeat').on('click', function () { setRepeatSingle(!repeatSingle); $ctx.hide(); });
     $('#ctx-subtitle-settings').on('click', function () { $ctx.hide(); openSubtitleSettings(); });
     $('#ctx-props').on('click',  function () { $ctx.hide(); openVideoInfo('props'); });
 }
 
+// ── Playback settings popup (gear button) ─────────────────────────────────────
+function setAlwaysShowVolumeBar(on) {
+    alwaysShowVolumeBar = !!on;
+    localStorage.setItem('movie_always_volume_bar', alwaysShowVolumeBar ? '1' : '0');
+    $('body').toggleClass('always-show-volume', alwaysShowVolumeBar);
+    $('#set-volbar-seg .set-seg-btn').removeClass('active')
+        .filter('[data-volbar="' + (alwaysShowVolumeBar ? 'always' : 'auto') + '"]').addClass('active');
+}
+
+function setRepeatSingle(on) {
+    repeatSingle = !!on;
+    vid.loop = false;   // handled manually in the 'ended' handler
+    localStorage.setItem('movie_repeat_one', repeatSingle ? '1' : '0');
+    $('#set-repeat-seg .set-seg-btn').removeClass('active')
+        .filter('[data-repeat="' + (repeatSingle ? 'one' : 'off') + '"]').addClass('active');
+}
+
+function setPlaybackSpeed(rate) {
+    playbackSpeed = parseFloat(rate) || 1;
+    vid.playbackRate = playbackSpeed;
+    localStorage.setItem('movie_playback_speed', String(playbackSpeed));
+    $('#set-speed-grid .speed-btn').removeClass('active')
+        .filter('[data-speed="' + playbackSpeed + '"]').addClass('active');
+}
+
+function openSettingsPopup() {
+    setRepeatSingle(repeatSingle);
+    setPlaybackSpeed(playbackSpeed);
+    setAlwaysShowVolumeBar(alwaysShowVolumeBar);
+    $('#player-ctx').hide();
+    $('#settings-popup').addClass('active');
+    showControls();
+}
+
+function closeSettingsPopup() { $('#settings-popup').removeClass('active'); }
+
+function toggleSettingsPopup() {
+    if ($('#settings-popup').hasClass('active')) { closeSettingsPopup(); }
+    else { openSettingsPopup(); }
+}
+
+function initSettingsPopup() {
+    $('#ctrl-settings').on('click', function (e) {
+        e.stopPropagation();
+        toggleSettingsPopup();
+    });
+
+    // Dismiss when clicking anywhere outside the popup or its button
+    $(document).on('mousedown.setpop', function (e) {
+        if (!$(e.target).closest('#settings-popup, #ctrl-settings').length) {
+            closeSettingsPopup();
+        }
+    });
+
+    $('#set-repeat-seg').on('click', '.set-seg-btn', function () {
+        setRepeatSingle($(this).data('repeat') === 'one');
+    });
+
+    $('#set-speed-grid').on('click', '.speed-btn', function () {
+        setPlaybackSpeed($(this).data('speed'));
+    });
+    $('#set-volbar-seg').on('click', '.set-seg-btn', function () {
+        setAlwaysShowVolumeBar($(this).data('volbar') === 'always');
+    });
+
+    $('#set-load-subtitle').on('click',    function () { closeSettingsPopup(); pickSubtitleFile(); });
+    $('#set-subtitle-settings').on('click', function () { closeSettingsPopup(); openSubtitleSettings(); });
+    $('#set-video-props').on('click',       function () { closeSettingsPopup(); openVideoInfo('props'); });
+
+    // Apply persisted values to the video element
+    setRepeatSingle(repeatSingle);
+    setPlaybackSpeed(playbackSpeed);
+    setAlwaysShowVolumeBar(alwaysShowVolumeBar);
+
+    // A transcode seek replaces the media source, which resets playbackRate —
+    // reapply the chosen speed every time new media is loaded.
+    $(vid).on('loadedmetadata', function () { vid.playbackRate = playbackSpeed; });
+}
+
 // ── Video info / stats modal ──────────────────────────────────────────────────
 function openVideoInfo(tab) {
     currentInfoTab = tab || 'props';
@@ -1032,20 +1277,25 @@ function initSubtitleMenu() {
 
     $('#ctx-sub-load').on('click', function () {
         $ctx.hide();
-        var startDir = 'user:/';
-        if (currentFile) {
-            var fp    = currentFile.filepath.replace(/\\/g, '/');
-            var slash = fp.lastIndexOf('/');
-            if (slash > 0) { startDir = fp.substring(0, slash); }
-        }
-        ao_module_openFileSelector(
-            window.movieEmbOnSubtitleFile,
-            startDir, 'file', false,
-            { fnameOverride: 'movieEmbOnSubtitleFile', extAllowed: '.srt' }
-        );
+        pickSubtitleFile();
     });
 }
 
+// Open the ArozOS file selector defaulted to the folder of the playing file
+function pickSubtitleFile() {
+    var startDir = 'user:/';
+    if (currentFile) {
+        var fp    = currentFile.filepath.replace(/\\/g, '/');
+        var slash = fp.lastIndexOf('/');
+        if (slash > 0) { startDir = fp.substring(0, slash); }
+    }
+    ao_module_openFileSelector(
+        window.movieEmbOnSubtitleFile,
+        startDir, 'file', false,
+        { fnameOverride: 'movieEmbOnSubtitleFile', extAllowed: '.srt' }
+    );
+}
+
 // ── Keyboard shortcuts ────────────────────────────────────────────────────────
 function initKeyboard() {
     $(document).on('keydown', function (e) {
@@ -1103,12 +1353,17 @@ function initKeyboard() {
             case 'r':
             case 'R':
                 e.preventDefault();
-                repeatSingle = !repeatSingle;
+                setRepeatSingle(!repeatSingle);
                 showToast('Repeat: ' + (repeatSingle ? 'On' : 'Off')); break;
 
+            case 's':
+            case 'S':
+                e.preventDefault(); toggleSettingsPopup(); break;
+
             case 'Escape':
                 e.preventDefault();
                 $('#player-ctx').hide();
+                closeSettingsPopup();
                 closeVideoInfo();
                 closeSubtitleSettings(); break;
 
@@ -1122,6 +1377,7 @@ function initKeyboard() {
 function initMain(){
     initVideoControls();
     initContextMenu();
+    initSettingsPopup();
     initSubtitleMenu();
     initSubtitleSettings();
     initKeyboard();

+ 1 - 0
src/web/Movie/img/icons/repeat_black.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#0C0C0C"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7Zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4Z"/></svg>

+ 1 - 0
src/web/Movie/img/icons/repeat_one_white.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#F3F3F3"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7Zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4Z"/><path d="M12.4 15.2V9.9h-1.6V8.7h3v6.5h-1.4Z"/></svg>

+ 1 - 0
src/web/Movie/img/icons/repeat_white.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#F3F3F3"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7Zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4Z"/></svg>

+ 1 - 0
src/web/Movie/img/icons/settings_black.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#434343"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm112-260q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Z"/></svg>

+ 1 - 0
src/web/Movie/img/icons/settings_white.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#F3F3F3"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm112-260q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Z"/></svg>

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 667 - 68
src/web/Movie/index.html


Vissa filer visades inte eftersom för många filer har ändrats