diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index e4866419d..514b4e70f 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -139,6 +139,33 @@ The click path must continue through each detail host's normal episode playback method so recent-item updates, inline/external player selection, resume offsets, and playback-position saving keep the same behavior as manual episode clicks. +## Series Inline Episode Continuation + +Xtream and Stalker series detail views own current-episode state and pass a +`SeriesPlaybackNavigation` payload through `PortalInlinePlayerComponent` and +`WebPlayerViewComponent` to the active embedded player. + +Current contract: + +- Video.js, HTML5, ArtPlayer, and embedded MPV emit `playbackEnded` only for + real media EOF/`ended` events. +- Teardown, replacement, manual close, player reload, idle state, and playback + errors must not emit `playbackEnded`. +- Series previous/next controls render only when the series navigation payload is + present. Movies, live streams, radio streams, and non-series VOD must not show + those buttons. +- The shared navigation payload contains `canPrevious`, `canNext`, and + `autoplayEnabled`. Player controls disable previous on the first episode and + next on the last episode of the current season. +- Autoplay is enabled by default for series playback. On `playbackEnded`, the + portal detail host starts the next episode only when `canNext` is true for the + current season. +- Autoplay and Next never cross season boundaries or lazy-load another season. + Quick start remains the flow that may choose an episode from another loaded or + newly loaded season before playback begins. +- Previous switches directly to the previous episode in the current season. It + does not implement a restart-threshold behavior. + ## Codec And Container Diagnostics The shared `WebPlayerViewComponent` is the central browser-player viewport for M3U, Xtream, and Stalker inline playback, including live streams opened from favorites and recently viewed collections. Video.js, HTML5, and ArtPlayer report native media errors, HLS.js errors, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index cd26918b3..7c6f4f8cd 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -184,8 +184,8 @@ Series inline playback behavior is shared across all three modes: - `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`. - The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`. -- When experimental embedded MPV is active, the player receives previous/next episode state for the current season only. -- Embedded MPV autoplay is enabled by default. On MPV EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list. +- Embedded players receive previous/next episode state for the current season only. +- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list. - Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded `is_series=1` season. Quick start remains the only flow that may load another VOD-series season before playback. - Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode. diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index 0d94f05ca..bdf573870 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { Channel } from '@iptvnator/shared/interfaces'; import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component'; @@ -31,9 +32,11 @@ class MockHls { static isSupported = jest.fn(() => true); readonly handlers = new Map void>(); - readonly on = jest.fn((event: string, handler: (...args: unknown[]) => void) => { - this.handlers.set(event, handler); - }); + readonly on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + this.handlers.set(event, handler); + } + ); readonly loadSource = jest.fn(); readonly attachMedia = jest.fn(); readonly destroy = jest.fn(); @@ -47,9 +50,11 @@ class MockHls { class MockMpegTsPlayer { readonly handlers = new Map void>(); readonly attachMediaElement = jest.fn(); - readonly on = jest.fn((event: string, handler: (...args: unknown[]) => void) => { - this.handlers.set(event, handler); - }); + readonly on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + this.handlers.set(event, handler); + } + ); readonly load = jest.fn(); readonly play = jest.fn(); readonly pause = jest.fn(); @@ -217,9 +222,11 @@ describe('ArtPlayerComponent', () => { artPlayerInstances[0].video, 'https://example.com/live/channel.ts' ); - mpegTsInstances[0].handlers - .get('error') - ?.('mediaError', 'unsupported codec', {}); + mpegTsInstances[0].handlers.get('error')?.( + 'mediaError', + 'unsupported codec', + {} + ); expect(issues).toEqual([ expect.objectContaining({ @@ -231,6 +238,97 @@ describe('ArtPlayerComponent', () => { ]); }); + it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => { + const events: string[] = []; + createComponent({ + url: 'https://example.com/series/s01e02.mp4', + name: 'Episode 2', + }); + ( + component as unknown as { + playbackEnded: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).playbackEnded.subscribe(() => events.push('ended')); + + artPlayerInstances[0].video.dispatchEvent(new Event('ended')); + fixture.componentRef.setInput('channel', { + url: 'https://example.com/series/s01e03.mp4', + name: 'Episode 3', + }); + fixture.detectChanges(); + fixture.destroy(); + + expect(events).toEqual(['ended']); + }); + + it('hides series navigation controls when series navigation is absent', () => { + createComponent({ + url: 'https://example.com/movie.mp4', + name: 'Movie', + }); + + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ) + ).toBeNull(); + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ) + ).toBeNull(); + }); + + it('renders series navigation controls with boundary disabled state', () => { + const events: string[] = []; + createComponent({ + url: 'https://example.com/series/s01e10.mp4', + name: 'Episode 10', + }); + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: false, + autoplayEnabled: true, + }); + ( + component as unknown as { + previousEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).previousEpisodeRequested.subscribe(() => events.push('previous')); + ( + component as unknown as { + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).nextEpisodeRequested.subscribe(() => events.push('next')); + + fixture.detectChanges(); + + const previousButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ); + const nextButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ); + expect(previousButton).not.toBeNull(); + expect(previousButton.nativeElement.disabled).toBe(false); + expect(nextButton).not.toBeNull(); + expect(nextButton.nativeElement.disabled).toBe(true); + + previousButton.nativeElement.click(); + nextButton.nativeElement.click(); + + expect(events).toEqual(['previous']); + }); + function createComponent(channel: Pick): void { fixture = TestBed.createComponent(ArtPlayerComponent); component = fixture.componentInstance; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 403cf984e..3ad090bd6 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -24,6 +24,8 @@ import { createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; +import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; +import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; type AudioTrackSelector = { html: string | HTMLElement; @@ -34,8 +36,18 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; @Component({ selector: 'app-art-player', - imports: [], - template: `
`, + imports: [SeriesPlaybackNavigationControlsComponent], + template: ` +
+
+ + +
+ `, styles: [ ` :host { @@ -43,6 +55,13 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; width: 100%; height: 100%; } + .art-player-shell { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + } .artplayer-container { width: 100%; height: 100%; @@ -55,11 +74,15 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { @Input() volume = 1; @Input() showCaptions = false; @Input() startTime = 0; + @Input() seriesNavigation: SeriesPlaybackNavigation | null = null; @Output() timeUpdate = new EventEmitter<{ currentTime: number; duration: number; }>(); @Output() playbackIssue = new EventEmitter(); + @Output() playbackEnded = new EventEmitter(); + @Output() previousEpisodeRequested = new EventEmitter(); + @Output() nextEpisodeRequested = new EventEmitter(); private player!: Artplayer; private hls: Hls | null = null; @@ -82,6 +105,10 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { this.playbackIssue.emit(null); }; + private readonly handlePlaybackEnded = () => { + this.playbackEnded.emit(); + }; + ngOnInit(): void { this.initPlayer(); } @@ -122,6 +149,10 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { 'playing', this.clearPlaybackIssue ); + this.player.video?.removeEventListener( + 'ended', + this.handlePlaybackEnded + ); this.player.destroy(); } } @@ -232,6 +263,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { this.clearPlaybackIssue ); this.player.video.addEventListener('playing', this.clearPlaybackIssue); + this.player.video.addEventListener('ended', this.handlePlaybackEnded); if (this.startTime > 0) { this.player.on('ready', () => { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html index 5442850b1..ca9596274 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html @@ -1 +1,14 @@ - +
+ + + +
diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.scss b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.scss index 08a451024..d30f9efd4 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.scss +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.scss @@ -10,3 +10,11 @@ width: 100%; height: 100%; } + +.html-video-player-shell { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index b499917ae..e4e449a44 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -1,5 +1,6 @@ import { SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; import { Channel } from '@iptvnator/shared/interfaces'; @@ -106,6 +107,10 @@ describe('HtmlVideoPlayerComponent', () => { }); it('passes channel headers and stream URL to Electron header overrides', () => { + jest.spyOn( + component.videoPlayer.nativeElement, + 'load' + ).mockImplementation(() => undefined); jest.spyOn( component.videoPlayer.nativeElement, 'play' @@ -130,6 +135,10 @@ describe('HtmlVideoPlayerComponent', () => { }); it('clears Electron header overrides for channels without custom headers', () => { + jest.spyOn( + component.videoPlayer.nativeElement, + 'load' + ).mockImplementation(() => undefined); jest.spyOn( component.videoPlayer.nativeElement, 'play' @@ -153,6 +162,36 @@ describe('HtmlVideoPlayerComponent', () => { ); }); + it('replaces and reloads native video sources when switching episodes', () => { + const video = component.videoPlayer.nativeElement; + const loadSpy = jest + .spyOn(video, 'load') + .mockImplementation(() => undefined); + const playSpy = jest.spyOn(video, 'play').mockResolvedValue(undefined); + + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://stream.example/series/s01e01.mp4', + }); + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://stream.example/series/s01e02.mp4', + }); + + const sources = Array.from(video.querySelectorAll('source')); + const [source] = sources; + expect(sources).toHaveLength(1); + expect(source?.src).toBe( + 'https://stream.example/series/s01e02.mp4' + ); + expect(source?.type).toBe('video/mp4'); + expect(loadSpy).toHaveBeenCalledTimes(2); + expect(playSpy).toHaveBeenCalledTimes(2); + expect(loadSpy.mock.invocationCallOrder[1]).toBeLessThan( + playSpy.mock.invocationCallOrder[1] + ); + }); + it('emits a playback issue when the native video element reports an unsupported source', () => { const issues: unknown[] = []; component.channel = TEST_CHANNEL; @@ -237,4 +276,89 @@ describe('HtmlVideoPlayerComponent', () => { expect(issues[0].details).toContain('xhr setup failed'); expect(issues[0].details).toContain('"status":0'); }); + + it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => { + const events: string[] = []; + ( + component as unknown as { + playbackEnded: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).playbackEnded.subscribe(() => events.push('ended')); + jest.spyOn( + component.videoPlayer.nativeElement, + 'load' + ).mockImplementation(() => undefined); + jest.spyOn( + component.videoPlayer.nativeElement, + 'play' + ).mockResolvedValue(undefined); + + component.videoPlayer.nativeElement.dispatchEvent(new Event('ended')); + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://stream.example/series/s01e03.mp4', + }); + fixture.destroy(); + + expect(events).toEqual(['ended']); + }); + + it('hides series navigation controls when series navigation is absent', () => { + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ) + ).toBeNull(); + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ) + ).toBeNull(); + }); + + it('renders series navigation controls with boundary disabled state', () => { + const events: string[] = []; + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: false, + autoplayEnabled: true, + }); + ( + component as unknown as { + previousEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).previousEpisodeRequested.subscribe(() => events.push('previous')); + ( + component as unknown as { + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).nextEpisodeRequested.subscribe(() => events.push('next')); + + fixture.detectChanges(); + + const previousButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ); + const nextButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ); + expect(previousButton).not.toBeNull(); + expect(previousButton.nativeElement.disabled).toBe(false); + expect(nextButton).not.toBeNull(); + expect(nextButton.nativeElement.disabled).toBe(true); + + previousButton.nativeElement.click(); + nextButton.nativeElement.click(); + + expect(events).toEqual(['previous']); + }); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index f1819bd5f..f24183368 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -25,6 +25,8 @@ import { createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; +import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; +import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); @@ -35,6 +37,7 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); selector: 'app-html-video-player', templateUrl: './html-video-player.component.html', styleUrls: ['./html-video-player.component.scss'], + imports: [SeriesPlaybackNavigationControlsComponent], standalone: true, }) export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { @@ -42,11 +45,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { @Input() channel!: Channel; @Input() volume = 1; @Input() startTime = 0; + @Input() seriesNavigation: SeriesPlaybackNavigation | null = null; @Output() timeUpdate = new EventEmitter<{ currentTime: number; duration: number; }>(); @Output() playbackIssue = new EventEmitter(); + @Output() playbackEnded = new EventEmitter(); + @Output() previousEpisodeRequested = new EventEmitter(); + @Output() nextEpisodeRequested = new EventEmitter(); private readonly dataService = inject(DataService); @@ -96,6 +103,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { }); }; + private readonly handlePlaybackEnded = (): void => { + this.playbackEnded.emit(); + }; + ngOnInit() { this.videoPlayer.nativeElement.addEventListener( 'volumechange', @@ -124,6 +135,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { 'playing', this.clearPlaybackIssue ); + this.videoPlayer.nativeElement.addEventListener( + 'ended', + this.handlePlaybackEnded + ); } /** @@ -157,6 +172,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.mpegtsPlayer = null; } if (this.hls) this.hls.destroy(); + this.clearNativeVideoSources(this.videoPlayer.nativeElement); if (channel.url) { this.playbackIssue.emit(null); const url = channel.url + (channel.epgParams ?? ''); @@ -225,21 +241,32 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.handlePlayOperation(); } else { debugHtmlPlayer('Using native video player'); - this.addSourceToVideo( + this.replaceNativeVideoSource( this.videoPlayer.nativeElement, url, 'video/mp4' ); - this.videoPlayer.nativeElement.play(); + this.handlePlayOperation(); } } } - addSourceToVideo(element: HTMLVideoElement, url: string, type: string) { + private clearNativeVideoSources(element: HTMLVideoElement): void { + element.removeAttribute('src'); + element.replaceChildren(); + } + + private replaceNativeVideoSource( + element: HTMLVideoElement, + url: string, + type: string + ): void { + this.clearNativeVideoSources(element); const source = document.createElement('source'); source.src = url; source.type = type; element.appendChild(source); + element.load(); } /** @@ -366,6 +393,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { 'playing', this.clearPlaybackIssue ); + this.videoPlayer.nativeElement.removeEventListener( + 'ended', + this.handlePlaybackEnded + ); if (this.mpegtsPlayer) { this.mpegtsPlayer.pause(); this.mpegtsPlayer.unload(); diff --git a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss new file mode 100644 index 000000000..c8fd3ae5a --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss @@ -0,0 +1,59 @@ +:host { + position: absolute; + right: 50%; + bottom: clamp(54px, 8%, 76px); + z-index: 4; + display: block; + transform: translateX(50%); + pointer-events: none; +} + +.series-playback-navigation-controls { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px; + border: 1px solid var(--mat-sys-outline-variant, rgba(255, 255, 255, 0.16)); + border-radius: 999px; + background: color-mix( + in srgb, + var(--mat-sys-surface, #101010) 76%, + transparent + ); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(16px); + pointer-events: auto; +} + +.series-playback-navigation-controls__button { + --mdc-icon-button-state-layer-size: 40px; + + width: 40px; + height: 40px; + color: var(--mat-sys-on-surface, #ffffff); +} + +.series-playback-navigation-controls__button:disabled { + color: color-mix( + in srgb, + var(--mat-sys-on-surface, #ffffff) 34%, + transparent + ); +} + +.series-playback-navigation-controls__button mat-icon { + width: 22px; + height: 22px; + font-size: 22px; +} + +@media (max-width: 599px) { + :host { + bottom: 64px; + } + + .series-playback-navigation-controls { + gap: 4px; + padding: 4px; + } +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.ts b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.ts new file mode 100644 index 000000000..56c325bea --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.ts @@ -0,0 +1,81 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import type { SeriesPlaybackNavigation } from './series-playback-navigation'; + +@Component({ + selector: 'app-series-playback-navigation-controls', + imports: [MatButtonModule, MatIconModule, MatTooltipModule], + template: ` + @if (navigation()) { + + } + `, + styleUrl: './series-playback-navigation-controls.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SeriesPlaybackNavigationControlsComponent { + readonly navigation = input(null); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); + + readonly canPrevious = computed( + () => this.navigation()?.canPrevious === true + ); + readonly canNext = computed(() => this.navigation()?.canNext === true); + + requestPreviousEpisode(): void { + if (!this.canPrevious()) { + return; + } + + this.previousEpisodeRequested.emit(); + } + + requestNextEpisode(): void { + if (!this.canNext()) { + return; + } + + this.nextEpisodeRequested.emit(); + } +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html index abe0d6204..fd7707d0e 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html @@ -1,9 +1,17 @@ - +
+ + + +
diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.scss b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.scss index 99c83a210..8fde05522 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.scss +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.scss @@ -12,6 +12,14 @@ height: 100% !important; } +.vjs-player-shell { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + /* Hide the captions settings item from the captions menu. */ .hide-captions { .vjs-texttrack-settings, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index afbe6202a..3316b1db0 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -1,5 +1,6 @@ import { SimpleChange } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component'; const videoJsMock = jest.fn(); @@ -26,6 +27,7 @@ jest.unstable_mockModule('mpegts.js', () => ({ describe('VjsPlayerComponent', () => { let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent; + let fixture: ComponentFixture; let component: VjsPlayerComponentInstance; let player: VjsPlayerComponentInstance['player']; @@ -58,12 +60,13 @@ describe('VjsPlayerComponent', () => { load: jest.fn(), play: jest.fn(), }); + videoJsMock.mockReset(); await TestBed.configureTestingModule({ imports: [VjsPlayerComponent], }).compileComponents(); - component = - TestBed.createComponent(VjsPlayerComponent).componentInstance; + fixture = TestBed.createComponent(VjsPlayerComponent); + component = fixture.componentInstance; player = { error: jest.fn(), src: jest.fn(), @@ -74,6 +77,10 @@ describe('VjsPlayerComponent', () => { component.player = player; }); + afterEach(() => { + fixture.destroy(); + }); + it('uses signal-based inputs and outputs', () => { const signalComponent = component as unknown as SignalApiShape; @@ -328,8 +335,153 @@ describe('VjsPlayerComponent', () => { expect(testComponent.player.duration).toHaveBeenCalledWith(164.072); }); + + it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => { + const events: string[] = []; + videoJsMock.mockReturnValue(createVideoJsPlayerMock()); + fixture.componentRef.setInput('options', { + sources: [ + { + src: 'https://example.com/series/s01e02.mp4', + type: 'video/mp4', + }, + ], + }); + ( + component as unknown as { + playbackEnded: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).playbackEnded.subscribe(() => events.push('ended')); + + fixture.detectChanges(); + fixture.nativeElement + .querySelector('video') + .dispatchEvent(new Event('ended')); + component.ngOnChanges({ + options: new SimpleChange( + { + sources: [ + { + src: 'https://example.com/series/s01e02.mp4', + type: 'video/mp4', + }, + ], + }, + { + sources: [ + { + src: 'https://example.com/series/s01e03.mp4', + type: 'video/mp4', + }, + ], + }, + false + ), + }); + fixture.destroy(); + + expect(events).toEqual(['ended']); + }); + + it('hides series navigation controls when series navigation is absent', () => { + videoJsMock.mockReturnValue(createVideoJsPlayerMock()); + fixture.componentRef.setInput('options', { + sources: [ + { + src: 'https://example.com/movie.mp4', + type: 'video/mp4', + }, + ], + }); + + fixture.detectChanges(); + + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ) + ).toBeNull(); + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ) + ).toBeNull(); + }); + + it('renders series navigation controls with boundary disabled state', () => { + const events: string[] = []; + videoJsMock.mockReturnValue(createVideoJsPlayerMock()); + fixture.componentRef.setInput('options', { + sources: [ + { + src: 'https://example.com/series/s01e01.mp4', + type: 'video/mp4', + }, + ], + }); + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: false, + canNext: true, + autoplayEnabled: true, + }); + ( + component as unknown as { + previousEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).previousEpisodeRequested.subscribe(() => events.push('previous')); + ( + component as unknown as { + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).nextEpisodeRequested.subscribe(() => events.push('next')); + + fixture.detectChanges(); + + const previousButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ); + const nextButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ); + expect(previousButton).not.toBeNull(); + expect(previousButton.nativeElement.disabled).toBe(true); + expect(nextButton).not.toBeNull(); + expect(nextButton.nativeElement.disabled).toBe(false); + + previousButton.nativeElement.click(); + nextButton.nativeElement.click(); + + expect(events).toEqual(['next']); + }); }); +function createVideoJsPlayerMock(): VjsPlayerComponentInstance['player'] { + return { + audioTracks: jest.fn(() => null), + currentTime: jest.fn(() => 0), + duration: jest.fn(() => 0), + error: jest.fn(() => null), + getChild: jest.fn(() => null), + on: jest.fn(), + reset: jest.fn(), + src: jest.fn(), + tech: jest.fn(() => ({ el: () => null })), + volume: jest.fn(), + dispose: jest.fn(), + qualitySelectorHls: jest.fn(), + aspectRatioPanel: jest.fn(), + } as unknown as VjsPlayerComponentInstance['player']; +} + function createTimeRanges(ranges: Array<[number, number]>): TimeRanges { return { length: ranges.length, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index e5cacbb7b..c2c0ce799 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -24,6 +24,8 @@ import { createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; +import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; +import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; /** * This component contains the implementation of video player that is based on video.js library @@ -98,6 +100,7 @@ const debugVjsPlayer = createDevLogger('VjsPlayer'); templateUrl: './vjs-player.component.html', styleUrls: ['./vjs-player.component.scss'], encapsulation: ViewEncapsulation.None, + imports: [SeriesPlaybackNavigationControlsComponent], standalone: true, }) export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { @@ -119,15 +122,22 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { ] as const; readonly volume = input(1); readonly startTime = input(0); + readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number; }>(); readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); private readonly clearPlaybackIssue = () => { this.playbackIssue.emit(null); }; + private readonly handlePlaybackEnded = () => { + this.playbackEnded.emit(); + }; private readonly scheduleMpegTsVodDurationSync = () => { this.syncMpegTsVodDuration(); this.queueDurationSync(() => this.syncMpegTsVodDuration()); @@ -142,6 +152,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { const targetVideo = this.target().nativeElement as HTMLVideoElement; targetVideo.addEventListener('loadeddata', this.clearPlaybackIssue); targetVideo.addEventListener('playing', this.clearPlaybackIssue); + targetVideo.addEventListener('ended', this.handlePlaybackEnded); // For raw MPEG-TS streams, init Video.js without a source (UI/controls only) const vjsOptions = isMpegTs @@ -303,6 +314,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.clearPlaybackIssue ); targetVideo.removeEventListener('playing', this.clearPlaybackIssue); + targetVideo.removeEventListener('ended', this.handlePlaybackEnded); } catch { // Required viewChild can be unavailable when a shallow unit test destroys an unrendered component. } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index cc4c21ce5..c0d97fd3a 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -4,8 +4,12 @@ [options]="vjsOptions" [volume]="volume()" [startTime]="startTime()" + [seriesNavigation]="seriesNavigation()" (timeUpdate)="timeUpdate.emit($event)" (playbackIssue)="handlePlaybackIssue($event)" + (playbackEnded)="playbackEnded.emit()" + (previousEpisodeRequested)="previousEpisodeRequested.emit()" + (nextEpisodeRequested)="nextEpisodeRequested.emit()" /> } @placeholder { @@ -17,8 +21,12 @@ [volume]="volume()" [showCaptions]="showCaptions()" [startTime]="startTime()" + [seriesNavigation]="seriesNavigation()" (timeUpdate)="timeUpdate.emit($event)" (playbackIssue)="handlePlaybackIssue($event)" + (playbackEnded)="playbackEnded.emit()" + (previousEpisodeRequested)="previousEpisodeRequested.emit()" + (nextEpisodeRequested)="nextEpisodeRequested.emit()" /> } @placeholder { @@ -30,8 +38,12 @@ [volume]="volume()" [showCaptions]="showCaptions()" [startTime]="startTime()" + [seriesNavigation]="seriesNavigation()" (timeUpdate)="timeUpdate.emit($event)" (playbackIssue)="handlePlaybackIssue($event)" + (playbackEnded)="playbackEnded.emit()" + (previousEpisodeRequested)="previousEpisodeRequested.emit()" + (nextEpisodeRequested)="nextEpisodeRequested.emit()" /> } @placeholder { @@ -52,8 +64,12 @@ [options]="vjsOptions" [volume]="volume()" [startTime]="startTime()" + [seriesNavigation]="seriesNavigation()" (timeUpdate)="timeUpdate.emit($event)" (playbackIssue)="handlePlaybackIssue($event)" + (playbackEnded)="playbackEnded.emit()" + (previousEpisodeRequested)="previousEpisodeRequested.emit()" + (nextEpisodeRequested)="nextEpisodeRequested.emit()" /> } @placeholder { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 9d07c80f0..cbd8638c9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -37,8 +37,12 @@ class StubVjsPlayerComponent { readonly options = input(); readonly volume = input(1); readonly startTime = input(0); + readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); } @Component({ @@ -50,8 +54,12 @@ class StubHtmlVideoPlayerComponent { readonly volume = input(1); readonly showCaptions = input(false); readonly startTime = input(0); + readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); } @Component({ @@ -63,8 +71,12 @@ class StubArtPlayerComponent { readonly volume = input(1); readonly showCaptions = input(false); readonly startTime = input(0); + readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); } @Component({ @@ -347,7 +359,10 @@ describe('WebPlayerViewComponent', () => { it('suppresses browser diagnostics while embedded MPV is selected', () => { const requests: unknown[] = []; runtimeCapabilities.supportsManagedExternalPlayers = true; - fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv); + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.EmbeddedMpv + ); component.externalFallbackRequested.subscribe((request) => requests.push(request) ); @@ -429,6 +444,61 @@ describe('WebPlayerViewComponent', () => { expect(events).toEqual(['ended', 'previous', 'next']); }); + it.each([ + { + player: VideoPlayer.VideoJs, + directive: StubVjsPlayerComponent, + }, + { + player: VideoPlayer.Html5Player, + directive: StubHtmlVideoPlayerComponent, + }, + { + player: VideoPlayer.ArtPlayer, + directive: StubArtPlayerComponent, + }, + ])( + 'passes series navigation to $player and forwards episode navigation events', + async ({ player: selectedPlayer, directive }) => { + const events: string[] = []; + const seriesNavigation = { + canPrevious: true, + canNext: false, + autoplayEnabled: true, + }; + fixture.componentRef.setInput('playerOverride', selectedPlayer); + fixture.componentRef.setInput('seriesNavigation', seriesNavigation); + component.playbackEnded.subscribe(() => events.push('ended')); + component.previousEpisodeRequested.subscribe(() => + events.push('previous') + ); + component.nextEpisodeRequested.subscribe(() => events.push('next')); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const playerElement = fixture.debugElement.query( + By.directive(directive) + ); + expect(playerElement).not.toBeNull(); + + const player = playerElement.componentInstance as { + seriesNavigation: () => unknown; + playbackEnded: { emit: () => void }; + previousEpisodeRequested: { emit: () => void }; + nextEpisodeRequested: { emit: () => void }; + }; + expect(player.seriesNavigation()).toBe(seriesNavigation); + + player.playbackEnded.emit(); + player.previousEpisodeRequested.emit(); + player.nextEpisodeRequested.emit(); + + expect(events).toEqual(['ended', 'previous', 'next']); + } + ); + it('uses the PWA browser access diagnostic description key outside desktop', () => { const issue = createBrowserAccessDiagnostic();