fix(gallery): keep videos playable under enhanced and maximum protection
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.
galleryQueryService emitted the secure template as a video's `url`. The
lightbox drops that straight into a <video> element; nothing substitutes the
`{{token}}` placeholder (the helper that could, secureToken.service.ts, has no
importers), so the request answered 403 "Invalid or expired token". Even with a
valid token it would still have failed — the secure-images route pipes every
byte through secureImageService.processProtectedImage, which calls sharp() and
throws on an mp4. routes/gallery/media.js bounced the JWT route to that same
endpoint before reaching its own video branch, so there was no way through.
Videos now keep the JWT route at every protection level, on both sides. That is
not a new exposure: thumbnails of those same videos have always been served
from it, and a valid gallery token is still required to reach it. 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, since the answer there is to download the file.
Relates to issue 1370
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1770,7 +1770,9 @@
|
||||
"folderPhotoCount_one": "{{count}} Foto",
|
||||
"folderPhotoCount_other": "{{count}} Fotos",
|
||||
"preparingProgress_one": "{{count}} Foto verpackt",
|
||||
"preparingProgress_other": "{{count}} Fotos verpackt"
|
||||
"preparingProgress_other": "{{count}} Fotos verpackt",
|
||||
"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",
|
||||
|
||||
@@ -1268,7 +1268,9 @@
|
||||
"folderPhotoCount_one": "{{count}} photos",
|
||||
"folderPhotoCount_other": "{{count}} photos",
|
||||
"preparingProgress_one": "{{count}} photos packaged",
|
||||
"preparingProgress_other": "{{count}} photos packaged"
|
||||
"preparingProgress_other": "{{count}} photos packaged",
|
||||
"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",
|
||||
|
||||
@@ -315,7 +315,9 @@
|
||||
"backToGallery": "Todas las fotos",
|
||||
"downloadFolder": "Descargar carpeta ({{count}})",
|
||||
"downloadFolderCapped": "Descargar las primeras {{limit}} de {{total}}",
|
||||
"downloadEverything": "Descargar todas las fotos"
|
||||
"downloadEverything": "Descargar todas las fotos",
|
||||
"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",
|
||||
|
||||
@@ -336,7 +336,9 @@
|
||||
"backToGallery": "Toutes les photos",
|
||||
"downloadFolder": "Télécharger le dossier ({{count}})",
|
||||
"downloadFolderCapped": "Télécharger les {{limit}} premières sur {{total}}",
|
||||
"downloadEverything": "Télécharger toutes les photos"
|
||||
"downloadEverything": "Télécharger toutes les 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",
|
||||
|
||||
@@ -336,7 +336,9 @@
|
||||
"backToGallery": "Alle foto's",
|
||||
"downloadFolder": "Map downloaden ({{count}})",
|
||||
"downloadFolderCapped": "Eerste {{limit}} van {{total}} downloaden",
|
||||
"downloadEverything": "Alle foto's downloaden"
|
||||
"downloadEverything": "Alle foto's downloaden",
|
||||
"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",
|
||||
|
||||
@@ -344,7 +344,9 @@
|
||||
"backToGallery": "Todas as fotos",
|
||||
"downloadFolder": "Baixar pasta ({{count}})",
|
||||
"downloadFolderCapped": "Baixar as primeiras {{limit}} de {{total}}",
|
||||
"downloadEverything": "Baixar todas as fotos"
|
||||
"downloadEverything": "Baixar todas as fotos",
|
||||
"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",
|
||||
|
||||
@@ -352,7 +352,9 @@
|
||||
"backToGallery": "Все фото",
|
||||
"downloadFolder": "Скачать папку ({{count}})",
|
||||
"downloadFolderCapped": "Скачать первые {{limit}} из {{total}}",
|
||||
"downloadEverything": "Скачать все фото"
|
||||
"downloadEverything": "Скачать все фото",
|
||||
"videoFormatUnsupported": "Этот формат видео не воспроизводится в этом браузере. Скачайте файл, чтобы посмотреть его.",
|
||||
"videoLoadFailed": "Не удалось загрузить это видео."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Категории фото",
|
||||
|
||||
@@ -336,7 +336,9 @@
|
||||
"backToGallery": "Vse fotografije",
|
||||
"downloadFolder": "Prenesi mapo ({{count}})",
|
||||
"downloadFolderCapped": "Prenesi prvih {{limit}} od {{total}}",
|
||||
"downloadEverything": "Prenesi vse fotografije"
|
||||
"downloadEverything": "Prenesi vse fotografije",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user