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:
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Videos under enhanced/maximum image protection (#1370).
|
||||
*
|
||||
* Both halves of the video path used to be routed through /api/secure-images
|
||||
* once an event left `standard` protection, and neither half could carry a
|
||||
* video:
|
||||
*
|
||||
* 1. galleryQueryService emitted `/api/secure-images/{slug}/secure/{id}/{{token}}`
|
||||
* as the video's `url`. The lightbox drops that straight into a <video>
|
||||
* element, nothing substitutes `{{token}}` (the helper that could is
|
||||
* unreferenced), and the route answers 403 "Invalid or expired token".
|
||||
* 2. Even with a valid token it would still fail: the secure-images route
|
||||
* pipes every byte through secureImageService.processProtectedImage,
|
||||
* which calls sharp() and throws on an mp4 → 404.
|
||||
*
|
||||
* The guest saw a poster frozen at 0:00 with no error of any kind.
|
||||
*
|
||||
* Videos now keep the JWT route at every protection level. That is not a new
|
||||
* exposure — thumbnails of those same videos have always been served from it —
|
||||
* so these tests also pin the inverse: still images must keep bouncing to the
|
||||
* secure endpoint. Every assertion here fails on the unfixed code except the
|
||||
* two guarding images.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-video-urls-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'video-urls-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-video-urls-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'protected-video-gallery';
|
||||
const VIDEO_BYTES = Buffer.from('not really an mp4, but the route only streams bytes');
|
||||
|
||||
describe('videos stay playable under enhanced/maximum protection (#1370)', () => {
|
||||
let db; let cleanup; let app; let eventId; let videoId; let imageId;
|
||||
|
||||
async function setProtection(level) {
|
||||
await db('events').where('id', eventId).update({ protection_level: level });
|
||||
}
|
||||
|
||||
async function photoPayload(id) {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
|
||||
expect(res.status).toBe(200);
|
||||
const photo = res.body.photos.find((p) => p.id === id);
|
||||
expect(photo).toBeDefined();
|
||||
return photo;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Protected Video',
|
||||
event_date: '2026-09-01',
|
||||
host_email: '[email protected]',
|
||||
admin_email: '[email protected]',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/s`,
|
||||
share_token: 'protected-video-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
// Password-free so verifyGalleryAccess takes the public path, same as
|
||||
// the sibling gallery suites.
|
||||
require_password: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
const mediaDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG, 'individual');
|
||||
fs.mkdirSync(mediaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(mediaDir, 'clip.mp4'), VIDEO_BYTES);
|
||||
fs.writeFileSync(path.join(mediaDir, 'still.jpg'), Buffer.from('jpeg-ish'));
|
||||
|
||||
const vid = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: `${SLUG}/individual/clip.mp4`,
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
duration: 43,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoId = vid[0]?.id ?? vid[0];
|
||||
|
||||
const img = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'still.jpg',
|
||||
path: `${SLUG}/individual/still.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
imageId = img[0]?.id ?? img[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe.each(['enhanced', 'maximum'])('protection_level = %s', (level) => {
|
||||
beforeAll(async () => { await setProtection(level); });
|
||||
|
||||
test('the video url is the JWT route, not a {{token}} template', async () => {
|
||||
const photo = await photoPayload(videoId);
|
||||
expect(photo.url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect(photo.url).not.toContain('{{token}}');
|
||||
expect(photo.requires_token).toBe(false);
|
||||
});
|
||||
|
||||
test('the video streams instead of bouncing to the secure endpoint', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(Buffer.from(res.body)).toEqual(VIDEO_BYTES);
|
||||
});
|
||||
|
||||
test('range requests still work, so seeking is possible', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photo/${videoId}`)
|
||||
.set('Range', 'bytes=0-9');
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers['content-range']).toBe(`bytes 0-9/${VIDEO_BYTES.length}`);
|
||||
});
|
||||
|
||||
test('still images keep bouncing to the secure endpoint', async () => {
|
||||
const photo = await photoPayload(imageId);
|
||||
expect(photo.url).toBe(`/api/secure-images/${SLUG}/secure/${imageId}/{{token}}`);
|
||||
expect(photo.requires_token).toBe(true);
|
||||
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${imageId}`);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.body.error).toBe('Secure access required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('protection_level = standard', () => {
|
||||
beforeAll(async () => { await setProtection('standard'); });
|
||||
|
||||
test('both media types take the JWT route, as before', async () => {
|
||||
expect((await photoPayload(videoId)).url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect((await photoPayload(imageId)).url).toBe(`/api/gallery/${SLUG}/photo/${imageId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,14 @@ router.get('/:slug/photo/:photoId',
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// Videos are exempt (#1370). The secure-images endpoint this bounces to
|
||||
// pipes every byte through sharp (secureImageService.processProtectedImage),
|
||||
// which throws on an mp4 — so under enhanced/maximum a video was
|
||||
// unservable by either route, and the lightbox showed a poster stuck at
|
||||
// 0:00. Serving it here instead is not a new exposure: thumbnails of the
|
||||
// same videos already come from this route at every protection level, and
|
||||
// the guest still needs a valid gallery token to get here at all.
|
||||
if (!isVideo && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
|
||||
@@ -459,7 +459,17 @@ async function getGalleryPhotos({ event, query = {}, identity, accessLevel, admi
|
||||
reveal_at: hiddenForGuest ? (event.reveal_at || null) : undefined,
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
// Videos always take the JWT route (#1370). The secure-images template
|
||||
// below can never serve one — the route runs the bytes through sharp,
|
||||
// which throws on an mp4 — and nothing substitutes the {{token}}
|
||||
// placeholder for the <video> element either, so under enhanced/maximum
|
||||
// a video resolved to a 403 and the lightbox sat at 0:00. The matching
|
||||
// exemption is in routes/gallery/media.js.
|
||||
const isVideo = photo.media_type === 'video'
|
||||
|| (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
const useJwtUrl = isVideo
|
||||
|| protectionSettings.protection_level === 'basic'
|
||||
|| protectionSettings.protection_level === 'standard';
|
||||
// Watermark version (cache-busting) + admin-preview flag (#868). In
|
||||
// preview mode no gallery cookie is minted, so each <img> request must
|
||||
// re-assert the admin session — thread the flag onto every /api/gallery
|
||||
|
||||
@@ -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