Files
iptvnator/docs/architecture/workspace-shell.md
T
4grayandClaude Fable 5.1 7d1503fd31 feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): add programme guide implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add window-scoped guide programme queries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): harden guide query scoping, caps and row mapping

- Scoped guide programme/coverage queries now include legacy
  (unsourced) rows via source_url IN (...) OR IS NULL OR '',
  mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
  the normalized, capped window.channelIds instead of the raw
  request, so a key cut by the cap is absent rather than [] — an
  invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
  and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
  parameter and moved (with guideWindowOverlapSqlText) into
  epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
  into epg-program-row.util.ts, used by both EpgQueryService and
  EpgGuideQueryService so invalid start/stop rows are dropped
  identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
  text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): render the guide predicate in tests and document its scope

Correct the guide query's JSDoc: it runs one query accepting the union
of requested-source and unsourced legacy rows, unlike EpgQueryService's
two-query scoped-then-legacy fallback. Replace the hand-maintained
plain-SQL twin of the Drizzle overlap predicate with a rendered copy of
the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a
source-scoping case, and drop the now-redundant operator-sequence test.
warnIfTruncated reuses uniqueTrimmedStrings and names which read
(programme/coverage) was truncated. Rename epg-query.service.ts's local
EpgProgramRow to EpgProgramSelectRow so it isn't confused with the
shared EpgProgramRow type, and document getProgramCoverage like its
sibling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): expose guide programme and coverage reads over the bridge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): separate coverage chunk size in the guide plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide source contract, day layout maths and preferences

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): key guide IPC answers by trimmed, present keys only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): guide search hits carry a row id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide geometry DST-safe and tighten the contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): cache guide programmes per day with batched loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide keyboard navigation controller

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide programme cache robust to first-run effects and coverage failures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add the programme guide grid components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add a Guide button to the timeline toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): adapt the playlist channel list to the guide contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): guard the guide's initial group scope and track language changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): open the programme guide in place with a docked player

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): remove the multi-EPG overlay and the channel-range IPC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): document the programme guide and its release note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* i18n(epg): translate the programme guide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): let the guide own the keyboard and gate its entry points

While the programme guide is open the docked player carries
`data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours
alongside `[inert]` — the arrows moved the player's volume instead of the
guide's row focus. The external-player strip loses its Collapse toggle
(nothing to reveal, no preference to write), the header action and its
palette command report `disabled` when the guide cannot open, the docked
strip derives its programme from the active channel's own schedule instead
of the retained NgRx value, switching playlists closes the guide, and the
collapsed strip can reach 48 px on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): keep the sidebar mounted while the guide is open

Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".

The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): cover the programme guide flow

Imports a two-channel playlist with XMLTV, opens the guide from the
timeline toolbar and asserts the row list, the "Only with EPG" filter, a
channel switch that keeps the guide open, the hidden-but-mounted sidebar,
and that the player element survives both the mode and channel switches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(epg): tidy guide docs, palette gating and the unbound output

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): match guide favorites by channel URL and skip re-activating the playing row

Favorites are persisted by channel URL (FavoritesActions.updateFavorites),
so the Favorites scope compared the wrong key; the id stays as a legacy
fallback. A double-click arrives as click, click, dblclick and each
activate restarts playback, so the guide now leaves the already-playing row
alone and the commit path only closes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(epg): let the guide window predicate use the programme time index

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): split the guide shell, add a roving focus model and offset-aware search times

The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.

Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.

Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): make guide row ids collision-proof and gate the G shortcut

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): describe guide row ids as scope-local

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown

Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers

The store spreads the selected channel, so the active row is matched by
id, url, group and name before widening; G no longer closes the guide from
a dialog or menu; guide answers use null-prototype records so a key named
__proto__ stays an own property.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row

EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let the guide return catch-up to live and normalise programme-search rows

The guide source contract gains an optional livePlayback signal: while the
host plays a catch-up URL, the active row may be activated again, which is
how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw
snake_case rows to the EpgProgram shape the bridge promises (plus the joined
channel name), so search hits resolve their channel and keep descriptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): name search hits, keep guide coverage strict on mapping failures

- Search results and the unresolved programme dialog show the channel's
  display name (playlist row name, else the XMLTV display name the search
  joined in) instead of the raw XMLTV id.
- The guide coverage read resolves manual mappings through a strict variant
  that rejects on database failure, so a mapped channel can never be reported
  as uncovered and hidden by "Only with EPG".
- Architecture doc describes the tiered active-row resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): offer the Guide action in the list view too

The EPG list view mirrors the timeline's input/output contract, but the Guide
action was bound only in the timeline branch, so Settings → EPG → Guide view =
List lost the in-panel entry point. The list toolbar now carries the same
icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U
host binds it in both branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:47:25 +02:00

26 KiB
Raw Blame History

Workspace Shell

This document records the current workspace-first shell contract. It is the stable replacement for the older UI refactor summary.

Related:

Summary

  • /workspace is the primary app surface.
  • WorkspaceShellComponent owns the persistent frame: rail, header, optional context panel, content outlet, and external playback footer.
  • Descendant workspace pages inherit layout = 'workspace' from the /workspace root route.
  • Provider route trees now bootstrap through route-scoped session providers instead of nested provider shell components.

Core implementation:

  1. apps/web/src/app/app.routes.ts
  2. libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts
  3. libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html
  4. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts
  5. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts
  6. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts
  7. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts
  8. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts
  9. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.ts
  10. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts
  11. libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts
  12. libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts
  13. libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts

Route Contract

Current workspace routes:

  1. / -> /workspace
  2. /workspace -> functional redirect workspaceEntryRedirect (WorkspaceStartupPreferencesService.resolveInitialWorkspacePath(); /workspace/dashboard by default, /workspace/sources when the dashboard is disabled, or the last restorable route under StartupBehavior.RestoreLastView — dashboard itself is guarded by dashboardAccessGuard)
  3. /workspace/dashboard
  4. /workspace/sources
  5. /workspace/playlists/:id/:view (plus favorites and recent siblings)
  6. /workspace/global-favorites
  7. /workspace/global-recent
  8. /workspace/search
  9. /workspace/downloads
  10. /workspace/settings/:section (/workspace/settings redirects to general; the settings context panel links each section page)
  11. /workspace/xtreams/:id/...
  12. /workspace/stalker/:id/...

Compatibility redirect:

  1. /settings -> /workspace/settings

Provider route integration:

  1. apps/web/src/app/app.routes.ts marks the /workspace root route with data.layout = 'workspace'.
  2. isWorkspaceLayoutRoute(...) treats that layout marker as inherited route state for all descendants.
  3. 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.
  4. 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.
  5. 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:

  1. Left rail:
    1. 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.
    2. Provider-aware context links derived from the active or current playlist.
    3. Settings remains a persistent footer shortcut in the rail.
  2. Top header:
    1. Playlist switcher.
    2. Route-aware search input and command palette trigger.
    3. Add source action.
    4. Optional playlist refresh and route-specific shortcut actions.
    5. Downloads shortcut in Electron.
  3. Main body:
    1. Optional left context panel.
    2. Main router outlet content.
  4. Optional footer:
    1. External playback session bar when a docked session is visible.

WorkspaceShellComponent binds only to WorkspaceShellFacade. The facade is kept as a thin template-facing API and delegates ownership to component-scoped services:

  1. WorkspaceShellRouteStateService owns current route parsing, rail links, context-panel state, dashboard startup preference, and playlist source signals.
  2. WorkspaceShellSearchService owns the route-aware header search capability and public search actions.
  3. WorkspaceShellSearchSyncService owns the search query signals, debounced application, provider-store synchronization, and query-param sync.
  4. WorkspaceShellHeaderService owns playlist title/subtitle, account/info actions, refresh action state, and recent-items bulk cleanup.
  5. WorkspaceShellCommandPaletteService owns command-palette dialog lifecycle and recent-command recording.
  6. WorkspaceShellXtreamImportService owns Xtream import/refresh overlay state and labels.

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.

Context Panel Rules

The shell decides which secondary panel to show from the current route:

  1. /workspace/sources
    1. WorkspaceSourcesFiltersPanelComponent
  2. Xtream category sections (live, vod, series)
    1. WorkspaceContextPanelComponent
  3. Stalker category sections (itv, radio, vod, series)
    1. WorkspaceContextPanelComponent
  4. /workspace/settings/:section
    1. WorkspaceSettingsContextPanelComponent
  5. Downloads sections
    1. 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.

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:

  1. Disabled on settings routes.
  2. Enabled on sources routes.
  3. Enabled for /workspace/search, which is the Electron-only routed global-search view. Ctrl/Cmd+F in Electron opens this route and focuses/selects the header search input instead of opening a fullscreen dialog.
  4. Enabled for supported Xtream and Stalker content/search views.
  5. Placeholder text and search handling vary by provider and section.
  6. Input changes are debounced before route/store updates are applied.
  7. Global search uses the header input as its primary input and writes the search phrase to the q query parameter, so history/back-forward behavior matches the rest of the workspace.
  8. The URL is authoritative for the search box only when it carries search intent. WorkspaceShellSearchSyncService re-reads q on every NavigationEnd, 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 trimmed q would 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 (applySearchQuery and setSearchState both trim), so the echoed q compares 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.
  9. 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.
  10. 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:

  1. Workspace-global entries are static.
  2. Provider entries come from buildPortalRailLinks(...).
  3. 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:

  1. The shell resolves commands into three groups in fixed order: current view, this playlist, then global.
  2. Shell-owned commands are derived from route context and current playlist state; empty groups are omitted instead of rendering disabled placeholders.
  3. Workspace features contribute current-view commands through WorkspaceViewCommandService.
  4. Header shortcut actions can opt into palette exposure by attaching palette metadata through WorkspaceHeaderContextService.
  5. Filtering matches command labels, descriptions, and keywords, and keyboard selection always lands on the first enabled command.
  6. 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 at STORE_KEY.RecentCommands). Storage is not pruned by route visibility — a navigation command like Open sources is invisible while the user is on /workspace/sources but the id stays in storage so it reappears in the recent section after navigating away.
  7. Six "Switch player to X" commands are registered globally by WorkspacePlayerCommandsContributor (VideoJS, HTML5, ArtPlayer, Embedded MPV, MPV, VLC). Each command carries a requires flag gating its visibility: the MPV/VLC ("managed-external") entries are visible only when RuntimeCapabilitiesService.supportsManagedExternalPlayers is true, and the Embedded MPV ("embedded-mpv") entry is visible only after the command palette lazily preloads an async window.electron.getEmbeddedMpvSupport() check and it resolves to supported (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 current SettingsStore.player() value is disabled. The new player setting applies to the next playback session; an existing stream is not re-mounted.

Keyboard shortcut help is shell-owned:

  1. WorkspaceKeyboardShortcutsService is provided by WorkspaceShellComponent. It owns the workspace-scoped document:keydown listener for ? / Shift+/.
  2. The listener ignores events from inputs, textareas, selects, and content-editable elements via isTypingInInput(...).
  3. libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts is the metadata registry for shortcuts shown in the help dialog and documented in README. keyboard-shortcuts.ts owns the display transformation and help trigger detection. Shortcuts that only work through the Electron bridge, such as embedded MPV controls, must set electronOnly: true so the PWA dialog does not advertise unavailable commands.
  4. New custom shortcuts should be added to that registry when the handler is added. Do not include native browser/editor behavior such as Tab or 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):

  1. macOS keeps the native traffic lights (titleBarOverlay: true, trafficLightPosition); the renderer draws no window buttons.
  2. Windows and Linux use renderer-drawn window controls (app-window-controls, libs/ui/components/src/lib/window-controls/). frame is 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):

  1. WINDOW:MINIMIZE, WINDOW:TOGGLE_MAXIMIZE, WINDOW:CLOSE, WINDOW:GET_STATE are ipcMain.handle channels resolved from the sender WebContents. Close goes through win.close() so the existing window-bounds persistence in app.ts still runs.

  2. WINDOW:STATE_CHANGED is pushed main → renderer on maximize/unmaximize and on the fullscreen events — enter/leave-full-screen plus the enter/leave-html-full-screen variants 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. attachWindowStateEvents seeds { 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() stays true during an HTML fullscreen exit, and isMaximized() reads false while 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.ts and window-controls.e2e.ts.

    The pushed isFullScreen is the OR of two flags tracked apart: native (OS-level, fed by enter/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 — no leave-full-screen fires and the window stays fullscreen — so a single flag cleared by leave-html-full-screen would un-hide the controls over a window that is still fullscreen. A fullscreen launch (below) or F11 followed by the player's F → Esc makes that path routine on Windows/Linux.

  3. WINDOW:TOGGLE_FULLSCREEN toggles OS-level fullscreen (setFullScreen) and, like the maximize toggle, reports the requested state and leaves the WINDOW:STATE_CHANGED push 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 in apps/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 (trackNativeFullScreen in initMainWindow, while no transition can be in flight) and fed afterwards only by the enter/leave-full-screen events, 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 than FULLSCREEN_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 in WorkspaceKeyboardShortcutsService (deliberately not gated by isTypingInInput — 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 while document.fullscreenElement is set, since the player's own F / Esc own 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):

  1. normal (default) / maximized / fullscreen, chosen in Settings → General ("Window on startup"). Electron only — the select renders only when RuntimeCapabilitiesService.supportsStartupWindowMode sees both updateSettings and toggleFullScreenWindow on the bridge, so the mode is never offered without its F11 exit.
  2. Settings live in the renderer's IndexedDB, which the main process cannot read at window creation, so the SETTINGS_UPDATE handler mirrors the value into electron-conf (STARTUP_WINDOW_MODE, the same pattern as the frame-copy flag) and initMainWindow reads it synchronously. A change therefore applies on the next launch. Both sides normalize through normalizeStartupWindowMode, so junk never reaches the config file or the window options.
  3. fullscreen is the BrowserWindow constructor 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 ready-to-show repeats the request with setFullScreen(true) right after show() wherever isFullScreen() 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 persisting getNormalBounds(). maximized calls maximize() inside ready-to-show right before show(), never earlier: maximize() on a hidden window shows it, and a blank window would flash.
  4. iptvnator --fullscreen (read via app.commandLine.hasSwitch, so it can sit anywhere in argv; the playlist-path extractor already skips every --prefixed argument) forces fullscreen for that launch only and is never persisted. Resolution lives in apps/electron-backend/src/app/services/startup-window-mode.ts. The switch is consumed by the first window (launchFullscreenSwitchConsumed in App): on macOS the process outlives its last window and the Dock re-creates it through the same initMainWindow, which must then follow the stored setting only. A second-instance launch carrying the switch is ignored — the window already exists.
  5. 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 in settings.e2e.ts.

Layout integration:

  1. document.body gets a frameless-platform class (set in AppComponent, same mechanism as dark-theme) — body-level so rules also reach cdk-overlay content rendered outside app-root.
  2. apps/web/src/styles.scss reserves padding-right: 150px in the top-aligned drag region (.workspace-header) for the 3 × 46px button strip.
  3. 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):

  1. 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.
  2. 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:

  1. better-sqlite3 remains 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's onlyBuiltDependencies: forcing an implicit node-gyp rebuild bypasses those binaries and makes installation depend on the host compiler toolchain. The old node-abi override belonged to v12's removed prebuild-install path and is no longer required.
  2. Local development supports Node 22.13–22.x or Node >= 24, declared in engines as ^22.13.0 || >=24.0.0. The direct @faker-js/faker dependency and current lint tooling require that floor. electron-builder 26.15.7 also pulls @electron/rebuild 4, which requires Node 22.12 or newer, and the root postinstall runs install-app-deps on every pnpm install. The 26.15.7 minimum also carries the v26 backport that fully extracts the Snap template's .tar.7z payload; 26.15.0–26.15.6 can produce a Snap that is missing desktop-init.sh. CI already runs Node 22.
  3. 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.
  4. Electron 42 and newer download their development binary on the first Electron command instead of during package postinstall, so electron must not remain in pnpm's onlyBuiltDependencies allowlist. The first local pnpm run serve:backend can include a one-time download; use pnpm exec electron --version to prewarm it before an offline run.

Known caveats:

  1. DIY buttons cannot show the Windows 11 Snap Layouts flyout (only native caption buttons or the Window Controls Overlay get that).
  2. 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.

  1. New top-level user destinations should default to child routes under /workspace.
  2. Shared provider navigation logic belongs in portal-shared util/UI libraries, not duplicated inside the shell.
  3. If a provider route changes how playlist/session bootstrap works, update the route-session provider and shell-facing route contract together.
  4. When adding a non-native keyboard shortcut, update the shared shortcuts registry, the help dialog tests, README, and the closest behavior test.
  5. Historical migration notes, cleanup lists, and one-off refactor steps should stay out of this file; track them in issues or PR notes instead.