* feat(services): server storage connection state and access token Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server kind for the playlist storage backend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server storage API client The @iptvnator/services/server-storage sub-entry keeps server mode out of the eager services barrel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): playlist document store over the server API Full documents are cached by ETag (weak tags included) and served as private copies; an upsert whose payload is the one the store handed out sends keepPayload=1 without it and retries in full on payload_missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server-synced user data storage Synced Xtream maps live in a write-through cache under the iptvnator-server: prefix; writes send only the playlist entries that changed, debounced, and flush with keepalive when the page hides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): server storage session and token interceptor The session plugs the HTTP document store into PlaylistStorageBackend, hydrates synced user data before the first read (bounded to 3 s), retries while offline, flushes on hide, and routes to sign-in when the server answers 401. The interceptor adds the access token to HttpClient calls to the backend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(services): move browser playlists to the server An empty server gets the browser playlists with their ids plus the legacy Xtream user-data maps; a server that already has playlists goes through the backup export and import, so duplicates merge. Browser data is never deleted; the decision is remembered per server instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(pwa): probe for server storage and wire server mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(pwa): server sign-in page and migration dialog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): server section for self-hosted storage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(workspace): server status in the rail and an offline banner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(settings): warn that delete-all clears the server Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(i18n): server storage strings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): server storage across browsers and the migration dialog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(self-hosted): server storage mode, sign-in and the release note Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pwa): stay in server mode when a known server does not answer A failed start-up probe used to fall back to browser mode, which showed an empty library while the backend restarted and let new playlists land in the browser. The browser now remembers the server; without an answer it starts in server mode, offline, and reloads once the server is back. Settings → Server shows the backend address instead of the page's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep Xtream data, ordered sync, sign-in and stale-cache fixes - Moving browser playlists matches them to the server's by id or source and carries the legacy Xtream maps under the server id; it no longer goes through the backup format, whose PWA export has no Xtream user data. A partial move stays on offer. - User-data batches go out in order, and a re-read keeps entries edited while it was in flight. - A keepPayload write keeps its cached copy only when the server replaced the version its payload came from (new X-Replaced-ETag header). - 502/503/504 from the proxy count as offline. - The start-up screen lets the sign-in page through while a token is required. - Settings → Server offers a reload after a move. - The probe detects Electron through RuntimeCapabilitiesService. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): merge user data on move, keep failed writes, sign-in route gate - A merged playlist gains the browser document's favorites and history; Xtream values for one server entry are combined (lists unioned, maps merged, the server winning conflicts) and written once. - A failed user-data write stays queued and survives a re-read; the page-hide flush waits for a batch still travelling. - The start-up screen lets the sign-in route through whatever its check reports. - The backend refuses IPTVNATOR_ACCESS_TOKEN without server storage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): positions by content, acknowledged keepalive, serial re-reads, token changes - Moved playback positions are matched by content type and Xtream id and name the server playlist. - Page-hide writes report whether the server took them; refused ones go back into the queue. Re-reads run one at a time. - The access guard's 401 carries WWW-Authenticate: Bearer realm="iptvnator"; the interceptor and PwaService's raw fetches turn it into sign-in, so a token changed on the server is noticed on provider calls too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Stalker keys by category, keepalive ordering, cut-off probe - Moving Stalker favorites and history keys items by id and category, so a channel and a movie with one id both survive. - Writes after a page-hide flush wait for its keepalive requests. - A probe whose body is cut off counts as unreachable and keeps the remembered server. - Settings → Server renders without @defer so settings search can reveal its rows on the first visit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): cache baseline before first read, versioned retries, M3U favorite ids - Before the first user-data read finishes, an edit is compared with the cache, so it sends only its own playlist entry. - Queued entries carry their edit number; a failed write is retried only when nothing newer replaced it. - Moving M3U favorites and history translates channel ids through the stream URL (ids are random per import). - The server-storage E2E runs in Chromium only: one shared database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep URL favorites, flush before moving, cache-full and cut-off bodies - Moving M3U favorites keeps URL favorites and anything the server cannot resolve; only older channel ids are translated. - Queued user-data edits are sent before the move writes combined values. - A re-read that does not fit the cache keeps the old cache and baseline together. - A response body cut off after its headers counts as offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): unsent writes survive reloads, guarded move, lazy storage - User-data writes the server has not confirmed are kept under iptvnator-server:unsent-writes and sent first on the next visit. - Moving browser data does not start while user-data writes are unsent, and a duplicate of a failed upload counts as failed, not merged. - The session reaches localStorage through the lazy adapter, so a browser that denies storage still boots. - Raise launch/renderer.domMutationsToFirstCard by 2 (569 → 571): the rail server shortcut and the offline banner each leave an @if anchor in every build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): locks follow merged playlists, typed legacy Xtream ids, instance-scoped cache - Moving a playlist under another server id takes its parental locks along (they stay in the browser). - Bare legacy Xtream ids in each copy are typed from that copy's own snapshots before favorites, history and snapshots are combined, so a live channel and a movie sharing an id both survive. - Writes still travelling are saved on page hide too; saved writes and the cache belong to one server database (cache owner = instanceId). - A late successful answer no longer turns unauthorized back to online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): apply early edits to the server value, newest unsent write, sticky sign-out - Edits made before the first successful user-data read are kept as before → after and applied to the server's value once it arrives (applyEdit), so a new device or a stale cache adds a favorite instead of replacing the server's list. - Saved unsent writes keep only the newest per entry; an answered older write no longer retires a newer one still travelling. - A late failure after sign-out no longer turns unauthorized into offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep early edits across reloads and tabs, numeric-string legacy ids - Edits made before the first read are saved (before → after) and restored after a reload; a second early edit keeps the first one's baseline, even an empty one; a cache-full first read still applies them to the server's value. - Queued writes are saved as soon as they are queued, and a re-read keeps entries another tab has not confirmed yet. - Legacy Xtream ids stored as numeric strings are typed too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): per-tab saved writes, memory overflow cache, snapshot aliases - Unconfirmed writes and early edits are kept in the tab's own sessionStorage, so tabs never clear each other's; a shared registry of unconfirmed entries (one record per tab, ignored after ten minutes) keeps a re-read in one tab from replacing another tab's values. - A cache value too big for localStorage stays readable from memory, so later edits are diffed against what the app reads. - Typing legacy Xtream ids keeps each snapshot's bare key as an alias, so a reference not yet rewritten still resolves and a retry can type it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional user-data writes, scoped token, deletes - User-state entries carry a content tag; PUT/DELETE accept If-Match or If-None-Match: * and answer 412 with the current value and tag. The PWA sends each edit on the tag it read and replays it (before → after) on the server's value after a conflict, so another device's change to the same playlist's favorites or positions is kept instead of overwritten. - The access token goes only to the backend URL (same origin and path), never to another host or a sibling path. - Deleting a playlist (or all) first drops its queued user-data edits and waits for writes in flight, so none recreates deleted data. - The migration copy writes on the tags it read. - The write queue moves out of ServerUserDataStorage into UserDataWriteQueue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(server-storage): conditional user-state writes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep tags with their values, rebase page-hide writes, hold deletes - A re-read that keeps an entry from the cache (another tab's or this tab's unconfirmed value) no longer takes the server's tag for it, so the next edit conflicts and is replayed instead of overwriting another device's change. - Page-hide writes behind a travelling one are taken from the queue when it is answered, so its new version becomes their baseline. - A playlist delete holds that playlist's user-data edits instead of discarding them up front; only a successful delete drops them, a failed one sends them as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): replay edits behind travelling writes in order, hold writes after an offline start - An edit queued behind a travelling write keeps its own before → after and carries the travelling write's edit as `prior`, also in the saved writes. A conflict replays both in order, so adding and then removing a favorite removes it whether or not the first write landed, and a lost write is not dropped. - Page-hide writes now report their new tag or a 412 like normal writes: a confirmed one rebases the next edit, a conflict is replayed. - After an offline start from a remembered instance, writes are held and the retry only checks /instance before reloading, so saved writes never reach another database behind the same address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): durable per-page journal, conflicts for edits on other tabs' changes, lock readability - Unconfirmed writes and early edits are saved in localStorage per page load; the tab remembers its page load in sessionStorage, so a reload takes them over, and another tab replays a closed tab's writes once its record is stale. Live tabs renew their record every 30 s. - An edit made on another tab's unsent change, and an entry a re-read keeps from the cache, are sent on a version no entry has, so the write conflicts and is replayed instead of carrying another tab's change past a later removal. - Moving browser data counts the parental-lock step as failed while the lock store cannot be read, so the move stays on offer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep edits as ordered steps, treat probe refusals as transient, read the cache through memory - A queued write keeps its edits as steps: an edit continuing the last one joins it, one made behind a travelling write or on another tab's change stays apart, and a conflict replays them in order. Saved writes and early edits store the steps too. - The instance probe treats any refusal but 404 (a proxy's 429 or 403) as unreachable, so a browser keeps its remembered server. - Cache reads that browser storage refuses fall back to memory, so a re-read and later edits still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep steps of possibly-landed writes apart, queue closed tabs' edits even when the cache shows them - Steps of a write whose answer was lost, of saved writes taken over after a reload, and of a closed tab's saved edits are marked uncertain, so a later edit never joins them: adding and then removing an item after a lost answer still removes it. - A closed tab's saved edits are queued ahead of this tab's own even when the cache already shows them, so a conflict replays them too. - The tag map moves into UserStateTags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(workspace): style the server banner from the shell, baseline 570 The offline banner's own stylesheet added a <style> element to every launch, one of the two mutations the launch journey counted on top of master. Its rules move into the workspace shell's stylesheet. The other one is a parser-inserted modulepreload link: the new lazy server-storage chunks split one shared initial chunk in two. A/B on macOS: master 555, this branch 556 (was 557); the baseline note now says so instead of blaming @if anchors, which cost nothing here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): apply a caller's edits to the cache as it is now ServerUserDataStorage remembers the map getItem handed out. When the cache changed since (another tab saved, a re-read), a write applies only the entries the caller changed to the current cache, so a map read before another tab's save no longer reads as deleting that save. The cache moves into UserDataCache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): retry unsynced user data while online, keep newer typed snapshots, explain a refused token store - The 30 s retry also re-reads and flushes while online when the first user-data read failed or writes the server refused (a 5xx) are queued; the same failure notice shows at most every five minutes. - Moving browser data adds a legacy snapshot alias only where no typed snapshot exists, so a newer typed copy is not overwritten. - writeServerAccessToken reports whether storage kept the token; the sign-in page says so (SERVER_STORAGE.SIGN_IN_STORAGE, 19 locales) instead of calling a right token rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional playlist metadata writes, retried on a fresh read A favorite or rename (keepPayload PUT) is sent with If-Match on the version it was read at. The server answers 412 when another device wrote since; the store throws PlaylistWriteConflict and PlaylistsService's per-playlist write queue runs the mutation again, which reads the playlist afresh, so two devices' metadata edits both survive. A retry on the same unread copy writes unconditionally, as before; refresh and import writes stay last write wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conditional writes for every playlist mutation, ordered adoption, keep journals until copied - Every playlist mutation is sent on the version it was read at: the payload's ETag, else the playlist's last read (portal documents have no payload). Refused versions are tracked per playlist, so a conflict on one never disables the check on another at the same revision. Imports and start-up migrations (dbUpsertAppPlaylists) stay unconditional. - A closed tab's adopted edits replay before this tab's queued ones, from the base of its first step, so newer progress is not replaced by older. - A reload or adoption removes the old page load's saved writes only when this page saved its copy; each closed tab is adopted once per page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): copy-bound portal versions, stale 401s, unreadable browser maps, early edits on recovered writes - A served copy carries the version it came from (a symbol property that spreads keep and JSON drops), so a mutation of a portal document names the version it was built on, not the latest read. - A 401 for a token replaced since (a new sign-in) neither clears the new token nor reports unauthorized, in the API client, the HttpClient interceptor and PwaService's proxy calls. - Moving browser data counts a browser user-data map that cannot be read as a failure, so the move stays on offer. - An edit made before the first read on top of this tab's recovered unsent write is applied to that write, not to the server's value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): favorite toggles as deltas, early edits as steps on recovered writes, release kept journal copies - PlaylistEffects.updateFavorites$ applies the toggled channel through transformPlaylistFavorites, on the favorites read at write time, instead of writing the reducer's whole array: a conflict retry keeps another device's favorites (#1919). - Edits made before the first read on top of this tab's recovered unsent write are appended to it as their own steps, so a removal of an item the recovered write adds survives a conflict replay. - Journal copies of earlier page loads kept because this page could not save its own are removed once it can. - adopt/extend move into server-user-data-adopt.ts over a small queue API (unsent, isQueued, put); the queue exposes its tag map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): refuse user state for deleted playlists PUT /v1/user-state/:namespace/:playlistId answers 404 playlist_missing when the server has no such playlist, so an edit queued on another device (including its conflict replay with If-None-Match: *) cannot recreate the state of a playlist deleted meanwhile. The client drops such an edit and its cache entry instead of retrying; page-hide writes report it as gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep duplicated tabs off live journals, drain writes around remove-all, normalize legacy Stalker rows on the move - A page marks its unconfirmed-writes record closed on pagehide (not into the back-forward cache); a new page takes over the previous page load's saved writes only when that record is closed, stale or missing, so a duplicated tab, which copies sessionStorage, leaves the running tab its writes. - PlaylistsService.removeAll lets queued playlist writes land first and makes writes issued meanwhile wait until it finished; the server store refuses a document without an id. - Moving browser data sets the explicit Stalker portal-mode flag on rows from before it (withExplicitStalkerPortalFlag, now shared with PlaylistsService). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): take over a reload's writes once the previous page load stops looking live When the close flag could not be saved (storage full), a reload took the previous page load for a running duplicated tab and left its saved writes unsent. The page now remembers that page load and its 30 s touch takes it over as soon as its record is closed, stale or gone, so the session's retry sends the writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopt a late previous page load instead of restoring it When touch() finds that the previous page load no longer looks live, this page may already have read and queued newer edits. Its saved writes and early edits are now adopted like a closed tab's (their steps ahead of this page's newer ones, queued at once) instead of restored as if this page had just started, which replaced newer progress with older and parked early edits that were never sent after the first read. Cache forgetting moves into UserDataCache.forget(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopted writes without a known version conflict instead of overwriting A page that never read the server has no version tags, so an adopted write went out unconditionally and replaced newer server data with its cached value. Adopted edits now use the never-matching version when this page knows none (and for a queued write without a tag), so the server answers 412 and the edits are replayed on its value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a re-read sent before a playlist delete skips that playlist ServerUserDataStorage records each successful delete; a re-read sent before it drops the deleted playlist's entries and tags from the server's answer (withoutDeleted), so a late answer cannot write the deleted favorites and positions back into the cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): keep a playlist id like __proto__ when filtering a read withoutDeleted() builds its maps without a prototype, so a surviving playlist whose id is __proto__ stays a key instead of changing the object's prototype and losing its favorites and tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): reorders on the stored favorites, URL-insensitive remembered server, prototype-free id maps - PlaylistEffects.setFavorites$ applies the drag's order through transformPlaylistFavorites (reorderFavorites): favorites added on another device stay, ones removed there stay out. - The remembered server matches the backend URL by origin and path without trailing slashes, so /api and /api/ start offline in server mode alike. - Maps keyed by playlist id are built without a prototype (idMap), so an id such as __proto__ is an ordinary key in the cache, conflict replays, re-reads and tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): one registry key per page load, server token only in server mode - The unconfirmed-writes registry stores each page load's record under its own key (iptvnator-server:unconfirmed:<instance>:<page>), listed through a new keys() on the user-data storage adapter, so tabs never overwrite each other's records. Saved writes without a fresh record, also when the record was lost, are adopted as a closed tab's (#1920). - PwaService sends the stored server token on its raw fetch calls only while server mode runs, and a backend that answers without server storage makes the browser forget the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): forget the token for another server, listen before sign-in, adopt only stale records - A server that answers as another backend URL or instance id than the remembered one makes the browser forget the stored token, so server A's token never reaches server B. - The session installs its page-hide and heartbeat listeners before it returns for sign-in, so a page holding another page load's writes still marks itself closed on unload. - Closed tabs are found by stale records only again: saved writes without a record may belong to a running tab whose record storage refused, and are left to that tab's own reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): no token for an unverified instance, sign-in remembers its server - ServerStorageStatusService.accessToken() returns the stored token only while server mode runs on an instance this start verified. The API client, the HttpClient interceptor and PwaService's raw fetch calls use it, so after an offline start from a remembered instance no request (including the /instance reachability check) carries the token to whatever answers at that address, and a 401 then does not clear it. - Sign-in remembers the server along with the token; when storage refuses either, it reports SIGN_IN_STORAGE instead of reloading into a start that would drop the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): check a remembered instance before sign-in sends the token A page started from memory (startedOffline) withholds the token until the instance is verified, so sign-in on that page sent no token and the server's 401 cleared the right one. Sign-in now first calls ServerStorageSession.ensureVerified(): it reads /instance without a token, marks the status verified when the instance id matches, and reloads the page when another server answers at the address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): read migration maps by own playlist ids only The browser's legacy maps and the server's user-state entries are read into null-prototype maps, so a playlist id such as `toString` is not taken for an entry and a missing server value no longer resolves to an inherited function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): bind the token to its instance; adopted edits yield to newer changes The access token is stored with the instance id it was given for, and a page reads it only for its own instance: a tab still open on the old backend after BACKEND_URL changed no longer sends the new server's token there. The API client hands every 401 to reportAccessDenied(), which now also sends a page without a token for its instance to sign-in, while one that holds its token back until the instance is checked keeps it. A closed tab's adopted edits carry `yields`: replayed, they change only what is still as that tab saw it, so they never replace progress this tab or another device saved since. The flag survives the journal. The sync spec's fake server and memory storage move to a test-helpers file to keep the spec under its line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): per-instance cache, idle tabs adopt closed ones, backups wait for the first read The user-data cache is keyed by server instance (`iptvnator-server:cache:<instanceId>:<namespace>`): a tab still open on the old backend after BACKEND_URL changed no longer writes over the new server's cache, which the new tab's next edit would have sent there. claimCache also removes the old owner's keys and the unscoped keys of earlier builds. touch(), which runs every 30 s, adopts closed tabs' saved writes once this tab has read: a tab with nothing to sync never re-reads, so they waited until a tab switch or reload. UserDataStorage gains an optional ready(); the server storage resolves it after its first read and rejects while that fails. A PWA backup export awaits it, so a fresh browser whose /user-state read is slow or failed cannot export empty favorites, history and positions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): token only to backend URLs in raw fetches; Stalker series keys in the move PwaService joins paths to the backend URL without its trailing slashes, so BACKEND_URL=/ no longer yields a protocol-relative //… URL that names another host, and it attaches the server token only when the request URL resolves to the backend (isBackendUrl), like the interceptor. The move's item key adds the Stalker series flag to id and category: a provider category can hold a movie and a series of one id, and the series favorite was dropped as a duplicate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Xtream reads and backups wait for the first read; keepalive deadline Startup waits for the first user-data read only a few seconds. ready() now waits for a first read in flight without starting one (an offline page is not held up) and tells whether one succeeded: - PwaXtreamDataSource awaits it before reading favorites, recent items and playback positions, so a new device whose /user-state read is slow does not show empty lists or start playback without its resume point. - A PWA backup export and restore fail while no read succeeded; the restore stays parked, since its replacement lists would otherwise merge into the server's (an empty favorites list changed nothing). A page-hide keepalive write gets the request timeout: a server that takes it and never answers no longer holds every later write, re-read and delete behind it until reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a failed sign-in keeps another tab's token; moved history newest first A sign-in that fails clears the token only while it is still the one this attempt stored: another tab that signed in meanwhile keeps its valid token. The move orders the combined Xtream recent items by viewedAt, newest first, as the app keeps them: browser views appended after the server's history were cut by the next view once the server held 50. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): recheck the instance after offline, back-forward cache pages, Stalker server rows Going offline now marks the instance unchecked: the token and writes wait until /instance answers with this page's instance (another one reloads the page). A replaced volume at the same address no longer gets the old token and queued user-state writes on reconnect. The session checks as soon as it goes offline, so a blip costs no retry interval. A page restored from the back-forward cache whose record another tab adopted meanwhile (its timers were frozen, so the record went stale) reloads instead of sending those writes again after newer edits. The Stalker playlist metadata migration also runs on server rows, once per server: a desktop database copied to the server can predate it, and its Stalker rows lacked isFullStalkerPortal. The take-over and change-diff loops move out of the user-data facade, and the adoption specs get their own file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): adopted edits keep yielding through conflicts; newest view per item; same-token sign-ins A conflict replay collapsed every step into one edit, so a closed tab's adopted step lost `yields` after the first conflict and a second one replayed its old position over a newer one. Its steps now stay as they are, and only this page's own collapse into one edit on top of them. The move combines recently viewed items one per item, keeping the newest view and its artwork, then newest first; before, the server's older copy of an item won over a later browser view. A failed sign-in clears the stored token only on a 401: a network failure says nothing about the token, and another tab may have stored the same one meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): suspended tabs give up adopted writes; history stays newest first; favorite checks wait A background tab the browser suspended past the record lifetime can have its writes adopted elsewhere, like a back-forward cache page: shown again, it now checks resumed() and reloads instead of sending them. Conflict replays appended a newly viewed item at the end of the history, where the next 50-item cut dropped it; a replayed recently viewed list is now kept newest first. isFavorite() waits for the first user-data read like the list reads, so a detail page opened during a slow first read does not toggle an existing favorite the wrong way. A parked backup restore replayed when the playlist opens waits too, and stays parked while no read succeeded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): offline recheck checks only the instance; sign-out always navigates The recheck on going offline ran a full refresh: when /instance answered but /user-state kept failing, each failed read set the page offline again and started the next one at once, bypassing the retry interval. It now checks the instance only; reads wait for the retry. Sign-out navigates to sign-in itself, also when the page is signed out already: a guard (unsaved Settings) may have cancelled the redirect the first time, and the state change that drives it does not repeat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a superseded page sends and saves nothing; re-reads keep other tabs' newer saves A suspended tab's retry timer could run before it was shown again: touch() recreated the record another tab had removed when it adopted the page's writes, so the page sent them again over newer edits. The facade now treats a page whose record is gone as superseded: the queue sends nothing (maySend hook), its journal and record are not written again, and the retry timer reloads it like a page shown again. A re-read answered after another tab saved an entry no longer writes the older answer over the shared cache: entries changed while the read travelled are kept, with an unknown tag, so the next write here conflicts and replays instead of removing what the other tab saved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(web-e2e): never reuse another backend as the storage E2E backend server-storage.e2e.ts deletes every playlist before each test. Reusing a backend already listening on STORAGE_BACKEND_PORT outside CI could have wiped a real library; an occupied port now fails the run instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): an outage on the sign-in page also unverifies the instance reportOffline() left `verified` set while the page was unauthorized, so a sign-in retried after an outage skipped the instance check and could send the old instance's token to a replacement database. Verification is now cleared on any outage; only the state change stays guarded, so the workspace remains locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): interceptor and raw fetches report backend outages too Only the storage API client reported an unreachable backend as offline. When the first sign of an outage was an HttpClient call through the interceptor (provider targets, playlist parsing) or one of PwaService's raw fetches (Stalker, connectivity reset), the instance stayed verified, and the next request after a replacement instance came up at the same address would carry the old token. Both now report no answer (status 0, a failed fetch) and a gateway's 502/503/504 as offline, so the session checks /instance first. The backend's own JSON 502 for a failed provider is an answer and changes nothing; neither does a fetch this page aborted on its own timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): the immediate offline check runs once per retry interval A response whose headers arrived but whose body was cut off reports success and then offline, which started the next immediate instance check at once: repeated cut-off answers kept requests going and the connection indicator flickering. The check on going offline now runs at most once per retry interval; later failures wait for the timed retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a saved write without a readable tag counts as saved; unverified online pages keep retrying A write whose headers said saved but whose body (with the new tag) was cut off was retired without confirmation: a newer queued edit kept its steps and old tag, and a later conflict replayed the saved step over a newer value from another device. It now counts as saved on an unknown version: the landed steps drop out and the next write conflicts, so only edits made after it replay. Writes that were unconditional stay so. The keepalive path does the same. A late answer to a request sent before an outage can set an unverified page online; with nothing to sync the retry then never ran and the token stayed withheld. The retry now also runs for online pages that are not verified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep server storage off the startup bundle; form-field defaults on sign-in PwaService imported isBackendUrl from the @iptvnator/services/server-storage barrel, which pulled the whole storage subsystem (sync, migration, API client) into main, though server mode loads it by dynamic import. The helper now lives with the token helpers in @iptvnator/services (re-exported from the interceptor): renderer.initialBytes 1,392,902 -> 1,357,795 bytes locally. The remaining 11,988 bytes over #1931's baseline are what must run before the first route (instance probe, connection status, token bound to its instance, storage backend and write serialization); the baseline is raised with a note, under the PR's perf-baseline-increase label. The sign-in component provides the form-field defaults per component, as master's check (#1928) now requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): conflict-safe Stalker metadata migration on the server; newest document views in the move The Stalker metadata migration wrote server rows back through the unconditional bulk upsert, from the list read: a device that edited a Stalker playlist in between (or a second browser migrating the same copied database) lost its change. Server rows are now read again one by one and written as conditional mutations on that version; a conflict leaves the flag unset, so the next start retries. Moving a document's history kept the server's copy of an item both held, even when the browser had viewed it later, and skipped the write when the length did not change. The combined history is now newest first by added_at, one entry per item, and any content change is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): M3U favorites by URL and history channel ids in the move; restores wait for the server The move's M3U remap swapped references for server channel ids, but the app looks favorites up by stream URL and history entries carry the URL as their id: legacy favorites became invisible and history duplicated. Legacy id favorites now become their channel's URL on both sides, and history keeps its URL id with the server's channel id as channel_id. A restore in server mode reported success once its lists were queued: a list the server refused (413) left the old data while the parked restore was consumed. Both restore paths now wait for that playlist's writes (UserDataStorage.flushed, a few flush rounds for conflict replays) and stay parked when the server did not take them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a closed page record stays closed when a late write rewrites it On pagehide the page flushes with keepalive and then marks its record closed. When writes were held (offline), the keepalive write fails at once and its requeue saved the journal again a microtask later, which rewrote the record without `closed`: the reload took the previous page load for live and left its favorites and progress unsent until the record aged out (ten minutes). markUnconfirmed now keeps an existing record's closed flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): the move uploads playlists create-only and merges into a copy moved first Two tabs or devices moving browser copies with the same playlist id both planned an upload; the browser documents carry no served version, so both PUTs were unconditional and the later one overwrote the first copy (and any change made on it since), with both moves reporting success. The backend's playlist PUT now honours `If-None-Match: *` (412 when the playlist exists; checked and written in one synchronous handler). The move uploads with it through ServerPlaylistDocumentStore .dbCreateAppPlaylist, and an upload that finds its id taken merges its favorites and history into that copy instead, counted as merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): Delete All waits for repairs holding a reservation; backups read the server again Delete All raised its write barrier before it held the exclusive authority reset. A Stalker repair already holding its row reservation then waited on that barrier for its conditional write, while the reset waited for the reservation: neither finished, and later playlist operations stalled behind them. The barrier now goes up inside the reset, once repairs in flight have finished; writes queued so far land first and new ones wait for the delete, as before. ready() said yes after any earlier read, so an export could miss what another device added since, and restoring an empty list did nothing when the cache was already empty. Backup export, restore and the restore replay now ask for a fresh read (ready(true)) and fail, the restore staying parked, when it does not succeed. The facade's adoption loop moves to the adopt module to keep the facade under its line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(services): Delete All drains queued writes before its reset; reserved writes skip its barrier The previous commit raised Delete All's barrier inside the exclusive authority reset and drained queued writes there, but a queued deletion or import takes the shared side of that lock: the reset waited for it while it waited for the reset. Delete All drains the writes queued before it outside the lock again, then takes the reset, with its barrier up from the start. The deadlock with a Stalker repair (or Edit) that holds a row reservation and writes under it is broken on the other side: such a write does not wait for the barrier (holdsPlaylistAuthority, counted per reservation in this context), and the reset waits for the reservation as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a re-read keeps writes another tab had unconfirmed when it went out A re-read checked other tabs' unconfirmed entries only after its answer arrived. A tab whose write was unconfirmed when the read went out (its edit already in the snapshot) and confirmed before the old answer came back matched neither check, so the stale answer replaced its saved value in the shared cache; that tab's next edit, holding the newer tag, then wrote the stale list and deleted the item on the server. The entries unconfirmed when the read is sent are kept too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(services): writes wait for Delete All before they queue; restores replace only settled lists Delete All, third variant: a write that arrived after Delete All waited for it inside its playlist's queue. A Stalker repair that reserved that playlist meanwhile skipped the barrier, but its write still queued behind that waiting one, while the reset waited for the repair's reservation. While a Delete All runs, writes now wait for it before they join their queue, so no queue holds work blocked on the reset; a write under a reservation held here joins at once. A restore replaced lists while this page's own write for the playlist was still unsent (refused, or lost): that write's edits joined the replacement and a conflict replay kept items the backup did not have. Both restore paths now require the playlist's writes to be on the server first (flushed), and otherwise stay parked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a reload finds page loads a reload before it kept; restore master's tick baselines When storage refused a reload's copy of the last page load's saved writes, the old copy stayed but only this page's memory named it: the tab's pointer already named the new page, so a second reload before any copy was saved recovered from the empty new page, and without a registry record no other tab could find the old one. The pointer now also lists the page loads a page keeps (keepPages), and a reload takes them over, oldest first, before its last page load. The pointer helpers move to server-page-pointer.ts. Both merges of master resolved journey-baselines.json by taking this branch's whole file, which dropped #1929's cdTicksToFirstCard, cdTicksIdle30s and cdTicksToFirstPage. The file is master's again with only this branch's initialBytes and domMutationsToFirstCard raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a duplicate leaves a running tab's kept page loads to it A duplicated tab copies the tab's session storage, so after a reload that kept older page loads its pointer lists them too, and the duplicate took them over at once, although the tab still running owns those edits and may have newer ones queued; its replay could then write older watch progress over newer. When the previous page load still runs, the kept ones now wait with it and `touch` adopts them, oldest first, once it no longer looks live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a restore merged by a conflict stays parked; first-read tests wait for the read A restore whose write conflicted with an edit another device made during it had the conflict replay merge that edit in, and flushed() still said the lists were taken, so the restore was consumed with lists that were not the backup's. Both restore paths now compare the stored lists with the ones they wrote once the server took them, and a difference keeps the restore parked to run again on a fresh read. The two first-read tests released the mocked read after a fixed number of microtask turns, which could come before the read started; they now wait until it has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): page-hide keepalive writes report an outage too sendKeepalive() turned a failed fetch, a timeout or a gateway's 502/503/504 into a failed outcome without telling the status, so the instance stayed verified: when the hidden tab came back, its refresh skipped the /instance check and resent the queued writes, which could reach a replacement database behind the same address. Like requests, a keepalive write now reports these as offline, so the instance is checked before anything goes out again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a keepalive answer cut off after its headers counts as offline A page-hide write whose headers said saved but whose body broke off was returned as saved with the read error dropped, so the instance stayed verified. Like readJson() for requests, it now reports offline (the outcome stays saved on an unknown version), and the instance is checked before the next token or write goes out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): kept page loads replay oldest first; restore checks compare normalized reads A page load and an older one it kept could both hold steps for one entry. Adopting them, each adopted page went ahead of what was queued, so adopting the oldest first put the newer page's steps before the older's; restoring them after a reload collapsed the newer page's steps into one, so its removal of an item the older page had added was lost on a conflict replay. Adoption now goes newest first, and a reload appends a later page's steps (and early edits) after an older copy's. restoreUserData() compared the lists it wrote with reads that drop duplicate favorites, so a saved restore listing one twice never finished, also in browser-only mode. It now compares reads right after writing with reads after the save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): flushes wait for a page-hide batch sent at once; keepalive 401s; parked restores move A flush scheduled in the same turn as a page-hide flush was chained on the queue before the keepalive batch, which went out at once beside it: a later edit (removing a favorite the keepalive batch added) could land first and be undone when the keepalive write arrived. A scheduled flush now waits for a page-hide batch sent at once before it takes entries. A keepalive write refused with 401 returned failed without telling the status; like requests it now reports the token it sent as refused. The move copies a parked Xtream restore (a browser backup import that could not finish) to the server playlist id a playlist merged into, so it is applied and exported there; one parked there already wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server-storage): a slow server does not reload a page started from memory forever The start probe gives /v1/instance 2.5 s. A remembered server slower than that started the page from memory; the recovery check (with the request timeout) then got an answer and reloaded, and the reloaded page timed out again: an endless reload loop for a server answering in 2.5 to 20 s. The reload now leaves a one-time mark in sessionStorage, and the next probe waits as long as requests do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
56 KiB
Workspace Shell
This document records the current workspace-first shell contract. It is the stable replacement for the older UI refactor summary.
Related:
Summary
/workspaceis the primary app surface.WorkspaceShellComponentowns the persistent frame: rail, header, optional context panel, content outlet, and external playback footer.- Descendant workspace pages inherit
layout = 'workspace'from the/workspaceroot route. - Provider route trees now bootstrap through route-scoped session providers instead of nested provider shell components.
Core implementation:
apps/web/src/app/app.routes.tslibs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.tslibs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.htmllibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.tslibs/portal/shared/util/src/lib/navigation/portal-route.utils.tslibs/portal/shared/util/src/lib/navigation/portal-rail-links.tslibs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts
Route Contract
Current workspace routes:
/->/workspace/workspace-> functional redirectworkspaceEntryRedirect(WorkspaceStartupPreferencesService.resolveInitialWorkspacePath();/workspace/dashboardby default,/workspace/sourceswhen the dashboard is disabled, or the last restorable route underStartupBehavior.RestoreLastView—dashboarditself is guarded bydashboardAccessGuard)/workspace/dashboard/workspace/sources/workspace/playlists/:id/:view(plusfavoritesandrecentsiblings)/workspace/global-favorites/workspace/global-recent/workspace/search/workspace/downloads/workspace/settings/:section(/workspace/settingsredirects togeneral; the settings context panel links each section page; sections:general,playback,epg,dashboard,remote-control,tmdb,parental— see parental lock —backup,reset,about)/workspace/xtreams/:id/.../workspace/stalker/:id/...
Compatibility redirect:
/settings->/workspace/settings
Provider route integration:
- Every routed view renders inside the workspace shell (the only top-level
paths outside
/workspaceare redirects), so views keep no standalone layout branches: the shell header owns search, Back and the playlist switcher. - Xtream and Stalker parent routes attach route-scoped session providers that bootstrap the active playlist, sync provider section state, and clean up provider-local state when the route is destroyed.
- Xtream route bootstrap is DB-first for already imported Electron playlists: if the requested section has persisted categories and content, the route hydrates from SQLite even when the portal status probe reports unavailable, expired, or inactive. Fresh/no-cache Xtream routes still use the status probe to block remote imports before the loading overlay starts.
- Workspace routes no longer rely on nested provider shell components for hidden local chrome.
Shell Structure
The shell is intentionally split into four persistent regions:
- Left rail:
- Static workspace links for dashboard, sources, global favorites, and recently viewed. The routed global-search rail link is Electron-only because its data source is the SQLite worker bridge.
- Provider-aware context links derived from the active or current playlist.
- Settings remains a persistent footer shortcut in the rail. In server storage mode a server status shortcut sits above it: its dot shows online, offline or sign-in needed, and it opens Settings → Server.
- No brand mark: it only repeated the first workspace link (Dashboard, or Sources when the dashboard is off).
- Top header:
- Leading Back slot: the current page's registered Back, else browser history while an in-app previous page exists, else nothing. See Header Back.
- Playlist switcher.
- Route-aware search input and command palette trigger.
- Add source action.
- Optional playlist refresh and route-specific shortcut actions.
- Downloads shortcut in Electron.
- Main body:
- Optional left context panel.
- Main router outlet content. In server storage mode an offline banner
with Retry takes the first row of
.workspace-content-frame, above the scroller, so full-height pages keep their height.
- Optional footer:
- External playback session bar when a docked session is visible.
Content surface corner
main.workspace-content (the scroller) sits in .workspace-content-frame.
The frame carries the surface's 16px top-left radius and its shadow and never
clips; the scroller clips to a rectangle and is a stacking context; a 16px
corner piece on the frame paints the notch in the body colour. The radius used
to sit on the scroller, which made Blink clip every composited effect inside it
(the rail chevrons' and hero controls' backdrop-filter, running transform
animations, isolated groups) through a mask layer synthesized per effect: its
shader path for rounded clips needs four equal radii on macOS and a
translation-only transform between the clip and the effect, and the fallback
mask is the size of the whole clipped area, about 24 MB of tile memory at 2x
on a 16" display. A dozen of them took the dashboard past Chromium's 512 MB
tile budget (tile memory limits exceeded, blank tiles while scrolling).
apps/electron-backend-e2e/src/compositing.e2e.ts guards it: no
drawing layer without an owner node and without a compositing reason (a
synthesized mask, unlike any content layer) may span half the content area,
and the backdrop-filtered controls must really be composited, so the check
cannot pass on a page with nothing to clip.
Layout slides
When a route change adds or removes the context panel, the content frame moves by the panel's width (323 px on desktop), and when Back or the phone drawer toggle appears or leaves, the header controls after it move by the slot's width (44 px). Both happen in the frame that swaps the page, so they used to be one jump: a layout shift of 0.233 in the open-source journey, 0.222 of it the content frame (see performance journeys).
LayoutSlideDirective (workspace-shell/layout-slide.directive.ts) turns
these jumps into a 180 ms slide, the duration of the context panel's own
width transition. The new layout applies at once; each target starts
translated back to the position it had and animates its transform to none
(FLIP), so nothing waits for the animation and Chromium records no layout
shift for a transform. The shell's .workspace-body slides
.workspace-content-frame when showContextPanel changes; the header slides
the drawer toggle, switcher, search and action groups when its leading
navigation (Back, drawer toggle) changes.
- Previous positions.
offsetLeftvalues, which ignore transforms, are recorded after every render in which the slide key or the record key changed (the header's record key covers the other inputs that move its controls: settings route, no playlists, parental lock, shortcuts) and when the host or a target resizes (window resize, a context panel folding or being dragged wider). A slide therefore starts where the target was last laid out, and a target that has no recorded position or did not move is left alone. - The uncovered strip. The frame is narrower beside the panel than
without it, so the side it moves away from would show the body colour
during the slide. While a slide runs the target carries
layout-slide-activeand the uncovered widths as--layout-slide-startand--layout-slide-end; the frame's stylesheet extends the content surface over that strip with an::afterbelow the scroller. - Back fades in over the same 180 ms, so the switcher still sliding away from its slot does not draw over the arrow.
- Reduced motion. Under
prefers-reduced-motionthere is no animation: the jump (and its layout shift) remains. - Compositing. While it runs, each slid target is its own composited
layer (
ActiveTransformAnimation): the frame at its width plus the covered strip (1220×664 CSS pixels at 1280×720), the header controls a few small ones. No synthesized clip mask appears, because no ancestor of the frame clips with a radius (content surface corner), and the layers go away when the slide ends:perf:compositingreads the same layer and mask counts on every route as before the slides (#1895). - Not covered. A slide does not change what the route renders. Going back from a portal to the dashboard used to lay the new dashboard out below the old page before the old page left, a shift a slide cannot hide. That was the route swap, fixed at its cause (see Route swaps).
WorkspaceShellComponent binds only to WorkspaceShellFacade. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
services:
WorkspaceShellRouteStateServiceowns current route parsing, rail links, context-panel state, dashboard startup preference, and playlist source signals.WorkspaceShellSearchServiceowns the route-aware header search capability and public search actions.WorkspaceShellSearchSyncServiceowns the search query signals, debounced application, provider-store synchronization, and query-param sync.WorkspaceShellHeaderServiceowns playlist title/subtitle, account/info actions, and refresh action state. It has no clear action: clearing recently viewed or favorites belongs to the collection page.WorkspaceShellCommandPaletteServiceowns command-palette dialog lifecycle and recent-command recording.WorkspaceShellXtreamImportServiceowns Xtream import/refresh overlay state and labels. Remote import phases and refresh preparation of the shown playlist (of any playlist on the dashboard) show the overlay at once. A session that has only read the saved catalog (loading-cached,isImportingstill false) shows it only afterXTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS(300 ms), so a warm open goes from the skeleton to the page without flashing the overlay, while a slow or stuck cache read still blocks and stays cancellable.
When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep WorkspaceShellFacade as a stable re-export layer
for the template unless the template contract itself intentionally changes.
Route swaps
The router's activation runs in one task, outside change detection:
deactivation destroys the old routed view, then activation creates the new
one and inserts its host element into main.workspace-content at once.
WorkspaceShellRouteStateService.currentUrl is set on NavigationEnd, which
the router emits synchronously after activation, so the shell's state does
not lag the outlet. The shell re-renders (the context panel leaves, the
content frame slides) in the next change detection. With event coalescing
that runs in the next requestAnimationFrame, before that frame paints.
The app does not provide provideAnimations(). Nothing uses
@angular/animations triggers (Material animates with CSS), and that
package's renderer queues every DOM removal until the end of the next change
detection, while insertions apply at once. Leaving a portal section for the
dashboard therefore inserted the dashboard below the still-present portal
page, and the old page left only in the coalesced tick. In between, Chromium
laid out the document with both pages, the dashboard at y=720 in a 720 px
window, and scored the swap as one layout shift: 0.337 at 1280 px, 0.394 at
900 px, 0.723 at 420 px (measured with a layout-shift observer in the
electron-performance build). With the default DOM renderer the old page is
removed in the same microtask, before the new one is inserted, and the swap
records no shift. app.config.spec.ts keeps the animations renderer out, and
workspace-route-swap.e2e.ts asserts that no mutation checkpoint holds both
pages and that neither the dashboard nor the content pane shifts.
Destroy hooks therefore run with the view's DOM already detached; the UI guidelines ("No animations module") say how hooks record geometry and focus instead.
Do not add provideAnimations() or provideAnimationsAsync() back; animate
with CSS. A leave animation (animate.leave included) delays its element's
removal in the same way, so a routed page must not carry one.
Header Back
The header's leading slot is the workspace's one page-level Back. Pages do not
render an arrow of their own: they register a WorkspaceBackTarget
(@iptvnator/portal/shared/util) with WorkspaceBackNavigationService
(@iptvnator/portal/shared/data-access), normally through
registerWorkspaceBack(), which registers for the calling component's
lifetime while its optional available predicate holds. The newest
registration wins, and each release removes only its own target. The button
(data-test-id="workspace-header-back") sits beside the macOS traffic lights,
never scrolls, and has Electron no-drag hit testing. A target supplies its
label (else the translated "Back"), whether Escape on the page runs it, and
run().
| Page | Registered by | Back runs | ≤640 px |
|---|---|---|---|
| Portal, collection, offline and recording details | PortalDetailShellComponent while backAvailable() |
the host's backClicked |
replaces the drawer toggle |
| Xtream and Stalker Discover and actor pages | DiscoverViewComponent, ActorViewComponent |
the route's history Back; parent: the catalog section Discover lists (vod for movies, series for TV), the portal's default section for actor |
(no drawer) |
| In-portal search, Xtream and Stalker | SearchLayoutComponent while backAvailable() and no inline detail replaces the results |
history Back; parent: the portal's default section | (no drawer) |
| Settings | WorkspaceSettingsContextPanelComponent, which exists exactly while the settings route shows |
history Back; parent: the first workspace view (WorkspaceStartupPreferencesService.resolveDashboardPath(): the dashboard, or sources when it is hidden) |
beside the drawer toggle |
Detail-page semantics (Escape, browse and watch) are in Portal Detail Navigation.
History fallback. Without a registration, the header shows Back while the
previous history entry is an in-app one, and runs Location.back(). The
service reads that from the Navigation API: the previous entry must be
same-document (NavigationHistoryEntry.sameDocument), so the router pushed it
after this document loaded. Entries from before a reload or from another page
of the origin never count, and the fallback can neither leave nor reload the
app. currententrychange keeps it current through pushes, replacements,
traversals and guard-cancelled Back navigations that the router rewrites.
Without the Navigation API (older Safari and Firefox, jsdom) there is no
fallback; registered pages are unaffected. The fallback reads "Back" and
advertises no Escape, because no page handles one for it. Pages that set
backAvailable=false, such as M3U details, therefore show it too when they
were reached by navigation.
Parent fallback. "Parent" in the table above: a registered page whose
Back is history Back calls WorkspaceBackNavigationService.back(resolveParent)
instead of Location.back(). It runs Location.back() while the previous
entry is an in-app one, by the same Navigation API test as the history
fallback. Without the API (older Safari and Firefox) the router's history
depth decides (trackRouterHistoryDepth): the document's first navigation is
depth 0, a push adds one, a replacement keeps it and a traversal restores the
depth recorded for its entry. The lazy workspace shell creates the service
after the first navigation began, so the tracker adopts the router's current
or last navigation: a first one is depth 0, a later one leaves the depth
unknown. A traversal to an entry from before a reload
leaves the depth unknown and keeps Location.back(), which then has a
previous entry. Otherwise the page opened
the session (a deep link, a reload or a restored view), where
Location.back() does nothing in Electron and leaves the app in a browser.
The service then navigates to the page's parent with replaceUrl, so history
Back cannot return to the page just left; with nothing in-app before it, the
parent shows no history fallback. The resolver returns a URL or router commands, may be asynchronous, and
returns null when the page knows no parent, which keeps Location.back().
Portal pages build their parent with workspacePortalCommands()
(@iptvnator/portal/shared/util) from the route's :id; without a section,
the portal route's redirectTo picks the default section within the same
navigation, so the replacement still applies. Detail pages keep their own
return logic (backClicked).
When there is nowhere to go, the slot is empty rather than a disabled arrow. Sessions often start on a page that never navigates (an M3U playlist or live TV), where a disabled arrow would stay for the whole session. When Back first appears or leaves, which happens only at the start of the history and together with a route change, the switcher and search slide by the slot's width instead of jumping (see Layout slides). There is no Forward button: Stalker inline details are store state, not history entries, so Forward would skip them.
Phone width. phoneDrawerToggle sets how Back shares the leading slot with
the context drawer toggle. replace (the default) takes the toggle's slot: a
detail page's drawer belongs to the list that Back returns to. beside keeps
both: the settings drawer holds the page's own sections. yield hides Back
while the toggle shows: the history fallback must not cost a category list its
only way into the drawer, and two navigation icons do not fit beside the
switcher. System and browser Back still work there.
Left in place. These controls stay inside their surface on purpose:
- Downloads offline and recording detail error states keep their labelled "Back to Downloads" button beside Retry or Remove. It is the error state's recovery action, not page chrome; the header shows the same Back.
- Back controls internal to a surface, which leave a panel rather than the page: the Embedded MPV dock panel and the alternative-sources panel inside the VOD "…" menu.
- The M3U player sidebar's Home button, which renders only outside the workspace shell.
Context Panel Rules
The shell decides which secondary panel to show from the current route:
/workspace/sourcesWorkspaceSourcesFiltersPanelComponent
- Xtream category sections (
live,vod,series)WorkspaceContextPanelComponent
- Stalker category sections (
itv,radio,vod,series)WorkspaceContextPanelComponent
/workspace/settings/:sectionWorkspaceSettingsContextPanelComponent
- Downloads sections
WorkspaceCollectionContextPanelComponent
The context panel is part of the shell contract. New workspace-level routes should explicitly decide whether they need one rather than adding local sidebars inside feature pages.
Xtream and Stalker category panels preserve provider/server category order by
default. The panel header exposes a sort menu next to category search with
Server sorting, A-Z, and Z-A; when alphabetical sorting is active,
synthetic "all categories" entries stay pinned before sorted provider
categories. Search and sort render disabled while the categories load (beside
the disabled Manage categories button), so the header row has its final width
under the skeleton instead of growing by two buttons when the list arrives;
they are hidden only once a load ended with no categories.
On live sections the category panel can be folded away independently of the
channels list; the folded panel is reachable as a popover from the channels
header through the LIVE_CATEGORIES_POPOVER token the shell provides. The
three nested levels, their affordances and persistence are specified in
iptvnator-ui-guidelines.md ("Collapsible Live Sidebar").
A category click in a LIVE section (Xtream live, Stalker itv and radio)
changes only the selected category: the live layouts gate their player on the
store's selected item, so the handler must not clear it — the channel the user
is watching keeps playing while the sidebar re-filters (Xtream: #936; Stalker:
onStalkerCategoryClicked returns before clearSelectedItem()). VOD and
series category clicks do drop the open detail (setSelectedItem(null) /
clearSelectedItem()) because they navigate to a list route.
The channel header offers Show playing channel when browsing excludes the active channel. It returns to that category and focuses the row without restarting playback; remote commands retain captured playback order. See the queue and reveal contract.
Search And Navigation Rules
Search is shell-owned and route-aware:
- On settings routes, searches the settings themselves (see Settings search).
- Enabled on sources routes.
- Enabled for
/workspace/search, which is the Electron-only routed global-search view.Ctrl/Cmd+Fin Electron opens this route and focuses/selects the header search input instead of opening a fullscreen dialog. - Enabled for supported Xtream and Stalker content/search views.
- Placeholder text and search handling vary by provider and section.
- Input changes are debounced before route/store updates are applied.
- Global search uses the header input as its primary input and writes the
search phrase to the
qquery parameter, so history/back-forward behavior matches the rest of the workspace. - The URL is authoritative for the search box only when it carries search
intent.
WorkspaceShellSearchSyncServicere-readsqon everyNavigationEnd, but an app-initiated navigation that stays on the same page and carries the term already applied is always ignored — whether or not a debounce is still pending. Otherwise a page writing an unrelated query param (a downloads filter chip, a refresh bump) or the router echoing back our own trimmedqwould reset the box to the applied term, eating everything typed since: the whole word while the first keystroke is still debouncing, or a just-typed trailing space once the debounce has fired ("Bein " would snap to "Bein" and typing on would yield "BeinSports"). Applied terms are always stored trimmed (applySearchQueryandsetSearchStateboth trim), so the echoedqcompares directly; the box keeps exactly what the user typed. Pages are free to write their own query params while the user types; they must not assume the shell will re-apply the search afterwards. - Browser history overrides that guard. The exemption is keyed on
Navigation.trigger === 'imperative', so back/forward always re-applies what the history entry carries, even mid-typing. - Applying a term explicitly supersedes a queued one.
applySearchQuery()cancels any pending debounce, so the Enter key committing a trimmed term cannot be overwritten a moment later by the untrimmed keystroke still waiting behind it.
Rail navigation is also shell-owned:
- Workspace-global entries are static.
- Provider entries come from
buildPortalRailLinks(...). - On dashboard, sources, settings, global search, global favorites, and global recent, the shell falls back to the currently selected playlist so provider navigation remains available even outside a provider route.
Command palette behavior is shell-owned but view-extensible:
- The shell resolves commands into groups in fixed order: current view, this playlist, global, then settings. The settings group appears only for a non-empty query and holds at most six settings matches (see Settings search).
- Shell-owned commands are derived from route context and current playlist state; empty groups are omitted instead of rendering disabled placeholders.
- Workspace features contribute current-view commands through
WorkspaceViewCommandService. - Header shortcut actions can opt into palette exposure by attaching palette
metadata through
WorkspaceHeaderContextService. - Filtering matches command labels, descriptions, and keywords, and keyboard selection always lands on the first enabled command.
- A "Recently used" section is rendered above the standard groups when the
query is empty and at least one stored id resolves to a visible+enabled
command; ids are persisted via
RecentCommandsService(capped at 5, stored atSTORE_KEY.RecentCommands). Storage is not pruned by route visibility — a navigation command likeOpen sourcesis invisible while the user is on/workspace/sourcesbut the id stays in storage so it reappears in the recent section after navigating away. - Six "Switch player to X" commands are registered globally by
WorkspacePlayerCommandsContributor(VideoJS, HTML5, ArtPlayer, Embedded MPV, MPV, VLC). Each command carries arequiresflag gating its visibility: the MPV/VLC ("managed-external") entries are visible only whenRuntimeCapabilitiesService.supportsManagedExternalPlayersis true, and the Embedded MPV ("embedded-mpv") entry is visible only after the command palette lazily preloads an asyncwindow.electron.getEmbeddedMpvSupport()check and it resolves tosupported(mirroring the Settings dropdown gate). Do not run this Embedded MPV support check from workspace shell bootstrap: supported desktop builds may load the native addon while resolving capabilities. The entry matching the currentSettingsStore.player()value is disabled. The new player setting applies to the next playback session; an existing stream is not re-mounted.
Settings search
Settings rows are searchable from the header search on /workspace/settings
and from the command palette. Both use the same index and ranking.
- The index is
SETTINGS_SEARCH_ENTRIESinlibs/workspace/shell/util/src/lib/settings-search/, published through the@iptvnator/workspace/shell/util/settings-searchsub-entrypoint. Eager code imports the main shell util barrel, so the index stays out of it and ships only in lazy chunks (the initial-bytes ratchet enforces this). - Each entry names its section, title and description translation keys,
untranslated synonyms (
keywords), runtimerequires, and an optionalfallbackId. Section definitions (SETTINGS_SECTION_DEFINITIONS) are the single source for the settings navigation too. - Every titled
.setting-itemin the section templates carriesdata-setting-id.settings-search-registry.spec.tsfails when a row, id, title key or description key drifts from the index, so a new settings row must be added to the index in the same change. SettingsSearchService.search()matches the translated title and description of the current language plus the keywords; every query token must match (AND), and a label prefix outranks a word start, which outranks an inner match. Rows whoserequiresthe runtime lacks are never returned. Embedded MPV rows depend on a lazy support probe (ensureEmbeddedMpvSupportLoaded()), run when the settings page or the command palette opens, never from shell bootstrap; frame copy also needsframeCopyAvailable, matching the settings page gate.- Settings routes use
local-filtersearch mode, so the term lives inq. Whileqis set, the settings page shows ranked results in place of the section page and the settings context panel shows per-section match counts, muting sections without matches. The search box is shown on settings even when no playlist exists. - Choosing a result, pressing
Enterin the header search (best match), or picking a settings command in the palette callsreveal(): it navigates to the section page withoutq(which clears the box) and the page scrolls to, focuses, and briefly highlights the row once the form is hydrated. A row hidden by the current form state falls back to itsfallbackId, the control that makes it appear. A reveal must win over the typed term:WorkspaceShellSearchSyncServicedrops a keystroke still waiting for its debounce throughonReveal(), and Enter does not apply the term first, because eitherqsync navigation would supersede the reveal navigation. Keyboard users keep a:focus-visiblering on the row after the highlight fades. Ctrl/Cmd+Fon settings focuses the header search instead of opening global search.
Keyboard shortcut help is shell-owned:
WorkspaceKeyboardShortcutsServiceis provided byWorkspaceShellComponent. It owns the workspace-scopeddocument:keydownlistener for?/Shift+/.- The listener ignores events from inputs, textareas, selects, and
content-editable elements via
isTypingInInput(...). libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.tsis the metadata registry for shortcuts shown in the help dialog and documented in README.keyboard-shortcuts.tsowns the display transformation and help trigger detection. Shortcuts that only work through the Electron bridge, such as embedded MPV controls, must setelectronOnly: trueso the PWA dialog does not advertise unavailable commands.- New custom shortcuts should be added to that registry when the handler is
added. Do not include native browser/editor behavior such as
Tabor platform text editing shortcuts.
Window Chrome And Custom Title Bar
The Electron window hides the native title bar on all desktop platforms
(titleBarStyle: 'hidden' in apps/electron-backend/src/app/app.ts):
-
macOS keeps the native traffic lights (
titleBarOverlay: true,trafficLightPositionfromMACOS_TRAFFIC_LIGHTS_POSITIONin@iptvnator/shared/interfaces); the renderer draws no window buttons. The lights sit in the header band (--workspace-header-band, 56 px) over the rail and the header's leading padding. The macOS rail (.app-rail.is-macos) starts its first link below the band: level with the content area and the dashboard hero, with its hover surface clear of the lights. The header's content starts 84 window pixels from the window's left edge (60 px rail plus 24 px padding). macOS 26 ends the lights at 76 (earlier releases at 68), which is where Back's left edge sits at 100 % because of its 8 px pull-in.App zoom (see "Zoom level") scales CSS pixels but not the lights. On macOS,
TrafficLightsClearanceDirectiveon.workspace-shellreads the page zoom factor (outerWidth / innerWidth, refreshed onresize) and publishes the clearance in CSS pixels as--traffic-lights-clear-x(84 window pixels) and--traffic-lights-clear-y(48: the lights' bottom plus a gap). Zoomed out, the band grows to the vertical clearance, so the lights never overlap the content area. The header's leading padding grows to the horizontal clearance, less the rail column (--workspace-header-lights-inset). At 100 % both match the default layout. Off macOS nothing is published and the defaults apply. The phone layout, which puts the rail in a row above the header, ignores the inset.window-controls.e2e.ts("macOS traffic lights") checks the rail alignment, the first header control (the switcher on the first page, which has no history fallback yet, then a detail page's Back) and the content top at default and minimum zoom. -
Windows and Linux use renderer-drawn window controls (
app-window-controls,libs/ui/components/src/lib/window-controls/).frameis intentionally left untouched so native resize borders and window snapping keep working.
The controls are mounted once in app-root (not inside the workspace
header) as a position: fixed top-right overlay so they stay clickable
above full-window content such as Material dialog backdrops — the same behavior as the macOS traffic lights. Because
CDK overlays render as popovers in the browser top layer (above any
z-index), the component host is itself a popover="manual" element: it
enters the top layer on init and re-enters it (hide + show) whenever
another popover opens, so the controls always paint last. The
z-index: 10000 remains only as a fallback when the popover API is
unavailable. They render only when
RuntimeCapabilitiesService.usesCustomWindowControls is true (Windows/Linux
Electron with the window-control bridge methods available); the PWA and
macOS never mount them.
IPC contract (constants in libs/shared/interfaces/src/lib/ipc-commands.ts,
handlers in apps/electron-backend/src/app/events/window.events.ts):
-
WINDOW:MINIMIZE,WINDOW:TOGGLE_MAXIMIZE,WINDOW:CLOSE,WINDOW:GET_STATEareipcMain.handlechannels resolved from the sender WebContents. Close goes throughwin.close()so the existing window-bounds persistence inapp.tsstill runs. -
WINDOW:STATE_CHANGEDis pushed main → renderer on maximize/unmaximize and on the fullscreen events —enter/leave-full-screenplus theenter/leave-html-full-screenvariants emitted for HTML-element fullscreen (video player fullscreen) — so the maximize/restore glyph stays correct for externally triggered changes (double-click on a drag region, OS snap, F11). The controls hide themselves while the window is fullscreen.Window state is never re-read at event time.
attachWindowStateEventsseeds{ isMaximized, isFullScreen }once at window creation and each event patches only the flag it names; every push carries a copy of that tracked state. On Windows both getters can still report the pre-transition value while the matching event fires —isFullScreen()staystrueduring an HTML fullscreen exit, andisMaximized()readsfalsewhile the window is fullscreen. Because the renderer replaces both flags on every push and no later event corrects a stale one, polling left the controls hidden forever after leaving fullscreen and stuck the maximize/restore glyph on the wrong icon. Regression coverage:app-window-state.spec.tsandwindow-controls.e2e.ts.The pushed
isFullScreenis the OR of two flags tracked apart: native (OS-level, fed byenter/leave-full-screen) and HTML-element (fed by the*-html-*pair). Electron remembers when the window was already natively fullscreen before the player entered HTML fullscreen and then leaves ONLY the HTML state on exit — noleave-full-screenfires and the window stays fullscreen — so a single flag cleared byleave-html-full-screenwould un-hide the controls over a window that is still fullscreen. A fullscreen launch (below) or F11 followed by the player'sF→Escmakes that path routine on Windows/Linux. -
WINDOW:TOGGLE_FULLSCREENtoggles OS-level fullscreen (setFullScreen) and, like the maximize toggle, reports the requested state and leaves theWINDOW:STATE_CHANGEDpush authoritative. Because the transition is asynchronous —isFullScreen()reports the old value until it lands, and on Windows even while the matching event fires — a toggle must never be decided against the getter. Every native fullscreen request goes through the tracker inapps/electron-backend/src/app/services/native-fullscreen-transitions.ts(the F11 toggle AND the startup fallback below). Like the state pushes above it keeps its own per-window fullscreen state, seeded from the getter once at window creation (trackNativeFullScreenininitMainWindow, while no transition can be in flight) and fed afterwards only by theenter/leave-full-screenevents, which also cover transitions the app did not request. A toggle is decided against the pending target while a transition is in flight, else against that tracked state: two quick presses are an enter-then-exit, not two enters, and F11 during the startup animation leaves fullscreen instead of asking for it again. The tracker observes and never issues a request on its own. The pending record is the LATEST requested target: an event landing on it clears it, an event landing on the other state (an earlier request of a burst landed first; ours is still queued) leaves it in place so the next press still follows the user's latest intent, and a record older thanFULLSCREEN_TRANSITION_TIMEOUT_MS(2 s) is ignored. Events cannot say which request they belong to, so any automatic "repeat the target" on a mismatch is indistinguishable from reversing the user's own green-button/Ctrl+Cmd+F action and is deliberately not done: should a platform ever drop a queued request (Electron queues them on macOS and applies them synchronously elsewhere), the record expires, the event-fed state takes over and the next press corrects the window. The renderer binds it to F11 inWorkspaceKeyboardShortcutsService(deliberately not gated byisTypingInInput— it must work from any focus, because it is the only exit from a fullscreen launch on Windows/Linux, where the title bar is hidden and the controls hide themselves) and skips the key whiledocument.fullscreenElementis set, since the player's ownF/Escown HTML fullscreen and F11 must not yank OS fullscreen out from under it. Without a bridge (PWA) F11 is left to the browser.
Startup window mode (Settings.startupWindowMode, issue #1455):
normal(default) /maximized/fullscreen, chosen in Settings → General ("Window on startup"). Electron only — the select renders only whenRuntimeCapabilitiesService.supportsStartupWindowModesees bothupdateSettingsandtoggleFullScreenWindowon the bridge, so the mode is never offered without its F11 exit.- Settings live in the renderer's IndexedDB, which the main process cannot
read at window creation, so the
SETTINGS_UPDATEhandler mirrors the value into electron-conf (STARTUP_WINDOW_MODE, the same pattern as the frame-copy flag) andinitMainWindowreads it synchronously. A change therefore applies on the next launch. Both sides normalize throughnormalizeStartupWindowMode, so junk never reaches the config file or the window options. fullscreenis theBrowserWindowconstructor option: on Windows/Linux the window is created hidden and enters fullscreen before its first paint. macOS ignores the option while the window is hidden (an NSWindow only toggles fullscreen once it is on screen), so the first show repeats the request withsetFullScreen(true)right aftershow()whereverisFullScreen()is still false — never unconditionally, or the platforms that honoured the option would animate a second toggle. The saved bounds stay spread into the options — they are the normal bounds the window returns to, and the close handler keeps persistinggetNormalBounds().maximizedcallsmaximize()right before the firstshow(), never earlier:maximize()on a hidden window shows it, and a blank window would flash. That first show happens atready-to-showor the main frame'sdid-finish-load, whichever comes first (services/main-window-first-show.ts): on Linux a hidden window whose startup scripts ran before its first frame gets the next one about a second later, soready-to-showalone left the window off screen and the splash's animation frame waiting. Atdid-finish-loadthe inline splash is parsed, and the window'sbackgroundColoris the splash colour (MAIN_WINDOW_BACKGROUND_COLOR, keep it in sync with#initial-splashinapps/web/src/index.html), so showing before the first paint does not flash.iptvnator --fullscreen(read viaapp.commandLine.hasSwitch, so it can sit anywhere in argv; the playlist-path extractor already skips every--prefixed argument) forcesfullscreenfor that launch only and is never persisted. Resolution lives inapps/electron-backend/src/app/services/startup-window-mode.ts. The switch is consumed by the first window (launchFullscreenSwitchConsumedinApp): on macOS the process outlives its last window and the Dock re-creates it through the sameinitMainWindow, which must then follow the stored setting only. A second-instance launch carrying the switch is ignored — the window already exists.- Deliberately not offered: kiosk mode (removes the exit path) and
"remember last state" (bounds persistence stores normal bounds only; an
explicit choice is clearer). Regression coverage:
app.spec.ts("startup window mode"),settings.events.spec.ts,window.events.spec.ts, and the startup-window-mode cases insettings.e2e.ts.
Zoom level (Cmd/Ctrl and +/−/0, issue #1109):
- The packaged renderer runs under
file://with path routing. Chromium keys per-host zoom by the FULL URL when a URL has no host, so everypushStateto another section owns a separate zoom entry: after a route changewebContents.getZoomLevel()already reports that entry (usually 0) and the next visual-properties sync — a window resize, a display change — snaps the renderer back to it. Chromium persists those per-URL entries inPreferenceson its own, which is why the level used to "appear briefly" onindex.htmlat startup and then reset. Dev mode (http://localhost:4200) is per-host and never shows this, so only a packaged orELECTRON_IS_DEV=0run can verify zoom behaviour. - Restore therefore happens in the preload, not the main process:
applyPersistedZoomLevel(api/preload-zoom-level.ts) asks for the stored level over the synchronousWINDOW:GET_ZOOM_LEVELIPC at preload start and applies it withwebFrame.setZoomLevel, which installs a TEMPORARY, frame-bound zoom level. It survives in-page navigation and resizes, the zoom shortcuts (point 4) step it through the same call, andgetZoomLevel()reports it regardless of the route.webContents.setZoomLevelfrom the main process would write the per-URL entry and re-create the bug. When nothing is stored the preload re-applies the current level for the same reason: entering temporary mode makes the first zoom shortcut URL-independent too. A failed request is swallowed and only costs this load its restore. The apply is deferred toDOMContentLoaded— never at preload start and never from asetTimeout: on Linux and Windows awebFrame.setZoomLevelthat early leaves the hidden window without a first frame,ready-to-shownever fires,show()never runs, and the renderer gets no animation frames (the splashmain.tsremoves in arequestAnimationFramestays). macOS is unaffected and CDP-driven tests force frames, so only the packaged Linux/Windows E2E asserting the splash is gone caught it (legacy-playlist-migration.e2e.ts, defer-epg). After the parser finishes the call is harmless and still lands before the first Angular paint. The preload then sendsWINDOW:ZOOM_LEVEL_APPLIED. - Chromium never persists temporary zoom, so
services/window-zoom-level.tsowns the electron-conf keyZOOM_LEVEL: the applied acknowledgement (not the request — between the two the sender'sgetZoomLevel()is still the per-URL default) marks the sender as owning the level,persistZoomLevelwrites it back from the windowcloseand appbefore-quithandlers (the bounds-only saves of before, folded intopersistWindowState), andattachZoomLevelPersistencealso writes it on every main-frame cross-documentdid-start-navigation— a reload drops the temporary level, and bydid-finish-loadthe new document's preload has already read whatever was stored. That navigation also releases ownership until the next preload answers, so a close mid-reload cannot save the per-URL default over the user's level. - The shortcuts are a renderer key binding, not a native menu: the
Windows/Linux window calls
setMenu(null), so no accelerator could reach it there.WorkspaceKeyboardShortcutsService(libs/workspace/shell) listens on the document like it does for F11 and resolves the chord withresolveZoomShortcutAction(libs/portal/shared/util): Cmd on macOS, Ctrl elsewhere, never Alt;+/=(soCtrl+=andCtrl+Shift+=both zoom in),-/_,0, and the numpad+/-/0(bycode, since a NumLock-off0reportsInsert). Keys are matched byevent.key, so non-US layouts zoom with their own+/-keys. Like F11 it is not gated by the typing-target check — browsers zoom from any focus — and a key another handler alreadypreventDefaulted is left alone. The binding calls the synchronous, preload-localwindow.electron.adjustZoomLevel(adjustFrameZoomLevelinapi/preload-zoom-level.ts), which steps the frame's temporary level through the samewebFrame.setZoomLevelas the restore and returns the level applied — never a main-processwebContents.setZoomLevel, which would re-create the per-URL bug. The step and limits live inlibs/shared/interfaces/src/lib/zoom-level.util.ts(stepZoomLevel): 0.5 per press, Electron's ownzoomIn/zoomOutrole step (≈10 %), clamped to levels −4…6 (≈48 %…299 %, inside Chromium's 25–500 %), off-grid levels snapping to the next grid point in the pressed direction;Ctrl/Cmd+0returns to level 0. A stored level already outside the limits (the macOS menu roles never clamped) is never moved against the request: a press further out leaves it, a press back in lands on the limit. Persistence needs nothing extra: the main process reads the live level back (point 3). On macOS the default application menu still carries thezoomIn/zoomOut/resetZoomroles, but Chromium hands a key equivalent to the web contents first and Electron performs the menu equivalent only inWebContents::PlatformHandleKeyboardEvent(shell/browser/api/electron_api_web_contents_mac.mm, Electron 43.3.0), the unhandled-keyboard-event hook — apreventDefaulted keydown never gets there, so the binding keeps one press at one step. CDP-dispatched keys (the E2E) never reach the menu at all. Without a bridge (PWA) the browser keeps its own zoom, and the help dialog lists the chords as Electron-only. Regression coverage:window-zoom-level.e2e.tspresses the real shortcuts (in, out, numpad, reset) and measures the rendered factor (content width ÷window.innerWidth) across a section change, a resize, a reload and a restart; key resolution and the bridge step are unit-tested inkeyboard-shortcuts.spec.ts,workspace-keyboard-shortcuts.service.spec.tsandpreload-zoom-level.spec.ts.
Reloading the renderer on an in-app route:
- The packaged renderer is
dist/apps/web/index.htmloverfile://and Angular routes by path (no hash strategy), so once the user is on a section the document URL isfile:///…/web/workspace/sources— a path with no file behind it. A reload of that URL fails withERR_FILE_NOT_FOUND(-6) or is cancelled outright, depending on who starts it. Two user-reachable triggers: the macOS default application menu (nothing callsMenu.setApplicationMenu, so View › Reload / Force Reload are live; Windows/Linux drop the menu bar viasetMenu(null)), and the settings unsaved-changes guard, which callswindow.location.reload()after the user confirms a reload intent on/workspace/settings/<section>. Dev mode (http://localhost:4200) never shows either — the dev server serves the index for every path. - Both legs live in
services/renderer-reload-fallback.tsand end in the samerestoreRendererRoute: load the packaged index with the routed URL's route — its path relative to the renderer root plus query and fragment (resolveRoutedRendererUrl) — in therestoreRoutequery parameter.- A main-process reload (
webContents.reload(), the menu role, DevTools) fires nowill-navigate, so it cannot be redirected up front: it fails, Chromium commitschrome-error://chromewebdata/andapp-rootstays empty until the app restarts.attachRendererReloadFallbackrecovers it after the fact from the main-framedid-fail-loadwithERR_FILE_NOT_FOUND(resolveReloadedRendererRoute); other error codes, subframes and non-file:URLs are left alone. The recovery load is deferred to the error page'sdom-readyand never issued from insidedid-fail-load: aloadFilestarted while Chromium is still committing the error page yields a document that never receives animation frames — the splash stays, nothing paints, whiledocument.visibilityStatestill saysvisible— and the same load afterdom-readypaints normally (Electron emitsdid-fail-loadbefore thatdom-ready). A cross-document navigation starting in between withdraws the pending recovery, so a staledom-readycan never re-load the index over a newer navigation. - A renderer-initiated reload (
location.reload(), the settings guard) does firewill-navigate, where the routed URL is not the trusted index andhandleRendererNavigationwould cancel it — silently, so the confirmed reload simply never happened. The handler now recognizes a routed renderer URL and sends it straight to the index with its route, with no failed load in between; every other untrusted navigation is still blocked (external URLs still open in the browser). A failedindex.htmlitself is never re-requested (it would loop):resolveRoutedRendererUrlrejects the index, and the recovery load carriesindex.htmlas its path, so a second failure cannot recurse.
- A main-process reload (
- The renderer consumes the parameter before Angular bootstraps:
apps/web/src/main.tscallsresolveRestoredRendererRoute(libs/shared/interfaces/src/lib/renderer-reload-route.util.ts, which also owns the parameter name) and installs the result withhistory.replaceState, so the router's initial navigation lands on the route the user was on. The route is resolved againstdocument.baseURI(the packaged<base href="./">, i.e. the renderer directory — the same prefix Angular strips fromlocation.pathname), and anything that would leave that directory (an absolute URL, another scheme, a..escape) is dropped with only the parameter removed, so the app boots at its default route instead of following an arbitrary target. - Zoom persistence is unaffected: the failed reload's
did-start-navigationalready saved the level and released ownership, the recovery load'sdid-start-navigationis then a no-op, and the new document's preload restores the level as after any other reload. The main-process close guard also treats the recovery like any full navigation (did-navigatedisarms it). - Regression coverage:
renderer-reload.e2e.tsreloads from the main process (webContents.reload(), the menu role) on Sources and from the renderer (window.location.reload(), the settings guard) on a settings section and asserts a NEW document is rendered on the same route with the parameter gone (renderer-reload.support.tsmarks the old document, since the URL alone is identical before and after);window-zoom-level.e2e.tsreloads the same way. Unit coverage:renderer-reload-fallback.spec.ts,renderer-reload-route.util.spec.ts,app.spec.ts("renderer reload recovery").
Layout integration:
document.bodygets aframeless-platformclass (set inAppComponent, same mechanism asdark-theme) — body-level so rules also reach cdk-overlay content rendered outsideapp-root.apps/web/src/styles.scssreservespadding-right: 150pxin the top-aligned drag region (.workspace-header) for the 3 × 46px button strip.- Button colors follow the theme via CSS variables (
--app-on-surface,--app-hover-overlay); the close button uses the Windows-style red hover (#e81123). No theme IPC is involved.
Window decorations on Linux (shadows, corners):
- Hiding the title bar removes the window manager's decorations, so the shadow/rounded corners must come from client-side decorations (CSD). Electron 43 enables rounded corners by default when the Linux desktop environment supports CSD. GTK drop shadows and extended resize boundaries remain environment-dependent; Electron selects native Wayland automatically on Wayland sessions.
- Linux environments without CSD support can still show a square, undecorated window. Windows 11 keeps its DWM rounded corners and shadow because the standard frame is retained.
Toolchain notes for the Electron 43 baseline:
better-sqlite3remains pinned exactly so native dependency updates happen deliberately with database-worker, packaging, and Electron E2E validation. Version 13 uses Node-API and ships its supported platform binaries in the package. It must not be listed in pnpm'sonlyBuiltDependencies: forcing an implicitnode-gyp rebuildbypasses those binaries and makes installation depend on the host compiler toolchain. The oldnode-abioverride belonged to v12's removedprebuild-installpath and is no longer required.- Local development supports Node 22.22.3–22.x or 24.15.0–24.x, declared in
enginesas^22.22.3 || ^24.15.0. Use.nvmrc(currently 22.23.2) for development and CI. Angular 22 sets this supported LTS range and requires TypeScript>=6.0 <6.1.electron-builder26.15.7 also pulls@electron/rebuild4, which requires Node 22.12 or newer, and the rootpostinstallrunsinstall-app-depson everypnpm install. The 26.15.7 minimum also carries the v26 backport that fully extracts the Snap template's.tar.7zpayload; 26.15.0–26.15.6 can produce a Snap that is missingdesktop-init.sh. - Dependabot keeps Electron, native database, packaging, EPG parser, and
version-locked Shaka/mpegts updates out of the shared npm minor/patch group.
Those dependencies require standalone PRs so their dedicated package,
worker, playback, and diagnostic-contract validation cannot be hidden by an
unrelated grouped update.
Electron majors are ignored: Electron 44 ships no
linux-armv7lbinary, and the release still packages armv7l AppImage, DEB and Snap builds. Remove the ignore together with those targets when 32-bit ARM support is dropped. - Electron 42 and newer download their development binary on the first
Electron command instead of during package
postinstall, soelectronmust not remain in pnpm'sonlyBuiltDependenciesallowlist. The first localpnpm run serve:backendcan include a one-time download; usepnpm exec electron --versionto prewarm it before an offline run.
Known caveats:
- DIY buttons cannot show the Windows 11 Snap Layouts flyout (only native caption buttons or the Window Controls Overlay get that).
- Double-click-to-maximize on drag regions is handled natively by Electron/Chromium; on Linux the exact behavior depends on the window manager.
Maintenance Guidance
Use this document as the source of truth when changing workspace shell behavior.
- New top-level user destinations should default to child routes under
/workspace. - Shared provider navigation logic belongs in portal-shared util/UI libraries, not duplicated inside the shell.
- If a provider route changes how playlist/session bootstrap works, update the route-session provider and shell-facing route contract together.
- When adding a non-native keyboard shortcut, update the shared shortcuts registry, the help dialog tests, README, and the closest behavior test.
- Historical migration notes, cleanup lists, and one-off refactor steps should stay out of this file; track them in issues or PR notes instead.