feat: fullscreen video button not working in pwa mode
Task #52 completed by ClaudeQueue ClaudeQueue
This commit is contained in:
@@ -2420,8 +2420,44 @@ document.querySelectorAll('.chip').forEach((c) => {
|
||||
els.repeatBtn.addEventListener('click', toggleRepeat);
|
||||
els.fsBtn.addEventListener('click', () => {
|
||||
const stage = els.video.parentElement;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else stage.requestFullscreen?.();
|
||||
const video = els.video;
|
||||
// iOS Safari — including an installed PWA running in standalone mode —
|
||||
// does not implement the standard Fullscreen API for arbitrary elements.
|
||||
// stage.requestFullscreen is simply undefined there, so the button did
|
||||
// nothing (this is the iPhone-in-portrait-PWA bug report). WebKit instead
|
||||
// exposes a video-only, non-standard fullscreen API that *does* work in
|
||||
// standalone mode: HTMLVideoElement.webkitEnterFullscreen/ExitFullscreen.
|
||||
// Try the standard API first everywhere else, then fall back to the
|
||||
// WebKit video API before giving up.
|
||||
if (document.fullscreenElement || video.webkitDisplayingFullscreen) {
|
||||
try {
|
||||
if (document.exitFullscreen) document.exitFullscreen()?.catch(() => {});
|
||||
else if (video.webkitExitFullscreen) video.webkitExitFullscreen();
|
||||
} catch { /* e.g. InvalidStateError — nothing more we can do */ }
|
||||
return;
|
||||
}
|
||||
if (stage.requestFullscreen) {
|
||||
// requestFullscreen() can both throw synchronously (e.g.
|
||||
// InvalidStateError when preconditions like active user-gesture
|
||||
// transient activation aren't met) and return a promise that rejects
|
||||
// asynchronously. Guard against both instead of letting either surface
|
||||
// as an uncaught error.
|
||||
try {
|
||||
stage.requestFullscreen()?.catch(() => {});
|
||||
} catch { /* no-op — fullscreen simply won't engage this time */ }
|
||||
} else if (video.webkitEnterFullscreen) {
|
||||
if (Player.mode === 'audio') {
|
||||
toast('Fullscreen isn’t available in audio-only mode');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Throws InvalidStateError if the element has no loaded media (e.g.
|
||||
// no video source yet) — nothing to show fullscreen in that case.
|
||||
video.webkitEnterFullscreen();
|
||||
} catch { /* no-op — no media loaded to go fullscreen with */ }
|
||||
} else {
|
||||
toast('Fullscreen isn’t supported on this device');
|
||||
}
|
||||
});
|
||||
|
||||
els.seek.addEventListener('input', () => {
|
||||
|
||||
98
tests/fullscreen-ios.smoke.spec.js
Normal file
98
tests/fullscreen-ios.smoke.spec.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Smoke test for the iOS/PWA fullscreen-button fix (Task #52).
|
||||
*
|
||||
* Bug: on an iPhone running the installed PWA in portrait (standalone
|
||||
* display-mode), tapping the fullscreen button did nothing. Root cause: iOS
|
||||
* Safari does not implement the standard Fullscreen API
|
||||
* (Element.requestFullscreen) for arbitrary elements — even in a standalone
|
||||
* PWA — so `stage.requestFullscreen?.()` silently no-ops. WebKit instead
|
||||
* exposes a video-only, non-standard fallback,
|
||||
* HTMLVideoElement.webkitEnterFullscreen/webkitExitFullscreen, which does
|
||||
* work in standalone mode.
|
||||
*
|
||||
* Playwright/Chromium implements the standard Fullscreen API, so to exercise
|
||||
* the WebKit fallback branch we delete `requestFullscreen` from the stage
|
||||
* element (simulating iOS Safari) and stub `webkitEnterFullscreen` /
|
||||
* `webkitExitFullscreen` on the <video> element, then confirm the fallback
|
||||
* is invoked instead of the button doing nothing.
|
||||
*/
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Fullscreen button — iOS WebKit fallback', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.app', { state: 'attached' });
|
||||
});
|
||||
|
||||
test('falls back to webkitEnterFullscreen when requestFullscreen is unavailable (iOS Safari/PWA)', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const video = document.getElementById('video');
|
||||
const stage = video.parentElement;
|
||||
|
||||
// Simulate iOS Safari: no standard Fullscreen API on the stage element.
|
||||
delete stage.requestFullscreen;
|
||||
|
||||
let entered = false;
|
||||
video.webkitEnterFullscreen = () => { entered = true; };
|
||||
|
||||
// Make sure we're not in audio-only mode so the fallback proceeds.
|
||||
Player.mode = 'progressive';
|
||||
|
||||
document.getElementById('fsBtn').click();
|
||||
|
||||
return { entered };
|
||||
});
|
||||
expect(result.entered).toBe(true);
|
||||
});
|
||||
|
||||
test('exits via webkitExitFullscreen when webkitDisplayingFullscreen is true', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const video = document.getElementById('video');
|
||||
const stage = video.parentElement;
|
||||
|
||||
delete stage.requestFullscreen;
|
||||
// No document.exitFullscreen available either, mirroring iOS.
|
||||
Object.defineProperty(document, 'exitFullscreen', { value: undefined, configurable: true });
|
||||
Object.defineProperty(video, 'webkitDisplayingFullscreen', { value: true, configurable: true });
|
||||
|
||||
let exited = false;
|
||||
video.webkitExitFullscreen = () => { exited = true; };
|
||||
|
||||
document.getElementById('fsBtn').click();
|
||||
|
||||
return { exited };
|
||||
});
|
||||
expect(result.exited).toBe(true);
|
||||
});
|
||||
|
||||
test('shows a toast instead of a silent no-op in audio-only mode', async ({ page }) => {
|
||||
const toastText = await page.evaluate(() => {
|
||||
const video = document.getElementById('video');
|
||||
const stage = video.parentElement;
|
||||
|
||||
delete stage.requestFullscreen;
|
||||
video.webkitEnterFullscreen = () => {};
|
||||
Player.mode = 'audio';
|
||||
|
||||
document.getElementById('fsBtn').click();
|
||||
|
||||
const el = document.querySelector('.toast-container .toast, .toast');
|
||||
return el ? el.textContent : null;
|
||||
});
|
||||
expect(toastText).toContain('audio-only');
|
||||
});
|
||||
|
||||
test('no JS errors are thrown when the fullscreen button is clicked', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
|
||||
// The fsBtn is only visible once a video is loaded (.player-pane loses
|
||||
// its .empty state) — dispatch the click via JS as the other tests in
|
||||
// this file do, rather than requiring a real video load in this smoke
|
||||
// test.
|
||||
await page.evaluate(() => document.getElementById('fsBtn').click());
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user