feat(playback): expose shared picture-in-picture action

This commit is contained in:
4gray committed 2026-07-17 20:03:45 +02:00
1 parent 8373f0381a
commit f5a97d36ff
21 files changed
+235 -6

No files matched your search

+2
View File
@@ -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": "الخروج من ملء الشاشة"
}
+2
View File
@@ -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": "خرج من ملء الشاشة"
}
+2
View File
@@ -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": "Выйсці з поўнаэкраннага рэжыму"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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": "Έξοδος από πλήρη οθόνη"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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": "全画面表示を終了"
}
+2
View File
@@ -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": "전체 화면 종료"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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": "Выйти из полноэкранного режима"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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": "退出全屏"
}
+2
View File
@@ -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
@@ -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;