fix(gallery): keep videos playable under enhanced and maximum protection (stable) (#1408)

Stable twin of the main-branch fix.

Once an event left `standard` protection, both halves of the video path were
routed through /api/secure-images, and neither half can carry a video: the
lightbox drops the emitted `{{token}}` template straight into a <video>
element and nothing substitutes the placeholder, while the secure-images route
pipes every byte through sharp, which throws on an mp4. The /photo/:photoId
route bounced to that same endpoint before reaching its own video branch —
isVideo was computed and then ignored — so there was no way through.

Videos now keep the JWT route at every protection level, on both sides. Not a
new exposure: thumbnails of those same videos have always been served from it,
and a valid gallery token is still required. Still images are unaffected and
keep bouncing to the secure endpoint.

VideoPlayer had no `error` listener, so all of this rendered as a poster
frozen at "0:00 / 0:00" behind a play button that did nothing —
indistinguishable from a codec the browser cannot decode, which is the other
common cause (HEVC/H.265 phone footage plays in Safari and nowhere else). It
now surfaces the failure and names the codec case.

The frontend half is identical to main; the backend half is hand-ported
because stable keeps these routes in the monolithic routes/gallery.js.

Relates to issue 1370

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-11 11:25:24 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent b869de33a5
commit 0732a160b5
12 changed files with 326 additions and 14 deletions
@@ -1,5 +1,6 @@
import React, { useRef, useState, useEffect } from 'react';
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize, AlertTriangle } from 'lucide-react';
interface VideoPlayerProps {
src: string;
@@ -24,8 +25,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
width = '100%',
height = 'auto'
}) => {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [isMuted, setIsMuted] = useState(muted);
const [isFullscreen, setIsFullscreen] = useState(false);
const [progress, setProgress] = useState(0);
@@ -51,11 +54,28 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handlePause = () => setIsPlaying(false);
const handleEnded = () => setIsPlaying(false);
// Without this the element just sits on its poster at 0:00 and says
// nothing (#1370) — a guest cannot tell a failed request from a codec
// their browser will not decode, and neither could we from their report.
// MEDIA_ERR_SRC_NOT_SUPPORTED is the one worth naming: it is what an
// HEVC/H.265 phone recording does everywhere except Safari, and the
// photographer's answer is to download the file rather than retry.
const handleError = () => {
const code = video.error?.code;
setLoadError(
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
? t('gallery.videoFormatUnsupported', 'This video format cannot be played in this browser. Download it to watch it.')
: t('gallery.videoLoadFailed', 'This video could not be loaded.')
);
setIsPlaying(false);
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
video.addEventListener('error', handleError);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
@@ -63,8 +83,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
video.removeEventListener('error', handleError);
};
}, []);
}, [t]);
// Arrowing to the next video in the lightbox reuses this element, so a
// stale error would otherwise stick to a clip that loads fine.
useEffect(() => {
setLoadError(null);
}, [src]);
const togglePlayPause = () => {
const video = videoRef.current;
@@ -161,7 +188,16 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onClick={togglePlayPause}
/>
{controls && (
{loadError && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 p-6 text-center">
<div className="flex flex-col items-center gap-2 text-white">
<AlertTriangle size={28} />
<span className="text-sm max-w-xs">{loadError}</span>
</div>
</div>
)}
{controls && !loadError && (
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
showControls ? 'opacity-100' : 'opacity-0'
@@ -214,7 +250,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
{/* Play button overlay when paused */}
{!isPlaying && showControls && (
{!isPlaying && showControls && !loadError && (
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={togglePlayPause}
@@ -0,0 +1,80 @@
/**
* A video that fails to load has to say so (#1370).
*
* The element carried no `error` listener, so every failure — an unplayable
* codec, a 403, a missing file — rendered identically: the poster frame, the
* transport stuck at "0:00 / 0:00", and a play button that did nothing. The
* reporter could not tell us which one they had hit, and neither could we.
*
* MEDIA_ERR_SRC_NOT_SUPPORTED gets its own wording because it is by far the
* most common cause in practice (HEVC/H.265 phone footage plays in Safari and
* nowhere else) and the useful advice for it — download the file — is not the
* advice for a transport failure.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { VideoPlayer } from '../VideoPlayer';
function failWith(video: HTMLVideoElement, code: number) {
Object.defineProperty(video, 'error', { value: { code }, configurable: true });
fireEvent.error(video);
}
describe('VideoPlayer load errors (#1370)', () => {
beforeAll(() => {
// jsdom implements HTMLMediaElement but not the MediaError constants.
if (typeof MediaError === 'undefined') {
(globalThis as unknown as { MediaError: unknown }).MediaError = {
MEDIA_ERR_ABORTED: 1,
MEDIA_ERR_NETWORK: 2,
MEDIA_ERR_DECODE: 3,
MEDIA_ERR_SRC_NOT_SUPPORTED: 4,
};
}
});
it('shows the transport controls while nothing has gone wrong', () => {
const { container } = render(<VideoPlayer src="/api/gallery/e/photo/1" />);
expect(container.querySelector('video')).toBeInTheDocument();
// Two: the transport button and the centre overlay.
expect(screen.getAllByLabelText('Play')).toHaveLength(2);
expect(screen.getByText('0:00 / 0:00')).toBeInTheDocument();
});
it('names the codec case and points at the download', () => {
const { container } = render(<VideoPlayer src="/api/gallery/e/photo/1" />);
failWith(container.querySelector('video')!, MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
expect(screen.getByText(/cannot be played in this browser/i)).toBeInTheDocument();
expect(screen.getByText(/Download it/i)).toBeInTheDocument();
});
it('reports a transport failure without blaming the format', () => {
const { container } = render(<VideoPlayer src="/api/gallery/e/photo/1" />);
failWith(container.querySelector('video')!, MediaError.MEDIA_ERR_NETWORK);
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
expect(screen.queryByText(/cannot be played in this browser/i)).not.toBeInTheDocument();
});
it('hides the 0:00 transport, which only ever misled', () => {
const { container } = render(<VideoPlayer src="/api/gallery/e/photo/1" />);
failWith(container.querySelector('video')!, MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
expect(screen.queryByText('0:00 / 0:00')).not.toBeInTheDocument();
// Including the centre overlay, which was the dead play button in the
// screenshots on the issue.
expect(screen.queryAllByLabelText('Play')).toHaveLength(0);
});
it('clears the error when the lightbox arrows to the next video', () => {
const { container, rerender } = render(<VideoPlayer src="/api/gallery/e/photo/1" />);
failWith(container.querySelector('video')!, MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
expect(screen.getByText(/cannot be played in this browser/i)).toBeInTheDocument();
rerender(<VideoPlayer src="/api/gallery/e/photo/2" />);
expect(screen.queryByText(/cannot be played in this browser/i)).not.toBeInTheDocument();
expect(screen.getByText('0:00 / 0:00')).toBeInTheDocument();
});
});
+3 -1
View File
@@ -931,7 +931,9 @@
},
"photosCount_one": "{{count}} Foto",
"photosCount_other": "{{count}} Fotos",
"poweredBy": "Bereitgestellt von PicPeak"
"poweredBy": "Bereitgestellt von PicPeak",
"videoFormatUnsupported": "Dieses Videoformat kann in diesem Browser nicht abgespielt werden. Laden Sie es herunter, um es anzusehen.",
"videoLoadFailed": "Dieses Video konnte nicht geladen werden."
},
"categories": {
"title": "Fotokategorien",
+3 -1
View File
@@ -472,7 +472,9 @@
"photosSelected_one": "{{count}} photo selected",
"photosSelected_other": "{{count}} photos selected",
"downloadSelected_one": "Download {{count}} photo",
"downloadSelected_other": "Download {{count}} photos"
"downloadSelected_other": "Download {{count}} photos",
"videoFormatUnsupported": "This video format cannot be played in this browser. Download it to watch it.",
"videoLoadFailed": "This video could not be loaded."
},
"categories": {
"title": "Photo Categories",
+3 -1
View File
@@ -313,7 +313,9 @@
"anonymous": "Anónimo"
},
"rated": "Valorado",
"commented": "Comentado"
"commented": "Comentado",
"videoFormatUnsupported": "Este formato de vídeo no se puede reproducir en este navegador. Descárgalo para verlo.",
"videoLoadFailed": "No se ha podido cargar este vídeo."
},
"categories": {
"title": "Categorías de fotos",
+3 -1
View File
@@ -334,7 +334,9 @@
"photosSelected_one": "{{count}} photo sélectionnée",
"photosSelected_other": "{{count}} photos sélectionnées",
"downloadSelected_one": "Télécharger {{count}} photo",
"downloadSelected_other": "Télécharger {{count}} photos"
"downloadSelected_other": "Télécharger {{count}} photos",
"videoFormatUnsupported": "Ce format vidéo ne peut pas être lu dans ce navigateur. Téléchargez-la pour la regarder.",
"videoLoadFailed": "Cette vidéo n'a pas pu être chargée."
},
"categories": {
"title": "Catégories de photos",
+3 -1
View File
@@ -334,7 +334,9 @@
},
"photosCount_one": "{{count}} foto",
"photosCount_other": "{{count}} foto's",
"poweredBy": "Mogelijk gemaakt door PicPeak"
"poweredBy": "Mogelijk gemaakt door PicPeak",
"videoFormatUnsupported": "Deze video-indeling kan niet in deze browser worden afgespeeld. Download de video om hem te bekijken.",
"videoLoadFailed": "Deze video kon niet worden geladen."
},
"categories": {
"title": "Fotocategorieen",
+3 -1
View File
@@ -342,7 +342,9 @@
"photosCount_many": "{{count}} fotos",
"photosCount_one": "{{count}} foto",
"photosCount_other": "{{count}} fotos",
"poweredBy": "Desenvolvido por PicPeak"
"poweredBy": "Desenvolvido por PicPeak",
"videoFormatUnsupported": "Este formato de vídeo não pode ser reproduzido neste navegador. Baixe o arquivo para assistir.",
"videoLoadFailed": "Não foi possível carregar este vídeo."
},
"categories": {
"title": "Categorias de Fotos",
+3 -1
View File
@@ -350,7 +350,9 @@
"photosCount_many": "{{count}} фото",
"photosCount_one": "{{count}} фото",
"photosCount_other": "{{count}} фото",
"poweredBy": "Работает на PicPeak"
"poweredBy": "Работает на PicPeak",
"videoFormatUnsupported": "Этот формат видео не воспроизводится в этом браузере. Скачайте файл, чтобы посмотреть его.",
"videoLoadFailed": "Не удалось загрузить это видео."
},
"categories": {
"title": "Категории фото",
+3 -1
View File
@@ -334,7 +334,9 @@
"photosSelected_one": "Izbrana {{count}} fotografija",
"photosSelected_other": "Izbranih {{count}} fotografij",
"downloadSelected_one": "Prenesi {{count}} fotografijo",
"downloadSelected_other": "Prenesi {{count}} fotografij"
"downloadSelected_other": "Prenesi {{count}} fotografij",
"videoFormatUnsupported": "Tega videoformata v tem brskalniku ni mogoče predvajati. Prenesite ga, da si ga ogledate.",
"videoLoadFailed": "Tega videa ni bilo mogoče naložiti."
},
"categories": {
"title": "Kategorije fotografij",