mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -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
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "فشل بدء التسجيل.",
|
||||
"RECORDING_FAILED_TO_STOP": "فشل إيقاف التسجيل.",
|
||||
"SAVED_TO": "تم الحفظ في {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "تشغيل صورة داخل صورة",
|
||||
"EXIT_PICTURE_IN_PICTURE": "إنهاء صورة داخل صورة",
|
||||
"ENTER_FULLSCREEN": "ملء الشاشة",
|
||||
"EXIT_FULLSCREEN": "الخروج من ملء الشاشة"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "التسجيل ما قدرش يبدا.",
|
||||
"RECORDING_FAILED_TO_STOP": "التسجيل ما قدرش يتوقف.",
|
||||
"SAVED_TO": "تحفظ في {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "شغّل صورة داخل صورة",
|
||||
"EXIT_PICTURE_IN_PICTURE": "خرج من صورة داخل صورة",
|
||||
"ENTER_FULLSCREEN": "دخل لملء الشاشة",
|
||||
"EXIT_FULLSCREEN": "خرج من ملء الشاشة"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Не ўдалося пачаць запіс.",
|
||||
"RECORDING_FAILED_TO_STOP": "Не ўдалося спыніць запіс.",
|
||||
"SAVED_TO": "Захавана ў {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Уключыць рэжым «выява ў выяве»",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Выйсці з рэжыму «выява ў выяве»",
|
||||
"ENTER_FULLSCREEN": "Увайсці ў поўнаэкранны рэжым",
|
||||
"EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Aufnahme konnte nicht gestartet werden.",
|
||||
"RECORDING_FAILED_TO_STOP": "Aufnahme konnte nicht gestoppt werden.",
|
||||
"SAVED_TO": "Gespeichert unter {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Bild-in-Bild starten",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Bild-in-Bild beenden",
|
||||
"ENTER_FULLSCREEN": "Vollbild aktivieren",
|
||||
"EXIT_FULLSCREEN": "Vollbild beenden"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Η έναρξη της εγγραφής απέτυχε.",
|
||||
"RECORDING_FAILED_TO_STOP": "Η διακοπή της εγγραφής απέτυχε.",
|
||||
"SAVED_TO": "Αποθηκεύτηκε στο {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Έναρξη εικόνας εντός εικόνας",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Έξοδος από εικόνα εντός εικόνας",
|
||||
"ENTER_FULLSCREEN": "Μετάβαση σε πλήρη οθόνη",
|
||||
"EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Recording failed to start.",
|
||||
"RECORDING_FAILED_TO_STOP": "Recording failed to stop.",
|
||||
"SAVED_TO": "Saved to {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Enter picture-in-picture",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Exit picture-in-picture",
|
||||
"ENTER_FULLSCREEN": "Enter fullscreen",
|
||||
"EXIT_FULLSCREEN": "Exit fullscreen"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "No se pudo iniciar la grabación.",
|
||||
"RECORDING_FAILED_TO_STOP": "No se pudo detener la grabación.",
|
||||
"SAVED_TO": "Guardada en {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Activar imagen en imagen",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Salir de imagen en imagen",
|
||||
"ENTER_FULLSCREEN": "Entrar en pantalla completa",
|
||||
"EXIT_FULLSCREEN": "Salir de pantalla completa"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "L'enregistrement n'a pas pu démarrer.",
|
||||
"RECORDING_FAILED_TO_STOP": "L'enregistrement n'a pas pu s'arrêter.",
|
||||
"SAVED_TO": "Enregistré dans {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Activer le mode image dans l’image",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Quitter le mode image dans l’image",
|
||||
"ENTER_FULLSCREEN": "Passer en plein écran",
|
||||
"EXIT_FULLSCREEN": "Quitter le plein écran"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Impossibile avviare la registrazione.",
|
||||
"RECORDING_FAILED_TO_STOP": "Impossibile interrompere la registrazione.",
|
||||
"SAVED_TO": "Salvato in {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Attiva Picture-in-Picture",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Esci da Picture-in-Picture",
|
||||
"ENTER_FULLSCREEN": "Attiva schermo intero",
|
||||
"EXIT_FULLSCREEN": "Esci dallo schermo intero"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "録画を開始できませんでした。",
|
||||
"RECORDING_FAILED_TO_STOP": "録画を停止できませんでした。",
|
||||
"SAVED_TO": "{{path}}に保存しました",
|
||||
"ENTER_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを開始",
|
||||
"EXIT_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを終了",
|
||||
"ENTER_FULLSCREEN": "全画面表示",
|
||||
"EXIT_FULLSCREEN": "全画面表示を終了"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "녹화를 시작하지 못했습니다.",
|
||||
"RECORDING_FAILED_TO_STOP": "녹화를 중지하지 못했습니다.",
|
||||
"SAVED_TO": "{{path}}에 저장되었습니다",
|
||||
"ENTER_PICTURE_IN_PICTURE": "PIP 모드 시작",
|
||||
"EXIT_PICTURE_IN_PICTURE": "PIP 모드 종료",
|
||||
"ENTER_FULLSCREEN": "전체 화면",
|
||||
"EXIT_FULLSCREEN": "전체 화면 종료"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Opname kon niet worden gestart.",
|
||||
"RECORDING_FAILED_TO_STOP": "Opname kon niet worden gestopt.",
|
||||
"SAVED_TO": "Opgeslagen in {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Beeld-in-beeld starten",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Beeld-in-beeld sluiten",
|
||||
"ENTER_FULLSCREEN": "Volledig scherm openen",
|
||||
"EXIT_FULLSCREEN": "Volledig scherm sluiten"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Nie udało się rozpocząć nagrywania.",
|
||||
"RECORDING_FAILED_TO_STOP": "Nie udało się zatrzymać nagrywania.",
|
||||
"SAVED_TO": "Zapisano w {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Włącz obraz w obrazie",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Wyłącz obraz w obrazie",
|
||||
"ENTER_FULLSCREEN": "Włącz pełny ekran",
|
||||
"EXIT_FULLSCREEN": "Wyłącz pełny ekran"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Falha ao iniciar a gravação.",
|
||||
"RECORDING_FAILED_TO_STOP": "Falha ao parar a gravação.",
|
||||
"SAVED_TO": "Salvo em {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Ativar imagem em imagem",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Sair de imagem em imagem",
|
||||
"ENTER_FULLSCREEN": "Entrar em tela cheia",
|
||||
"EXIT_FULLSCREEN": "Sair da tela cheia"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Не удалось начать запись.",
|
||||
"RECORDING_FAILED_TO_STOP": "Не удалось остановить запись.",
|
||||
"SAVED_TO": "Сохранено в {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Включить режим «картинка в картинке»",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Выйти из режима «картинка в картинке»",
|
||||
"ENTER_FULLSCREEN": "Во весь экран",
|
||||
"EXIT_FULLSCREEN": "Выйти из полноэкранного режима"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "Kayıt başlatılamadı.",
|
||||
"RECORDING_FAILED_TO_STOP": "Kayıt durdurulamadı.",
|
||||
"SAVED_TO": "{{path}} konumuna kaydedildi",
|
||||
"ENTER_PICTURE_IN_PICTURE": "Resim içinde resim modunu aç",
|
||||
"EXIT_PICTURE_IN_PICTURE": "Resim içinde resim modundan çık",
|
||||
"ENTER_FULLSCREEN": "Tam ekrana geç",
|
||||
"EXIT_FULLSCREEN": "Tam ekrandan çık"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "录制启动失败。",
|
||||
"RECORDING_FAILED_TO_STOP": "录制停止失败。",
|
||||
"SAVED_TO": "已保存至 {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "进入画中画",
|
||||
"EXIT_PICTURE_IN_PICTURE": "退出画中画",
|
||||
"ENTER_FULLSCREEN": "进入全屏",
|
||||
"EXIT_FULLSCREEN": "退出全屏"
|
||||
}
|
||||
|
||||
@@ -489,6 +489,8 @@
|
||||
"RECORDING_FAILED_TO_START": "無法開始錄製。",
|
||||
"RECORDING_FAILED_TO_STOP": "無法停止錄製。",
|
||||
"SAVED_TO": "已儲存至 {{path}}",
|
||||
"ENTER_PICTURE_IN_PICTURE": "進入子母畫面",
|
||||
"EXIT_PICTURE_IN_PICTURE": "退出子母畫面",
|
||||
"ENTER_FULLSCREEN": "進入全螢幕",
|
||||
"EXIT_FULLSCREEN": "離開全螢幕"
|
||||
}
|
||||
|
||||
@@ -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