mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(ui): keep one Back arrow on detail pages (#1627)
Movie and series detail pages showed two arrow_back controls while the inline player was open: the shell's sticky arrow (added in #1576 so Back survives scrolling) meant "Close player" in watch state, while the now-playing bar carried a second arrow that meant route-level Back — the same icon with two meanings, next to a "Close player" button that duplicated the first. The shell's sticky arrow is now route-level Back in browse and watch alike, and the bar carries no arrow of its own. Closing the player is the bar's "Close player" button and Escape, which still unwinds one level (close, then back). Hosts without a browse Back target (M3U, downloads) render no arrow in either state. Unit specs for the shell and the inline player cover the new contract; the Electron and web E2E helpers that pressed Back from watch are updated, and the M3U flow closes the player through the bar's button. Docs, the mirrored CLAUDE.md/AGENTS.md paragraph and a release note follow the change. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
5273e5fb55
commit
c76a901e8d
22 files changed
+127
-107
No files matched your search
-1
@@ -216,7 +216,6 @@
|
||||
[seasonLoadStates]="vodSeasonLoadStates()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(playbackEnded)="handleInlinePlaybackEnded()"
|
||||
(previousEpisodeRequested)="playPreviousEpisode()"
|
||||
|
||||
@@ -222,7 +222,6 @@
|
||||
[episodePlaybackPositions]="episodePlaybackPositions()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(playbackEnded)="handleInlinePlaybackEnded()"
|
||||
(previousEpisodeRequested)="playPreviousEpisode()"
|
||||
|
||||
@@ -290,7 +290,6 @@
|
||||
(playbackFailed)="onPlaybackFailed()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
@if (backAvailable() || isWatch()) {
|
||||
@let label =
|
||||
isWatch()
|
||||
? ('PORTALS.CLOSE_PLAYER' | translate)
|
||||
: backLabel() || ('BACK' | translate);
|
||||
@if (backAvailable()) {
|
||||
@let label = backLabel() || ('BACK' | translate);
|
||||
<!-- Route-level Back in both states. Watch keeps the arrow's meaning;
|
||||
closing the player belongs to the player's own Close button and Escape. -->
|
||||
<div class="shell__navigation">
|
||||
<button
|
||||
#backButton
|
||||
class="shell__back-button"
|
||||
type="button"
|
||||
[attr.aria-label]="label"
|
||||
aria-keyshortcuts="Escape"
|
||||
[title]="label + ' (Esc)'"
|
||||
(click)="onBack()"
|
||||
[attr.aria-keyshortcuts]="isWatch() ? null : 'Escape'"
|
||||
[title]="isWatch() ? label : label + ' (Esc)'"
|
||||
(click)="backClicked.emit()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
+38
-7
@@ -277,23 +277,44 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps one labelled back control outside the collapsing hero', async () => {
|
||||
it('keeps one route-back control outside the collapsing hero in both states', () => {
|
||||
const back = requiredQuery('.shell__back-button');
|
||||
expect(back.closest('app-content-hero')).toBeNull();
|
||||
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
|
||||
back.click();
|
||||
expect(host.backRequests).toBe(1);
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(query('.shell__back-button')).toBe(back);
|
||||
expect(back.getAttribute('aria-label')).toBe('Close player');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
|
||||
requiredQuery('.fake-player').tabIndex = 0;
|
||||
requiredQuery('.fake-player').focus();
|
||||
// Watch keeps the arrow's meaning: it leaves the page, it does not
|
||||
// close the player. Escape is the close shortcut, so the hint goes.
|
||||
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBeNull();
|
||||
expect(back.getAttribute('title')).toBe('Return to downloads');
|
||||
back.click();
|
||||
expect(host.backRequests).toBe(2);
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('moves lost focus to the back control after Escape closes the player', async () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
const back = requiredQuery('.shell__back-button');
|
||||
const player = requiredQuery('.fake-player');
|
||||
player.tabIndex = 0;
|
||||
player.focus();
|
||||
player.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(host.closeRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(0);
|
||||
expect(document.activeElement).toBe(back);
|
||||
});
|
||||
|
||||
@@ -311,8 +332,18 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.backRequests).toBe(0);
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
requiredQuery('.shell__back-button').click();
|
||||
// No route to go back to, so no arrow in watch either; the player's
|
||||
// own Close button and Escape remain the exits.
|
||||
expect(query('.shell__back-button')).toBeNull();
|
||||
requiredQuery('app-portal-detail-shell').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
expect(host.closeRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -32,7 +32,9 @@ import {
|
||||
* About block below the episodes slot.
|
||||
*
|
||||
* The shell owns the page scroll, the browse↔watch animation, Escape
|
||||
* handling, and never conditionally wraps the `[detail-player]` slot — the
|
||||
* handling, the one sticky Back control (route-level in both states; closing
|
||||
* the player is the player's own Close button and Escape), and never
|
||||
* conditionally wraps the `[detail-player]` slot — the
|
||||
* host's own `@if (inlinePlayback())` is the only thing that creates or
|
||||
* destroys the player, so shell state changes cannot recreate it.
|
||||
*/
|
||||
@@ -80,8 +82,9 @@ export class PortalDetailShellComponent {
|
||||
/** True while inline playback is active — flips the layout to watch state. */
|
||||
readonly playbackActive = input(false);
|
||||
|
||||
/** The sticky control in either state, or Escape in browse. */
|
||||
readonly backClicked = output<void>();
|
||||
/** Emitted by the sticky control or Escape during inline playback. */
|
||||
/** Emitted by Escape during inline playback. */
|
||||
readonly closePlayerRequested = output<void>();
|
||||
|
||||
protected readonly tagsTemplate = contentChild(DetailTagsTemplateDirective);
|
||||
@@ -198,10 +201,11 @@ export class PortalDetailShellComponent {
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
this.onBack();
|
||||
this.unwind();
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
/** One level per Escape: watch closes the player, browse leaves the page. */
|
||||
private unwind(): void {
|
||||
if (!this.playbackActive()) {
|
||||
if (this.backAvailable()) this.backClicked.emit();
|
||||
return;
|
||||
|
||||
@@ -2,17 +2,6 @@
|
||||
<section class="player-shell" aria-label="Inline player">
|
||||
<header class="player-shell__header">
|
||||
<div class="player-shell__title-row">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-shell__back"
|
||||
data-testid="inline-player-back"
|
||||
[attr.aria-label]="'BACK' | translate"
|
||||
[matTooltip]="'BACK' | translate"
|
||||
(click)="onBack()"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<div class="player-shell__heading">
|
||||
<div class="player-shell__eyebrow">
|
||||
{{ 'PORTALS.NOW_PLAYING' | translate }}
|
||||
|
||||
@@ -48,10 +48,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-shell__back {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.player-shell__heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
+12
-21
@@ -252,33 +252,24 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('emits backClicked (not closed) from the back button in the now-playing bar', () => {
|
||||
let backCount = 0;
|
||||
let closedCount = 0;
|
||||
it('offers no back arrow in the now-playing bar: leaving the page belongs to the detail shell', () => {
|
||||
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 };
|
||||
}
|
||||
).closed.subscribe(() => closedCount++);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const backButton = fixture.nativeElement.querySelector(
|
||||
'[data-testid="inline-player-back"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(backButton).toBeTruthy();
|
||||
backButton.click();
|
||||
expect(backCount).toBe(1);
|
||||
expect(closedCount).toBe(0);
|
||||
const header = fixture.nativeElement.querySelector(
|
||||
'.player-shell__header'
|
||||
) as HTMLElement;
|
||||
expect(header).toBeTruthy();
|
||||
expect(header.querySelector('.player-shell__back')).toBeNull();
|
||||
expect(
|
||||
Array.from(header.querySelectorAll('mat-icon')).map((icon) =>
|
||||
icon.textContent?.trim()
|
||||
)
|
||||
).not.toContain('arrow_back');
|
||||
expect('backClicked' in component).toBe(false);
|
||||
});
|
||||
|
||||
describe('with strip country prefix enabled', () => {
|
||||
|
||||
@@ -234,9 +234,9 @@ export class PortalInlinePlayerComponent {
|
||||
fallbackTitle: this.title,
|
||||
});
|
||||
|
||||
/** The now-playing bar's Close button: back to browse, no navigation.
|
||||
* Leaving the page is the detail shell's sticky Back, not this bar's. */
|
||||
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;
|
||||
@@ -294,10 +294,6 @@ export class PortalInlinePlayerComponent {
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
|
||||
onTimeUpdate(event: { currentTime: number; duration: number }): void {
|
||||
this.timeUpdate.emit(event);
|
||||
}
|
||||
|
||||
@@ -320,7 +320,6 @@
|
||||
[playback]="playback"
|
||||
(timeUpdate)="onInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayback()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="onStreamUrlCopied()"
|
||||
(externalFallbackRequested)="
|
||||
onInlineExternalFallbackRequested($event)
|
||||
|
||||
Reference in new issue
Block a user