feat(video): add streamable video events & dashboard controls
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).
This commit is contained in:
@@ -19,11 +19,15 @@ type CustomEventData = {
|
||||
weekdays: number[];
|
||||
repeatUntil: Date | null;
|
||||
skipHolidays: boolean;
|
||||
media?: { id: string; path: string; name: string } | null; // <--- ergänzt
|
||||
slideshowInterval?: number; // <--- ergänzt
|
||||
pageProgress?: boolean; // NEU
|
||||
autoProgress?: boolean; // NEU
|
||||
websiteUrl?: string; // <--- ergänzt
|
||||
media?: { id: string; path: string; name: string } | null;
|
||||
slideshowInterval?: number;
|
||||
pageProgress?: boolean;
|
||||
autoProgress?: boolean;
|
||||
websiteUrl?: string;
|
||||
// Video-specific fields
|
||||
autoplay?: boolean;
|
||||
loop?: boolean;
|
||||
volume?: number;
|
||||
};
|
||||
|
||||
// Typ für initialData erweitern, damit Id unterstützt wird
|
||||
@@ -112,6 +116,12 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
initialData.autoProgress ?? true
|
||||
);
|
||||
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
|
||||
|
||||
// Video-specific state
|
||||
const [autoplay, setAutoplay] = React.useState<boolean>(initialData.autoplay ?? true);
|
||||
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? false);
|
||||
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
|
||||
|
||||
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -141,7 +151,13 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||
setMedia(initialData.media ?? null);
|
||||
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
||||
setPageProgress(initialData.pageProgress ?? true);
|
||||
setAutoProgress(initialData.autoProgress ?? true);
|
||||
setWebsiteUrl(initialData.websiteUrl ?? '');
|
||||
// Video fields
|
||||
setAutoplay(initialData.autoplay ?? true);
|
||||
setLoop(initialData.loop ?? false);
|
||||
setVolume(initialData.volume ?? 0.8);
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
@@ -192,9 +208,15 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
if (type === 'website') {
|
||||
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
|
||||
}
|
||||
if (type === 'video') {
|
||||
if (!media) newErrors.media = 'Bitte ein Video auswählen';
|
||||
}
|
||||
// Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used)
|
||||
|
||||
setErrors({});
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||||
|
||||
@@ -259,7 +281,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
};
|
||||
|
||||
if (type === 'presentation') {
|
||||
payload.event_media_id = media?.id;
|
||||
payload.event_media_id = media?.id ? Number(media.id) : undefined;
|
||||
payload.slideshow_interval = slideshowInterval;
|
||||
payload.page_progress = pageProgress;
|
||||
payload.auto_progress = autoProgress;
|
||||
@@ -269,6 +291,13 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
payload.website_url = websiteUrl;
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
payload.event_media_id = media?.id ? Number(media.id) : undefined;
|
||||
payload.autoplay = autoplay;
|
||||
payload.loop = loop;
|
||||
payload.volume = volume;
|
||||
}
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (editMode && initialData && typeof initialData.Id === 'string') {
|
||||
@@ -601,6 +630,50 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === 'video' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, marginTop: 16 }}>
|
||||
<button
|
||||
className="e-btn"
|
||||
onClick={() => setMediaModalOpen(true)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Video auswählen/hochladen
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<b>Ausgewähltes Video:</b>{' '}
|
||||
{media ? (
|
||||
media.path
|
||||
) : (
|
||||
<span style={{ color: '#888' }}>Kein Video ausgewählt</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<CheckBoxComponent
|
||||
label="Automatisch abspielen"
|
||||
checked={autoplay}
|
||||
change={e => setAutoplay(e.checked || false)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<CheckBoxComponent
|
||||
label="In Schleife abspielen"
|
||||
checked={loop}
|
||||
change={e => setLoop(e.checked || false)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<TextBoxComponent
|
||||
placeholder="Lautstärke (0.0 - 1.0)"
|
||||
floatLabelType="Auto"
|
||||
type="number"
|
||||
value={String(volume)}
|
||||
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import CustomMediaInfoPanel from './components/CustomMediaInfoPanel';
|
||||
import {
|
||||
@@ -96,6 +97,89 @@ const Media: React.FC = () => {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user