mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
feat(playback): expose shared picture-in-picture action
This commit is contained in:
1 parent
8373f0381a
commit
f5a97d36ff
21 files changed
+235
-6
No files matched your search
@@ -568,6 +568,36 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (capabilities().pictureInPicture) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-controls__button"
|
||||
[disabled]="!state().canPictureInPicture"
|
||||
(click)="togglePictureInPicture()"
|
||||
[attr.aria-pressed]="state().pictureInPictureActive"
|
||||
[attr.aria-label]="
|
||||
(state().pictureInPictureActive
|
||||
? 'EMBEDDED_MPV.PLAYER.EXIT_PICTURE_IN_PICTURE'
|
||||
: 'EMBEDDED_MPV.PLAYER.ENTER_PICTURE_IN_PICTURE'
|
||||
) | translate
|
||||
"
|
||||
[matTooltip]="
|
||||
(state().pictureInPictureActive
|
||||
? 'EMBEDDED_MPV.PLAYER.EXIT_PICTURE_IN_PICTURE'
|
||||
: 'EMBEDDED_MPV.PLAYER.ENTER_PICTURE_IN_PICTURE'
|
||||
) | translate
|
||||
"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>{{
|
||||
state().pictureInPictureActive
|
||||
? 'picture_in_picture_alt'
|
||||
: 'picture_in_picture'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (capabilities().fullscreen) {
|
||||
<button
|
||||
mat-icon-button
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { signal, type WritableSignal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
DEFAULT_PLAYER_CAPABILITIES,
|
||||
createEmptyControlsState,
|
||||
} from './player-controls-defaults';
|
||||
import { PlayerControlsComponent } from './player-controls.component';
|
||||
import type {
|
||||
PlayerController,
|
||||
PlayerControlsCapabilities,
|
||||
PlayerControlsCommands,
|
||||
PlayerControlsState,
|
||||
} from './player-controls.model';
|
||||
|
||||
function createFakeController() {
|
||||
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
});
|
||||
const state: WritableSignal<PlayerControlsState> = signal(
|
||||
createEmptyControlsState()
|
||||
);
|
||||
const commands: jest.Mocked<PlayerControlsCommands> = {
|
||||
togglePlay: jest.fn(),
|
||||
seekTo: jest.fn(),
|
||||
seekBy: jest.fn(),
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
const controller: PlayerController = { capabilities, state, commands };
|
||||
return { controller, capabilities, state, commands };
|
||||
}
|
||||
|
||||
describe('PlayerControlsComponent picture-in-picture action', () => {
|
||||
let fixture: ComponentFixture<PlayerControlsComponent>;
|
||||
let component: PlayerControlsComponent;
|
||||
let fake: ReturnType<typeof createFakeController>;
|
||||
|
||||
const setCapabilities = (
|
||||
overrides: Partial<PlayerControlsCapabilities>
|
||||
) => {
|
||||
fake.capabilities.set({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
const setState = (overrides: Partial<PlayerControlsState>) => {
|
||||
fake.state.set({ ...createEmptyControlsState(), ...overrides });
|
||||
};
|
||||
|
||||
const queryButton = (label: string) =>
|
||||
fixture.nativeElement.querySelector(
|
||||
`[aria-label="${label}"]`
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: {
|
||||
ENTER_PICTURE_IN_PICTURE: 'Enter picture-in-picture',
|
||||
EXIT_PICTURE_IN_PICTURE: 'Exit picture-in-picture',
|
||||
ENTER_FULLSCREEN: 'Enter fullscreen',
|
||||
},
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
|
||||
fake = createFakeController();
|
||||
fixture = TestBed.createComponent(PlayerControlsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('controller', fake.controller);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('omits the action when picture-in-picture is not supported', () => {
|
||||
expect(queryButton('Enter picture-in-picture')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a disabled inactive action when picture-in-picture is unavailable', () => {
|
||||
setCapabilities({ pictureInPicture: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = queryButton('Enter picture-in-picture');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(button?.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('renders the active state with its exit label and alternate icon', () => {
|
||||
setCapabilities({ pictureInPicture: true });
|
||||
setState({
|
||||
canPictureInPicture: true,
|
||||
pictureInPictureActive: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = queryButton('Exit picture-in-picture');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.disabled).toBe(false);
|
||||
expect(button?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(button?.querySelector('mat-icon')?.textContent?.trim()).toBe(
|
||||
'picture_in_picture_alt'
|
||||
);
|
||||
button?.click();
|
||||
expect(fake.commands.togglePictureInPicture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dispatches one toggle command from an available inactive action', () => {
|
||||
setCapabilities({ pictureInPicture: true });
|
||||
setState({ canPictureInPicture: true });
|
||||
fixture.detectChanges();
|
||||
const reveal = jest.spyOn(component, 'reveal');
|
||||
|
||||
queryButton('Enter picture-in-picture')?.click();
|
||||
|
||||
expect(reveal).toHaveBeenCalledTimes(1);
|
||||
expect(fake.commands.togglePictureInPicture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('guards the component command when the capability is absent', () => {
|
||||
setState({ canPictureInPicture: true });
|
||||
const reveal = jest.spyOn(component, 'reveal');
|
||||
|
||||
component.togglePictureInPicture();
|
||||
|
||||
expect(reveal).toHaveBeenCalledTimes(1);
|
||||
expect(fake.commands.togglePictureInPicture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('guards the component command when the action is unavailable', () => {
|
||||
setCapabilities({ pictureInPicture: true });
|
||||
const reveal = jest.spyOn(component, 'reveal');
|
||||
|
||||
component.togglePictureInPicture();
|
||||
|
||||
expect(reveal).toHaveBeenCalledTimes(1);
|
||||
expect(fake.commands.togglePictureInPicture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('places picture-in-picture immediately before fullscreen', () => {
|
||||
setCapabilities({ pictureInPicture: true, fullscreen: true });
|
||||
setState({ canPictureInPicture: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
const pictureInPicture = queryButton('Enter picture-in-picture');
|
||||
const fullscreen = queryButton('Enter fullscreen');
|
||||
expect(pictureInPicture).not.toBeNull();
|
||||
expect(fullscreen).not.toBeNull();
|
||||
expect(pictureInPicture?.nextElementSibling).toBe(fullscreen);
|
||||
});
|
||||
});
|
||||
@@ -283,11 +283,9 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
event.preventDefault();
|
||||
this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05);
|
||||
}
|
||||
|
||||
onVolumeHoverEnter(): void {
|
||||
this.volume.hoverEnter();
|
||||
}
|
||||
|
||||
onVolumeHoverLeave(): void {
|
||||
this.volume.hoverLeave();
|
||||
}
|
||||
@@ -304,7 +302,6 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
this.menus.toggle(menu);
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
toggleRecording(): void {
|
||||
if (!this.canRecord()) {
|
||||
return;
|
||||
@@ -313,6 +310,13 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
this.controller().commands.toggleRecording();
|
||||
}
|
||||
|
||||
togglePictureInPicture(): void {
|
||||
this.reveal();
|
||||
const state = this.state();
|
||||
if (this.capabilities().pictureInPicture && state.canPictureInPicture) {
|
||||
this.controller().commands.togglePictureInPicture();
|
||||
}
|
||||
}
|
||||
async toggleFullscreen(): Promise<void> {
|
||||
this.reveal();
|
||||
if (!this.canFullscreen()) {
|
||||
@@ -320,7 +324,6 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
}
|
||||
await this.fullscreen.toggle();
|
||||
}
|
||||
|
||||
private adjustVolume(delta: number): void {
|
||||
if (!this.capabilities().volume) {
|
||||
return;
|
||||
@@ -328,13 +331,11 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
this.volume.adjust(delta);
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
private readTimelineValue(event: Event): number | null {
|
||||
return this.normalizeTimelineValue(
|
||||
Number((event.target as HTMLInputElement).value)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeTimelineValue(value: number): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user