mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
b7de3e47acb7bf03168df4e485ca1617bf5ce4dd
112
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b30c783e85 |
fix(search): locale-invariant Turkish case folding in every search path (#1640)
Turkish upper and lower case queries now return the same results everywhere a title can be searched. Lower-casing the dotted capital "İ" (U+0130) leaves a combining dot behind, so "İnş" and "inş" reached different search arms and different results. - Case folding is locale-invariant: `toLowerCase()`, never `toLocaleLowerCase()`, which under a Turkish or Azeri OS locale maps ASCII "I" to the dotless "ı". - The Electron content search composes to NFC and drops the leftover combining marks before tokenizing, and its LIKE/GLOB pattern builders additionally spell the `'tr'`-locale İ forms, since SQLite LIKE folds only ASCII. - A shared `foldSearchText` covers every in-memory filter: channel lists, the Xtream and Stalker catalogs, category filters, collections, the EPG guide, the command palette, sources, the playlist switcher and the download lists. - Composing before the strip keeps canonically equivalent spellings equal while the fold stays accent-sensitive; the Turkish I/ı pair is deliberately left alone, as the FTS index does not fold it either. Covered by a SQLite-backed spec over the real trigram index plus regression cases in the affected renderer specs. Closes #609. Co-Authored-By: Justin Willhite <5132924+thejdubb02@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c016c73f86 |
feat(shell): zoom shortcuts on Windows and Linux (#1109) (#1623)
* feat(shell): zoom shortcuts on Windows and Linux (#1109) Cmd/Ctrl and +/−/0 (numpad included) now zoom the app on every platform. Windows/Linux run without a menu (`setMenu(null)`), so the shortcuts are a renderer key binding in `WorkspaceKeyboardShortcutsService` calling a new synchronous, preload-local bridge method `adjustZoomLevel`, which steps the frame-bound temporary level through `webFrame.setZoomLevel` — never a main-process `webContents.setZoomLevel`, whose per-URL entry the app's `file://` path routing resets. Step and limits live in `libs/shared/interfaces` (`stepZoomLevel`: 0.5 per press like Electron's zoomIn/zoomOut roles, clamped to levels −4…6). On macOS the renderer sees the key before the application menu, and `preventDefault()` keeps the menu role from stepping a second time (Electron only performs the menu key equivalent in its unhandled-keyboard-event hook). Persistence is unchanged: the main process still reads the live level back on close, quit and reload. The zoom E2E now drives the real shortcuts (in, out, numpad, reset). Help dialog entries added and translated for all locales; contract updated in docs/architecture/workspace-shell.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(shell): never step a stored out-of-range zoom level against the request A level persisted before the shortcuts existed (the macOS menu roles never clamped, and the store restores any finite level) was clamped BEFORE the step, so the first zoom-in from level 7 rendered smaller. Step from the raw level instead: a press further out leaves an out-of-range level where it is, a press back in lands on the limit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(shell): state the zoom bridge's return contract precisely `adjustZoomLevel` steps by `stepZoomLevel`'s rules; a stored out-of-range level is never moved against the request, so the returned level is not itself guaranteed to be within `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(release): add a release note for the zoom shortcuts The Release note gate requires an added `.changes/*.md` for runtime changes; the shortcuts are a user-visible feature of their own, so they get their own note and the persistence note stays about persistence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e9eca1c386 |
chore(deps): upgrade Angular to 22.1 and Nx to 23.2 (#1603)
* chore(deps): upgrade Angular to 22.1 and Nx to 23.2 * fix(deps): complete Angular migrations after rebasing on master * fix(ci): use the Node pin for Windows runtime refresh * docs(deps): synchronize the workspace-shell Node requirements |
||
|
|
bd848aaad6 | feat(playlist): clean up selected inactive desktop sources (#1593) (#1596) | ||
|
|
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> |
||
|
|
0a2373f192 |
feat(portals): fold live TV panels in nested levels with a category dropdown (#1556)
## Summary Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together: 1. **Categories + channels + player** (browse, unchanged). 2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back. 3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1. Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session. ## Design notes - Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex). - The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area. - `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider. - M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched. ## Merged with #1555 (per-surface rail state) #1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree. ## Also fixed along the way - The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now. - A collapsed context panel left a 22px padding strip beside the channels rail. - The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales). Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
0dcfba7045 |
fix(live-tv): keep a hidden channel list discoverable and scoped per surface (#1555)
* fix(live-tv): keep a hidden channel list discoverable and scoped per surface The second report in #1458 ("all channels disappear after clearing the playback history, reset does not bring them back") was not data loss: the history write never touches playlist items. The reporter's screenshot shows a collapsed channel rail, a state persisted under one localStorage key shared by the M3U player, the Xtream/Stalker live layouts and the favorites/recent live tab. It survived restart, "Remove all playlists" and re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B. - LiveLayoutSidebarStateService keeps the state per surface (m3u / portal / collection) under live-sidebar-state:<surface>; the M3U player now goes through the service instead of its own signal. The legacy shared key is forgotten on startup and never read, so the update itself restores the list for everyone who got stuck. - The workspace header renders a view_sidebar toggle on every route that renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so the control exists in both states instead of disappearing with the rail. Collection pages keep their own toggle beside the content switch. - While the rail is collapsed and nothing plays, every live host shows app-channel-list-hidden-state (title, shortcut hint, full-size "Show channels list" button) instead of asking to pick from a list that is not on screen. app-portal-empty-state gained optional hint/action inputs. - New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales. Tests: service, empty-state, hidden-state and header component specs, a separate video-player-sidebar spec (the main M3U spec sits at the test line budget), and an Electron E2E covering history clearing, restore via button/header/shortcut across restart and re-import, per-surface scoping against an Xtream portal, and legacy-key cleanup. Refs #1458 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(live-tv): mirror the EPG offset setting in the sidebar spec mock Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the new sidebar spec was cloned from the movie-gate harness before that field landed, so its playing-channel case threw inside the EPG effect. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(web-e2e): scope the Stalker radio rail toggles to the rail The workspace header now carries a second "Hide/Show channels list" toggle, so the role+name locators matched more than one button and tripped Playwright's strict mode. Target the rail's own chevron and the floating restore button, and assert the header toggle mirrors the state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones Codex review follow-ups on #1555: - The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent collection page had no handler; only the routed M3U/Xtream/Stalker live layouts did. The page now toggles the collection surface while its live tab is on screen, with the same typing/inert guards as the other hosts. - At the phone breakpoint the header already holds the drawer toggle, switcher, search and Add; the live rail is a bottom drawer with its own toggle there, so the header rail toggle is hidden below 640px. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API master (#1554) added `XtreamLiveChannelNavigationService` and `stalker-live-navigation.ts`, which expand the rail through `sidebar.setState('expanded')` on the pre-split signature. Point them at the `portal` surface and update their specs; drop the now-unused hidden-state stub from the Xtream layout spec, which master pushed to the max-lines budget. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
002faafbd7 | fix(xtream): improve sync overlay contrast in both themes (#1546) | ||
|
|
eb602db5fc |
fix(ui): restore channel and detail keyboard scrolling (#1542)
* fix(ui): restore channel and detail keyboard scrolling * test(ui): drag below the Windows scrollbar arrow |
||
|
|
2fc1bd5601 |
fix(stalker): keep live playback when switching the ITV/radio category (#1517)
Switching the Live TV or radio category in the shell context panel tore down the Stalker player: `onStalkerCategoryClicked` cleared the selected item for every section, and the live layout gates its player on `selectedItem`. Xtream live (#936) and M3U groups already keep the channel playing across a category/group switch. - Context panel: return before `clearSelectedItem()` for `itv`/`radio`; VOD/series clicks still drop the open detail before navigating. - Live layout: the category-change reset effect no longer wipes the playing channel's short-EPG fallback or cancels a fallback load in flight; only a section change (itv <-> radio) does that now. - Regression coverage in the context panel spec, the live layout spec and a new web E2E scenario; docs and a `.changes/` note added. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
fa9084fca3 |
feat(shell): startup window mode, --fullscreen switch and F11 toggle (#1514)
Settings > General gains "Window on startup" (normal / maximized / fullscreen), Electron only, mirrored into the main-process config by SETTINGS_UPDATE and applied at the next window creation. `--fullscreen` forces one fullscreen launch (consumed by the first window). F11 toggles OS-level fullscreen through WINDOW:TOGGLE_FULLSCREEN — the exit path on Windows/Linux where the title bar is hidden — and is skipped while the player owns document.fullscreenElement. attachWindowStateEvents tracks native and HTML fullscreen as two flags, since Electron leaves only the HTML state when the window was already natively fullscreen. macOS ignores the constructor `fullscreen` option on a hidden window, so ready-to-show repeats the request after show(). Toggles are decided by an observe-only, event-fed tracker (native-fullscreen-transitions.ts), never against isFullScreen(). Closes #1455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
7efb8c4f66 |
fix(search): keep a trailing space when the trimmed q echo lands after the debounce (#1474)
* fix(search): keep a trailing space when the trimmed q echo lands after the debounce The #1432 guard only held while a debounce was still pending. Once it fired, the router echo of our own trimmed q (replaceUrl navigation) reset the one-way-bound search box to the trimmed term, deleting the just-typed trailing space — typing "Bein Sports" collapsed into "BeinSports". Applied terms are now always trimmed at the apply choke point (URL sync and portal stores only ever act on the trimmed form anyway), so the echoed q compares directly, and the echo guard no longer requires a pending debounce. Back/forward stays authoritative via the untouched imperative-trigger check. Residual part of #1338. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(search): make the trimmed-applied-term invariant structural and update the shell contract doc Review follow-ups on the echo-guard widening: - setSearchState now trims too, so URL-sourced terms (deep links with ?q=Bein%20, actor/discover prefills passing raw provider titles) cannot put an untrimmed term into appliedSearchQuery — previously that path failed the echo guard's equality check, snapped the box, and dispatched the portal search twice. Regression spec added. - docs/architecture/workspace-shell.md item 8 updated: the applied-term echo is now always ignored, not only while input is still debouncing. - The facade spec's router mock exposes a mutable navigation trigger so facade-level tests can exercise the popstate branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(search): compare the echoed q in trimmed form in the echo guard Adoption trims, so a same-page imperative navigation still carrying a not-yet-rewritten untrimmed q adopts to exactly the applied state — syncing it could only cancel a pending debounce. Comparing the trimmed form closes that window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5b2eb515d1 |
feat(downloads): track live-TV recordings in the download manager (#1452)
* feat(downloads): track live-TV recordings in the download manager Embedded MPV recordings were written to disk and forgotten: no list, no reveal/play, no missing-file handling, and the channel/EPG context was lost the moment the recording stopped. Recordings now live beside downloads: - New `recordings` table (no unique index, no playlist FK — recordings survive source deletion; playlist name stored via playlistDisplayLabel). - EmbeddedMpvRecordingTracker persists the lifecycle: start/stop hooks plus a session-snapshot observer for implicit stops (stream-replacement auto-stop, frame-copy helper crash, session error/close); startup repair turns rows a hard kill left behind into playable `interrupted` partials. - Channel/EPG metadata is captured at recording START in all four live hosts (M3U, Xtream, Stalker ITV, unified live tab); a clean stop triggers renderer-side enrichment with every program overlapping the recorded window, keyed by target path — covering recordings that span a program boundary. Provider EPG never reaches SQLite, so post-hoc lookup is impossible by design. - Own RECORDINGS_* IPC surface + RECORDINGS_UPDATE_EVENT ping and a separate supportsRecordings capability gate (the supportsDownloads allowlist is all-or-nothing and stays untouched). Reveal/play shell IPCs are gated on the recordings table, so the renderer-supplied recording directory stays a write-location preference, not a shell-access grant. - Manager UI: `recording` filter chip, "Recording now" queue section (REC pulse, elapsed, live file size — no percentage, the length is unknown), 16:9 channel-logo Recordings library, Needs attention with Remove only, focused detail at /workspace/downloads/recording/:recordingId. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): close the stop-enrichment race and repair player stubs Greptile spotted a real ordering bug: the stop IPC returns as soon as mpv acknowledges, while the recording row's terminal-state update is still queued in the tracker. The renderer answers that snapshot with stop enrichment, whose handler only accepts a terminal row — so the covered-program metadata could be silently dropped with "Recording not found". - EmbeddedMpvRecordingTracker.whenSettled() exposes the serialized write chain; RECORDINGS_UPDATE_PROGRAMS awaits it before the terminal-row lookup. Regression covered from both sides: the handler must not touch the database until the barrier resolves, and the barrier must imply a committed row. CI also caught spec stubs that had not learned the new player inputs (my local run-many had been an Nx cache hit, so the failures only surfaced in CI): - Teach the `app-web-player-view` and `app-embedded-mpv-player` stubs the `recordingMetadata` input and `recordingStopped` output across the m3u, Xtream, Stalker, unified-live-tab and web-player-view specs. - The races spec now asserts the metadata argument explicitly instead of matching a two-argument call. - Extract the Stalker and unified-live-tab spec stubs into sibling `*.spec-stubs.ts` files (the pattern ui/playback already uses) so both specs stay under the 1200-line test limit without shaving assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(downloads): make the recordings events spec a module The spec deliberately has no static imports — every dependency is swapped through jest.doMock before the harness's dynamic import — which also made it a TS script rather than a module, so its top-level `registeredHandlers` landed in the global scope and collided with the same-named const in stream-probe.spec.ts (TS2451). Local per-project runs compile the specs separately and stayed green; only the Tier A coverage suite builds them into one program, so CI caught it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): address Codex review on recording lifecycle Four findings from the Codex review, all real: - P1: `addon.stopRecording()` only dispatches — native-view uses `mpv_set_property_async`, frame-copy writes a helper command — so finalizing inside the stop hook could stat a file mpv had not flushed and even unlink bytes still being written. The tracker now treats the hook as a request and finalizes on the acknowledged inactive snapshot, with a 10 s bound so a lost acknowledgement cannot strand the row. Only a recording that never went active has its empty reservation removed. Stop enrichment follows through `whenFinalized(targetPath)` (bounded) instead of merely draining the write queue. - Live file size: `file_size_bytes` is written at finalization only, so the manager's 15 s refresh reported nothing while recording. Active rows are now decorated with a current `fs.stat` size. - Manager-initiated Stop bypassed both player stop paths, so recordings spanning program boundaries kept only the start-time program. `EmbeddedMpvPlayerComponent` now owns the active→inactive edge and emits `recordingStopped` for every trigger; the adapter and legacy toggle no longer emit it themselves. - Startup recovery could terminate a row another live instance was still writing under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES. Rows carry `owner_pid` and recovery skips those whose owner process is alive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): derive the enrichment wait from the stop fallback Greptile caught the seam my previous fix left: the enrichment barrier waited 5 s while the tracker's acknowledgement fallback only finalizes at 10 s, so a stop mpv never confirms let the terminal-row lookup expire early and drop the covered programs with no retry — precisely the case the fallback exists for. The wait is now derived from the acknowledgement bound (fallback + 1 s), with a regression test that fails if the two ever drift apart again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): address the second Codex pass on recordings Four more findings, all real: - P1 (macOS native-view): `StopRecording` clears `recordingActive` *before* dispatching the async property set and restores it if the request is rejected, so the first inactive snapshot is optimistic, not an acknowledgement — the tracker could finalize (and stat) a file mpv was still writing, and a rejected stop would leave the row `completed` while recording continued. An inactive snapshot now has to survive a 1.5 s settle window (three poll cycles); a revived recording cancels the pending finalization. - Removing a failed row unlinked its path unconditionally, which takes the file of a newer recording that reused the freed name within the same timestamp second. The cleanup now runs only while no other row claims it. - The All chip and the header's active badge ignored recordings, so a manager holding only recordings read "All 0" and an active recording never showed up in the badge. - Switching channels auto-stops the recording, but by the time the host handled the stop its `activeChannel`/EPG already described the NEW channel, so the old recording was enriched with the wrong schedule (and an unrelated program could be promoted to its title). The stop event now carries the EPG key captured while the recording was active and every host compares it before enriching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): close the persistence race and two recording UX gaps - Greptile P1: the enrichment deadline (fallback + 1 s) still raced the terminal write — if the tracker queue or the UPDATE took longer than the remaining margin, `whenFinalized` returned while the row was still `recording` and the one-shot enrichment was dropped. The deadline now bounds only the wait for mpv; `finalize()` removes the entry synchronously, so once it has started the wait follows the write itself. - Codex: `RECORDINGS_STOP` ignored `owner_pid`. Session ids restart per process, so under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stopping another instance's row could stop an unrelated local recording. Foreign rows are now refused. - Codex: the In progress chip counted active recordings while its filter deliberately hid them, so clicking it showed "no matches". Active recordings now belong to that filter — a chip whose count disagrees with its page is a lie. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(downloads): drop the enrichment barrier instead of tuning it Three review rounds circled the same class: synchronizing mpv's asynchronous stop acknowledgement with a one-shot program enrichment. Each fix moved the deadline (5 s → fallback+1 s → wait-on-the-write) without removing the reason a deadline existed at all — the handler insisted on a *terminal* row. It never needed one. `openSync('wx')` makes the reserved path exclusive while a recording owns it, so the newest row for that path IS the recording that was stopped, and `finalize()` writes only status/end time/size and never `programs_json`. Enrichment and finalization are therefore order-independent: - `RECORDINGS_UPDATE_PROGRAMS` matches the newest row for the path in any status and awaits only the tracker's write queue, which exists solely to guarantee the INSERT committed (a recording stopped milliseconds after it started). - `whenFinalized`, its deadline constant, and the per-entry finalized promise are gone; the tracker keeps only the settle window and fallback that make *finalization* itself correct. No behavior is lost and the whole timing class disappears with the code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): bind recording finalization to its entry and shield live rows from startup repair Two races from the Codex review: - Tracker timers finalized by reusable session id, so a stop followed by an immediate restart on the same session let the old settle timer finalize the NEW row (marked completed while mpv kept writing) and strand the old row in 'recording'. Finalization is now bound to the exact open entry, and replacing a session's entry arms the old entry's settle timer so an unobserved stop still finalizes it. - reconcileStaleRecordings() runs after the renderer is interactive; a recording started during bootstrap has ownerPid === process.pid and was repaired to interrupted/failed mid-write. Recovery now skips rows the tracker reports as actively tracked (activeRowIds()). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): harden recording startup repair against recycled pids and stale renderer lists Second Codex pass on the recovery path: - A live ownerPid alone no longer shields a row: after a crash the OS can recycle the pid for an unrelated process, which would park the row in 'recording' with no instance able to finalize it. Recovery now also checks (best-effort, ps/tasklist) that the process looks like an IPTVnator/Electron instance; an unreadable name stays conservative and keeps the skip. - The renderer loads before the repair pass runs and may already hold the pre-repair list with a stale Stop affordance; recovery now broadcasts one RECORDINGS_UPDATE_EVENT after changing any rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): defer teardown finalization behind the flush window and bound the live-size stat Third Codex pass: - A synthetic error/closed snapshot from disposeSession() arrives while the frame-copy helper may still be flushing (0.5 s quit grace + 2 s SIGTERM grace before SIGKILL). Finalizing there statted a file mid-write — short captures became terminal 'failed', longer rows persisted a truncated size, and startup recovery could repair neither. The tracker now defers that finalization behind a 2.5 s flush window; the row stays 'recording' (repairable) meanwhile, and an already-acknowledged stop's settle timer keeps its 'completed' verdict instead of being relabelled 'interrupted'. - The active row's live file size used a bare await stat(): one stat hanging on a dead network filesystem wedged every RECORDINGS_GET_LIST. The probe now mirrors the availability probe's contract — in-flight coalescing plus a 1 s deadline degrading to no size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): unmask recycled recording owners, guard the PWA recording route, and unblock file probes Fourth Codex pass: - Recycled-pid discrimination no longer stops at the process-name family check (any Electron app could shield the row): a live holder must also not provably have started after the recording did (ps -o etime= / PowerShell StartTime). A pid frees only when its previous owner dies, so a recycled pid's holder is always younger than the recording; unreadable evidence stays conservative. - /workspace/downloads/recording/:recordingId gets a supportsRecordings capability guard redirecting the PWA to the manager — RecordingsService never becomes authoritative there, so the detail rendered a permanently blank workspace. - Finalization and startup repair stat through a bounded async probe (3 s deadline, ENOENT/ENOTDIR as the only proof of absence) instead of main-thread statSync: a dead network mount no longer freezes the main thread or the tracker queue, repair leaves unjudgeable rows recoverable, and finalization keeps the requested status with an unknown size rather than branding a likely-good file failed. The 0-byte reservation unlink is fire-and-forget for the same reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): keep inconclusive recording probes out of Needs attention and bound repair batches Fifth Codex pass: - Recording list decoration now uses the bounded availability variant that preserves 'unknown': a timed-out or permission-errored probe is not proof of absence, so a good recording on a slow mount no longer lands in Needs attention with its Play/Reveal hidden. ElectronRecordingItem.fileAvailability widens accordingly; consumers already gate on === 'missing'. - Startup repair probes its whole batch concurrently, so main.ts awaits roughly one 3 s deadline instead of one per stale row. Cross-process ping propagation under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stays out of scope (debug-only flag, same single-window design as DOWNLOADS_UPDATE_EVENT) — rationale left on the review thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): fix duration rounding at hour boundaries and bound owner-process probes Sixth Codex pass: - The recording duration formatter rounded minutes after flooring hours, so 59:45 read '60 min' and 1:59:45 read '1 h 60 min'. One shared recordingDurationLabel() now rounds the total minutes before splitting (both the detail page and the library card used a duplicated copy). - Startup repair's synchronous ps/tasklist/PowerShell ownership probes get a 2 s spawn timeout and are memoized per unique pid, so a batch of rows from one crashed instance costs at most one name query and one start-time query, and a hung process query degrades to the conservative fallback instead of blocking the main thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): return to the manager through history from the recording detail Seventh Codex pass (single finding): with a validated returnUrl the manager is already the previous history entry, so Back now uses Location.back() instead of pushing a third entry that made the browser Back button reopen the detail; router navigation remains the fallback for direct links — matching the offline-detail navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): bound removal cleanup and shell gates, date interrupted rows by file mtime Eighth Codex pass: - RECORDINGS_REMOVE no longer awaits an unbounded unlink of a failed row's leftover reservation: cleanup is raced against the 1 s deadline, so a hung network unlink cannot keep the Remove action busy — the row deletion is what matters. - Reveal/Play swap the synchronous lstat gate for the bounded async availability probe: a dead mount no longer blocks the main process, and only PROVEN absence refuses the action — an inconclusive probe lets the shell try and answer honestly. - Startup repair dates an interrupted row's endedAt from the captured file's mtime (mpv's last write) instead of the repair time, so an overnight shutdown no longer inflates a five-minute capture into an hours-long recording; the repair-time fallback remains when mtime is unreadable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): keep recording-start program metadata fresh across EPG boundaries Ninth Codex pass (single finding): the unified live tab's recordingMetadata computed cached its Date.now() verdict — starting a recording after an EPG boundary snapshotted the previous show. It now tracks the existing 30 s progress tick. The Stalker live layout's currentProgram had the same memoization (feeding recording metadata, the EPG panel summary, and external-player metadata); it gains a 30 s clock tick with interval cleanup in ngOnDestroy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): re-select the Xtream current program against the 30 s tick at recording start Tenth Codex pass (single finding): the Xtream live layout's recording snapshot read withEpg().currentEpgItem, a computed whose Date.now() verdict stays cached until epgItems changes — a recording started after an EPG boundary snapshotted the previous show. The selection logic is extracted as the pure findCurrentEpgItem(items, nowMs), the store computed delegates to it unchanged, and recordingMetadata re-selects with the layout's existing 30 s currentTimeMs tick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): scope stop enrichment to the exact recorded list item Eleventh Codex pass (single finding): the stop-enrichment guard compared only the EPG key, which is not unique for M3U items — two list entries sharing a tvgId (or the display-name fallback) could hand the first item's recording the second item's schedule after a switch-triggered auto-stop. RecordingStartMetadata/RecordingStoppedEvent gain an opaque sourceItemKey (unified tab: item.uid; M3U player: channel.id), captured while the recording is active exactly like the EPG key, carried through the player's stop edge, and compared by the hosts before enriching. Xtream/Stalker keys are already playlist+id-scoped and need no extra key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): derive the M3U start-snapshot program from the active channel's schedule Twelfth Codex pass (single finding): the M3U recording snapshot read the NgRx currentEpgProgram, which retains its last value across a channel switch and through EPG gaps (the mirror effect only dispatches when a program exists) — a recording started on a channel with no airing program could persist the previous channel's title, which stop enrichment deliberately never overwrites. The snapshot now derives the program from the active channel's own schedule against the existing 30 s clock, and an EPG gap snapshots no program. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): keep finalizing rows in the recovery ledger and guard the repair update Thirteenth Codex pass (single finding): finalize() removes an entry from the open map before its queued terminal update commits, so activeRowIds() briefly omitted a row still persisted as 'recording' — startup recovery overlapping a clean stop could relabel it interrupted, after which the tracker's status-guarded update could not restore 'completed'. Finalizing entries now stay in a dedicated ledger until the update settles, and the repair UPDATE itself is guarded on status='recording' as a second belt against a finalization that commits between recovery's SELECT and its write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): register update listeners before the initial list load Fourteenth Codex pass (single finding): RecordingsService awaited its initial RECORDINGS_GET_LIST before subscribing to the update ping — a recording transition during that request pinged into the void while the response still reflected the pre-transition state, and recording pings are rare enough that nothing self-healed until the 15 s poll (armed only once an active row is visible). The listener now registers first so the load-state coalescing queues the trailing refresh. DownloadsService had the same latent window and gets the same reorder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: 4gray <fourgray@proton.me> |
||
|
|
bee7df1e02 |
feat(playlist): auto-detect import method that parses pasted provider messages (#1445)
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a provider sent — links, Xtream credentials, a MAC address with device identity — and a deterministic parser recognizes the source(s) and prefills the matching import form. - detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC addresses and labeled fields, classifies each finding as Xtream, Stalker or an M3U link/body, and returns ranked candidates. Pure and synchronous. - Built against a corpus of 19 real reseller handouts kept verbatim in the spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a guard so a parental PIN is never read as the account password. - Detection only proposes: the target form's own validation and behavioral probes remain the sole path into the store, and no pasted text leaves the app. Passwords are masked on candidate cards, including query and HTTP Basic userinfo forms. - Covered by parser, component and dialog unit tests plus two web E2E specs for the paste → pick → prefilled form workflow; i18n for all 19 languages. |
||
|
|
30d76a3a46 |
fix(search): keep in-flight typing when the page rewrites its query params (#1432)
* fix(search): keep in-flight typing when the page rewrites its query params `WorkspaceShellSearchSyncService` re-read `q` on every `NavigationEnd` and unconditionally called `setSearchState(...)`, which cancels the pending debounce and overwrites the search box. Any same-page navigation that carried no search intent — a downloads filter chip writing `?filter=…`, a refresh bump, or the router echoing back our own `q` — therefore ate whatever had been typed since the last applied term. While input is debouncing, ignore a navigation that stays on the same page and carries the term already applied. Real search intent (route change, back/forward, a different `q`) still syncs as before. This is the race behind the flaky `@downloads @electron keeps global and scoped libraries truthful …` e2e test: it clicks a filter chip and fills the searchbox with nothing awaited in between, so under CI load the chip's navigation lands after the keystroke, wipes the term, and `q` is never written. Reproduced deterministically by dispatching the chip click and the `input` event in the same page task; the searchbox value goes to `""` and `q` stays `null`. The new spec fails on the old code for the two regression cases and passes for the three guard cases on both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(search): keep history authoritative and retire the debounce on Enter Two follow-ups from review of the same-page navigation guard. Greptile: the guard could not tell an app-initiated `q` echo from back/forward landing on a history entry that carries the same term. Key the exemption on `Navigation.trigger === 'imperative'` instead, so browser history always wins over in-flight typing. `lastSuccessfulNavigation` is set immediately before `NavigationEnd` is emitted, so it describes the navigation being handled. Codex: with the guard in place, an Enter commit no longer had its queued debounce cancelled as a side effect of the resulting `NavigationEnd`. Typing "Beta " and pressing Enter before the debounce expired applied the trimmed term, then the stale timeout reapplied the untrimmed one — leaving the box and URL on "Beta" while the provider store searched "Beta ". `applySearchQuery()` now cancels the pending debounce itself, which is the correct owner of that rule rather than relying on a navigation side effect. Both new tests were mutation-checked: dropping either sub-fix fails exactly its own test and no other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d73acd6bfc | fix(playback): clarify external player launch feedback (#1388) | ||
|
|
d5f84fb130 |
feat(xtream): catch-up badge for live channels with archive (#1341)
* feat(xtream): show a catch-up badge on live channels that have archive Live channels whose provider declares playable catch-up (tv_archive=1 with a positive tv_archive_duration) now show a small history badge in the channel sidebar next to the name and on the all-channels grid cards, with the archive window (days) in the tooltip. Closes #1128 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): expose the catch-up badge status to assistive technology The mat-icon is aria-hidden and the tooltip is pointer-only, so the badge status was invisible to keyboard and screen-reader users. Both badge surfaces now also render the translated status as visually-hidden text (Codex review, P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(xtream): show the catch-up badge in favorites and recent lists Carries tvArchive/tvArchiveDuration through UnifiedFavoriteChannel so the shared favorites list (portal favorites/recent tabs and global favorites) renders the same catch-up badge as the live sidebar. Requested in PR feedback by the issue author. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): show the programme-info button in portal sidebars, stacked vertically Adds the (i) programme-info button to the Xtream and Stalker live sidebars and reworks the row action column: buttons stack vertically (favorite on top, info below), so the second button costs no horizontal space — the column is actually narrower than the previous single-button row. The info slot is reserved (inert, visibility:hidden) while the row has no programme, so the star never shifts when EPG data arrives. Requested by the issue author in PR feedback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ui): move the archive passthrough spec out of the budget-capped file CI lints the merge with master, where unified-live-tab.component.spec.ts grew (#1374) to one line under the 1200 max-lines test budget — the archive passthrough test added here tipped the merged result over. The test moves to a focused template-less spec (plus a null-normalisation case), leaving the main spec at master's size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): constrain Material touch targets to the stacked button bounds mat-icon-button keeps a 48px touch target; stacked 28px buttons overlapped by 20px and the later sibling (programme info) stole clicks from the lower third of the favorite star. Verified via document.elementFromPoint before/after: the star's visual bounds now hit the star, and clicks left of the column reach the row again instead of the button's oversized target (Codex review). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1a6af75761 |
feat(settings): per-section pages with unsaved-changes bar (#1384)
* feat(settings): split settings into per-section pages with an unsaved-changes bar Replace the single scrolling settings page with routed section pages (/workspace/settings/:section): the context-panel rail links each section, only the active section renders, and unknown or capability-gated sections redirect to General. The shared form lives on the parent component, so staged edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the always-visible footer Save button. Rail links navigate with replaceUrl so Back still leaves settings in one step. Along the way: - delete the unreachable settings dialog mode and the dead AppPortalNavigationActionsService with both of its never-injected DI tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS) - delete the scroll-spy directive and pendingScrollTarget plumbing - revive the EPG panel's "Open EPG settings" empty-state button as a deep link to /workspace/settings/epg; the M3U player now reports m3u-needs-setup only when the channel has no programmes and no EPG source exists in settings or on the playlist itself - load TMDB cache stats when the Metadata page opens (the section component now only exists while its page is open) - add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): confirm before leaving with unsaved changes Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with a three-action dialog: save and leave, leave without saving, keep editing. The guard only intercepts leaving the settings AREA — section switches share the one settings form and pass unconditionally, so the dialog can never nag while moving between pages. A failed save cancels the navigation instead of silently dropping the edits it promised to keep; leaving without saving also reverts the live theme preview. Save-and-leave is disabled while the form is invalid, with a hint explaining why. New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stage cover size and EPG view mode; adapt e2e to section pages Cover size and EPG view mode were the only two controls that persisted eagerly on click, which made Discard (and leave-without-saving) unable to revert them: hydrateFromStore() faithfully reloaded the just-persisted edit. They now stage in the form like every other setting and reach the store on Save. Review finding by Greptile (P1) and Codex. E2E suites that walk through settings are updated for one-section-page rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the staged cover size (downloads asserts the dataset after Save); the EPG icon fallback test saves before leaving settings so the new unsaved-changes dialog does not block its navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fd96b85c19 |
feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations * docs(playback): plan recovery recommendations * refactor(playback): extract diagnostic utilities * feat(playback): define recovery recommendation contracts * feat(playback): rank recovery recommendations * feat(playback): track session recovery attempts * feat(playback): identify content recovery sessions * feat(ui): add ranked playback diagnostic panel * feat(playback): switch temporarily to recommended players * test(playback): cover temporary player recommendation * test(playback): verify recommendation capability guards * docs(playback): document recovery recommendations * fix(playback): keep recovery keys credential-free * fix(playback): remove derived tracking ownership * fix(playback): preserve distinct recovery fallbacks * fix(playback): reset resume for new sources * fix(playback): preserve desktop recovery guidance * docs(playback): clarify recovery policy exceptions * fix(playback): reject stale progress updates * fix(playback): keep protected recovery guidance neutral * test(playback): cover stale progress output * fix(playback): neutralize protected diagnostic copy * fix(playback): harden runtime guidance ownership * fix(playback): stabilize recovery application ownership * fix(ci): classify playback util coverage * fix(e2e): preserve playback fixture bytes |
||
|
|
9ff1c6ae01 |
feat(stalker): identity hardening (#1370)
MAC addresses are canonicalized to the uppercase colon form a real STB
sends and validated at the input boundary, with a hint when they fall
outside Infomir's OUI — which the stock server's default filter refuses
with a bare {status: 1} no user could diagnose. Normalization applies
only to a value the user actually edits: rewriting stored bytes would
move the session fingerprint for every existing playlist with no user
action, and the MAC is the account key.
Device IDs can optionally be derived from the MAC the way StbEmu and
stalker-to-m3u do — SHA256(MAC) and SHA256(MAC + "stalker"), which a
real box never reports as equal. The portal pins the first non-empty
device_id/device_id2 it sees to the MAC permanently, refuses a different
one, and treats a later empty value as an unrecoverable lockout, so
derived values are written into the visible fields and persisted as
literal strings, never recomputed at request time. The option is offered
at import only; the edit dialog warns instead once an ID has actually
reached the portal.
get_profile now reports one coherent MAG250 (ver, stb_type — previously
empty —, hw_version, image_version, client_type), and a device conflict
gets its own StalkerPortalError kind so the UI can explain it instead of
relaying the portal's "Your STB is damaged".
Closes the identity-fields cluster: #927, #860.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d2a83164ec | feat(stalker): protocol-correct auth lifecycle (#1354) | ||
|
|
c741815b97 |
fix(build): include shared UI stylesheets in Nx cache inputs (#1360)
* fix(build): include shared UI stylesheets in Nx cache inputs `libs/ui/styles` held shared SCSS partials but had no `project.json`, so its files belonged to no Nx project and were absent from every task hash. Editing a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and shipped the previous CSS — a silent wrong build rather than a failure. Nx derives its project graph from TypeScript imports only, so a relative Sass `@use` that crosses a project root creates no edge. Verified directly: after adding the project but before declaring anything, `ui-styles` still had zero dependents in the graph. Make it the `ui-styles` project (no targets — it exists to be hashed) and declare `implicitDependencies` on the 8 consumers. Chosen over adding the path to `sharedGlobals`, which would put shared styles into every project's hash and make a one-line SCSS tweak mark the whole workspace affected. A styles edit now marks 15 projects affected and leaves electron-backend, website, the mock servers and the shared libs alone. `libs/ui/styles` was the only projectless directory holding files under `libs/` or `apps/`. Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every relative stylesheet import against Nx's real project graph and fails when one escapes the input closure of a build that compiles it, naming the project to declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of `apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes that file, and a lib -> app edge would make the graph cyclic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(build): spawn git without a shell in the stylesheet check `execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where single quotes are literal characters rather than quoting. Git received the pathspec with the quotes intact, matched nothing and exited 0, so `styles:inputs:validate` reported success after checking zero stylesheets — silently disabling the check for Windows developers while staying green. Spawn with `execFileSync` so no shell is involved and git expands its own pathspec; verified to return the identical 133 files. Both this and the eslint glob trap next to it in the docs report success while covering nothing, so also make an empty scan fail rather than pass: the workspace always contains SCSS, and a listing that returns none means the scan broke. Reported by Codex review on #1360. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(styles): move nav-list partial into ui-styles (#1361) * fix(build): count every target of a comma-separated Sass @import `@import` is the only rule that takes a list, and the scan read just its first target. A later entry crossing an Nx project boundary escaped the cache key while the check still reported success — the same silent-pass failure the tool exists to prevent. Parse every target of an `@import` list. The obvious "read all quoted strings" fix trades one silent gap for a phantom one, so the rule decides: `@use`/`@forward` load exactly one module and a quoted string after it is `with (...)` configuration, and `url(...)` stays a plain CSS import the browser resolves at runtime. Neither is a module Sass compiles. The workspace has no relative `@import` at all today, so the scan still finds the same 42 imports across 133 files; this closes the gap before someone writes one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f9e78ff90 |
fix(workspace): report a local phase while reading the cached Xtream catalog (#1345)
* fix(workspace): report a local phase while reading the cached Xtream catalog Since #1311 the sync overlay is shown for the whole import session, but the DB-first read path never emitted an import phase, so switching to an already-imported Xtream playlist showed a bare "Syncing playlist" card with no badge or description. The Electron data source now reports a 'loading-cached' phase (local-library badge, its own label and detail text) before reading categories/content from SQLite, and the PWA data source reports the remote loading phases on API fetches it previously swallowed. Adds the two new i18n keys to en.json and all 18 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): keep the loading-cached phase from marking a real import The store's onPhaseChange callbacks set isImporting unconditionally, and the initialization error path gates import-cache cleanup on that flag — so a cancelled or failed warm SQLite read would have wiped the healthy cached catalog and forced a full provider redownload. The shared publishImportPhase helper now publishes 'loading-cached' as a presentation-only phase; any remote/save phase still marks the import as running. Adds regression specs (verified to fail against the previous behavior) in a dedicated spec file to stay under the test max-lines limit. Addresses Codex P1 review feedback on #1345. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): scope cancelled-import cleanup to types with remote work A session-wide isImporting flag meant that once any content type contacted the provider, cancelling during a later cache-only read cleared the healthy cached catalogs of every not-yet-completed type. Cleanup now consults a per-session set of types that actually performed remote or save work (populated from typed phase callbacks and save-content events), so cache-only types keep their catalogs on cancellation while genuinely partial types are still cleared. Mixed-scenario regression spec added (mutation-verified against the unguarded behavior). Addresses the second Codex P1 on #1345. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6c065124ed |
feat(stalker): add account info dialog for Stalker portals (#1330)
* feat(stalker): add account info dialog for Stalker portals Xtream playlists have had an account-info dialog for a while; Stalker portals stored the same facts (login, expiry, tariff, status captured at import) as dead weight in the database and showed them nowhere. Add StalkerAccountInfoComponent mirroring the Xtream dialog's visual language: status pill, days-left/tariff/MAC hero stats, account and portal panels. Data is cached-first — the import-time snapshot renders instantly with a "Saved data" badge, then StalkerAccountInfoService refreshes it: full /stalker_portal/ installations re-run handshake+get_profile, portal.php panels are queried best-effort via account_info/get_main_info. A failed refresh keeps the cached snapshot; no data at all shows a retry-able error state. Entry points are unified behind shared portal-account predicates (isXtreamAccountPlaylist / isStalkerAccountPlaylist in shared/interfaces) so both portal types get the same set: header playlist switcher (bottom section + new per-row ⋮ Account info item), dashboard source card ⋮ menu, and the command palette (now visible on stalker routes with its own description). The header service picks the dialog by playlist type; the per-row path works for non-active playlists and skips the session-scoped stream counts. Also adds the missing top-level LOADING/RETRY i18n keys the Xtream dialog already referenced (they rendered as raw keys), a get_main_info handler in the stalker mock server, and STALKER.ACCOUNT_INFO translations for all 19 locales. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): unwrap nested js.account_info envelope in get_main_info Ministra-style portals nest the account block — fetchStalkerExpireDate() in stalker-player-request.utils already consumes exactly that shape, so the flat-only mapper silently discarded valid responses and legacy imports (which have no cached snapshot) got an empty account panel. Merge nested fields over flat aliases, send the JsHttpRequest parameter the existing get_main_info caller sends, switch the mock server to the nested envelope so the E2E covers the realistic shape, and document the account-info feature in CLAUDE.md (review feedback from Greptile and Codex on #1330). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): pin account-info expiry fixture below the day boundary Math.round on the epoch could round up half a second, putting the fixture's expiry just past the 30-day mark so daysLeft ceil'd to 31 on CI. Floor keeps the interval strictly inside 30 days regardless of when within the second the spec runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(stalker): address account-info review round two Three P2s from Codex on #1330: - Normalize the cached stalkerAccountInfo snapshot before rendering: the import path persists portal values verbatim, so expireDate can be a date string or milliseconds at runtime despite the declared number type. normalizeStoredStalkerAccountInfo() runs the same parsers as the fresh path. - Publish the re-auth token into StalkerSessionService's cache: strict portals invalidate the previous token per handshake, so the dialog's authenticate() would otherwise strand an active portal session on a dead token. - Extract the duplicated ~460-line account-dialog stylesheet into libs/ui/styles/_account-dialog.scss, shared by both dialogs with the provider accent injected via --account-dialog-accent; each consumer keeps only its accent and layout overrides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): serialize account-profile refresh with session auth The dialog's direct authenticate() call bypassed the pendingAuth map ensureToken() uses, so a refresh could run a second handshake while a catalog or watchdog request was still authenticating. On strict portals each handshake invalidates the other's token, and the later setCachedToken() could publish an already-dead one. Move the refresh into StalkerSessionService.refreshAccountProfile(): it waits for any in-flight authentication, registers its own so later callers wait for it, and republishes the resulting token. A failed pending auth no longer aborts the refresh, and the pendingAuth entry is only cleared when it is still this call's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): move pendingAuth cleanup out of the promise initializer TS2454 under the Angular compiler: the finally block referenced authPromise inside its own initializer, so every Electron/web production build failed even though jest and lint accepted it. Await the promise at the call site and retire the map entry there instead — same only-clear-our-own-entry semantics. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): harden account-info portal detection and expiry math Review round four (Codex P2s on #1330): - Fall back to the URL rule when isFullStalkerPortal is undefined: a playlist restored from an older backup carries no flag once the one-shot metadata migration has run, and it would then be sent down the unauthenticated legacy path and labelled a legacy panel. - Parse a bare YYYY-MM-DD expiry as a local calendar date. Date.parse reads it as UTC midnight, which renders as the previous day west of UTC and shifts the days-left boundary; timestamps carrying a time or offset keep standard parsing. - Decide expiry from the raw timestamp, not the rounded counter: an expiry that passed less than a day ago ceil's to 0/-0, so the hero stat claimed "0 days left" on a dead subscription. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): make account-profile refresh own the auth slot Review round five (Codex P2s on #1330): - Claim the pendingAuth slot in a loop and publish it before the first await. One settled promise releases every waiter at once, so a single pre-check let two queued refreshes both start handshakes that invalidate each other on strict portals. - Retire the cached token before the handshake: ensureToken() reads tokenCache before pendingAuth, so catalog and watchdog requests starting mid-handshake were handed a token this refresh was about to kill instead of queueing on the slot. - Render the portal type from the same resolver the fetch path uses, so a restored backup without an explicit flag is no longer labelled a legacy panel while authenticating as a full portal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): retire only the token that actually failed auth A request dispatched with the previous token can see its authorization failure arrive after a profile refresh has already cached a fresh one. The retry path deleted the cache blindly, killing the fresh token and kicking off another handshake that in turn invalidated tokens of newer requests — cascading retries on strict portals. makeAuthenticatedRequest() now retires the cached token only while it still equals the token that failed; a late failure of a stale token leaves the refreshed token in place and the retry reuses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): distinguish the two no-data outcomes of the account dialog A portal that answers but publishes no account facts renders the ready-state "No account details" panel; only an unreachable portal without a cached snapshot enters the error state with retry. The doc conflated both as "error with retry" (review feedback on #1330). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): reject negative expiry sentinels before date parsing Portals encode unlimited/missing expiry as "-1" or "0"; the unsigned-digit check let "-1" fall through to Date.parse, which V8 reads as January 1, 2001 — an unlimited account rendered as expired. Signed numeric strings now take the numeric branch, whose non-positive guard already discards them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): reject out-of-range calendar components in expiry dates The multi-argument Date constructor normalizes invalid components ('2026-00-00' becomes Nov 30, 2025), fabricating an expiry and countdown from a placeholder. Round-trip the parsed year/month/day and reject any date that does not survive unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e86e988e72 |
feat(ui): turn the phone context panel into an off-canvas drawer (#1332)
* feat(ui): turn the phone context panel into an off-canvas drawer On ≤640px viewports the workspace context panel (categories, filters, settings sections, collection filters) no longer stacks above the route content capped at 30vh — it is a hidden-by-default drawer that slides in from the left over a backdrop, opened via a new header toggle (phone-only, CSS-gated) and closed by selection, backdrop tap, Escape, or any navigation. State lives in the new WorkspaceShellContextDrawerService provided by the shell component; panels close it explicitly after selections that do not navigate (Stalker ITV/radio categories, settings sections, sources filters, collection filters), since NavigationEnd alone cannot cover those. Desktop behavior is untouched, including the ResizableDirective inline width. Closes the drawer follow-up deferred from #1100 / PR #1326. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): make the phone context drawer modal for keyboard users Addresses Greptile P1 and Codex P2 review feedback on #1332: - CdkTrapFocus on the sidebar captures focus into the drawer on open and contains it while the drawer is modal; the shell restores focus to the header toggle on close, since the closed drawer is visibility: hidden and focus left inside it would silently drop to <body>. - The drawer service closes the drawer when the viewport leaves the phone breakpoint (matchMedia), so the trap can never hold the in-flow desktop sidebar after a resize. - The toggle's tooltip and aria-label are now variant-aware — categories on portal routes, filters on sources/collection routes, settings sections on the settings route — instead of a fixed 'Categories & filters' that misdescribed two of the three; the two generic i18n keys are replaced by six variant keys across all 19 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): remove background content from the a11y tree while the drawer is open Round-2 review feedback on #1332 (Greptile P1, Codex P2): - The rail, header, route content and playback footer are marked inert while the phone drawer is open — CdkTrapFocus constrains Tab focus, but a screen reader's virtual cursor could still reach and activate the visually obscured controls behind the backdrop. - The drawer panel itself is the trap's initial focus target (tabindex=-1 + cdkFocusInitial), so focus capture still works when a category list is loading, empty, or failed and renders no focusable rows. - Focus restore on close is deferred one tick: the toggle lives in the inert header, and focus() on a still-inert element is silently ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): gate global shortcuts and Escape behind the open phone drawer Round-3 review feedback on #1332 (Codex P2s): - The shell consumes Escape while the drawer is open: downstream Escape consumers (the portal detail shell's inline player close, the shared controls shortcuts) check defaultPrevented, so one keypress no longer closes both the drawer and the obscured playback surface. - inert does not silence document-level keydown listeners, so players opt out themselves while inside an inert region: ControlsShortcuts gains an optional hostElement handler and ignores every shortcut (including Escape) when that host has an inert ancestor, and the radio audio player applies the same check to its volume/mute keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): close control, Cmd+F gate, and Embedded MPV inert guard for the drawer Round-4 review feedback on #1332 (Greptile P1, Codex P2s): - The drawer carries its own phone-only close button: touch screen-reader users have no hardware Escape and cannot reach the inert header toggle or the aria-hidden backdrop, so the trapped surface must offer dismissal itself — even when a category list is loading or empty and renders no actionable entries. - Ctrl/Cmd+F no longer opens global search while the drawer is modal; the shortcut would have navigated and focused an input inside the inert header. - EmbeddedMpvShortcuts (native-view legacy dock) gains the same hostElement/inert-ancestor guard as the shared controls shortcuts, so the obscured player cannot react to Space/arrows/M/Escape behind the drawer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-5 drawer feedback + update phone-layout e2e for the drawer Merges master (#1333 landed mobile-layout.e2e.ts pinning the #1326 stacked-panel behavior this PR replaces) and updates that spec to pin the drawer contract instead: panel hidden by default with full-width content, header toggle opens it over a backdrop, category selection and backdrop tap close it. Verified locally on Chromium, Firefox and WebKit (12/12). The spec's getByTestId calls needed plain [data-test-id=...] locators — the web-e2e Playwright config never mapped testIdAttribute. Also addresses Codex round-5 P2s: - Focus restore now reports whether the toggle received focus; when a drawer selection navigated to a route without a context panel (toggle gone), focus falls back to the route content instead of dropping to <body>. - The Xtream and Stalker live layouts' Ctrl/Cmd+B sidebar shortcut opts out while their host sits inside an inert region, matching the other document-level listeners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): suppress command palette and shortcuts dialog behind the open drawer Greptile round-6 finding on #1332: the document-level Ctrl/Cmd+K handler in WorkspaceShellFacade and the '?' help-key handler in WorkspaceKeyboardShortcutsService still opened their dialogs while the phone context drawer was modal, stacking a second focus-trapped surface on top of it. Both now check the drawer service (injected optionally, same shell-component providers) and stay quiet while it is open, like the Ctrl/Cmd+F global-search gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-7 drawer feedback — Cmd+R gate and native MPV surface hiding Addresses the two Codex round-7 P2s on #1332: - WorkspaceShellContextDrawerService moves to @iptvnator/workspace/shell/util and becomes root-provided, so AppComponent's document-level Ctrl/Cmd+R global-recent shortcut can observe the modal drawer without pulling the lazy shell chunk into the eager bundle. Cmd+R is now suppressed while the drawer is open, like Cmd+F/Cmd+K/'?'. - The shell registers the open drawer with a new EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface() API: the native-view video surface is composited outside DOM stacking and would paint straight over the drawer regardless of z-index. The service treats registered external modal surfaces exactly like open Material dialogs. - The service's recompute no longer reads overlayActive back before setting it: signals already skip notification on equal values, and that hidden read registered overlayActive as a dependency of any reactive context calling into the service — the shell's acquire/release effect looped forever on exactly that (caught by a live browser probe; the unit suite mocked the service). The effect also wraps the acquire in untracked() for caller-side hygiene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): expose the phone drawer as a named modal dialog Round-8 review feedback on #1332 (Codex P2s): - While open, the drawer carries role=dialog, aria-modal=true, and a variant-appropriate accessible name (categories / filters / settings sections) — assistive technology now hears that a named modal surface opened instead of an unnamed complementary landmark. Closed (and the always-visible desktop sidebar) stays a plain landmark. - The UI-guidelines drawer section no longer claims the drawer service is component-provided; it is root-provided from workspace/shell/util since the round-7 move, and the stale claim could have led a future change to re-scope it and silently break the AppComponent shortcut gate and the Embedded MPV overlay observer. Matching code comments updated everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-9 — gate M3U player keys behind the drawer, raise drawer stacking Greptile round-9 P1 + Codex round-9 P2 on #1332: - The M3U video player's document-level digit-key channel switching and Ctrl/Cmd+B sidebar toggle now apply the same inert-ancestor guard as every other routed-content key listener. A codebase sweep confirms this closes the class: every document-level key listener on routed content is now either gated by the shell (Escape, Cmd+F/K/R, '?') or opts out via closest('[inert]'); the guidelines now require the guard for any new listener. - The drawer moves from z-index 99/98 to 951/950: above the settings action bar (100) and the root EPG/update panels (900/901), which inert removes from interaction but not from paint order — below the CDK overlay container (1000), since dialogs opened from inside the drawer (Manage categories) must stack on top of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8f861a3a1b |
fix(ui): make the workspace usable on phone-sized screens (#1326)
* fix(ui): make the workspace usable on phone-sized screens
The shell was half-adapted below 640px: the rail flipped to a horizontal
bar but the link lists inside it kept stacking downwards, so the navigation
was drawn outside the bar and over the header (#1100).
Three resizable rails — the shell context panel, the live-layout channel
sidebar and the M3U channel drawer — kept their persisted desktop width,
which left the content around 50px on a 375px screen. They now span the
full width and stack above the content. The inline width written by
ResizableDirective is why these rules need `!important`.
Found while walking the rest of the UI at 375px and 768px:
- The detail hero kept poster and details side by side, squeezing the
action row below its own labels until "Play" was clipped to its icon.
- The settings section list did not scroll and painted over the footer,
which also affected short desktop windows.
- Hiding the M3U channel list on a phone was one-way: the restore handle
was hidden and only Cmd/Ctrl+B could bring it back.
- The live header drew the channel count and the paginator on top of each
other up to tablet width, because the paginator does not shrink and the
meta collapsed to zero width and overflowed its box.
- The search scope checkbox was pushed off the right edge.
Live TV states a floor for the player instead of a ceiling for the lists,
so the video keeps a usable share of the screen under the categories panel
and the channel list.
Closes #1100
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — keep the palette reachable and the video visible
Two findings from the Codex review on #1326.
Hiding the command-palette trigger on phones removed the only pointer-driven
way to open it: the rail renders route links plus Settings and emits nothing,
so `commandPaletteRequested` had exactly one source. The button stays and its
keyboard-shortcut label is swapped for an icon instead. Doing that exposed a
latent flex trap in the same row — an <input> keeps an intrinsic min-width
from its `size`, and `min-width: auto` honours it, so the field refused to
shrink and pushed the trigger out onto the buttons beside it.
The M3U drawer released the shared player floor, which on a short landscape
phone (600-640px wide) left the content container at half the shell body.
The inline guide inside it is `flex: 0 0 <basis>` and took its full 180px out
of a container that no longer had it, so the video could reach zero height.
The floor is restored and now yields on short viewports, the video states its
own minimum, and the guide is what gives way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the channel list keep its height on a landscape phone
Follow-up to the review: the player floor added in the previous commit was
measured against the viewport, not against what the shell had left. On a
640x360 landscape phone the stacked categories panel already takes 30vh, so
claiming another 50vh here drove the channel sidebar to zero height while it
was still marked expanded — no way to pick another channel — and pushed the
layout past the viewport.
The floor now applies only where the screen can afford it (`min-height:
600px`), the sidebar states a floor of its own so it cannot be squeezed out,
and the collapsed rule clears that floor so hiding the list still works.
Below that height the two panes simply share what is left.
Portrait is unchanged: categories 244px, channel list 220px, player 240px on
a 375x812 screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — settings nav on landscape, poster dead space
Two more findings from the Codex review.
The stacked settings context panel capped itself at 30vh, which on a 360px
screen is 108px — less than the panel's own title and footer, so the seven
section rows collapsed to nothing behind an overlapping footer. On short
screens the caption gives way (the rail already labels the page), the footer
sheds its tall-screen padding, and the settings variant gets a slightly
larger cap: unlike the live routes there is no player below competing for
height, only a scrollable form.
The poster kept a 330px minimum from the skeleton fallback at the bottom of
the file — sized for the 220px desktop poster — while the stacked phone hero
renders it 140px wide with a ~210px aspect-ratio height. Every loaded detail
page carried ~120px of empty space between the poster and the title. The
override sits after that rule because it wins on source order, not
specificity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the playlist switcher yield to the search field on narrow phones
Codex review of
|
||
|
|
760099358b |
feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign * docs(downloads): plan manager MVP implementation * docs(downloads): tighten manager validation plan * fix(downloads): keep renderer download state global * fix(downloads): make active count accessible * feat(downloads): derive queue and library view model * test(downloads): close view model coverage gaps * fix(downloads): stabilize malformed view model data * refactor(downloads): isolate library navigation * fix(downloads): report library navigation failures * feat(downloads): add ready-to-watch library * feat(downloads): add active download queue * feat(downloads): finish manager MVP * docs(downloads): clarify detail-first offline behavior * docs(downloads): plan detail navigation follow-up * fix(downloads): open completed movies in details * test(downloads): cover pending series navigation * fix(downloads): honor the global cover size * fix(downloads): prefer local playback in shared details * fix(downloads): preserve external launch priority * fix(downloads): prefer local playback in Xtream details * test(downloads): cover offline detail journey * docs(downloads): document offline detail behavior * docs(downloads): format detail navigation plan * fix(downloads): open Stalker items in provider details * docs(downloads): clarify Stalker navigation fallback * fix(xtream): isolate reused detail identities * fix(xtream): ignore stale VOD positions * fix(downloads): keep offline Xtream playback available * docs(downloads): clarify provider playback availability * docs(downloads): design missing-file recovery * docs(downloads): plan missing-file recovery * feat(downloads): derive completed file availability * feat(downloads): recover missing completed files * feat(downloads): refresh missing local files * feat(downloads): separate missing files from ready media * feat(downloads): surface missing files for recovery * refactor(downloads): simplify ready cards * test(downloads): cover missing-file and series journeys * feat(downloads): finish missing-file recovery * docs(downloads): design offline detail views * docs(downloads): plan offline detail views * feat(downloads): persist offline metadata snapshots * fix(downloads): complete metadata snapshot bridge contract * feat(downloads): manage offline metadata snapshots * fix(downloads): harden metadata snapshot updates * fix(downloads): restrict snapshot artwork * fix(downloads): guard restart artwork URL * fix(downloads): refine artwork URL checks * feat(downloads): expose offline metadata updates * fix(downloads): keep metadata service change focused * fix(downloads): preserve metadata error conventions * feat(downloads): derive offline detail content * fix(downloads): preserve unknown episode coordinates * feat(downloads): add focused offline detail routes * fix(downloads): ignore fragments in shell route state * fix(downloads): normalize fragments before queries * feat(downloads): open ready cards in offline details * fix(downloads): use native disabled card styles * feat(downloads): enrich offline detail metadata * fix(downloads): harden offline metadata resolution * fix(downloads): preserve stalker provider titles * fix(downloads): distinguish stalker metadata seeds * fix(downloads): stabilize offline metadata refresh * fix(downloads): throttle sparse metadata refreshes * fix(downloads): type metadata language settings * feat(downloads): render offline movie and series details * fix(downloads): harden offline detail interactions * fix(downloads): close offline detail edge cases * feat(downloads): hand off to provider-only details * fix(downloads): preserve stalker provider handoff * feat(downloads): capture metadata at download time * fix(downloads): preserve snapshot source semantics * fix(downloads): preserve episode snapshot identity * docs(downloads): document offline details flow * docs(downloads): clarify stalker provider fallback * test(downloads): cover offline detail journeys * test(downloads): stabilize offline detail selectors * style(downloads): format changed files * docs(downloads): clean design spec formatting * fix(downloads): preserve offline library ownership * test(downloads): fix Windows workspace navigation * test(database): preserve Electron tsconfig resolution * perf(downloads): avoid blocking file availability probes |
||
|
|
32ba209b63 |
fix(portals): restore fresh-import pins atomically (#1311)
* fix(portals): restore fresh-import pins atomically * fix(portals): preserve Xtream restore retry state * fix(portals): serialize Xtream restore revisions |
||
|
|
0b967d66d4 |
feat(tmdb): metadata cache panel with a clear button in settings (#1244)
* feat(tmdb): metadata cache panel with a clear button in settings Adds "Metadata cache — N entries · X MB" with a Clear button to Settings > Metadata (TMDB), next to the API key it belongs to. Three things it is good for: dropping stale or wrong metadata so the next open refetches it, seeing what the cache actually costs on disk, and reclaiming rows that a lookup-key version bump has orphaned — a bump makes rows unreachable, not deleted, so nothing else would ever collect them. Sizing the cache is a full table scan (LENGTH() on TEXT counts characters, so the SUM casts to BLOB to get bytes), which is why stats load lazily and only once the TMDB section is the active one rather than on every settings open. Clearing is always safe: enrichment refetches on demand, so the only cost is the next few requests. Works in both environments — the PWA has no bridge, so the service reports and clears its session-scoped in-memory map instead. i18n: 4 keys across all 19 locales via the tools/i18n workflow; placeholder integrity verified. Contract fixtures updated for both the preload bridge and the DB-worker payload shapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): make cache clearing durable and stop reporting failures as empty Four review findings, all real: - A metadata write already in flight when the user cleared would land afterwards and silently restore what they removed. Writes now carry the generation they started in; a write that outlives a clear is dropped (PWA) or undone (Electron). - The PWA byte count used String.length, i.e. UTF-16 code units, so localized payloads under-reported and disagreed with the SQLite BLOB byte count. TextEncoder now measures actual bytes. - A failed stats read returned a valid zero-entry result, so the panel claimed an empty cache and disabled Clear while rows were still there. getStats/clear now return null on failure and the panel says so instead of inventing state. - No behavioural coverage existed for either side. Tests: SQL ops (entry/byte reporting, empty table, missing row, delete count) and the service (encoded bytes, clear count, and a write racing a clear). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): make the cache clear precise and version skew visible Review follow-ups on the cache panel: - A write that was in flight when the user cleared used to trigger a second full-table clear once it landed, which also deleted anything written in between. clear() now waits for the writes issued before it and lets the single clear take them; later writes survive. - An Electron shell without the maintenance ops fell through to the renderer map, which is always empty there — it reported an empty cache and disabled the Clear button while SQLite was full. Both operations now report unsupported instead. - Component coverage for the panel (deferred scan, clear + re-read, failed clear, failed read) and Electron-path service coverage. - The canonical IPC and settings sections of the enrichment doc, plus the matching CLAUDE.md lines, now list the maintenance ops. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): drop Promise.allSettled from the cache clear The web target compiles against lib es2018, so allSettled broke the Windows frontend build (TS2550). The pending writes swallow their own errors, so a plain Promise.all over neutralized promises does the job. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): keep a synchronous bridge throw inside the cache write Moving the write into a tracked promise dropped the try/catch that used to cover the call itself, so a bridge that threw synchronously would escape set(). Wrap it in an async IIFE, which turns that back into a rejection the same handler swallows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): retry the cache size read when the section is reopened The effect skipped the read once cacheError was set, so one transient IPC failure left the panel showing "could not read the cache" for the life of the settings page — and the only enabled control that could shift it was the destructive Clear button. Gate on the stats signal alone: reopening the section retries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): queue writes that start while the cache is being cleared Awaiting the in-flight writes closed one side of the race and left the other open: a set() that started during that wait dispatched its IPC immediately, was absent from the snapshot, and could reach SQLite just before the delete — so a row written after the user clicked Clear was removed anyway. clear() now holds its own promise for the whole operation and set() waits on it, which puts such a write on the far side of the delete. Rows are stamped when they are dispatched rather than when set() was called, since a write may have waited. Covered by a test that fails without the guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(tmdb): add the release note for the cache panel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(tmdb): cover the cache panel with an Electron E2E The panel drives IPC and SQLite, and nothing exercised that path end to end. The new test seeds a row through the preload bridge — enrichment itself needs a TMDB key that CI does not have — then opens the section, asserts the reported size, clears, and reads the database back to confirm the row is gone rather than merely hidden. Verified both ways: dropping the DELETE from clearTmdbMetadata fails it. Settings nav buttons gained a data-test-id so the section can be opened without matching translated labels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
6bdd6fd8a8 |
fix(playlists): serialize per-playlist collection writes to prevent lost updates (#1255)
* fix(playlists): serialize per-playlist collection writes to prevent lost updates All per-playlist mutations (portal favorites, recently viewed, playlist meta/favorites updates) used an uncoordinated read -> patch -> replace-whole-row pattern, so two overlapping mutations on the same playlist were last-write-wins and silently dropped each other's changes (flagged by Greptile on PR #1253). Chain every read-modify-write through a per-playlist promise queue (Map<playlistId, Promise>) inside defer(), covering both the SQLite upsert and IndexedDB update paths while keeping the Observable-based public API, laziness, and emitted values unchanged. A failed mutation does not wedge the queue, and different playlists are not serialized against each other. Regression coverage: overlapping favorite+recently-viewed adds, two rapid favorite adds, IndexedDB-path overlap (all three fail on the old code), plus queue-continues-after-error and cross-playlist independence guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): close serialization gaps flagged by review bots Greptile P1 (writers bypassing the queue): route updateManyPlaylists and updatePlaylistPositions through the same per-playlist write queue. Auto-refresh batch writes now re-read the stored row inside the queue and preserve user-owned fields (favorites, recently viewed, position) instead of writing a pre-refresh snapshot over them; position updates re-read and patch inside the queue on both storage paths. Codex P1 (callers precompute stale snapshots): add an atomic PlaylistsService.transformPlaylistFavorites(playlistId, transform) that applies the favorites transform to the freshly-read row inside the queue, and convert every read-then-set call site to it: UnifiedFavoritesDataService M3U add/remove/clear/reorder and Stalker reorder/clear, GlobalFavoritesService M3U removal, DashboardDataService M3U removal. Reorders now keep concurrently added favorites (appended after the dragged order) instead of dropping them. New coverage: overlapping favorites transforms, auto-refresh batch write vs queued favorite add, position update vs queued favorite add, and a public addFavorite race through UnifiedFavoritesDataService; existing specs updated to the transform-based contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): share the canonical refresh merge with the auto-refresh batch The batch path previously spread the stale refresh snapshot over the freshly read row and pinned only favorites/recently-viewed/position, so a queued metadata mutation (hiddenGroupTitles, curated EPG sources) finishing before the refresh write could be reverted. Extract updatePlaylist's merge into mergeRefreshedPlaylist() and use it for both the single-playlist update flow and updateManyPlaylists: refresh-owned data (parsed content, count, EPG detection) comes from the payload, user-owned state comes from the current row, and manual/disabled EPG configuration is resolved through resolvePlaylistEpgSourceState instead of being overwritten. Regression test: queued hiddenGroupTitles meta update overlapping an auto-refresh batch write keeps both the metadata change and the refreshed content, including preserved manualEpgUrls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): let the current row decide autoRefresh during batch refresh The batch path force-set autoRefresh: true after the merge, so disabling auto-refresh while a refresh was in flight was reverted by the completing write. Drop the override — mergeRefreshedPlaylist already prefers the current row's autoRefresh over the snapshot — and add a count fallback to the snapshot value for rows without a stored copy. Regression test: disabling auto-refresh concurrently with the batch write keeps autoRefresh false while still applying the refreshed content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b3e130aa65 |
feat(stalker): full Live TV channel list for complete search, count badges, and all-channels grid (#1209)
Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs. |
||
|
|
a51c537bb2 |
fix(theme): make scrollbars follow the app theme instead of the OS color scheme (#1179)
On Windows with a light OS theme, scrollbars rendered light even when the app was switched to dark. Two combined causes: - The page never declared `color-scheme`, so Chromium colored native scrollbars from the OS preference. Declare `color-scheme: light` on html and flip it to `dark` via `html:has(> body.dark-theme)` plus the `.dark-theme` block itself. - Scrollbar styling referenced `--mat-sys-*` tokens, which are never emitted by the current Material theme setup (mat.define-theme + all-component-themes does not produce system tokens). Those `scrollbar-color` declarations computed to `auto`, falling back to the native (light) scrollbar. Switch scrollbar styling to the `--app-muted-color` design token (defined for both themes), replace hardcoded white `rgba(255,255,255,.08)` thumbs, and add an explicit `scrollbar-color` where only `scrollbar-width: thin` was set. Verified live in Electron via CDP in both themes: scrollbar-color resolves and scrollbars render dark in dark theme regardless of the OS setting. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
54265755ee |
fix(favorites): persist custom drag-and-drop order for Xtream favorites (#1143)
* fix(favorites): persist custom drag-and-drop order for Xtream favorites
Prepared-statement writes dispatched via drizzle's `.execute()` on the
better-sqlite3 driver return a promise and defer the write to a microtask.
Inside a synchronous `db.transaction(() => ...)` callback (which cannot
await), the transaction commits before that promise settles, so the write
is a silent no-op — no error, no rows changed.
This bit `reorderGlobalFavorites`: the custom favorites order never
persisted for the per-playlist ("This playlist") Xtream scope, which relies
solely on the `favorites.position` column. The global ("All playlists")
scope masked the bug because it also persists an order to the `appState`
`global-favorites-channel-order-v1` key and re-applies it on read.
`removeRecentItemsBatch` had the same latent bug — batch "clear recent
items" silently did nothing.
Switch both writers to synchronous `.run()`. Add regression coverage that
asserts `.run()` (not `.execute()`) is used and would fail on the old
behavior, and document the gotcha in the DB worker architecture doc.
Verified over CDP against a live Electron instance: reorder writes
positions 0..N, and the order survives navigation and a full reload.
Fixes #1137
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(favorites): scope reorder position writes by playlist
The global favorites reorder wrote the new position filtering only by
content_id, so two Xtream playlists holding a favorite with the same
content_id would clobber each other's persisted order (greptile P1).
Thread playlist_id through the whole reorder path — the renderer builder
(UnifiedCollectionItem already carries playlistId), the IPC contract
(ElectronBridgeFavoriteReorderUpdate + inline payload types), the worker
op — and scope the prepared UPDATE by (contentId, playlistId), matching
the favorites composite unique index.
Tests: favorites.operations.spec asserts the playlistId placeholder and
per-row playlistId payload; preload contract fixture updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(favorites): include playlist_id in workspace global favorites reorder payload
The workspace global-favorites reorder path still sent updates with only
content_id and position. Since the backend UPDATE is now scoped by
(contentId, playlistId), that payload binds an undefined playlist id and
matches no rows — the DB write silently no-ops (flagged by Greptile P1).
Also scope the prepared-statement example in the sqlite-db-worker gotcha
doc by (contentId, playlistId) so it no longer documents the
cross-playlist rewrite this PR fixes (flagged by Codex P3).
Regression spec asserts the reorder payload carries playlist_id per item
(fails on the old payload shape) and that the appState uid order is
still persisted for non-Xtream items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
e14b8ae8d9 |
feat(ci): enforce lint, guard coverage policy, add max-lines rule (#1117)
* fix(lint): resolve module-boundary and prefer-inject errors Retag workspace-shell-util as type:data-access to match its injectable services that depend on @iptvnator/services, and convert RemoteControlService to inject(HttpClient). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lint): enforce max-lines 400 with generated baseline Add a max-lines ESLint error (hard cap 400 raw lines per TypeScript file) per the repo file-size rule. The 134 pre-existing offenders are baselined in tools/eslint/max-lines-baseline.mjs, regenerable via generate-max-lines-baseline.mjs; the list should only shrink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): enforce lint on PRs and guard coverage policy drift - Add a Lint job to ci.yml running nx run-many -t lint --all, so module-boundary tags, legacy-alias bans, and max-lines gate merges. - Fix the root lint script (was linting only electron-backend). - Add tools/coverage/check-coverage-policy.mjs: fails CI when a project with a test target is missing from coverage-policy.json; wired into coverage:ci as coverage:policy:check. - Run Tier B/C unit tests in CI without coverage (list derived from the policy), so website/packaging/remote-control tests run on PRs. - Replace the hand-picked 16-project test:unit:ci list with --all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document CI lint enforcement and coverage policy guard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): address bot review feedback on policy guard and baseline generator - Drive Tier B/C validation from each policy entry's validationCommand (falling back to nx test), skipping projects with an e2e target since the E2E workflow already runs them (Codex). - Fail when a Tier A entry has no test target (Greptile, adapted: checking all entries against test targets would false-positive on the intentionally spec-less e2e/mock-server tiers). - Guard against missing JSON array in nx show projects output (Greptile). - Scan .tsx files in the max-lines baseline generator to match the ESLint rule's file patterns (Greptile). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d7e7a8abda | fix(workspace): defer embedded mpv palette support check | ||
|
|
f7ad17298f |
feat(workspace): add embedded MPV to the command palette
Register an "Switch player to Embedded MPV" command in the Cmd+K command
palette so the embedded MPV player can be activated like the other players.
Visibility is gated on an async getEmbeddedMpvSupport() check, mirroring the
Settings dropdown so the command only appears when embedded MPV is usable.
Generalizes the per-command visibility flag from desktopOnly to a `requires`
discriminator ('none' | 'managed-external' | 'embedded-mpv').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
89916af9d8 |
feat(search): add workspace global search with M3U support
Moves global search into a routed workspace view, adds M3U live/radio results, lazy pagination, and DB-backed matching improvements. |
||
|
|
c276d48bb7 | Show sources playlist scrollbar | ||
|
|
e3388de989 | Address sources header review feedback | ||
|
|
4e732d3e51 | Align sources header with panel style | ||
|
|
3d4d2ca9bf |
fix(angular): remove template diagnostics warnings (#1011)
* fix(angular): remove template diagnostics warnings * fix(angular): preserve template fallback behavior |
||
|
|
1bbe8f794e |
refactor(workspace): split workspace shell facade
Split WorkspaceShellFacade into focused component-scoped services and keep the facade as the stable template-facing delegation layer. |
||
|
|
ed8680116c | fix(runtime): address consolidated review feedback | ||
|
|
676e8bb5da | refactor(runtime): gate downloads navigation by capability | ||
|
|
820d93e5ca | refactor(workspace): use runtime player capability | ||
|
|
988905478d | refactor(workspace): use runtime shortcuts capability | ||
|
|
f894e5242b | refactor(workspace-shell): use runtime capabilities | ||
|
|
45ee1ac3e4 | refactor(portal-ui): use runtime capabilities | ||
|
|
820fbef121 | refactor(playlists): move refresh action to ui boundary | ||
|
|
4ab8915483 | chore(web): enable strict TypeScript mode |