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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 20:30:09 +02:00
1 parent 46975c5260
commit d4f6a8b1f7
27 files changed
+47 -27

No files matched your search

+1 -1
View File
@@ -621,7 +621,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
**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 the now-playing back button close the inline player back to browse without route navigation
- 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")
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "إلغاء",
"CLEAR": "مسح",
"CLOSE": "إغلاق",
"BACK": "رجوع",
"SHOW_MORE": "المزيد",
"SHOW_LESS": "أقل",
"PLAYLISTS": "قوائم التشغيل",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "نسخ رابط البث",
"NOW_PLAYING": "قيد التشغيل",
"CLOSE_PLAYER": "إغلاق المشغل",
"BACK_TO_BROWSE": "العودة إلى التفاصيل",
"ABOUT": "نبذة",
"SEASONS_AND_EPISODES": "المواسم والحلقات",
"SEASON_TAB": "الموسم {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "إلغاء",
"CLEAR": "مسح",
"CLOSE": "سد",
"BACK": "رجع",
"SHOW_MORE": "وري كثر",
"SHOW_LESS": "وري قل",
"PLAYLISTS": "قوائم التشغيل",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "نسخ رابط البث",
"NOW_PLAYING": "كيتشغل دابا",
"CLOSE_PLAYER": "سد المشغل",
"BACK_TO_BROWSE": "رجوع للتفاصيل",
"ABOUT": "حول",
"SEASONS_AND_EPISODES": "المواسم والحلقات",
"SEASON_TAB": "الموسم {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Адмена",
"CLEAR": "Ачысціць",
"CLOSE": "Закрыць",
"BACK": "Назад",
"SHOW_MORE": "Болей",
"SHOW_LESS": "Меней",
"PLAYLISTS": "Плэйлісты",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Капіраваць URL-адрас патоку",
"NOW_PLAYING": "Зараз прайграецца",
"CLOSE_PLAYER": "Закрыць прайгравальнік",
"BACK_TO_BROWSE": "Назад да дэталяў",
"ABOUT": "Пра фільм",
"SEASONS_AND_EPISODES": "Сезоны і эпізоды",
"SEASON_TAB": "Сезон {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Abbrechen",
"CLEAR": "Leeren",
"CLOSE": "Schließen",
"BACK": "Zurück",
"SHOW_MORE": "Mehr",
"SHOW_LESS": "Weniger",
"PLAYLISTS": "Playlisten",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Stream-URL kopieren",
"NOW_PLAYING": "Wird wiedergegeben",
"CLOSE_PLAYER": "Player schließen",
"BACK_TO_BROWSE": "Zurück zu den Details",
"ABOUT": "Info",
"SEASONS_AND_EPISODES": "Staffeln und Folgen",
"SEASON_TAB": "Staffel {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Ακύρωση",
"CLEAR": "Εκκαθάριση",
"CLOSE": "Κλείσιμο",
"BACK": "Πίσω",
"SHOW_MORE": "Περισσότερα",
"SHOW_LESS": "Λιγότερα",
"PLAYLISTS": "Λίστα Αναπαραγωγής",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής",
"NOW_PLAYING": "Τώρα παίζει",
"CLOSE_PLAYER": "Κλείσιμο προγράμματος αναπαραγωγής",
"BACK_TO_BROWSE": "Πίσω στις λεπτομέρειες",
"ABOUT": "Σχετικά",
"SEASONS_AND_EPISODES": "Σεζόν και επεισόδια",
"SEASON_TAB": "Σεζόν {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Cancel",
"CLEAR": "Clear",
"CLOSE": "Close",
"BACK": "Back",
"SHOW_MORE": "More",
"SHOW_LESS": "Less",
"PLAYLISTS": "Playlists",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Copy Stream URL",
"NOW_PLAYING": "Now playing",
"CLOSE_PLAYER": "Close player",
"BACK_TO_BROWSE": "Back to details",
"ABOUT": "About",
"SEASONS_AND_EPISODES": "Seasons and Episodes",
"SEASON_TAB": "Season {{number}}",
+1 -1
View File
@@ -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",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Copiar URL de transmisión",
"NOW_PLAYING": "Reproduciendo ahora",
"CLOSE_PLAYER": "Cerrar reproductor",
"BACK_TO_BROWSE": "Volver a los detalles",
"ABOUT": "Acerca de",
"SEASONS_AND_EPISODES": "Temporadas y episodios",
"SEASON_TAB": "Temporada {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Annuler",
"CLEAR": "Effacer",
"CLOSE": "Fermer",
"BACK": "Retour",
"SHOW_MORE": "Plus",
"SHOW_LESS": "Moins",
"PLAYLISTS": "Listes de lecture",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Copier l'URL du flux",
"NOW_PLAYING": "En cours de lecture",
"CLOSE_PLAYER": "Fermer le lecteur",
"BACK_TO_BROWSE": "Retour aux détails",
"ABOUT": "À propos",
"SEASONS_AND_EPISODES": "Saisons et épisodes",
"SEASON_TAB": "Saison {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Annulla",
"CLEAR": "Cancella",
"CLOSE": "Chiudi",
"BACK": "Indietro",
"SHOW_MORE": "Altro",
"SHOW_LESS": "Meno",
"PLAYLISTS": "Playlist",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Copia URL streaming",
"NOW_PLAYING": "In riproduzione",
"CLOSE_PLAYER": "Chiudi lettore",
"BACK_TO_BROWSE": "Torna ai dettagli",
"ABOUT": "Info",
"SEASONS_AND_EPISODES": "Stagioni ed episodi",
"SEASON_TAB": "Stagione {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "キャンセル",
"CLEAR": "クリア",
"CLOSE": "閉じる",
"BACK": "戻る",
"SHOW_MORE": "もっと見る",
"SHOW_LESS": "折りたたむ",
"PLAYLISTS": "プレイリスト",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "ストリームURLをコピー",
"NOW_PLAYING": "再生中",
"CLOSE_PLAYER": "プレーヤーを閉じる",
"BACK_TO_BROWSE": "詳細に戻る",
"ABOUT": "概要",
"SEASONS_AND_EPISODES": "シーズンとエピソード",
"SEASON_TAB": "シーズン{{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "취소",
"CLEAR": "지우기",
"CLOSE": "닫기",
"BACK": "뒤로",
"SHOW_MORE": "더 보기",
"SHOW_LESS": "간략히 보기",
"PLAYLISTS": "재생목록",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "스트림 URL 복사",
"NOW_PLAYING": "지금 재생 중",
"CLOSE_PLAYER": "플레이어 닫기",
"BACK_TO_BROWSE": "세부 정보로 돌아가기",
"ABOUT": "정보",
"SEASONS_AND_EPISODES": "시즌 및 에피소드",
"SEASON_TAB": "시즌 {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Annuleren",
"CLEAR": "Wissen",
"CLOSE": "Afsluiten",
"BACK": "Terug",
"SHOW_MORE": "Meer",
"SHOW_LESS": "Minder",
"PLAYLISTS": "Afspeellijst",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Kopieër Stream URL",
"NOW_PLAYING": "Nu bezig",
"CLOSE_PLAYER": "Speler sluiten",
"BACK_TO_BROWSE": "Terug naar details",
"ABOUT": "Over",
"SEASONS_AND_EPISODES": "Seizoenen en afleveringen",
"SEASON_TAB": "Seizoen {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Anuluj",
"CLEAR": "Wyczyść",
"CLOSE": "Zamknij",
"BACK": "Wstecz",
"SHOW_MORE": "Więcej",
"SHOW_LESS": "Mniej",
"PLAYLISTS": "Listy odtwarzania",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Kopiuj URL strumienia",
"NOW_PLAYING": "Teraz odtwarzane",
"CLOSE_PLAYER": "Zamknij odtwarzacz",
"BACK_TO_BROWSE": "Wróć do szczegółów",
"ABOUT": "Informacje",
"SEASONS_AND_EPISODES": "Sezony i odcinki",
"SEASON_TAB": "Sezon {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Cancelar",
"CLEAR": "Limpar",
"CLOSE": "Fechar",
"BACK": "Voltar",
"SHOW_MORE": "Mais",
"SHOW_LESS": "Menos",
"PLAYLISTS": "Playlists",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Copiar URL do Stream",
"NOW_PLAYING": "Reproduzindo agora",
"CLOSE_PLAYER": "Fechar reprodutor",
"BACK_TO_BROWSE": "Voltar aos detalhes",
"ABOUT": "Sobre",
"SEASONS_AND_EPISODES": "Temporadas e episódios",
"SEASON_TAB": "Temporada {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "Отмена",
"CLEAR": "Очистить",
"CLOSE": "Закрыть",
"BACK": "Назад",
"SHOW_MORE": "Больше",
"SHOW_LESS": "Меньше",
"PLAYLISTS": "Плейлисты",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Копировать URL-адрес потока",
"NOW_PLAYING": "Сейчас воспроизводится",
"CLOSE_PLAYER": "Закрыть плеер",
"BACK_TO_BROWSE": "Назад к сведениям",
"ABOUT": "Об этом",
"SEASONS_AND_EPISODES": "Сезоны и эпизоды",
"SEASON_TAB": "Сезон {{number}}",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "İptal",
"CLEAR": "Temizle",
"CLOSE": "Kapat",
"BACK": "Geri",
"SHOW_MORE": "Daha fazla",
"SHOW_LESS": "Daha az",
"PLAYLISTS": "Oynatma Listeleri",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "Yayın URL'sini kopyala",
"NOW_PLAYING": "Şimdi oynatılıyor",
"CLOSE_PLAYER": "Oynatıcıyı kapat",
"BACK_TO_BROWSE": "Ayrıntılara dön",
"ABOUT": "Hakkında",
"SEASONS_AND_EPISODES": "Sezonlar ve Bölümler",
"SEASON_TAB": "{{number}}. Sezon",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "取消",
"CLEAR": "清除",
"CLOSE": "关闭",
"BACK": "返回",
"SHOW_MORE": "更多",
"SHOW_LESS": "收起",
"PLAYLISTS": "播放列表",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "复制流 URL",
"NOW_PLAYING": "正在播放",
"CLOSE_PLAYER": "关闭播放器",
"BACK_TO_BROWSE": "返回详情页",
"ABOUT": "简介",
"SEASONS_AND_EPISODES": "季与集",
"SEASON_TAB": "第 {{number}} 季",
+1 -1
View File
@@ -2,6 +2,7 @@
"CANCEL": "取消",
"CLEAR": "清除",
"CLOSE": "關閉",
"BACK": "返回",
"SHOW_MORE": "更多",
"SHOW_LESS": "收起",
"PLAYLISTS": "播放清單",
@@ -920,7 +921,6 @@
"COPY_STREAM_URL": "複製串流網址",
"NOW_PLAYING": "現正播放",
"CLOSE_PLAYER": "關閉播放器",
"BACK_TO_BROWSE": "返回詳細資訊",
"ABOUT": "簡介",
"SEASONS_AND_EPISODES": "季數與集數",
"SEASON_TAB": "第 {{number}} 季",
@@ -112,9 +112,12 @@ Contracts:
- Escape closes inline playback: the shell emits `closePlayerRequested`
when playback is active, the event was not `defaultPrevented`, and no
element is in browser fullscreen; hosts wire it to `closeInlinePlayer()`.
- The inline player's now-playing bar has a back button that emits the
existing `closed` output — in watch state, back returns to browse, it
does not navigate the route.
- The now-playing bar separates two exits: the back arrow emits
`backClicked`, which hosts wire to their route-level `goBack()` (straight
back to the list — everything browse offers is also visible in watch, so
a two-step unwind would be ceremony); the "Close player" button and
Escape emit `closed`/`closePlayerRequested` and return to browse without
navigating.
- Entering watch scrolls the shell to the top; leaving keeps the scroll
position.
@@ -125,6 +125,7 @@
[seriesNavigation]="inlineSeriesNavigation()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
(streamUrlCopied)="showCopyNotification()"
(playbackEnded)="handleInlinePlaybackEnded()"
(previousEpisodeRequested)="playPreviousEpisode()"
@@ -134,6 +134,7 @@
[seriesNavigation]="inlineSeriesNavigation()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
(streamUrlCopied)="showCopyNotification()"
(playbackEnded)="handleInlinePlaybackEnded()"
(previousEpisodeRequested)="playPreviousEpisode()"
@@ -205,6 +205,7 @@
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
(streamUrlCopied)="showCopyNotification()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
@@ -7,9 +7,9 @@
type="button"
class="player-shell__back"
data-testid="inline-player-back"
[attr.aria-label]="'PORTALS.BACK_TO_BROWSE' | translate"
[matTooltip]="'PORTALS.BACK_TO_BROWSE' | translate"
(click)="onClose()"
[attr.aria-label]="'BACK' | translate"
[matTooltip]="'BACK' | translate"
(click)="onBack()"
>
<mat-icon>arrow_back</mat-icon>
</button>
@@ -122,12 +122,18 @@ describe('PortalInlinePlayerComponent', () => {
expect(events).toEqual(['ended', 'previous', 'next']);
});
it('emits closed from the back button in the now-playing bar', () => {
it('emits backClicked (not closed) from the back button in the now-playing bar', () => {
let backCount = 0;
let closedCount = 0;
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/vod/1.mp4',
title: 'Movie',
});
(
component as unknown as {
backClicked: { subscribe: (fn: () => void) => void };
}
).backClicked.subscribe(() => backCount++);
(
component as unknown as {
closed: { subscribe: (fn: () => void) => void };
@@ -141,6 +147,7 @@ describe('PortalInlinePlayerComponent', () => {
) as HTMLButtonElement;
expect(backButton).toBeTruthy();
backButton.click();
expect(closedCount).toBe(1);
expect(backCount).toBe(1);
expect(closedCount).toBe(0);
});
});
@@ -62,6 +62,8 @@ export class PortalInlinePlayerComponent {
});
readonly closed = output<void>();
/** Back arrow in the now-playing bar: route-level back, not just close. */
readonly backClicked = output<void>();
readonly timeUpdate = output<{
currentTime: number;
duration: number;
@@ -76,6 +78,10 @@ export class PortalInlinePlayerComponent {
this.closed.emit();
}
onBack(): void {
this.backClicked.emit();
}
onTimeUpdate(event: { currentTime: number; duration: number }): void {
this.timeUpdate.emit(event);
}
@@ -181,6 +181,7 @@
[playback]="playback"
(timeUpdate)="onInlineTimeUpdate($event)"
(closed)="closeInlinePlayback()"
(backClicked)="goBack()"
(streamUrlCopied)="onStreamUrlCopied()"
(externalFallbackRequested)="
onInlineExternalFallbackRequested($event)