Add videos: library, block and section background

Videos join the media library as their own table (schema v28), with a
video block and a looping background on sections. video.js starts loops
only while visible, offers a pause button and leaves the still image for
reduced motion and data saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWYm8kjAWmZaB2QB2xuBhU
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-11-05 05:23:17 +00:00
1 parent 592e3d5342
commit 76613b9f8e
25 files changed
+674 -20

No files matched your search

+77 -3
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Content\DocumentLibrary;
use App\Content\MediaLibrary;
use App\Content\SiteSettings;
use App\Content\VideoLibrary;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -17,7 +18,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
final class MediaController extends AbstractController
{
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents)
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos)
{
}
@@ -29,7 +30,7 @@ final class MediaController extends AbstractController
$archived = $request->query->getBoolean('archiv');
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)];
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived), 'videos' => $this->videos->list($archived)];
if ($request->isXmlHttpRequest()) {
return $this->json([
'html' => $this->renderView('admin/media-results.html.twig', $view),
@@ -57,7 +58,11 @@ final class MediaController extends AbstractController
try {
// Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der
// Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt.
$this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file);
match (true) {
$this->isPdf($file) => $this->documents->upload($file),
$this->videos->claims($file) => $this->videos->upload($file),
default => $this->media->upload($file),
};
++$done;
} catch (\InvalidArgumentException $error) {
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : ''));
@@ -120,6 +125,31 @@ final class MediaController extends AbstractController
return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success');
}
#[Route('/%kant.admin_path%/media/video/{id}', name: 'admin_video_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function formUpdateVideo(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if (!$this->videos->find($id)) {
throw $this->createNotFoundException();
}
try {
$this->videos->update($id, [
'title' => $request->request->getString('title'),
'archived' => $request->request->getBoolean('archived'),
'version' => $request->request->getInt('version'),
]);
} catch (StaleWrite) {
return $this->back($request, 'Das Video wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
} catch (\InvalidArgumentException $error) {
return $this->back($request, $error->getMessage());
}
return $this->back($request, 'Die Angaben zum Video wurden gespeichert.', 'success');
}
private function isPdf(UploadedFile $file): bool
{
$mime = $file->getMimeType();
@@ -195,6 +225,36 @@ final class MediaController extends AbstractController
}
}
/** Die Videos fuer die Auswahl im Editor. */
#[Route('/%kant.admin_path%/api/videos', methods: ['GET'])]
public function listVideos(): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
return $this->json(['items' => $this->videos->list(false), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
}
#[Route('/%kant.admin_path%/api/videos/upload', methods: ['POST'])]
public function uploadVideo(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
}
try {
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
}
return $this->json(['item' => $this->videos->upload($file)], 201);
} catch (\InvalidArgumentException $error) {
return $this->json(['error' => $error->getMessage()], 422);
} catch (\RuntimeException) {
return $this->json(['error' => 'Das Video konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503);
}
}
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
public function read(string $id): Response
{
@@ -324,6 +384,20 @@ final class MediaController extends AbstractController
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
}
/**
* Ein Video, so wie es hochgeladen wurde.
*
* BinaryFileResponse beantwortet Range-Anfragen; ohne sie lässt Safari ein Video gar nicht
* abspielen. Die Datei ändert sich nie, ihre Adresse ist an die Kennung gebunden.
*/
#[Route('/media/{id}.{extension}', name: 'video_serve', requirements: ['id' => '[a-f0-9]{32}', 'extension' => 'mp4|webm'], methods: ['GET'])]
public function serveVideo(string $id, string $extension): Response
{
$path = $this->videos->file($id, $extension) ?? throw $this->createNotFoundException();
return new BinaryFileResponse($path, headers: ['Content-Type' => 'video/'.$extension, 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
}
/**
* Ein PDF, so wie es hochgeladen wurde.
*