Repository navigation
fix(content): fake players refused, site dialogs left alone, a reloaded or anchored player keeps its video - #306
Merged
Merged
Conversation
The background kept each request's headers on its frame until the response came, and the frame's reset threw them away: the page naming itself (FRAME_ADDED) or the tab going to another site. A request sent before that and answered after - a preload, 103 Early Hints - was detected without its Referer/Origin, and a CDN that checks them gave the player a 403. The headers are now kept on the tab, by request id (unique in the session), and go when the response or the error comes; at most 500 per tab. Forgetting them on a request's error no longer makes a holder again for a tab already closed (part of #235). Tests: tests/unit/backgroundHarness.mjs runs background.mjs in Node against a stand-in for the WebExtension API, so its listeners can be driven in order. backgroundDetection.test.mjs fails on the old code (headers undefined after FRAME_ADDED and after the new-site reset); TabTracker.test.mjs covers the map itself (the closed-tab case fails with getTabOrCreate back in). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pened it The player page is web-accessible, so any page, or an ad's iframe in it, can frame moz-extension://<uuid>/player/index.html?parent_frame_id=0. The player's own load is a moz-extension request that webRequest never sees, so the background adopted that frame id on trust: the top frame then counted as holding a player, every stream the page requested was dropped, and no player opened on it until the page navigated. A made-up id (parseInt of junk) became a NaN frame of the tab. PLAYER_LOADED now adopts the named parent only when the player's opener (the page name content.js puts in each player URL, known only to its content script) is that frame's page or the player frame's own (TabHolder.playerParentProof). When the background restarted and lost the page's name, it asks the frame's content script (new IS_PLAYER_OPENER message). An unproven player is answered null and forgotten, like a player of a gone page. The rest of the handler moved unchanged into acceptPlayer. Proven by tests/unit/TabTracker.test.mjs "playerParentProof" (fails with the opener check or the refusal removed) and tests/unit/contentScript.test.mjs "IS_PLAYER_OPENER" (fails when content.js does not answer for its page), on a new stand-in DOM for content.js (tests/unit/contentDom.mjs). mpv-shortcut's new /framed e2e case frames the player page the way the issue describes. Issue #225. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1. A failed first options load was kept: until the event page unloaded, every event ran with no options (MPV mode and both URL lists off). The next event now loads them again. 2. A container tab's download: the 30 s timeout could clear the download while the hidden tab's player saved it, and the player's answer then threw a TypeError. The download is taken when the player is told, and answered once, by the player or by the timeout. 3. The toolbar's On sorted frames by a video size that is undefined for a frame without a content script: NaN, an arbitrary order, and the first player opened is the one that plays. A missing size is 0. 4. Custom source patterns: a pattern with its own group named like the matcher's (o0) made compile() throw, unhandled, and the old patterns stayed in use; a named group in patterns of two outputs gave no output; a backreference meant another group once joined. The matcher reads its own groups only, compiles a backreference pattern on its own, and falls back to one regex per pattern when the joined one is refused. 5. The stream-link redirect's `[\?|#]` held a literal `|`; now `[?#]`. 6. RuleManager's two background promises were unhandled, and a refused removal of an expired header rule freed its id while the browser still held the rule. Now warned, and the rule stays listed and is tried again (three times). Tests: backgroundRobustness.test.mjs (1, 2, 3, 5 through the background's listeners), MultiRegexMatcher.test.mjs (4), NetRequestRuleManager.test.mjs (6). Each new test was run against the code before the fix and failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r; drop dead messages Three parts of the content.js review (issue #233): - SCRAPE_CAPTIONS counted each <track> only as it asked for it, and a request that fails at once (a src XMLHttpRequest.open refuses) calls back inside the loop. With such a track first, the answer went out empty before the next track was even requested, and the player opened without captions it could have had. All tracks are counted first now. - Opening a player and removing it each sent SEND_TO_CONTENT 'active-state', which the background passed back to the same frame with destination 'custom'; nothing reads it since the YouTube site script was removed. Two round trips per player for nothing; gone. The other dormant hooks the issue lists (Config's custom* fields, OPEN_PLAYER's force/softReplace, the POST branch of httpRequest, the player's key_down/seek_to) cost nothing at run time and are upstream code; they stay. - types/messages.d.ts: GET_VIDEO_SIZE's answer is the video's visible area (a number), not {width, height}; OPEN_PLAYER carries attempt; noRedirect's comment had the top frame backwards. Proven by tests/unit/contentScript.test.mjs: "waits for every track when one request fails at once" and "sends the background nothing for the site scripts on opening and removing a player" fail against the previous content.js; GET_VIDEO_SIZE's tests pin the answer the type now describes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d next (#282) Shortcut MPV, the user presses play before the page asks for the video's stream. The first stream after the play took the play's slot while its length was read; a second one in that time (the episode's manifest 200 ms after an ad's) found the slot taken and was only tracked. When the first turned out to be another video's length, the slot came back, and nothing looked at the second again: the play sent nothing, and said nothing. Streams detected while one is checked now wait in the tab's mpvPlayChecking and are checked in turn (sendPendingPlay); the first that can be the video's goes. A new page, a reload or MPV started again drop the queue with the play. Tests: tests/unit/backgroundMpv.test.mjs. "an ad's came first" fails on the old code (nothing sent) and with the queue push taken out; "the next page's play" fails when the URL change does not drop the queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#158) Firefox stops an idle background, and restarts it for the next event. The tab state that has to survive that is kept in storage.session (PersistedTabFields), but four MPV fields were not: - mpvError: a woken background redraws every tab's toolbar button from what it knows, so the "!" of a failed hand-off went away with the failure unresolved. - mpvPlayPendingUntil / mpvPlayedVideo: a play in shortcut MPV whose stream the player asks for only afterwards; after a restart that stream was only tracked, and nothing went to mpv. - mpvLastPlaySend: the same video played again within 10 s only pauses the page; after a restart it opened a second mpv window. They are saved where they change and put back with the rest. Tests: tests/unit/backgroundMpv.test.mjs loads the background twice with the same session storage. The three restart tests fail with the fields left out of PersistedTabFields; "no '!' once a later hand-off worked" fails without the save in setMpvError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d> alone fillScreenIframe (windowed fullscreen, and the overlay player) hides every element of the page but the player's ancestors. Each element was looked up in the array of elements already changed, and undoFillScreenIframe took each one out with a splice inside a forward loop, plus an includes() on the whitelist: quadratic both ways. On a page of 50,000 elements, entering and leaving took 3.7 s on the page's main thread in the unit test, inside the message handler. The record is a Map now (it is only ever looked up by element), the whitelist a Set, and the ancestor trace a Set. The walk also put display: none on every <meta>, <script>, <style> and <link> in the <head>; it skips the <head> now. Proven by tests/unit/contentScript.test.mjs "windowed fullscreen": "takes well under a second for a page of 50,000 elements" (3.7 s before, about 50 ms now) and "leaves the <head> alone" fail against the previous content.js; "hides the rest of the page, and gives every element its style back" holds before and after. Issue #227. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…page's (#289) On an allowlisted site, a play on a page the back-forward cache gave back (an MSE player, so the play names no stream) sends that page's known stream after 3 s, unless one went meanwhile. The timer checked the tab, not the page: Back once more within the 3 s gave another page back with its kept stream, and that stream went to mpv for the first page's play. It now checks the address and the page's name, as onUserPlay already did for its own wait. Tests: tests/unit/backgroundMpv.test.mjs, "the allowlist's MPV, a play on a page Back gave back". "another page within the 3 s" fails on the old code (B's stream sent), "at the same address" without the page-name check; "no other went" pins the 3 s hand-off itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oval, miniplayer edge cases Three of the four content.js lifecycle defects in issue #229: 1. A soft replace turns hard a second after opening when the page's element collapses once shown (makeSoftIntoHard). That check only visits players that have linked up (iframeMap), and on a slow start the player links after it, so the element stayed in the page next to the player for good. The 1-second timer now marks a player that has not linked yet, and the link handler runs the check for it. 2. removePlayers gave back the page elements windowed fullscreen had hidden, but not the style of the linked iframe itself, which only leaving windowed fullscreen restores: an embed iframe holding the player stayed fixed over the whole page after a same-site navigation, and its state stayed "active". removePlayers leaves windowed fullscreen first, as a reload of the frame does. 4. The miniplayer threw inside the message handler when the page had taken the player's iframe out (insertBefore on a missing parent), leaving the request unanswered and the state half made; it now leaves such a player alone, and any other failure is answered 'error'. A wrapper that came to fill the body made the body the miniplayer, with a second <body> put in before the real one; the body is never taken now. A placeholder without a size gave NaN or a 0 px high miniplayer; it falls back to 16:9. Part 3 (REMOVE_PLAYERS reaching frame 0 only) is left for a decision: sending it to every frame would also tear down players in embeds a single-page site keeps across its navigations. Proven by tests/unit/contentScript.test.mjs: "turns hard when the player linked up later", "takes an embed holding the player out of windowed fullscreen" and the three TOGGLE_MINIPLAYER cases fail against the previous content.js. Issue #229. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the page its element "This Frame > Reload Frame" on the in-page player loads the player page again in the same frame (a right-click on the video shows Firefox's menu). Two things went wrong: - The background's sendSources takes what it hands a player out of the page's frames (collectSources(frame, true)), so the reloaded player's REQUEST_SOURCES got nothing and it sat on the welcome screen. The page's frame now keeps what it last handed a player (FrameHolder.noteHandedToPlayer), and a player in that same frame that finds nothing new gets it again. A new player is a new iframe and so a new frame: it is not affected. FRAME_ADDED's reset forgets it. - The page's content script linked the same iframe again under a new entry whose replacedData came from the queue, emptied at the first link. A later removePlayers (same-site link, pushState) then never gave the page its element back: it stayed at 0x0 with its media pausing itself on every play until a reload. The new entry keeps the old one's replacedData. Proven by tests/unit/contentScript.test.mjs "a player that loads again in its frame" (fails against the previous content.js) and tests/unit/TabTracker.test.mjs "handedTo" (fails with the frame check or the reset taken out). The new content-cleanup e2e case reloads the player's iframe and checks both. Issue #288. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…self (#283) When a tab's address came to match the MPV allowlist or the auto-enable list, tabs.onUpdated handed off the streams the tab tracked at that moment. That event can come before the new page's FRAME_ADDED, which is what drops the page before's streams. With an allowlist entry for a site's /watch path, the trailer its home page played went to mpv for the episode the user opened from it, and the episode's own stream then found the page handed off already; on the auto-enable list the in-page player opened for it. The start now runs 500 ms later (startWithTrackedLater), when the new page has named itself, and only if it still applies; a stream the page asks for meanwhile goes by itself as before, and the tab's next address change cancels it. A page that changes its address itself (pushState) names no new page, and its streams still go. Tests: tests/unit/backgroundMpv.test.mjs, "a same-site link into an allowlisted or auto-enabled path". The trailer and in-page player cases fail on the old code; "changes again meanwhile" fails without the cancel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ross private browsing (#235, part of #145) Requests that belong to no tab (tabId -1: a service worker's) were detected into a holder for "tab -1". No player opens there and nothing goes to mpv from it, and no tab event ever resets it: it collected every stream such requests fetched for the whole session, and each one sent the growing list, with the requests' headers, to every player tab. The background now ignores those requests, as it already ignored its own length reads (the same check, which no longer needs the request's originUrl; its type goes too). A player opened in a tab of its own lists the streams the other tabs' pages ask for. That list now stays on the side of private browsing the page is on: a private window's streams, with their cookies, showed in an ordinary window's player tab, and the other way round. (Containers are left as they are: a question for the owner on #145.) Tests: tests/unit/backgroundDetection.test.mjs, "the streams a player tab lists from other tabs": the tab -1 and the private-window cases fail on the old code; the plain case passes on both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…layer" on its whole box The overlay guard hides what a page lays over FastStream's player, by geometry. Two cases it got wrong (issue #226): - A site's own dialog opened while the player is up (sign-in, cookie consent, settings) is a layer over the whole page with the dialog in it: the geometry of an ad layer. Within a second it was hidden, so the user saw no dialog, and clicks went through its backdrop. An element that is or holds a dialog (dialog[open], role="dialog"/"alertdialog", aria-modal="true"), or holds the focused element, is now left alone with everything that holds it, and one that becomes such a dialog after it was hidden is given back. An ad that calls itself a dialog stays visible: the price of not hiding real dialogs. - "Covers half the player" was measured against the part of the player on screen. With the player scrolled mostly out of view, a sticky page header covered "half" of that strip: it was hidden, came back when the player left the screen, and went again on the way back. Both rules now measure against the iframe's whole box; only "is any of it on screen" and the hit test use the visible part. Proven by the new tests/unit/overlayGuard.test.mjs, on the stand-in DOM: the four dialog cases and "leaves a page header alone over a player scrolled mostly out of view" fail against the previous overlay-guard.js; the ad layer and bar cases hold before and after. A new content-overlays e2e case opens a dialog over a player in Firefox. Issue #226. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (part of #145) Its only sender was the YouTube content script, deleted with YouTube support (e3622c3); since then the handler, the frames' loadedCallbacks that FRAME_ADDED and forgetFrame answered, and the enum entry were dead. The handler's PING_TAB also went to every frame of the tab, so any iframe's answer would have counted as the main frame's. No behaviour changes; the two TabTracker tests of the callbacks go with them. content.js still answers PING_TAB, now from nothing (left alone here). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#234) Nothing trimmed a frame's detected streams and subtitle files but a navigation. An HLS or DASH player's pieces are detected one by one when the site names them .mp4/.vtt (Shaka Packager does): a page left open for an evening collected thousands, each with its request's headers, sent the whole growing list to every player tab on each new one, and a player opened then read the length of every one of them (64 KB each). A frame now keeps at most 20 files of one shape (StreamLengths.shape: the same URL but for its numbers) - the first, which keeps the shape known as a stream's pieces for StreamLengths.lengthsOf, and the newest - and 200 streams in all, dropping files before manifests. Subtitle files the same. Tests: TabTracker.test.mjs "what a frame keeps of its streams" (fails with either rule undone); backgroundDetection.test.mjs "a page that is never left" drives 60 pieces of each kind through the background and fails on the old code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fest, and log less The Facebook, Bilibili and Instagram site scripts (issue #232): - facebook_content.js took the first playback_video's playlist unchecked: one without a manifest became a source whose manifest was the word "undefined" (btoa(undefined)). It takes the first one that has a manifest now. - bilibili_content.js threw, uncaught, at document_end on play info of another shape (FLV, data.durl, instead of data.dash) or with more code after it on its line, and detected nothing. Each script tag's parse and conversion is caught now. - All three built the data: URL with btoa(), which throws on any character outside Latin-1 (a title, an address), and wrote Latin-1 bytes for the rest, which the player read as UTF-8. They encode the manifest's UTF-8 now (base64Utf8). - instagram_inject.js logged "No video_dash_manifest found" as an error for every JSON response of the page; gone. - instagram_content.js passed on any same-origin postMessage of type fs_source_detected, whatever its ext, and the background recorded a report of no known type as a source of mode undefined. The content script now passes on only DASH manifests, and the background takes a DETECTED_SOURCE only with a string URL and a type it plays (BackgroundUtils.detectedSourceMode). Not done here: rescanning on the single-page sites' URL changes and a fetch() hook for Instagram both need checking on the live sites first. Proven by tests/unit/customSiteScripts.test.mjs (the site scripts in a vm on a stand-in page; 8 of its 9 cases fail against the previous scripts) and the new case in tests/unit/instagramInject.test.mjs (fails against the previous instagram_inject.js). Issue #232. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e (part of #143) A file a <video> or <audio> element loaded, with no type in its URL, was always taken for an MP4 video. A site's notification sound or a podcast's MP3 then went to mpv as an allowlisted page's first stream (and the video after it was only tracked), or opened the player on a tab that is on; an extensionless WebM went to the player's MP4 mode, which reads MP4 only. Now audio/* is no stream, video/webm and video/ogg play as they are (as a .webm URL already did), and anything else is an MP4 as before (ManifestTypes.modeFromMediaType). Tests: ManifestTypes.test.mjs (modeFromMediaType); backgroundMpv.test.mjs "a page that plays a sound first" fails with the old ACCELERATED_MP4 line back in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Firefox reports a tab's fragment change (an anchor link, a page updating #t=) through tabs.onUpdated as it reports a new page. The background took it for one: it reset the allowlist's per-page mpv latch, so the page's next stream request (a quality switch, the next segment file) opened a second mpv window while the first still played, and it sent REMOVE_PLAYERS, which took the in-page player down. content.js's own link handler already keeps the player for such a link. Now a URL that differs only in its fragment is the same page (BackgroundUtils.isSamePageUrlChange), and neither happens. A hash route (#/episode/2, #!/...) is still a page of its own, as single-page sites built on hash routing play their next episode that way, and the same URL again (a reload) still is too. Proven by tests/unit/samePageUrlChange.test.mjs: the same-page cases fail when every change counts as a new page (the old behaviour), and the hash-route cases fail without the route check. Issue #236. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a background restart When Firefox has suspended the background, it knows no stream of a tab and asks each frame what its page loaded (REPORT_LOADED_MEDIA), from the page's Resource Timing entries. That timeline keeps a page's first 250 requests, and a streaming page laden with ads has made that many before its player asks for the manifest: the manifest was never there, and switching FastStream on found nothing to open. content.js now runs a PerformanceObserver from document_start, which Firefox tells of every resource entry whether the buffer is full or not (Performance::InsertResourceEntry queues each entry to observers before it checks the buffer), and keeps the first 1000 requests of the kinds it reports (XHR, fetch, media, other; a URL once). loadedMedia reports those the timeline no longer has. Nothing the page can see changes, unlike raising the page's buffer with setResourceTimingBufferSize. Where there is no observer, it is as before. Proven by tests/unit/contentScript.test.mjs "reports a manifest the page asked for after 300 other requests" (fails against the previous content.js). The new toolbar-recover e2e case loads 300 images before the stream, checks the page's own timeline indeed lacks it, suspends the background and switches FastStream on: the browser part, which no unit test can show. Issue #231. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both change background.mjs, so this branch carries #305's commits and the two PRs can merge in either order. Resolved: - tabs.onUpdated: this branch's `if (!samePage)` around the per-page resets, with #305's two new ones (mpvPlayChecking, the deferred start's timer) inside it. - PLAYER_LOADED: this branch moved the body into acceptPlayer(); #305's download fix (take tab.downloadInfo first, catch the tab close) is ported there. #305 also removed the loadedCallbacks plumbing, which acceptPlayer no longer has either. - TabTracker.test.mjs: both branches' new describe blocks. - backgroundDetection.test.mjs (#305's): its in-page player now names its page (?opener=page-1), as content.js's players do; #225's check refuses a player that does not. Lint, typecheck and 1072 unit tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to its own start
CI's page-subtitles e2e caught it: on an auto-enable URL the player opened twice, the
first time half a second after the address changed, from the stream the new page had
asked for meanwhile, before its <track> captions were read; the player then showed the
detected subtitle files ("en", "de") instead of the page's labelled tracks. Before #283
the start ran at the address change itself, when the new page had no streams yet.
startWithTrackedLater now passes the time of the address change, and openPlayersWithSources
counts only streams found before it; a later one opens the player through onSourceRecieved
(captions first, then replaceDelay), as before. New test in backgroundMpv.test.mjs: two
OPEN_PLAYERs without the fix, one, after SCRAPE_CAPTIONS, with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…laude/issues-content
Nawid3333
added a commit
that referenced
this pull request
Oct 3, 2026
httpRequest's one caller is the <track> scrape, so it now hands over the file's text read by decodeSubtitleBytes, and the caller is as it was on main. The content PR (#306) rewrote that caller's loop, and the two changes to it conflicted; now neither PR's merge waits on the other. The call-site check matched content.js's own copy of the function, so it passed with no call at all; it now wants a call. With httpRequest handing over the bytes undecoded, it fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uest as Firefox does The subtitle PR (#302) has content.js ask for a track's bytes (responseType 'arraybuffer', xhr.response, getResponseHeader) to read Windows-1252 files. This harness answered only responseText and had no getResponseHeader, so once both PRs are merged each request threw in its callback and two SCRAPE_CAPTIONS tests timed out (seen in a trial merge of every open PR). The stand-in now hands the bytes to an arraybuffer request, throws on responseText then as Firefox does, answers getResponseHeader, and the page has a TextDecoder. With this, all 24 tests here pass on this branch and all 1481 on the trial merge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… read at run time (CodeQL js/code-injection) CodeQL flagged contentDom.mjs and customSiteScripts.test.mjs: vm runs content.js, overlay-guard.js and the site scripts as read with fs.readFileSync, "user-provided" under this repository's threat model. They are ?raw imports now (import.meta.glob of chrome/custom/*.js for the site scripts), which vitest includes as each file's text when it loads the test: the same text, so the same scripts run, under the same filename. A site script that is not there fails the test by name. Checked with CodeQL 2.27.1, the suite and threat model this repository's code scanning uses (security-extended, local). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017JiuRmQkMrNTDSUbPnwKsn
11 tasks
Nawid3333
pushed a commit
that referenced
this pull request
Oct 3, 2026
…ayer was handed #306 (#288) keeps what a page frame handed its player (handedTo), so a player that loads again in its frame gets its stream back. CLEAR_SOURCES emptied every frame's source list but not that record, so the player's next REQUEST_SOURCES sent the cleared stream again: embed-page-query.e2e.mjs's new Clear test (#258) got [1] on every ask on Firefox Beta, once this branch had #306 from main. CLEAR_SOURCES now also calls forgetHandedToPlayer() on every frame. Unit tests drive the real background: a player asking again still gets its stream, and gets nothing after a Clear (fails without the fix). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JzfFWJzzaUDto87UaqoJGy
This was referenced Oct 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In-page player fixes: fake players, hidden site dialogs, lost videos
What was wrong, as you'd notice it:
What changed:
<head>alone.How it's proven:
Fixes #225
Fixes #226
Fixes #227
Fixes #231
Fixes #236
Fixes #288
Not fixed here (your call, recommendations in brackets):
🤖 Generated with Claude Code