From ce24e16e32f892d76990801f11db2c6b2b934943 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:28:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(portals):=20two-state=20VOD=20detail=20pag?= =?UTF-8?q?es=20(browse=20=E2=86=94=20watch)=20with=20season=20tabs=20(#11?= =?UTF-8?q?27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): slim ContentHero to non-scrolling hero block The page scroll container moves out of the hero into the upcoming PortalDetailShellComponent; the hero no longer hosts a default content slot for player/seasons/extras. Co-Authored-By: Claude Fable 5 * feat(ui): add PortalDetailShellComponent with browse/watch states Two-state detail layout shell: owns page scroll, hero collapse animation (~300ms, reduced-motion aware), Escape-to-close-player handling, and an About block that re-stamps host-provided *detailTags/*detailMeta templates in watch state. The [detail-player] slot is never wrapped in a shell conditional so the host-owned player subtree survives state changes. Co-Authored-By: Claude Fable 5 * feat(playback): restyle inline player header as now-playing bar Adds a back button (emits the existing closed output — in watch state back returns to browse, not route navigation) and replaces the hardcoded header strings with i18n keys (PORTALS.NOW_PLAYING, CLOSE_PLAYER, BACK_TO_BROWSE). Co-Authored-By: Claude Fable 5 * refactor(xtream): migrate VOD and serial detail templates to detail shell Hero chips/meta/actions become *detailTags/*detailMeta/*detailActions templates; the inline player moves to the full-width [detail-player] slot; trailer and similar rail move to [detail-extras]. Escape and the now-playing back button close the inline player back to browse. Co-Authored-By: Claude Fable 5 * refactor(stalker): migrate shared VodDetails and series view to detail shell The shared VodDetailsComponent (Stalker VOD, collections, search) and StalkerSeriesViewComponent move their hero content into *detailTags/*detailMeta/*detailActions templates and host the inline player in the full-width [detail-player] slot. Co-Authored-By: Claude Fable 5 * feat(ui): season tabs replace two-level seasons navigation SeasonTabsComponent renders a pill row (dropdown beyond 6 seasons), an optional season description and a back-to-playing chip. The season container auto-selects a season (inline-playing episode's season → most recent in-progress → first), emits seasonSelected for auto-selections so lazy-load/enrichment hooks keep firing, highlights the inline-playing episode, and loses the seasons grid + 'Back to seasons' button. Download/progress helpers move to pure utils; season strings are now translatable. Co-Authored-By: Claude Fable 5 * feat(portals): season descriptions under the season tabs Xtream reads season overviews from get_series_info; Stalker exposes the TMDB season overview through TmdbEnrichmentService.getSeason (same cache rows as the episode enrichment) and keys it per tmdbId so views reused across detail navigations cannot leak descriptions. Co-Authored-By: Claude Fable 5 * docs: document two-state detail layout and season tabs Co-Authored-By: Claude Fable 5 * fix(ui): app-prefix detail template directive selectors, build fixes detailTags/detailMeta/detailActions → appDetailTags/appDetailMeta/ appDetailActions per @angular-eslint/directive-selector; type the Escape host listener as Event; drop a redundant ?? in season tabs. Co-Authored-By: Claude Fable 5 * chore(i18n): translate new detail-page keys across all 17 locales 13 PORTALS.* keys (now-playing bar, About block, season tabs, episode empty states) translated via the i18n-fill workflow. Co-Authored-By: Claude Fable 5 * fix(playback): keep now-playing bar actions inside the viewport The nowrap title made the header grid track grow to min-content and pushed Copy/Close out of the page; min-width: 0 on the header/title row lets the title truncate instead. Co-Authored-By: Claude Fable 5 * feat(ui): expandable season description and episode info dialog Season descriptions get the same show more/less toggle as the hero (new shared ExpandableTextComponent); episode cards and list rows get an info button that opens a dialog with the full plot, duration, air date and a Play action — card heights stay fixed and click-to-play semantics stay intact. Also: drop the per-frame backdrop blur on the player card and make the watch-enter scroll instant so the hero morph stops competing for frame budget. Co-Authored-By: Claude Fable 5 * fix(portals): re-run TMDB season enrichment when the show match arrives With season tabs the first seasonSelected fires as soon as seasons load — usually before the async show-level TMDB enrichment has written tmdb_id — so the season fetch silently no-oped and was never retried, leaving episode stills/plots and the season description empty. Both detail hosts now key the fetch on (tmdb_id, selected season) in an effect, so it runs whichever arrives last. Also adds breathing room above the About divider in watch state. Co-Authored-By: Claude Fable 5 * fix(ui): move episode info button into the card body, compact overlay actions Three overlay buttons crowded the thumbnail on hover and the new info button had no styling at all. Info belongs with the content text: it now sits as a quiet ghost button at the end of the title row (revealed on card hover, MDC touch target clamped so it cannot swallow the play-on-click card area). Download + watched-toggle stay on the artwork, shrunk to 28px with a subtle edge border. List view already used the shared action style and is unchanged. Co-Authored-By: Claude Fable 5 * fix(ui): single watched indicator-toggle on episode cards Watched state was signalled three times on one card: the green badge, the hover toggle in the top-right cluster, and a checkmark appended to the title. Now one control does both jobs — a toggle at the top-left of the artwork that looks like the old green badge when watched (always visible) and appears as a ghost circle on hover when not. The right cluster keeps only the download action; the title checkmark is gone. Co-Authored-By: Claude Fable 5 * feat(playback): back arrow in watch mode navigates straight to the list Leaving the theater took two clicks: back to browse, then back to the list. Watch state already shows everything browse offers (episodes, About, extras), so the bar's back arrow is now route-level back (new backClicked output wired to each host's goBack), while Close player and Escape keep exiting to browse without navigating. Replaces PORTALS.BACK_TO_BROWSE with a generic top-level BACK key (all locales). Co-Authored-By: Claude Fable 5 * feat(ui): compact episode stills in the list view When a season has genuinely distinct per-episode art (TMDB stills or provider images), list rows show a 96x54 thumbnail with the episode number riding on it instead of the number square. When every episode repeats the same image (providers often send the series poster), the plain number square stays — a column of identical posters is worse than none. Co-Authored-By: Claude Fable 5 * test(ui): compact list-thumbnail spec under the max-lines limit Co-Authored-By: Claude Fable 5 * fix(ui): address Greptile review — real fallback asset, untracked overflow measure default-episode.png never existed (pre-existing in the grid card, copied into the list thumbs) — both onerror handlers now fall back to the real default-poster.png. The expandable-text and content-hero measuring effects read the expanded signal via untracked so toggling show more/less no longer rebuilds their ResizeObservers. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CLAUDE.md | 8 + apps/web/src/assets/i18n/ar.json | 14 + apps/web/src/assets/i18n/ary.json | 14 + apps/web/src/assets/i18n/by.json | 14 + apps/web/src/assets/i18n/de.json | 14 + apps/web/src/assets/i18n/el.json | 14 + apps/web/src/assets/i18n/en.json | 14 + apps/web/src/assets/i18n/es.json | 14 + apps/web/src/assets/i18n/fr.json | 14 + apps/web/src/assets/i18n/it.json | 14 + apps/web/src/assets/i18n/ja.json | 14 + apps/web/src/assets/i18n/ko.json | 14 + apps/web/src/assets/i18n/nl.json | 14 + apps/web/src/assets/i18n/pl.json | 14 + apps/web/src/assets/i18n/pt.json | 14 + apps/web/src/assets/i18n/ru.json | 14 + apps/web/src/assets/i18n/tr.json | 14 + apps/web/src/assets/i18n/zh.json | 14 + apps/web/src/assets/i18n/zhtw.json | 14 + docs/architecture/embedded-inline-playback.md | 55 +++ docs/architecture/portal-detail-navigation.md | 5 + .../stalker-collection-detail.component.ts | 6 +- .../stalker-series-tmdb-seasons.service.ts | 50 +- .../stalker-series-view.component.html | 57 ++- .../stalker-series-view.component.spec.ts | 79 +++- .../stalker-series-view.component.ts | 45 +- .../serial-details.component.html | 95 ++-- .../serial-details.component.spec.ts | 23 +- .../serial-details.component.ts | 45 +- .../vod-details-route.component.html | 144 +++--- .../vod-details-route.component.ts | 12 +- .../lib/xtream-collection-detail.component.ts | 6 +- .../src/lib/tmdb/tmdb-enrichment.service.ts | 13 + .../src/lib/tmdb/tmdb-season.service.ts | 24 +- libs/services/src/lib/tmdb/tmdb.types.ts | 1 + libs/ui/components/src/index.ts | 4 + .../content-hero/content-hero.component.html | 3 - .../content-hero/content-hero.component.scss | 21 +- .../content-hero/content-hero.component.ts | 10 +- .../expandable-text.component.spec.ts | 74 +++ .../expandable-text.component.ts | 149 ++++++ .../content-about.component.scss | 111 +++++ .../content-about.component.ts | 59 +++ .../detail-template.directives.ts | 33 ++ .../portal-detail-shell.component.html | 67 +++ .../portal-detail-shell.component.scss | 140 ++++++ .../portal-detail-shell.component.spec.ts | 134 ++++++ .../portal-detail-shell.component.ts | 107 +++++ .../season-container/episode-download.util.ts | 103 +++++ .../episode-info-dialog.component.ts | 180 ++++++++ .../season-container/episode-progress.util.ts | 66 +++ .../season-container/episode-utils.spec.ts | 106 +++++ .../season-container.component.html | 223 +++++---- .../season-container.component.scss | 355 ++++++++------- .../season-container.component.spec.ts | 316 ++++++++++--- .../season-container.component.ts | 427 ++++++++++-------- .../season-tabs.component.html | 81 ++++ .../season-tabs.component.scss | 106 +++++ .../season-container/season-tabs.component.ts | 62 +++ .../portal-inline-player.component.html | 29 +- .../portal-inline-player.component.scss | 24 +- .../portal-inline-player.component.spec.ts | 32 +- .../portal-inline-player.component.ts | 8 + .../vod-details/vod-details.component.html | 51 ++- .../lib/vod-details/vod-details.component.ts | 12 +- 65 files changed, 3248 insertions(+), 765 deletions(-) create mode 100644 libs/ui/components/src/lib/expandable-text/expandable-text.component.spec.ts create mode 100644 libs/ui/components/src/lib/expandable-text/expandable-text.component.ts create mode 100644 libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss create mode 100644 libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts create mode 100644 libs/ui/components/src/lib/portal-detail-shell/detail-template.directives.ts create mode 100644 libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html create mode 100644 libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss create mode 100644 libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts create mode 100644 libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts create mode 100644 libs/ui/components/src/lib/season-container/episode-download.util.ts create mode 100644 libs/ui/components/src/lib/season-container/episode-info-dialog.component.ts create mode 100644 libs/ui/components/src/lib/season-container/episode-progress.util.ts create mode 100644 libs/ui/components/src/lib/season-container/episode-utils.spec.ts create mode 100644 libs/ui/components/src/lib/season-container/season-tabs.component.html create mode 100644 libs/ui/components/src/lib/season-container/season-tabs.component.scss create mode 100644 libs/ui/components/src/lib/season-container/season-tabs.component.ts diff --git a/CLAUDE.md b/CLAUDE.md index 851eb439e..fdfb23910 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -618,6 +618,14 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - External players: MPV, VLC (via IPC to Electron backend) - Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. +**VOD/Series Detail Pages (two-state layout)**: + +- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes) +- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`) +- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block +- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker) +- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout") + **Radio Player**: - Dedicated audio player for channels with `radio="true"` M3U attribute diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index dc48580c1..ba2530487 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -2,6 +2,7 @@ "CANCEL": "إلغاء", "CLEAR": "مسح", "CLOSE": "إغلاق", + "BACK": "رجوع", "SHOW_MORE": "المزيد", "SHOW_LESS": "أقل", "PLAYLISTS": "قوائم التشغيل", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "تم نسخ رابط البث إلى الحافظة", "COPY_STREAM_URL": "نسخ رابط البث", + "NOW_PLAYING": "قيد التشغيل", + "CLOSE_PLAYER": "إغلاق المشغل", + "ABOUT": "نبذة", + "SEASONS_AND_EPISODES": "المواسم والحلقات", + "SEASON_TAB": "الموسم {{number}}", + "BACK_TO_PLAYING_EPISODE": "العودة إلى الحلقة قيد التشغيل", + "GRID_VIEW": "عرض شبكي", + "LIST_VIEW": "عرض قائمة", + "NO_EPISODES_AVAILABLE": "لا توجد حلقات متاحة", + "NO_EPISODES_AVAILABLE_MESSAGE": "هذا المسلسل مدرج لدى مزودك، لكن لا توجد مواسم أو حلقات متاحة للتشغيل.", + "SEASON_EMPTY": "هذا الموسم فارغ", + "SEASON_EMPTY_MESSAGE": "هذا الموسم مدرج لدى مزودك، لكن لا توجد حلقات قابلة للتشغيل متاحة.", + "EPISODE_INFO": "تفاصيل الحلقة", "SELECT_CATEGORY": "اختر فئة", "PAGE": "الصفحة", "CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index ab4d6be74..0c7280ba1 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -2,6 +2,7 @@ "CANCEL": "إلغاء", "CLEAR": "مسح", "CLOSE": "سد", + "BACK": "رجع", "SHOW_MORE": "وري كثر", "SHOW_LESS": "وري قل", "PLAYLISTS": "قوائم التشغيل", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "رابط البث تنقل للحافظة", "COPY_STREAM_URL": "نسخ رابط البث", + "NOW_PLAYING": "كيتشغل دابا", + "CLOSE_PLAYER": "سد المشغل", + "ABOUT": "حول", + "SEASONS_AND_EPISODES": "المواسم والحلقات", + "SEASON_TAB": "الموسم {{number}}", + "BACK_TO_PLAYING_EPISODE": "رجوع للحلقة اللي كتشغل", + "GRID_VIEW": "عرض الشبكة", + "LIST_VIEW": "عرض اللائحة", + "NO_EPISODES_AVAILABLE": "ما كاينش حلقات متوفرة", + "NO_EPISODES_AVAILABLE_MESSAGE": "هاد السلسلة مذكورة عند المزود ديالك، ولكن ما كاينش مواسم ولا حلقات متوفرة للتشغيل.", + "SEASON_EMPTY": "هاد الموسم خاوي", + "SEASON_EMPTY_MESSAGE": "هاد الموسم مذكور عند المزود ديالك، ولكن ما كاينش حلقات متوفرة للتشغيل.", + "EPISODE_INFO": "تفاصيل الحلقة", "SELECT_CATEGORY": "اختار فئة", "PAGE": "الصفحة", "CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7ead61989..454e6c65f 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -2,6 +2,7 @@ "CANCEL": "Адмена", "CLEAR": "Ачысціць", "CLOSE": "Закрыць", + "BACK": "Назад", "SHOW_MORE": "Болей", "SHOW_LESS": "Меней", "PLAYLISTS": "Плэйлісты", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "URL-адрас патоку быў скапіяваны ў буфер абмену.", "COPY_STREAM_URL": "Капіраваць URL-адрас патоку", + "NOW_PLAYING": "Зараз прайграецца", + "CLOSE_PLAYER": "Закрыць прайгравальнік", + "ABOUT": "Пра фільм", + "SEASONS_AND_EPISODES": "Сезоны і эпізоды", + "SEASON_TAB": "Сезон {{number}}", + "BACK_TO_PLAYING_EPISODE": "Назад да бягучага эпізоду", + "GRID_VIEW": "Выгляд сеткай", + "LIST_VIEW": "Выгляд спісам", + "NO_EPISODES_AVAILABLE": "Няма даступных эпізодаў", + "NO_EPISODES_AVAILABLE_MESSAGE": "Гэты серыял пазначаны вашым правайдэрам, але сезоны або эпізоды для прайгравання недаступныя.", + "SEASON_EMPTY": "Гэты сезон пусты", + "SEASON_EMPTY_MESSAGE": "Гэты сезон пазначаны вашым правайдэрам, але даступных для прайгравання эпізодаў няма.", + "EPISODE_INFO": "Падрабязнасці эпізода", "SELECT_CATEGORY": "Выберыце катэгорыю", "PAGE": "Старонка", "CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 064431be4..015d8e617 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -2,6 +2,7 @@ "CANCEL": "Abbrechen", "CLEAR": "Leeren", "CLOSE": "Schließen", + "BACK": "Zurück", "SHOW_MORE": "Mehr", "SHOW_LESS": "Weniger", "PLAYLISTS": "Playlisten", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "Stream-URL in die Zwischenablage kopiert", "COPY_STREAM_URL": "Stream-URL kopieren", + "NOW_PLAYING": "Wird wiedergegeben", + "CLOSE_PLAYER": "Player schließen", + "ABOUT": "Info", + "SEASONS_AND_EPISODES": "Staffeln und Folgen", + "SEASON_TAB": "Staffel {{number}}", + "BACK_TO_PLAYING_EPISODE": "Zurück zur laufenden Folge", + "GRID_VIEW": "Rasteransicht", + "LIST_VIEW": "Listenansicht", + "NO_EPISODES_AVAILABLE": "Keine Folgen verfügbar", + "NO_EPISODES_AVAILABLE_MESSAGE": "Diese Serie wird von Ihrem Anbieter gelistet, es sind jedoch keine Staffeln oder Folgen zum Abspielen verfügbar.", + "SEASON_EMPTY": "Diese Staffel ist leer", + "SEASON_EMPTY_MESSAGE": "Diese Staffel wird von Ihrem Anbieter gelistet, es sind jedoch keine abspielbaren Folgen verfügbar.", + "EPISODE_INFO": "Details zur Folge", "SELECT_CATEGORY": "Wählen Sie eine Kategorie aus", "PAGE": "Seite", "CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index bfbeb3b79..44eb43e3b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -2,6 +2,7 @@ "CANCEL": "Ακύρωση", "CLEAR": "Εκκαθάριση", "CLOSE": "Κλείσιμο", + "BACK": "Πίσω", "SHOW_MORE": "Περισσότερα", "SHOW_LESS": "Λιγότερα", "PLAYLISTS": "Λίστα Αναπαραγωγής", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "Η διεύθυνση URL ροής αντιγράφηκε στο πρόχειρο", "COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής", + "NOW_PLAYING": "Τώρα παίζει", + "CLOSE_PLAYER": "Κλείσιμο προγράμματος αναπαραγωγής", + "ABOUT": "Σχετικά", + "SEASONS_AND_EPISODES": "Σεζόν και επεισόδια", + "SEASON_TAB": "Σεζόν {{number}}", + "BACK_TO_PLAYING_EPISODE": "Πίσω στο επεισόδιο που παίζει", + "GRID_VIEW": "Προβολή πλέγματος", + "LIST_VIEW": "Προβολή λίστας", + "NO_EPISODES_AVAILABLE": "Δεν υπάρχουν διαθέσιμα επεισόδια", + "NO_EPISODES_AVAILABLE_MESSAGE": "Αυτή η σειρά είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμες σεζόν ή επεισόδια για αναπαραγωγή.", + "SEASON_EMPTY": "Αυτή η σεζόν είναι κενή", + "SEASON_EMPTY_MESSAGE": "Αυτή η σεζόν είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμα επεισόδια για αναπαραγωγή.", + "EPISODE_INFO": "Λεπτομέρειες επεισοδίου", "SELECT_CATEGORY": "Επιλέξτε μια κατηγορία", "PAGE": "Σελίδα", "CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 54ccd5cd2..0c10f8cef 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -2,6 +2,7 @@ "CANCEL": "Cancel", "CLEAR": "Clear", "CLOSE": "Close", + "BACK": "Back", "SHOW_MORE": "More", "SHOW_LESS": "Less", "PLAYLISTS": "Playlists", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "Stream URL copied to clipboard", "COPY_STREAM_URL": "Copy Stream URL", + "NOW_PLAYING": "Now playing", + "CLOSE_PLAYER": "Close player", + "ABOUT": "About", + "SEASONS_AND_EPISODES": "Seasons and Episodes", + "SEASON_TAB": "Season {{number}}", + "BACK_TO_PLAYING_EPISODE": "Back to playing episode", + "GRID_VIEW": "Grid view", + "LIST_VIEW": "List view", + "NO_EPISODES_AVAILABLE": "No episodes available", + "NO_EPISODES_AVAILABLE_MESSAGE": "This series is listed by your provider, but no seasons or episodes are available to play.", + "SEASON_EMPTY": "This season is empty", + "SEASON_EMPTY_MESSAGE": "This season is listed by your provider, but no playable episodes are available.", + "EPISODE_INFO": "Episode details", "SELECT_CATEGORY": "Select a category", "PAGE": "Page", "CONTENT_NOT_AVAILABLE": "This content is not available on this server", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c83e0207b..9fea237d8 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -2,6 +2,7 @@ "CANCEL": "Cancelar", "CLEAR": "Borrar", "CLOSE": "Cerrar", + "BACK": "Atrás", "SHOW_MORE": "Más", "SHOW_LESS": "Menos", "PLAYLISTS": "Listas de reproducción", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "URL de transmisión copiada al portapapeles", "COPY_STREAM_URL": "Copiar URL de transmisión", + "NOW_PLAYING": "Reproduciendo ahora", + "CLOSE_PLAYER": "Cerrar reproductor", + "ABOUT": "Acerca de", + "SEASONS_AND_EPISODES": "Temporadas y episodios", + "SEASON_TAB": "Temporada {{number}}", + "BACK_TO_PLAYING_EPISODE": "Volver al episodio en reproducción", + "GRID_VIEW": "Vista de cuadrícula", + "LIST_VIEW": "Vista de lista", + "NO_EPISODES_AVAILABLE": "No hay episodios disponibles", + "NO_EPISODES_AVAILABLE_MESSAGE": "Tu proveedor incluye esta serie en el listado, pero no hay temporadas ni episodios disponibles para reproducir.", + "SEASON_EMPTY": "Esta temporada está vacía", + "SEASON_EMPTY_MESSAGE": "Tu proveedor incluye esta temporada en el listado, pero no hay episodios reproducibles disponibles.", + "EPISODE_INFO": "Detalles del episodio", "SELECT_CATEGORY": "Selecciona una categoría", "PAGE": "Página", "CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index e62dc207d..153aed137 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -2,6 +2,7 @@ "CANCEL": "Annuler", "CLEAR": "Effacer", "CLOSE": "Fermer", + "BACK": "Retour", "SHOW_MORE": "Plus", "SHOW_LESS": "Moins", "PLAYLISTS": "Listes de lecture", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "URL du flux copiée dans le presse-papiers", "COPY_STREAM_URL": "Copier l'URL du flux", + "NOW_PLAYING": "En cours de lecture", + "CLOSE_PLAYER": "Fermer le lecteur", + "ABOUT": "À propos", + "SEASONS_AND_EPISODES": "Saisons et épisodes", + "SEASON_TAB": "Saison {{number}}", + "BACK_TO_PLAYING_EPISODE": "Retour à l'épisode en cours", + "GRID_VIEW": "Vue en grille", + "LIST_VIEW": "Vue en liste", + "NO_EPISODES_AVAILABLE": "Aucun épisode disponible", + "NO_EPISODES_AVAILABLE_MESSAGE": "Cette série est répertoriée par votre fournisseur, mais aucune saison ni épisode n'est disponible à la lecture.", + "SEASON_EMPTY": "Cette saison est vide", + "SEASON_EMPTY_MESSAGE": "Cette saison est répertoriée par votre fournisseur, mais aucun épisode lisible n'est disponible.", + "EPISODE_INFO": "Détails de l'épisode", "SELECT_CATEGORY": "Sélectionner une catégorie", "PAGE": "Page", "CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 4879114b5..ae4fcf81d 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -2,6 +2,7 @@ "CANCEL": "Annulla", "CLEAR": "Cancella", "CLOSE": "Chiudi", + "BACK": "Indietro", "SHOW_MORE": "Altro", "SHOW_LESS": "Meno", "PLAYLISTS": "Playlist", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "URL streaming copiato negli appunti", "COPY_STREAM_URL": "Copia URL streaming", + "NOW_PLAYING": "In riproduzione", + "CLOSE_PLAYER": "Chiudi lettore", + "ABOUT": "Info", + "SEASONS_AND_EPISODES": "Stagioni ed episodi", + "SEASON_TAB": "Stagione {{number}}", + "BACK_TO_PLAYING_EPISODE": "Torna all'episodio in riproduzione", + "GRID_VIEW": "Vista griglia", + "LIST_VIEW": "Vista elenco", + "NO_EPISODES_AVAILABLE": "Nessun episodio disponibile", + "NO_EPISODES_AVAILABLE_MESSAGE": "Questa serie è elencata dal tuo fornitore, ma non sono disponibili stagioni o episodi da riprodurre.", + "SEASON_EMPTY": "Questa stagione è vuota", + "SEASON_EMPTY_MESSAGE": "Questa stagione è elencata dal tuo fornitore, ma non sono disponibili episodi riproducibili.", + "EPISODE_INFO": "Dettagli episodio", "SELECT_CATEGORY": "Seleziona una categoria", "PAGE": "Pagina", "CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index a3804e70b..1b4143563 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -2,6 +2,7 @@ "CANCEL": "キャンセル", "CLEAR": "クリア", "CLOSE": "閉じる", + "BACK": "戻る", "SHOW_MORE": "もっと見る", "SHOW_LESS": "折りたたむ", "PLAYLISTS": "プレイリスト", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "ストリームURLがクリップボードにコピーされました", "COPY_STREAM_URL": "ストリームURLをコピー", + "NOW_PLAYING": "再生中", + "CLOSE_PLAYER": "プレーヤーを閉じる", + "ABOUT": "概要", + "SEASONS_AND_EPISODES": "シーズンとエピソード", + "SEASON_TAB": "シーズン{{number}}", + "BACK_TO_PLAYING_EPISODE": "再生中のエピソードに戻る", + "GRID_VIEW": "グリッド表示", + "LIST_VIEW": "リスト表示", + "NO_EPISODES_AVAILABLE": "利用可能なエピソードはありません", + "NO_EPISODES_AVAILABLE_MESSAGE": "このシリーズはプロバイダーに登録されていますが、再生可能なシーズンやエピソードはありません。", + "SEASON_EMPTY": "このシーズンにはエピソードがありません", + "SEASON_EMPTY_MESSAGE": "このシーズンはプロバイダーに登録されていますが、再生可能なエピソードはありません。", + "EPISODE_INFO": "エピソードの詳細", "SELECT_CATEGORY": "カテゴリーを選択", "PAGE": "ページ", "CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d36b11733..bb8a15cc4 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -2,6 +2,7 @@ "CANCEL": "취소", "CLEAR": "지우기", "CLOSE": "닫기", + "BACK": "뒤로", "SHOW_MORE": "더 보기", "SHOW_LESS": "간략히 보기", "PLAYLISTS": "재생목록", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "스트림 URL이 클립보드에 복사되었습니다.", "COPY_STREAM_URL": "스트림 URL 복사", + "NOW_PLAYING": "지금 재생 중", + "CLOSE_PLAYER": "플레이어 닫기", + "ABOUT": "정보", + "SEASONS_AND_EPISODES": "시즌 및 에피소드", + "SEASON_TAB": "시즌 {{number}}", + "BACK_TO_PLAYING_EPISODE": "재생 중인 에피소드로 돌아가기", + "GRID_VIEW": "그리드 보기", + "LIST_VIEW": "목록 보기", + "NO_EPISODES_AVAILABLE": "사용 가능한 에피소드가 없습니다", + "NO_EPISODES_AVAILABLE_MESSAGE": "이 시리즈는 공급자 목록에 있지만 재생 가능한 시즌이나 에피소드가 없습니다.", + "SEASON_EMPTY": "이 시즌은 비어 있습니다", + "SEASON_EMPTY_MESSAGE": "이 시즌은 공급자 목록에 있지만 재생 가능한 에피소드가 없습니다.", + "EPISODE_INFO": "에피소드 정보", "SELECT_CATEGORY": "카테고리를 선택하세요", "PAGE": "페이지", "CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index d8a2b49e0..6feeaa613 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -2,6 +2,7 @@ "CANCEL": "Annuleren", "CLEAR": "Wissen", "CLOSE": "Afsluiten", + "BACK": "Terug", "SHOW_MORE": "Meer", "SHOW_LESS": "Minder", "PLAYLISTS": "Afspeellijst", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "Stream URL Gekopieërd", "COPY_STREAM_URL": "Kopieër Stream URL", + "NOW_PLAYING": "Nu bezig", + "CLOSE_PLAYER": "Speler sluiten", + "ABOUT": "Over", + "SEASONS_AND_EPISODES": "Seizoenen en afleveringen", + "SEASON_TAB": "Seizoen {{number}}", + "BACK_TO_PLAYING_EPISODE": "Terug naar lopende aflevering", + "GRID_VIEW": "Rasterweergave", + "LIST_VIEW": "Lijstweergave", + "NO_EPISODES_AVAILABLE": "Geen afleveringen beschikbaar", + "NO_EPISODES_AVAILABLE_MESSAGE": "Deze serie staat in de lijst van je provider, maar er zijn geen seizoenen of afleveringen beschikbaar om af te spelen.", + "SEASON_EMPTY": "Dit seizoen is leeg", + "SEASON_EMPTY_MESSAGE": "Dit seizoen staat in de lijst van je provider, maar er zijn geen afspeelbare afleveringen beschikbaar.", + "EPISODE_INFO": "Afleveringsdetails", "SELECT_CATEGORY": "Selecteer een categorie", "PAGE": "Pagina", "CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2dde3f1e8..cb9af4de2 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -2,6 +2,7 @@ "CANCEL": "Anuluj", "CLEAR": "Wyczyść", "CLOSE": "Zamknij", + "BACK": "Wstecz", "SHOW_MORE": "Więcej", "SHOW_LESS": "Mniej", "PLAYLISTS": "Listy odtwarzania", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "URL strumienia skopiowany do schowka", "COPY_STREAM_URL": "Kopiuj URL strumienia", + "NOW_PLAYING": "Teraz odtwarzane", + "CLOSE_PLAYER": "Zamknij odtwarzacz", + "ABOUT": "Informacje", + "SEASONS_AND_EPISODES": "Sezony i odcinki", + "SEASON_TAB": "Sezon {{number}}", + "BACK_TO_PLAYING_EPISODE": "Wróć do odtwarzanego odcinka", + "GRID_VIEW": "Widok siatki", + "LIST_VIEW": "Widok listy", + "NO_EPISODES_AVAILABLE": "Brak dostępnych odcinków", + "NO_EPISODES_AVAILABLE_MESSAGE": "Ten serial jest dostępny u Twojego dostawcy, ale nie ma sezonów ani odcinków do odtworzenia.", + "SEASON_EMPTY": "Ten sezon jest pusty", + "SEASON_EMPTY_MESSAGE": "Ten sezon jest dostępny u Twojego dostawcy, ale nie ma odcinków do odtworzenia.", + "EPISODE_INFO": "Szczegóły odcinka", "SELECT_CATEGORY": "Wybierz kategorię", "PAGE": "Strona", "CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 95399c668..ceddf223d 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -2,6 +2,7 @@ "CANCEL": "Cancelar", "CLEAR": "Limpar", "CLOSE": "Fechar", + "BACK": "Voltar", "SHOW_MORE": "Mais", "SHOW_LESS": "Menos", "PLAYLISTS": "Playlists", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "URL do stream copiado para a área de transferência", "COPY_STREAM_URL": "Copiar URL do Stream", + "NOW_PLAYING": "Reproduzindo agora", + "CLOSE_PLAYER": "Fechar reprodutor", + "ABOUT": "Sobre", + "SEASONS_AND_EPISODES": "Temporadas e episódios", + "SEASON_TAB": "Temporada {{number}}", + "BACK_TO_PLAYING_EPISODE": "Voltar ao episódio em reprodução", + "GRID_VIEW": "Visualização em grade", + "LIST_VIEW": "Visualização em lista", + "NO_EPISODES_AVAILABLE": "Nenhum episódio disponível", + "NO_EPISODES_AVAILABLE_MESSAGE": "Esta série está listada pelo seu provedor, mas não há temporadas ou episódios disponíveis para reprodução.", + "SEASON_EMPTY": "Esta temporada está vazia", + "SEASON_EMPTY_MESSAGE": "Esta temporada está listada pelo seu provedor, mas não há episódios reproduzíveis disponíveis.", + "EPISODE_INFO": "Detalhes do episódio", "SELECT_CATEGORY": "Selecione uma categoria", "PAGE": "Página", "CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 09cc42b94..548ce29b2 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -2,6 +2,7 @@ "CANCEL": "Отмена", "CLEAR": "Очистить", "CLOSE": "Закрыть", + "BACK": "Назад", "SHOW_MORE": "Больше", "SHOW_LESS": "Меньше", "PLAYLISTS": "Плейлисты", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "URL-адрес потока был скопирован в буфер обмена.", "COPY_STREAM_URL": "Копировать URL-адрес потока", + "NOW_PLAYING": "Сейчас воспроизводится", + "CLOSE_PLAYER": "Закрыть плеер", + "ABOUT": "Об этом", + "SEASONS_AND_EPISODES": "Сезоны и эпизоды", + "SEASON_TAB": "Сезон {{number}}", + "BACK_TO_PLAYING_EPISODE": "Вернуться к воспроизводимому эпизоду", + "GRID_VIEW": "Вид сеткой", + "LIST_VIEW": "Вид списком", + "NO_EPISODES_AVAILABLE": "Нет доступных эпизодов", + "NO_EPISODES_AVAILABLE_MESSAGE": "Этот сериал есть в списке вашего провайдера, но доступных для воспроизведения сезонов или эпизодов нет.", + "SEASON_EMPTY": "Этот сезон пуст", + "SEASON_EMPTY_MESSAGE": "Этот сезон есть в списке вашего провайдера, но доступных для воспроизведения эпизодов нет.", + "EPISODE_INFO": "Детали эпизода", "SELECT_CATEGORY": "Выберите категорию", "PAGE": "Страница", "CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 8640198ba..36bbb0ed9 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -2,6 +2,7 @@ "CANCEL": "İptal", "CLEAR": "Temizle", "CLOSE": "Kapat", + "BACK": "Geri", "SHOW_MORE": "Daha fazla", "SHOW_LESS": "Daha az", "PLAYLISTS": "Oynatma Listeleri", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "Yayın URL'si panoya kopyalandı", "COPY_STREAM_URL": "Yayın URL'sini kopyala", + "NOW_PLAYING": "Şimdi oynatılıyor", + "CLOSE_PLAYER": "Oynatıcıyı kapat", + "ABOUT": "Hakkında", + "SEASONS_AND_EPISODES": "Sezonlar ve Bölümler", + "SEASON_TAB": "{{number}}. Sezon", + "BACK_TO_PLAYING_EPISODE": "Oynatılan bölüme dön", + "GRID_VIEW": "Izgara görünümü", + "LIST_VIEW": "Liste görünümü", + "NO_EPISODES_AVAILABLE": "Bölüm mevcut değil", + "NO_EPISODES_AVAILABLE_MESSAGE": "Bu dizi sağlayıcınız tarafından listeleniyor, ancak oynatılabilecek sezon veya bölüm bulunmuyor.", + "SEASON_EMPTY": "Bu sezon boş", + "SEASON_EMPTY_MESSAGE": "Bu sezon sağlayıcınız tarafından listeleniyor, ancak oynatılabilir bölüm bulunmuyor.", + "EPISODE_INFO": "Bölüm ayrıntıları", "SELECT_CATEGORY": "Bir kategori seçin", "PAGE": "Sayfa", "CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 38886c40a..777ae5d33 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -2,6 +2,7 @@ "CANCEL": "取消", "CLEAR": "清除", "CLOSE": "关闭", + "BACK": "返回", "SHOW_MORE": "更多", "SHOW_LESS": "收起", "PLAYLISTS": "播放列表", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "流 URL 已复制到剪贴板", "COPY_STREAM_URL": "复制流 URL", + "NOW_PLAYING": "正在播放", + "CLOSE_PLAYER": "关闭播放器", + "ABOUT": "简介", + "SEASONS_AND_EPISODES": "季与集", + "SEASON_TAB": "第 {{number}} 季", + "BACK_TO_PLAYING_EPISODE": "返回正在播放的剧集", + "GRID_VIEW": "网格视图", + "LIST_VIEW": "列表视图", + "NO_EPISODES_AVAILABLE": "没有可用的剧集", + "NO_EPISODES_AVAILABLE_MESSAGE": "您的提供商列出了此剧集,但没有可播放的季或集。", + "SEASON_EMPTY": "本季暂无内容", + "SEASON_EMPTY_MESSAGE": "您的提供商列出了本季,但没有可播放的剧集。", + "EPISODE_INFO": "剧集详情", "SELECT_CATEGORY": "选择一个分类", "PAGE": "页", "CONTENT_NOT_AVAILABLE": "此服务器上没有此内容", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 275771b37..495789324 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -2,6 +2,7 @@ "CANCEL": "取消", "CLEAR": "清除", "CLOSE": "關閉", + "BACK": "返回", "SHOW_MORE": "更多", "SHOW_LESS": "收起", "PLAYLISTS": "播放清單", @@ -918,6 +919,19 @@ }, "STREAM_URL_COPIED": "串流網址已複製到剪貼簿", "COPY_STREAM_URL": "複製串流網址", + "NOW_PLAYING": "現正播放", + "CLOSE_PLAYER": "關閉播放器", + "ABOUT": "簡介", + "SEASONS_AND_EPISODES": "季數與集數", + "SEASON_TAB": "第 {{number}} 季", + "BACK_TO_PLAYING_EPISODE": "返回正在播放的集數", + "GRID_VIEW": "格狀檢視", + "LIST_VIEW": "清單檢視", + "NO_EPISODES_AVAILABLE": "沒有可用的集數", + "NO_EPISODES_AVAILABLE_MESSAGE": "此供應商列出了這部影集,但目前沒有可播放的季數或集數。", + "SEASON_EMPTY": "此季暫無內容", + "SEASON_EMPTY_MESSAGE": "此供應商列出了這一季,但目前沒有可播放的集數。", + "EPISODE_INFO": "集數詳情", "SELECT_CATEGORY": "選擇類別", "PAGE": "頁", "CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 8d53a7051..69f7c642d 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -82,8 +82,63 @@ Current limitation: - because of that, the setting is auto-sanitized back to the default inline player unless support detection reports that the experimental runtime is available - this follows the rollout gate: keep the native work in-tree, but do not leave it user-facing until playback, resize, focus, and packaging are stable +## Two-State Detail Layout (Browse ↔ Watch) + +Portal VOD/series detail pages are hosted by the shared +`PortalDetailShellComponent` +(`libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`), +which owns the page scroll container and two layout states: + +- **Browse** (`playbackActive=false`): the hero (`ContentHeroComponent`, + now a natural-height, non-scrolling block) renders poster, title, chips, + description, credits, and actions. Episodes render below. +- **Watch** (`playbackActive=true`, bound by hosts to + `inlinePlayback() !== null`): the hero collapses (~300ms CSS morph, + disabled under `prefers-reduced-motion`), the host-projected + `[detail-player]` slot renders the inline player at full content width, + and the shell renders an About block (`ContentAboutComponent`) below the + `[detail-episodes]` slot so the hero metadata stays reachable. + +Contracts: + +- Hosts provide hero chips/meta/actions as `*appDetailTags` / `*appDetailMeta` / + `*appDetailActions` templates; the shell stamps them into the hero and again + into the About block. Degradation stays "missing → not rendered". +- The shell never wraps the `[detail-player]` slot in a conditional; the + host's `@if (inlinePlayback())` is the only creator/destroyer of the + player subtree, so shell state changes cannot recreate the `