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

@@ -1,6 +1,6 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.13",
"version": "2025.1.0-alpha.10",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -26,128 +26,92 @@
]
},
"buildInfo": {
"buildDate": "2025-10-19T12:00:00Z",
"buildDate": "2025-10-25T12:00:00Z",
"commitId": "9f2ae8b44c3a"
},
"changelog": [
{
"version": "2025.1.0-alpha.13",
"date": "2025-10-19",
"changes": [
"🆕 Events: Neuer Termin-Typ WebUntis nutzt die systemweite Vertretungsplan-URL; Darstellung ident mit Website.",
"🛠️ Scheduler/Clients: Einheitliches Website-Payload für Website und WebUntis (type: browser, url).",
"🛠️ Einstellungen Events: WebUntis verwendet jetzt die bestehende Vertretungsplan-URL (Supplement-Table); kein separates WebUntis-URL-Feld mehr.",
"📖 Doku: MQTT-Event-Payload-Leitfaden und Implementierungsnotizen zu WebUntis/Website ergänzt."
]
},
{
"version": "2025.1.0-alpha.12",
"date": "2025-10-18",
"changes": [
"✨ Einstellungen Events Präsentationen: Neue Felder für Slide-Show Intervall, Seitenfortschritt (Page-Progress) und Präsentationsfortschritt (Auto-Progress) inspiriert von Impressive Presenter (-q, -k).",
" Event-Modal: Präsentations-Einstellungen werden beim Erstellen aus globalen Defaults geladen; beim Bearbeiten aus Event-Daten; individuell pro Event anpassbar.",
"🐛 Bugfix: Scheduler sendet jetzt leere retained Messages (`[]`) wenn keine Events mehr aktiv sind (Client-Display wird korrekt gelöscht).",
"🔧 Bugfix: Nur aktuell aktive Events werden via MQTT an Clients gesendet (reduziert Datenübertragung).",
"📖 Doku: Copilot-Instructions um Präsentations-Settings, Scheduler-Logik und Event-Modal erweitert."
]
},
{
"version": "2025.1.0-alpha.11",
"date": "2025-10-16",
"changes": [
"✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit Tabs: 📅 Akademischer Kalender, 🖥️ Anzeige & Clients, 🎬 Medien & Dateien, 🗓️ Events, ⚙️ System.",
"🗓️ Einstellungen Events: WebUntis/Vertretungsplan Zusatz-Tabelle (URL) in den Events-Tab verschoben; Aktivieren/Deaktivieren, Speichern und Vorschau; systemweite Einstellung.",
"📅 Einstellungen Akademischer Kalender: Aktive akademische Periode kann direkt gesetzt werden.",
" Doku: README zur Einstellungen-Seite (Tabs) und System-Settings-API ergänzt."
]
},
{
"version": "2025.1.0-alpha.10",
"date": "2025-10-15",
"date": "2025-10-25",
"changes": [
"🔐 Auth: Login und Benutzerverwaltung implementiert (rollenbasiert, persistente Sitzungen).",
" UI: Benutzer-Menü oben rechts DropDownButton mit Benutzername/Rolle; Einträge: Profil und Abmelden.",
"🧩 Frontend: Syncfusion SplitButtons integriert (react-splitbuttons) und Vite-Konfiguration für Pre-Bundling ergänzt.",
"🐛 Fix: Import-Fehler @syncfusion/ej2-react-splitbuttons Anleitung in README hinzugefügt (optimizeDeps + Volume-Reset)."
"🎬 Client: Client kann jetzt Videos wiedergeben (Playback/UI surface) — Benutzerseitige Präsentation wurde ergänzt.",
"🧩 UI: Event-Modal ergänzt um Video-Auswahl und Wiedergabe-Optionen (Autoplay, Loop, Lautstärke).",
"📁 Medien-UI: FileManager erlaubt größere Uploads für Full-HD-Videos; Client-seitige Validierung begrenzt Videolänge auf 10 Minuten."
]
},
{
"version": "2025.1.0-alpha.9",
"date": "2025-10-14",
"date": "2025-10-19",
"changes": [
"✨ UI: Einheitlicher Lösch-Workflow für Termine alle Typen (Einzeltermin, Einzelinstanz, ganze Serie) werden mit eigenen, benutzerfreundlichen Dialogen behandelt.",
"🔧 Frontend: Syncfusion-RecurrenceAlert und DeleteAlert werden abgefangen und durch eigene Dialoge ersetzt (inkl. finale Bestätigung für Serienlöschung).",
"✅ Bugfix: Keine doppelten oder verwirrenden Bestätigungsdialoge mehr beim Löschen von Serienterminen.",
"📖 Doku: README und Copilot-Instructions um Lösch-Workflow und Dialoghandling erweitert."
"🆕 Events: Darstellung für WebUntis harmonisiert mit Website (UI/representation).",
"🛠️ Einstellungen Events: WebUntis verwendet jetzt die bestehende Supplement-Table-Einstellung (Settings UI updated)."
]
},
{
"version": "2025.1.0-alpha.8",
"date": "2025-10-11",
"date": "2025-10-18",
"changes": [
"🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports in main.tsx",
"🧹 Cleanup: Tailwind CSS komplett entfernt (Pakete, PostCSS, Stylelint, Konfigurationsdateien)",
"🧩 Gruppenverwaltung: \"infoscreen_groups\" auf Syncfusion-Komponenten (Buttons, Dialoge, DropDownList, TextBox) umgestellt; Abstände verbessert",
"🔔 Benachrichtigungen: Vereinheitlichte Toast-/Dialog-Texte; letzte Alert-Verwendung ersetzt",
"📖 Doku: README und Copilot-Anweisungen angepasst (Material 3, zentrale Styles, kein Tailwind)"
"✨ Einstellungen Events Präsentationen: Neue UI-Felder für Slide-Show Intervall, Page-Progress und Auto-Progress.",
" UI: Event-Modal lädt Präsentations-Einstellungen aus Global-Defaults bzw. Event-Daten (behaviour surfaced in UI)."
]
},
{
"version": "2025.1.0-alpha.7",
"date": "2025-09-21",
"date": "2025-10-16",
"changes": [
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout",
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler 'Ferien im Blick'",
"📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe",
"📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 24)",
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
"✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit.",
"🗓️ Einstellungen Events: WebUntis/Vertretungsplan in Events-Tab (enable/preview in UI).",
"📅 UI: Akademische Periode kann in der Einstellungen-Seite direkt gesetzt werden."
]
},
{
"version": "2025.1.0-alpha.6",
"date": "2025-09-20",
"date": "2025-10-15",
"changes": [
"🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester",
"🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden",
"🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen"
"✨ UI: Benutzer-Menü (top-right) mit Name/Rolle und Einträgen 'Profil' und 'Abmelden'."
]
},
{
"version": "2025.1.0-alpha.5",
"date": "2025-09-14",
"date": "2025-10-14",
"changes": [
"Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung."
"✨ UI: Einheitlicher Lösch-Workflow für Termine mit benutzerfreundlichen Dialogen (Einzeltermin, Einzelinstanz, Serie).",
"🔧 Frontend: RecurrenceAlert/DeleteAlert werden abgefangen und durch eigene Dialoge ersetzt (Verbesserung der UX).",
"✅ Bugfix (UX): Keine doppelten oder verwirrenden Bestätigungsdialoge mehr beim Löschen von Serienterminen."
]
},
{
"version": "2025.1.0-alpha.4",
"date": "2025-09-01",
"date": "2025-10-11",
"changes": [
"Grundstruktur für Deployment getestet und optimiert.",
"FIX: Programmfehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
"🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports (UI theme update).",
"🧩 UI: Gruppenverwaltung ('infoscreen_groups') auf Syncfusion-Komponenten umgestellt.",
"🔔 UI: Vereinheitlichte Notifications / Toast-Texte für konsistente UX."
]
},
{
"version": "2025.1.0-alpha.3",
"date": "2025-08-30",
"date": "2025-09-21",
"changes": [
"NEU: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
"NEU: Logout-Funktionalität implementiert.",
"FIX: Breite der Sidebar im eingeklappten Zustand korrigiert."
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompakte Layout-Verbesserung.",
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + 'Ferien im Blick' Zähler (UI indicator).",
"📤 UI: Ferien-Upload (TXT/CSV) Benutzer-Workflow ergänzt."
]
},
{
"version": "2025.1.0-alpha.2",
"date": "2025-08-29",
"date": "2025-09-01",
"changes": [
"INFO: Analyse und Anzeige der verwendeten Open-Source-Bibliotheken."
"UI Fix: Fehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
]
},
{
"version": "2025.1.0-alpha.1",
"date": "2025-08-28",
"date": "2025-08-30",
"changes": [
"Initiales Setup des Projekts und der Grundstruktur."
"🆕 UI: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
"✨ UI: Logout-Funktionalität (Frontend) implementiert.",
"🐛 UI Fix: Breite der Sidebar im eingeklappten Zustand korrigiert."
]
}
]

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>

View File

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