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:
RobbStarkAustria
2025-10-25 16:48:14 +00:00
parent e6c19c189f
commit 38800cec68
14 changed files with 453 additions and 83 deletions

View File

@@ -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>