Add end-to-end support for video events: server streaming, scheduler metadata, API fields, and dashboard UI. - Server: range-capable streaming endpoint with byte-range support. - Scheduler: emits `video` object; best-effort HEAD probe adds `mime_type`, `size`, `accept_ranges`; placeholders for richer metadata (duration/resolution/bitrate/qualities/thumbnails). - API/DB: accept and persist `event_media_id`, `autoplay`, `loop`, `volume` for video events. - Frontend: Event modal supports video selection + playback options; FileManager increased upload size and client-side duration check (max 10 minutes). - Docs/UX: bumped program-info, added UX-only changelog and updated Copilot instructions for contributors. - Notes: metadata extraction (ffprobe), checksum persistence, and HLS/DASH transcoding are recommended follow-ups (separate changes).
227 lines
9.3 KiB
TypeScript
227 lines
9.3 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import React, { useState, useRef, useMemo } from 'react';
|
|
import CustomMediaInfoPanel from './components/CustomMediaInfoPanel';
|
|
import {
|
|
FileManagerComponent,
|
|
Inject,
|
|
NavigationPane,
|
|
DetailsView,
|
|
Toolbar,
|
|
} from '@syncfusion/ej2-react-filemanager';
|
|
import { useAuth } from './useAuth';
|
|
|
|
const hostUrl = '/api/eventmedia/filemanager/'; // Dein Backend-Endpunkt für FileManager
|
|
|
|
const Media: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const isSuperadmin = useMemo(() => user?.role === 'superadmin', [user]);
|
|
// State für die angezeigten Dateidetails
|
|
const [fileDetails] = useState<null | {
|
|
name: string;
|
|
size: number;
|
|
type: string;
|
|
dateModified: number;
|
|
description?: string | null;
|
|
}>(null);
|
|
// Ansicht: 'LargeIcons', 'Details'
|
|
const [viewMode, setViewMode] = useState<'LargeIcons' | 'Details'>('LargeIcons');
|
|
const fileManagerRef = useRef<FileManagerComponent | null>(null);
|
|
|
|
// Hilfsfunktion für Datum in Browser-Zeitzone
|
|
function formatLocalDate(timestamp: number) {
|
|
if (!timestamp) return '';
|
|
const date = new Date(timestamp * 1000);
|
|
return date.toLocaleString('de-DE'); // Zeigt lokale Zeit des Browsers
|
|
}
|
|
|
|
// Ansicht umschalten, ohne Remount
|
|
React.useEffect(() => {
|
|
if (fileManagerRef.current) {
|
|
const element = fileManagerRef.current.element as HTMLElement & { ej2_instances?: unknown[] };
|
|
if (element && element.ej2_instances && element.ej2_instances[0]) {
|
|
// Typisiere Instanz als unknown, da kein offizieller Typ vorhanden
|
|
const instanz = element.ej2_instances[0] as { view: string; dataBind: () => void };
|
|
instanz.view = viewMode;
|
|
instanz.dataBind();
|
|
}
|
|
}
|
|
}, [viewMode]);
|
|
|
|
type FileItem = { name: string; isFile: boolean };
|
|
type ReadSuccessArgs = { action: string; result?: { files?: FileItem[] } };
|
|
type FileOpenArgs = { fileDetails?: FileItem; cancel?: boolean };
|
|
|
|
// Hide "converted" for non-superadmins after data load
|
|
const handleSuccess = (args: ReadSuccessArgs) => {
|
|
if (isSuperadmin) return;
|
|
if (args && args.action === 'read' && args.result && Array.isArray(args.result.files)) {
|
|
args.result.files = args.result.files.filter((f: FileItem) => !(f.name === 'converted' && !f.isFile));
|
|
}
|
|
};
|
|
|
|
// Prevent opening the "converted" folder for non-superadmins
|
|
const handleFileOpen = (args: FileOpenArgs) => {
|
|
if (!isSuperadmin && args && args.fileDetails && args.fileDetails.name === 'converted' && !args.fileDetails.isFile) {
|
|
args.cancel = true;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="text-xl font-bold mb-4">Medien</h2>
|
|
{/* Ansicht-Umschalter */}
|
|
<div style={{ marginBottom: 12 }}>
|
|
<button
|
|
className={viewMode === 'LargeIcons' ? 'e-btn e-active' : 'e-btn'}
|
|
onClick={() => setViewMode('LargeIcons')}
|
|
style={{ marginRight: 8 }}
|
|
>
|
|
Icons
|
|
</button>
|
|
<button
|
|
className={viewMode === 'Details' ? 'e-btn e-active' : 'e-btn'}
|
|
onClick={() => setViewMode('Details')}
|
|
>
|
|
Details
|
|
</button>
|
|
</div>
|
|
{/* Debug-Ausgabe entfernt, da ReactNode erwartet wird */}
|
|
<FileManagerComponent
|
|
ref={fileManagerRef}
|
|
cssClass="e-bigger media-icons-xl"
|
|
success={handleSuccess}
|
|
fileOpen={handleFileOpen}
|
|
ajaxSettings={{
|
|
url: hostUrl + 'operations',
|
|
getImageUrl: hostUrl + 'get-image',
|
|
uploadUrl: hostUrl + 'upload',
|
|
downloadUrl: hostUrl + 'download',
|
|
}}
|
|
// Increase upload settings: default maxFileSize for Syncfusion FileManager is ~30_000_000 (30 MB).
|
|
// Set `maxFileSize` in bytes and `allowedExtensions` for video types you want to accept.
|
|
// We disable autoUpload so we can validate duration client-side before sending.
|
|
uploadSettings={{
|
|
maxFileSize: 1.5 * 1024 * 1024 * 1024, // 1.5 GB - enough for 10min Full HD video at high bitrate
|
|
allowedExtensions: '.pdf,.ppt,.pptx,.odp,.mp4,.webm,.ogg,.mov,.mkv,.avi,.wmv,.flv,.mpg,.mpeg,.jpg,.jpeg,.png,.gif,.bmp,.tiff,.svg',
|
|
autoUpload: false,
|
|
minFileSize: 0, // Allow all file sizes (no minimum)
|
|
// chunkSize can be added later once server supports chunk assembly
|
|
}}
|
|
// Validate video duration (max 10 minutes) before starting upload.
|
|
created={() => {
|
|
try {
|
|
const el = fileManagerRef.current?.element as any;
|
|
const inst = el && el.ej2_instances && el.ej2_instances[0];
|
|
const maxSeconds = 10 * 60; // 10 minutes
|
|
if (inst && inst.uploadObj) {
|
|
// Override the selected handler to validate files before upload
|
|
const originalSelected = inst.uploadObj.selected;
|
|
inst.uploadObj.selected = async (args: any) => {
|
|
const filesData = args && (args.filesData || args.files) ? (args.filesData || args.files) : [];
|
|
const tooLong: string[] = [];
|
|
// Helper to get native File object
|
|
const getRawFile = (fd: any) => fd && (fd.rawFile || fd.file || fd) as File;
|
|
|
|
const checks = Array.from(filesData).map((fd: any) => {
|
|
const file = getRawFile(fd);
|
|
if (!file) return Promise.resolve(true);
|
|
// Only check video MIME types or common extensions
|
|
if (!file.type.startsWith('video') && !/\.(mp4|webm|ogg|mov|mkv)$/i.test(file.name)) {
|
|
return Promise.resolve(true);
|
|
}
|
|
return new Promise<boolean>((resolve) => {
|
|
const url = URL.createObjectURL(file);
|
|
const video = document.createElement('video');
|
|
video.preload = 'metadata';
|
|
video.src = url;
|
|
const clean = () => {
|
|
try { URL.revokeObjectURL(url); } catch { /* noop */ }
|
|
};
|
|
video.onloadedmetadata = function () {
|
|
clean();
|
|
if (video.duration && video.duration <= maxSeconds) {
|
|
resolve(true);
|
|
} else {
|
|
tooLong.push(`${file.name} (${Math.round(video.duration||0)}s)`);
|
|
resolve(false);
|
|
}
|
|
};
|
|
video.onerror = function () {
|
|
clean();
|
|
// If metadata can't be read, allow upload and let server verify
|
|
resolve(true);
|
|
};
|
|
});
|
|
});
|
|
|
|
const results = await Promise.all(checks);
|
|
const allOk = results.every(Boolean);
|
|
if (!allOk) {
|
|
// Cancel the automatic upload and show error to user
|
|
args.cancel = true;
|
|
const msg = `Upload blocked: the following videos exceed ${maxSeconds} seconds:\n` + tooLong.join('\n');
|
|
// Use alert for now; replace with project's toast system if available
|
|
alert(msg);
|
|
return;
|
|
}
|
|
// All files OK — proceed with original selected handler if present,
|
|
// otherwise start upload programmatically
|
|
if (typeof originalSelected === 'function') {
|
|
try { originalSelected.call(inst.uploadObj, args); } catch { /* noop */ }
|
|
}
|
|
// If autoUpload is false we need to start upload manually
|
|
try {
|
|
inst.uploadObj.upload(args && (args.filesData || args.files));
|
|
} catch { /* ignore — uploader may handle starting itself */ }
|
|
};
|
|
}
|
|
} catch (e) {
|
|
// Non-fatal: if we can't hook uploader, uploads will behave normally
|
|
console.error('Could not attach video-duration hook to uploader', e);
|
|
}
|
|
}}
|
|
toolbarSettings={{
|
|
items: [
|
|
'NewFolder',
|
|
'Upload',
|
|
'Download',
|
|
'Rename',
|
|
'Delete',
|
|
'SortBy',
|
|
'Refresh',
|
|
'Details',
|
|
],
|
|
}}
|
|
contextMenuSettings={{
|
|
file: ['Open', '|', 'Download', '|', 'Rename', 'Delete', '|', 'Details'],
|
|
folder: ['Open', '|', 'Rename', 'Delete', '|', 'Details'],
|
|
layout: ['SortBy', 'Refresh', '|', 'View', 'Details'],
|
|
}}
|
|
allowMultiSelection={false}
|
|
view={viewMode}
|
|
detailsViewSettings={{
|
|
columns: [
|
|
{ field: 'name', headerText: 'Name', minWidth: '120', width: '200' },
|
|
{ field: 'size', headerText: 'Größe', minWidth: '80', width: '100' },
|
|
{
|
|
field: 'dateModified',
|
|
headerText: 'Upload-Datum',
|
|
minWidth: '120',
|
|
width: '180',
|
|
template: (data: { dateModified: number }) => formatLocalDate(data.dateModified),
|
|
},
|
|
{ field: 'type', headerText: 'Typ', minWidth: '80', width: '100' },
|
|
],
|
|
}}
|
|
menuClick={() => {}}
|
|
>
|
|
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
|
</FileManagerComponent>
|
|
{/* Details-Panel anzeigen, wenn Details verfügbar sind */}
|
|
{fileDetails && <CustomMediaInfoPanel {...fileDetails} />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Media;
|