Every video in the app was unplayable. Two independent defects, either one
sufficient on its own, and nothing in the suite covered either — no test
anywhere played media or asserted a `<video>` src.
1. The lightbox handed `<video>` a JPEG.
`pickMediaUrl` is mime-agnostic, and compression only ever produces a THUMBNAIL
for a video (one `ffmpeg -vframes 1` frame) — no preview, no display. So in the
DEFAULT saver mode the element's src resolved to `/api/v1/upload/{id}/thumbnail`,
served as `image/jpeg` with `nosniff` so the browser can't even sniff its way
out. Chromium reports DEMUXER_ERROR_COULD_NOT_OPEN.
Fixed in the lightbox rather than in `pickMediaUrl`: FeedListCard shares that
helper and legitimately wants the thumbnail for its `<img>` poster, so a central
mime branch would break the feed. This mirrors the rule the diashow already
applies ("videos play the original file directly"). Added `preload="none"` so
saver-mode guests on cellular still fetch nothing until they press play — there
is no smaller video derivative to offer them — plus `playsinline`, without which
iOS hijacks playback into fullscreen.
2. `stream_media_file` ignored Range entirely.
It took no request headers, so it could not see `Range`; it always returned 200
with the whole body and never sent Accept-Ranges or Content-Range. iOS Safari
opens every `<video>` with a `Range: bytes=0-1` probe and abandons the load
without a 206 — so video failed on the app's primary platform even in `original`
mode, where the src was already correct.
Adds single-range support (`bytes=N-`, `bytes=N-M`, `bytes=-S`) with 206 +
Content-Range, 416 + `bytes */len` past EOF, and Accept-Ranges advertised on
every response. Anything it won't handle — multi-range, non-bytes units, garbage
— falls back to a full 200, which RFC 9110 explicitly permits and which is safer
than guessing. All four media routes share the helper, so seeking works
uniformly.
`get_original` now serves `inline` instead of `attachment`. An attachment
disposition is hostile to a `<video>` element, and this route is the only source
of playable video bytes; it also matches what the UI promises, since the action
is labelled "Original anzeigen" — view, not download. `no-store` is deliberately
kept so a takedown still revokes access promptly; ranges work fine under it, the
client just re-fetches.
Tests: 11 unit tests pin the parser (the iOS `bytes=0-1` probe, inclusive ends,
suffix ranges, clamping past EOF, 416 vs 200, malformed fallbacks). A new
03-feed/video-playback spec asserts the src is the original and not the
thumbnail, that the browser accepts the bytes as media (readyState > 0, no
MediaError), that no video bytes are delivered before play, and that Range
returns the correct 206 slices and a 416 past EOF — verified on both Chromium
and WebKit.
The "not downloaded before play" test asserts no *delivered body* rather than no
request: WebKit opens a connection for a preload="none" video and immediately
aborts it (GET, no Range, status 0, nothing transferred) while Chromium issues
nothing at all. The portable guarantee is that no response carrying bytes
completes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
338 lines
11 KiB
Svelte
338 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import type { FeedUpload } from '$lib/types';
|
|
import { api } from '$lib/api';
|
|
import { onSseEvent } from '$lib/sse';
|
|
import { getUserId } from '$lib/auth';
|
|
import { isStaff } from '$lib/role-store';
|
|
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
|
import { doubletap } from '$lib/actions/doubletap';
|
|
import { focusTrap } from '$lib/actions/focus-trap';
|
|
import { scrollLock } from '$lib/actions/scroll-lock';
|
|
import { modalInert } from '$lib/actions/modal-inert';
|
|
import { toastError } from '$lib/toast-store';
|
|
import { vibrate } from '$lib/haptics';
|
|
import { commentsEnabled } from '$lib/event-config-store';
|
|
import HeartBurst from './HeartBurst.svelte';
|
|
|
|
const COMMENT_MAX = 500;
|
|
|
|
interface CommentDto {
|
|
id: string;
|
|
upload_id: string;
|
|
user_id: string;
|
|
uploader_name: string;
|
|
body: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface Props {
|
|
upload: FeedUpload;
|
|
onclose: () => void;
|
|
onlike: (id: string) => void;
|
|
}
|
|
|
|
let { upload, onclose, onlike }: Props = $props();
|
|
|
|
let comments = $state<CommentDto[]>([]);
|
|
let newComment = $state('');
|
|
let loading = $state(false);
|
|
let userId = getUserId();
|
|
let heartBurst = $state(false);
|
|
let burstTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Videos always play the ORIGINAL. `pickMediaUrl` is mime-agnostic and compression only
|
|
// ever produces a *thumbnail* for a video (one ffmpeg frame), so in the default saver
|
|
// mode it hands back `/thumbnail` — a JPEG, served as image/jpeg with nosniff. Feeding
|
|
// that to <video> is why every video failed with DEMUXER_ERROR_COULD_NOT_OPEN. There is
|
|
// no smaller video derivative to offer, so `preload="none"` keeps saver-mode users on
|
|
// cellular from fetching anything until they actually press play; the poster is the
|
|
// thumbnail, which is what they saw in the feed anyway.
|
|
// Fixed here rather than in `pickMediaUrl` because FeedListCard shares that helper and
|
|
// legitimately wants the thumbnail for its <img>. Same rule as the diashow.
|
|
const mediaSrc = $derived(
|
|
isVideo(upload.mime_type)
|
|
? `/api/v1/upload/${upload.id}/original`
|
|
: pickMediaUrl($dataMode, upload)
|
|
);
|
|
|
|
function triggerHeartBurst() {
|
|
heartBurst = true;
|
|
vibrate(10);
|
|
onlike(upload.id);
|
|
if (burstTimer) clearTimeout(burstTimer);
|
|
burstTimer = setTimeout(() => (heartBurst = false), 700);
|
|
}
|
|
|
|
// Drop a comment live when it's deleted elsewhere (host moderation or the
|
|
// author on another device), so the open panel doesn't show a ghost comment.
|
|
const unsubCommentDeleted = onSseEvent('comment-deleted', (data) => {
|
|
try {
|
|
const { comment_id } = JSON.parse(data) as { comment_id: string };
|
|
comments = comments.filter((c) => c.id !== comment_id);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
|
|
// Pull in a comment posted from another device on the CURRENTLY-open upload, so the
|
|
// open list matches the count. The `new-comment` broadcast carries only the count (not
|
|
// the body), so refetch — skipped when it's for a different upload, and the dedupe
|
|
// below makes our own just-posted optimistic comment a no-op.
|
|
const unsubNewComment = onSseEvent('new-comment', (data) => {
|
|
try {
|
|
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
|
if (upload_id === upload.id) void loadComments(upload.id);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (burstTimer) clearTimeout(burstTimer);
|
|
unsubCommentDeleted();
|
|
unsubNewComment();
|
|
});
|
|
|
|
// Only refetch when a *different* upload is shown. The feed reassigns the
|
|
// `upload` prop object on every SSE like/comment count update; keying the
|
|
// effect off the memoized id avoids a refetch storm that would also clobber
|
|
// a just-posted optimistic comment.
|
|
const uploadId = $derived(upload.id);
|
|
|
|
$effect(() => {
|
|
loadComments(uploadId);
|
|
});
|
|
|
|
async function loadComments(id: string) {
|
|
try {
|
|
comments = await api.get<CommentDto[]>(`/upload/${id}/comments`);
|
|
} catch {
|
|
// Background fetch — failure leaves the panel empty; reopening the lightbox retries.
|
|
}
|
|
}
|
|
|
|
async function submitComment() {
|
|
if (!newComment.trim()) return;
|
|
loading = true;
|
|
try {
|
|
const comment = await api.post<CommentDto>(`/upload/${upload.id}/comments`, {
|
|
body: newComment.trim()
|
|
});
|
|
comments = [...comments, comment];
|
|
newComment = '';
|
|
} catch (e) {
|
|
toastError(e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `asHost` routes to the moderation endpoint. The guest route only ever deletes the
|
|
* caller's OWN comment, and it refuses a banned author outright — so without this a
|
|
* host who banned an abusive guest was left with the abuse still on screen and no way
|
|
* to remove it, since the ban itself blocks the author's own delete.
|
|
*/
|
|
async function deleteComment(id: string, asHost: boolean) {
|
|
try {
|
|
await api.delete(asHost ? `/host/comment/${id}` : `/comment/${id}`);
|
|
comments = comments.filter((c) => c.id !== id);
|
|
} catch (e) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
function isVideo(mime: string): boolean {
|
|
return mime.startsWith('video/');
|
|
}
|
|
|
|
function formatTime(iso: string): string {
|
|
return new Date(iso).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="lightbox-title"
|
|
use:focusTrap={{ onclose }}
|
|
use:scrollLock
|
|
use:modalInert
|
|
>
|
|
<div
|
|
class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
|
>
|
|
<!-- Media -->
|
|
<div class="relative bg-black">
|
|
<button
|
|
onclick={onclose}
|
|
aria-label="Schließen"
|
|
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
|
>
|
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<div class="relative" use:doubletap ondoubletap={triggerHeartBurst}>
|
|
{#if isVideo(upload.mime_type)}
|
|
<video
|
|
src={mediaSrc}
|
|
controls
|
|
preload="none"
|
|
playsinline
|
|
class="max-h-[60vh] w-full object-contain"
|
|
poster={upload.thumbnail_url ?? undefined}
|
|
></video>
|
|
{:else}
|
|
<img
|
|
src={mediaSrc}
|
|
alt=""
|
|
class="max-h-[60vh] w-full object-contain select-none"
|
|
draggable="false"
|
|
/>
|
|
{/if}
|
|
|
|
<HeartBurst active={heartBurst} />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info + Comments -->
|
|
<div class="flex flex-1 flex-col overflow-hidden">
|
|
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100"
|
|
>{upload.uploader_name}</span
|
|
>
|
|
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500"
|
|
>{formatTime(upload.created_at)}</span
|
|
>
|
|
</div>
|
|
<button
|
|
onclick={() => onlike(upload.id)}
|
|
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
|
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
|
>
|
|
<svg
|
|
class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
|
/>
|
|
</svg>
|
|
{upload.like_count}
|
|
</button>
|
|
</div>
|
|
{#if upload.caption}
|
|
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300">{upload.caption}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Comments list + composer — hidden entirely when comments are disabled instance-wide -->
|
|
{#if $commentsEnabled}
|
|
<div class="flex-1 overflow-y-auto p-3">
|
|
{#if comments.length === 0}
|
|
<p class="text-center text-sm text-gray-400 dark:text-gray-500">
|
|
Noch keine Kommentare.
|
|
</p>
|
|
{:else}
|
|
<div class="space-y-3">
|
|
{#each comments as comment (comment.id)}
|
|
<div class="flex items-start gap-2">
|
|
<div class="flex-1">
|
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
|
>{comment.uploader_name}</span
|
|
>
|
|
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span
|
|
>
|
|
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
|
{formatTime(comment.created_at)}
|
|
</div>
|
|
</div>
|
|
{#if comment.user_id === userId || $isStaff}
|
|
<button
|
|
onclick={() => deleteComment(comment.id, comment.user_id !== userId)}
|
|
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
|
aria-label={comment.user_id === userId ? 'Löschen' : 'Kommentar entfernen'}
|
|
>
|
|
<svg
|
|
class="h-3.5 w-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Comment input -->
|
|
<form
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
submitComment();
|
|
}}
|
|
class="border-t border-gray-100 p-3 dark:border-gray-800"
|
|
>
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="text"
|
|
bind:value={newComment}
|
|
placeholder="Kommentar schreiben..."
|
|
maxlength={COMMENT_MAX}
|
|
class="input flex-1"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={loading || !newComment.trim()}
|
|
class="btn btn-primary btn-sm"
|
|
>
|
|
Senden
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="mt-1 text-right text-xs"
|
|
class:text-gray-400={newComment.length < 450}
|
|
class:dark:text-gray-500={newComment.length < 450}
|
|
class:text-amber-600={newComment.length >= 450 && newComment.length < COMMENT_MAX}
|
|
class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX}
|
|
class:text-red-600={newComment.length >= COMMENT_MAX}
|
|
class:dark:text-red-400={newComment.length >= COMMENT_MAX}
|
|
>
|
|
{newComment.length}/{COMMENT_MAX}
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|