mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Re-lands #1788, which merged into #1782's branch after #1782 had already reached master, so none of it is on master. The hidden main window was shown on ready-to-show only. On Linux under X11, when the startup scripts run before the window's first frame, the next frame comes about a second later: nothing is on screen and the splash's requestAnimationFrame waits, so J1's first card came ~940 ms after load instead of ~480 ms in most runner launches (18 bridge calls / 1,031-1,033 DOM mutations instead of 15 / 558). The window is now shown at ready-to-show or the main frame's did-finish-load, whichever comes first, with the splash colour as its background so showing before the first paint does not flash. The journey gate keeps the app's did-finish-load listeners away from its about:blank detour, as it already does for ready-to-show. Three dispatched runs on this branch (37192092882, 37192097790, 37192103151) read 15 calls and 558 mutations in all 18 iterations, stable: true. Both become baselines (slack 0), and the Performance journeys job checks them with check-journey-ratchet.mjs --only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
785 lines
46 KiB
Markdown
785 lines
46 KiB
Markdown
# Workspace Shell
|
||
|
||
This document records the current workspace-first shell contract. It is the
|
||
stable replacement for the older UI refactor summary.
|
||
|
||
Related:
|
||
|
||
- [Workspace Dashboard](./workspace-dashboard.md)
|
||
|
||
## 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; sections:
|
||
`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`,
|
||
`parental` — see [parental lock](parental-lock.md) — `backup`, `reset`,
|
||
`about`)
|
||
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.
|
||
4. No brand mark: it only repeated the first workspace link (Dashboard,
|
||
or Sources when the dashboard is off).
|
||
2. Top header:
|
||
1. Leading Back slot: the current page's registered Back, else browser
|
||
history while an in-app previous page exists, else nothing. See
|
||
[Header Back](#header-back).
|
||
2. Playlist switcher.
|
||
3. Route-aware search input and command palette trigger.
|
||
4. Add source action.
|
||
5. Optional playlist refresh and route-specific shortcut actions.
|
||
6. 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.
|
||
|
||
## 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 `Location.back()` | (no drawer) |
|
||
| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | `Location.back()` | (no drawer) |
|
||
| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | `Location.back()` | beside the drawer toggle |
|
||
|
||
Detail-page semantics (Escape, browse and watch) are in
|
||
[Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus).
|
||
|
||
**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.
|
||
|
||
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. The cost is one
|
||
shift of the switcher and search when Back first appears or leaves, which
|
||
happens only at the start of the history and together with a route change.
|
||
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:
|
||
|
||
1. 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.
|
||
2. 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.
|
||
3. 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:
|
||
|
||
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](./remote-control.md#live-channel-return-and-playback-order).
|
||
|
||
## Search And Navigation Rules
|
||
|
||
Search is shell-owned and route-aware:
|
||
|
||
1. On settings routes, searches the settings themselves (see
|
||
[Settings search](#settings-search)).
|
||
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 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](#settings-search)).
|
||
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.
|
||
|
||
### 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.
|
||
|
||
1. The index is `SETTINGS_SEARCH_ENTRIES` in
|
||
`libs/workspace/shell/util/src/lib/settings-search/`, published through the
|
||
`@iptvnator/workspace/shell/util/settings-search` sub-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).
|
||
2. Each entry names its section, title and description translation keys,
|
||
untranslated synonyms (`keywords`), runtime `requires`, and an optional
|
||
`fallbackId`. Section definitions (`SETTINGS_SECTION_DEFINITIONS`) are the
|
||
single source for the settings navigation too.
|
||
3. Every titled `.setting-item` in the section templates carries
|
||
`data-setting-id`. `settings-search-registry.spec.ts` fails 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.
|
||
4. `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 whose `requires` the 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 needs
|
||
`frameCopyAvailable`, matching the settings page gate.
|
||
5. Settings routes use `local-filter` search mode, so the term lives in `q`.
|
||
While `q` is 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.
|
||
6. Choosing a result, pressing `Enter` in the header search (best match), or
|
||
picking a settings command in the palette calls `reveal()`: it navigates
|
||
to the section page without `q` (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 its
|
||
`fallbackId`, the control that makes it appear. A reveal must win over the
|
||
typed term: `WorkspaceShellSearchSyncService` drops a keystroke still
|
||
waiting for its debounce through `onReveal()`, and Enter does not apply
|
||
the term first, because either `q` sync navigation would supersede the
|
||
reveal navigation. Keyboard users keep a `:focus-visible` ring on the row
|
||
after the highlight fades.
|
||
7. `Ctrl/Cmd+F` on settings focuses the header search instead of opening
|
||
global search.
|
||
|
||
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. The lights
|
||
sit in the 56 px header band above the rail, so the macOS rail
|
||
(`.app-rail.is-macos`) starts its first link at 56 px: level with the
|
||
content area and the dashboard hero, with its hover surface clear of the
|
||
lights. App zoom scales CSS pixels but not the lights, so the rail
|
||
publishes the page zoom factor (`outerWidth / innerWidth`, refreshed on
|
||
`resize`) as `--rail-zoom-factor` and keeps at least 48 window pixels when
|
||
zoomed out. `window-controls.e2e.ts` checks the alignment and the gap at
|
||
default and minimum zoom on macOS.
|
||
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 the first 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()` right before the
|
||
first `show()`, never earlier: `maximize()` on a hidden window shows it,
|
||
and a blank window would flash. That first show happens at
|
||
`ready-to-show` or the main frame's `did-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, so `ready-to-show` alone left the window off screen and
|
||
the splash's animation frame waiting. At `did-finish-load` the inline
|
||
splash is parsed, and the window's `backgroundColor` is the splash colour
|
||
(`MAIN_WINDOW_BACKGROUND_COLOR`, keep it in sync with `#initial-splash`
|
||
in `apps/web/src/index.html`), so showing before the first paint does
|
||
not 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`.
|
||
|
||
Zoom level (Cmd/Ctrl and +/−/0, issue #1109):
|
||
|
||
1. 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 every
|
||
`pushState` to another section owns a separate zoom entry: after a route
|
||
change `webContents.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 in `Preferences` on its own, which is why the level used to
|
||
"appear briefly" on `index.html` at startup and then reset. Dev mode
|
||
(`http://localhost:4200`) is per-host and never shows this, so only a
|
||
packaged or `ELECTRON_IS_DEV=0` run can verify zoom behaviour.
|
||
2. Restore therefore happens in the preload, not the main process:
|
||
`applyPersistedZoomLevel` (`api/preload-zoom-level.ts`) asks for the
|
||
stored level over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC at preload
|
||
start and applies it with `webFrame.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, and
|
||
`getZoomLevel()` reports it regardless of the route.
|
||
`webContents.setZoomLevel` from 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 to `DOMContentLoaded` — never at preload start and
|
||
never from a `setTimeout`: on Linux and Windows a `webFrame.setZoomLevel`
|
||
that early leaves the hidden window without a first frame, `ready-to-show`
|
||
never fires, `show()` never runs, and the renderer gets no animation
|
||
frames (the splash `main.ts` removes in a `requestAnimationFrame` stays).
|
||
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 sends `WINDOW:ZOOM_LEVEL_APPLIED`.
|
||
3. Chromium never persists temporary zoom, so `services/window-zoom-level.ts`
|
||
owns the electron-conf key `ZOOM_LEVEL`: the applied acknowledgement (not
|
||
the request — between the two the sender's `getZoomLevel()` is still the
|
||
per-URL default) marks the sender
|
||
as owning the level, `persistZoomLevel` writes it back from the window
|
||
`close` and app `before-quit` handlers (the bounds-only saves of before,
|
||
folded into `persistWindowState`), and `attachZoomLevelPersistence` also
|
||
writes it on every main-frame cross-document `did-start-navigation` —
|
||
a reload drops the temporary level, and by `did-finish-load` the 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.
|
||
4. 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 with
|
||
`resolveZoomShortcutAction` (`libs/portal/shared/util`): Cmd on macOS,
|
||
Ctrl elsewhere, never Alt; `+`/`=` (so `Ctrl+=` and `Ctrl+Shift+=` both
|
||
zoom in), `-`/`_`, `0`, and the numpad `+`/`-`/`0` (by `code`, since a
|
||
NumLock-off `0` reports `Insert`). Keys are matched by `event.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 already `preventDefault`ed is left alone. The binding
|
||
calls the synchronous, preload-local `window.electron.adjustZoomLevel`
|
||
(`adjustFrameZoomLevel` in `api/preload-zoom-level.ts`), which steps the
|
||
frame's temporary level through the same `webFrame.setZoomLevel` as the
|
||
restore and returns the level applied — never a main-process
|
||
`webContents.setZoomLevel`, which would re-create the per-URL bug. The
|
||
step and limits live in `libs/shared/interfaces/src/lib/zoom-level.util.ts`
|
||
(`stepZoomLevel`): 0.5 per press, Electron's own `zoomIn`/`zoomOut` role
|
||
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+0` returns 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 the `zoomIn`/`zoomOut`/`resetZoom`
|
||
roles, but Chromium hands a key equivalent to the web contents first and
|
||
Electron performs the menu equivalent only in
|
||
`WebContents::PlatformHandleKeyboardEvent`
|
||
(`shell/browser/api/electron_api_web_contents_mac.mm`, Electron 43.3.0),
|
||
the unhandled-keyboard-event hook — a `preventDefault`ed 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.ts` presses 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 in
|
||
`keyboard-shortcuts.spec.ts`, `workspace-keyboard-shortcuts.service.spec.ts`
|
||
and `preload-zoom-level.spec.ts`.
|
||
|
||
Reloading the renderer on an in-app route:
|
||
|
||
1. The packaged renderer is `dist/apps/web/index.html` over `file://` and
|
||
Angular routes by path (no hash strategy), so once the user is on a
|
||
section the document URL is `file:///…/web/workspace/sources` — a path
|
||
with no file behind it. A reload of that URL fails with
|
||
`ERR_FILE_NOT_FOUND` (-6) or is cancelled outright, depending on who
|
||
starts it. Two user-reachable triggers: the macOS default application
|
||
menu (nothing calls `Menu.setApplicationMenu`, so View › Reload / Force
|
||
Reload are live; Windows/Linux drop the menu bar via `setMenu(null)`),
|
||
and the settings unsaved-changes guard, which calls
|
||
`window.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.
|
||
2. Both legs live in `services/renderer-reload-fallback.ts` and end in the
|
||
same `restoreRendererRoute`: load the packaged index with the routed
|
||
URL's route — its path relative to the renderer root plus query and
|
||
fragment (`resolveRoutedRendererUrl`) — in the `restoreRoute` query
|
||
parameter.
|
||
- A main-process reload (`webContents.reload()`, the menu role,
|
||
DevTools) fires no `will-navigate`, so it cannot be redirected up
|
||
front: it fails, Chromium commits `chrome-error://chromewebdata/`
|
||
and `app-root` stays empty until the app restarts.
|
||
`attachRendererReloadFallback` recovers it after the fact from the
|
||
main-frame `did-fail-load` with `ERR_FILE_NOT_FOUND`
|
||
(`resolveReloadedRendererRoute`); other error codes, subframes and
|
||
non-`file:` URLs are left alone. The recovery load is deferred to
|
||
the error page's `dom-ready` and never issued from inside
|
||
`did-fail-load`: a `loadFile` started while Chromium is still
|
||
committing the error page yields a document that never receives
|
||
animation frames — the splash stays, nothing paints, while
|
||
`document.visibilityState` still says `visible` — and the same load
|
||
after `dom-ready` paints normally (Electron emits `did-fail-load`
|
||
before that `dom-ready`). A cross-document navigation starting in
|
||
between withdraws the pending recovery, so a stale `dom-ready` can
|
||
never re-load the index over a newer navigation.
|
||
- A renderer-initiated reload (`location.reload()`, the settings
|
||
guard) does fire `will-navigate`, where the routed URL is not the
|
||
trusted index and `handleRendererNavigation` would 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 failed `index.html` itself is never re-requested (it would loop):
|
||
`resolveRoutedRendererUrl` rejects the index, and the recovery load
|
||
carries `index.html` as its path, so a second failure cannot recurse.
|
||
3. The renderer consumes the parameter before Angular bootstraps:
|
||
`apps/web/src/main.ts` calls `resolveRestoredRendererRoute`
|
||
(`libs/shared/interfaces/src/lib/renderer-reload-route.util.ts`, which
|
||
also owns the parameter name) and installs the result with
|
||
`history.replaceState`, so the router's initial navigation lands on the
|
||
route the user was on. The route is resolved against `document.baseURI`
|
||
(the packaged `<base href="./">`, i.e. the renderer directory — the same
|
||
prefix Angular strips from `location.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.
|
||
4. Zoom persistence is unaffected: the failed reload's
|
||
`did-start-navigation` already saved the level and released ownership,
|
||
the recovery load's `did-start-navigation` is 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-navigate` disarms it).
|
||
5. Regression coverage: `renderer-reload.e2e.ts` reloads 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.ts` marks the old document,
|
||
since the URL alone is identical before and after);
|
||
`window-zoom-level.e2e.ts` reloads the same way. Unit coverage:
|
||
`renderer-reload-fallback.spec.ts`, `renderer-reload-route.util.spec.ts`,
|
||
`app.spec.ts` ("renderer reload recovery").
|
||
|
||
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.22.3–22.x or 24.15.0–24.x**, declared in
|
||
`engines` as `^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-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`.
|
||
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.
|