feat: add diagnostics page and functionality, enhance transport settings in index.html
- Introduced a new diagnostics page (diagnostics.html) with real-time monitoring for Art-Net, sACN, MQTT, and KNX. - Added JavaScript functionality (diagnostics.js) to handle diagnostics data and render updates. - Enhanced index.html with new transport settings including timeline length, beat snap options, and diagnostics button. - Updated styles.css to accommodate new diagnostics layout and transport settings. - Added splash screen (splash.html) for improved user experience during startup. - Included new assets: Time4Midi.psd and updated executable files for version 0.3.6.
This commit is contained in:
@@ -1,42 +1,284 @@
|
|||||||
<<<<<<< HEAD
|
|
||||||
# Time4Midi
|
# Time4Midi
|
||||||
|
|
||||||
Desktop-Prototyp fuer einen plattformuebergreifenden Timeline-Editor fuer:
|
<p align="center">
|
||||||
|
<img src="./time4midi.png" alt="Time4Midi Logo" width="220">
|
||||||
|
</p>
|
||||||
|
|
||||||
- MIDI Notes
|
<p align="center">
|
||||||
- Art-Net DMX Values
|
Desktop-Timeline fuer Show-Control, MIDI, DMX, Audio und technische Automationen.
|
||||||
- externe Synchronisation via MIDI Timecode (MTC)
|
</p>
|
||||||
- externe Synchronisation via Art-Net Timecode
|
|
||||||
|
|
||||||
## Aktueller Stand
|
<p align="center">
|
||||||
|
<img src="./JHArtworks2023_wh.png" alt="J.H. Artworks" width="180">
|
||||||
|
</p>
|
||||||
|
|
||||||
Die erste Version enthaelt bereits:
|
## Was ist Time4Midi?
|
||||||
|
|
||||||
- mehrere Timelines pro Projekt
|
Time4Midi ist eine Electron-Desktop-App fuer zeitbasierte Show-Automation. Du legst Punkte auf einer Timeline an und steuerst damit je nach Track-Typ zum Beispiel:
|
||||||
- Speichern und Laden als JSON
|
|
||||||
- Tracks fuer `MIDI Note` und `Art-Net Channel`
|
|
||||||
- Punkt-/Keyframe-Editing direkt auf dem Zeitstrahl
|
|
||||||
- `Hold` und `Linear` pro Segment
|
|
||||||
- konfigurierbare MIDI In/Out Ports
|
|
||||||
- konfigurierbares Art-Net Ziel inklusive Broadcast/Unicast
|
|
||||||
- Live-Playhead fuer `Internal`, `MTC` und `Art-Net Timecode`
|
|
||||||
|
|
||||||
## Starten
|
- MIDI Notes und MIDI CC
|
||||||
|
- Art-Net und sACN / E1.31
|
||||||
|
- OSC
|
||||||
|
- MQTT
|
||||||
|
- Serial
|
||||||
|
- Modbus TCP / RTU
|
||||||
|
- KNX
|
||||||
|
- Audio-Tracks mit Waveform, Beat-Raster und Fade-Automation
|
||||||
|
|
||||||
|
Die App ist darauf ausgelegt, waehrend einer Show schnell lesbar zu bleiben: links Projekt und Tracks, in der Mitte die Timeline, rechts der Inspector fuer den aktuell gewaehlten Track oder Punkt.
|
||||||
|
|
||||||
|
## Auf einen Blick
|
||||||
|
|
||||||
|
- Mehrere Timelines pro Projekt
|
||||||
|
- Freie Timeline-Laenge bis 24 Stunden
|
||||||
|
- Zoomen mit `Strg + Mausrad`
|
||||||
|
- Horizontal scrollen mit dem Mausrad
|
||||||
|
- Auto-Follow des Timecursors beim Abspielen
|
||||||
|
- Audio-Waveform mit Beat-, Takt- und Drop-Markern
|
||||||
|
- Beat-Snap fuer Punkte auf anderen Tracks
|
||||||
|
- gelbe Snap-Linien ueber alle Tracks mit einstellbarer Staerke
|
||||||
|
- Diagnosefenster fuer Art-Net, sACN, MQTT und KNX
|
||||||
|
- Mapping-Bibliotheken fuer wiederverwendbare Zieldefinitionen
|
||||||
|
- Splashscreen beim Start mit Logo und Ladebalken
|
||||||
|
- Speichern und Laden als JSON-Projektdatei
|
||||||
|
|
||||||
|
## Bedienkonzept
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Projekt anlegen"] --> B["Timeline waehlen"]
|
||||||
|
B --> C["Tracks hinzufuegen"]
|
||||||
|
C --> D["Punkte setzen oder Audio laden"]
|
||||||
|
D --> E["Ausgaenge konfigurieren"]
|
||||||
|
E --> F["Play / Timecode / Diagnose"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aufbau der Oberflaeche
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
A["Sidebar<br/>Projekt, Timelines, Tracks"] --> B["Editor<br/>Timeline, Zoom, Scroll, Playhead"]
|
||||||
|
B --> C["Inspector<br/>Track- und Punktparameter"]
|
||||||
|
B --> D["Transportleiste<br/>Play, Sync, Timeline-Laenge, Beat-Snap"]
|
||||||
|
D --> E["Diagnosefenster<br/>Live-Werte fuer Netzwerk-Outputs"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtige Features im Detail
|
||||||
|
|
||||||
|
### 1. Timeline und Keyframes
|
||||||
|
|
||||||
|
- Punkte lassen sich per Doppelklick setzen.
|
||||||
|
- Punkte koennen mit der Maus verschoben werden.
|
||||||
|
- Je nach Track-Typ stehen `Hold` oder `Linear` als Interpolation zur Verfuegung.
|
||||||
|
- Die Timeline-Laenge kann direkt oben in der Transportleiste eingestellt werden.
|
||||||
|
|
||||||
|
### 2. Audio-Analyse
|
||||||
|
|
||||||
|
Audio-Tracks koennen mehr als nur abspielen:
|
||||||
|
|
||||||
|
- Waveform wird direkt in der Timeline angezeigt
|
||||||
|
- erkannte Beats werden als feine Marker eingezeichnet
|
||||||
|
- erkannte Taktanfange werden hervorgehoben
|
||||||
|
- erkannte Drops werden markiert
|
||||||
|
- Audio-Clips koennen auf der Timeline nach links und rechts verschoben werden
|
||||||
|
- Fade-Punkte im Audiotrack steuern den Gain ueber die Zeit
|
||||||
|
- das aktuell gewaehlte Snap-Muster wird als gelbes Raster sichtbar
|
||||||
|
|
||||||
|
Hinweis:
|
||||||
|
Wenn ein geladenes Audio laenger ist als die aktuelle Timeline, wird die Timeline automatisch verlaengert. Ist die Timeline schon laenger als das Audio, bleibt sie unveraendert.
|
||||||
|
|
||||||
|
### 3. Beat-Snap
|
||||||
|
|
||||||
|
Wenn ein Audiotrack analysiert wurde, kann dessen Beat-Raster als Fangnetz fuer andere Tracks dienen.
|
||||||
|
|
||||||
|
- `Beat Snap` aktivieren
|
||||||
|
- `Snap Raster` waehlen
|
||||||
|
- Punkte auf MIDI-, DMX-, OSC-, MQTT- oder anderen Tracks setzen oder ziehen
|
||||||
|
- Punkte rasten auf das Audio-Raster ein
|
||||||
|
|
||||||
|
Verfuegbare Raster:
|
||||||
|
|
||||||
|
- `1/4 Beat`
|
||||||
|
- `1/2 Beat`
|
||||||
|
- `1 Beat`
|
||||||
|
- `2 Beats`
|
||||||
|
- `1 Takt`
|
||||||
|
- `2 Takte`
|
||||||
|
|
||||||
|
Damit kannst du z. B. in einem 4/4-Track nur jeden zweiten Takt oder eben halbe Beats als Raster verwenden.
|
||||||
|
|
||||||
|
Zusaetzlich:
|
||||||
|
|
||||||
|
- das aktive Snap-Muster wird als gelbe Linien in der Timeline gespiegelt
|
||||||
|
- die Linienstaerke kann direkt oben in der Transportleiste eingestellt werden
|
||||||
|
- Punkte lassen sich trotz Snap weiter sauber ziehen und springen stabil Raster fuer Raster
|
||||||
|
|
||||||
|
### 4. Diagnosefenster
|
||||||
|
|
||||||
|
Das Diagnosefenster ist gedacht, damit du waehrend einer laufenden Show sehen kannst, was wirklich rausgeht.
|
||||||
|
|
||||||
|
Aktuell enthalten:
|
||||||
|
|
||||||
|
- Art-Net Universe + aktive Kanaele
|
||||||
|
- sACN Universe + aktive Kanaele
|
||||||
|
- MQTT Topics + letzter gesendeter Wert
|
||||||
|
- KNX Gruppenadresse + letzter gesendeter Wert
|
||||||
|
- Verlauf der letzten Events
|
||||||
|
|
||||||
|
### 5. Mapping-Bibliotheken
|
||||||
|
|
||||||
|
Mappings koennen projektunabhaengig gespeichert werden. Damit kannst du technische Ziele mit sprechenden Namen versehen, zum Beispiel:
|
||||||
|
|
||||||
|
- `MIDI Note 1 = Strobe`
|
||||||
|
- `Art-Net Universe 4 / Channel 7 = Shutter`
|
||||||
|
- `KNX 1/2/20 = Preset Licht`
|
||||||
|
|
||||||
|
Diese Bibliotheken lassen sich in den Einstellungen pro Treiber pflegen, speichern und laden.
|
||||||
|
|
||||||
|
## Unterstuetzte Sync-Quellen
|
||||||
|
|
||||||
|
- Internal
|
||||||
|
- MIDI Timecode `MTC`
|
||||||
|
- Art-Net Timecode
|
||||||
|
|
||||||
|
## Unterstuetzte Track-Typen
|
||||||
|
|
||||||
|
| Bereich | Track-Typen |
|
||||||
|
| --- | --- |
|
||||||
|
| MIDI | Note, CC |
|
||||||
|
| DMX | Art-Net Channel, sACN Channel |
|
||||||
|
| Netzwerk | OSC, MQTT |
|
||||||
|
| Feldbus / Technik | KNX, Modbus TCP, Modbus RTU |
|
||||||
|
| Serielle Ausgaben | Serial |
|
||||||
|
| Media | Audio File |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js
|
||||||
|
- npm
|
||||||
|
- Windows fuer den Portable-Build
|
||||||
|
|
||||||
|
### Entwicklung starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Unter Windows kannst du alternativ einfach die Datei `start-time4midi.cmd` im Projektordner doppelklicken.
|
Unter Windows kannst du alternativ auch `start-time4midi.cmd` benutzen.
|
||||||
|
|
||||||
## Naechste sinnvolle Ausbaustufen
|
### Windows Portable Build
|
||||||
|
|
||||||
1. Mehr Track-Typen: MIDI CC, Program Change, komplette DMX-Ranges
|
```bash
|
||||||
2. Copy/Paste, Zoom, Snapping, Loop, Undo/Redo
|
npm run dist:win
|
||||||
3. bessere MTC-Unterstuetzung inkl. Full-Frame und Locate
|
```
|
||||||
4. Projektbrowser, mehrere Cue-Listen, Szenen und Trigger
|
|
||||||
5. Packaging fuer Windows, macOS und Linux
|
## Schritt-fuer-Schritt Anleitung
|
||||||
=======
|
|
||||||
# Time4Midi
|
### Ein erstes Projekt anlegen
|
||||||
>>>>>>> 15ae412fbc06f021a6c052af2af6ea2f12d870e6
|
|
||||||
|
1. App starten.
|
||||||
|
2. Ein neues Projekt anlegen.
|
||||||
|
3. Eine Timeline waehlen oder eine neue erzeugen.
|
||||||
|
4. Oben die gewuenschte Timeline-Laenge eintragen.
|
||||||
|
5. Die benoetigten Tracks links hinzufuegen.
|
||||||
|
|
||||||
|
### MIDI oder DMX programmieren
|
||||||
|
|
||||||
|
1. Einen passenden Track hinzufuegen.
|
||||||
|
2. Rechts im Inspector Ziel, Kanal, Universe oder Adresse setzen.
|
||||||
|
3. Punkte per Doppelklick in die Timeline setzen.
|
||||||
|
4. Werte und Interpolation im Inspector feinjustieren.
|
||||||
|
|
||||||
|
### Audio laden und als Raster nutzen
|
||||||
|
|
||||||
|
1. Einen `Audio`-Track anlegen.
|
||||||
|
2. Eine Datei waehlen.
|
||||||
|
3. Warten, bis Waveform und Analyse erscheinen.
|
||||||
|
4. Falls gewuenscht `Beat Snap` aktivieren.
|
||||||
|
5. Das `Snap Raster` waehlen.
|
||||||
|
6. Optional die Staerke der gelben Snap-Linien einstellen.
|
||||||
|
7. Punkte auf anderen Tracks setzen oder verschieben.
|
||||||
|
|
||||||
|
### Audio-Fades zeichnen
|
||||||
|
|
||||||
|
1. Den Audio-Track auswaehlen.
|
||||||
|
2. Per Doppelklick direkt im Audiotrack Fade-Punkte setzen.
|
||||||
|
3. Punkte ziehen, um Zeit und Gain zu aendern.
|
||||||
|
4. Im Inspector `Zeit im Clip`, `Gain` und Interpolation anpassen.
|
||||||
|
|
||||||
|
### Tastatursteuerung fuer Snap
|
||||||
|
|
||||||
|
Wenn ein Punkt markiert ist:
|
||||||
|
|
||||||
|
1. `Pfeil links` springt zum vorherigen Beat bzw. Takt des aktiven Snap-Rasters.
|
||||||
|
2. `Pfeil rechts` springt zum naechsten Beat bzw. Takt.
|
||||||
|
3. `Strg + Pfeil links/rechts` springt um 4 Takte zurueck oder vor.
|
||||||
|
4. `Bild auf / Bild ab` scrollt horizontal durch die Timeline.
|
||||||
|
|
||||||
|
### Netzwerk-Ausgaenge pruefen
|
||||||
|
|
||||||
|
1. Transport starten.
|
||||||
|
2. `Diagnose` oeffnen.
|
||||||
|
3. Live beobachten, welche Werte auf welchen Universes, Kanaelen oder Topics ankommen.
|
||||||
|
|
||||||
|
## Projektdateien
|
||||||
|
|
||||||
|
Time4Midi speichert Projekte als JSON. Darin enthalten sind unter anderem:
|
||||||
|
|
||||||
|
- Projektname
|
||||||
|
- Timelines
|
||||||
|
- Tracks
|
||||||
|
- Punkte / Automation
|
||||||
|
- Routing
|
||||||
|
- ausgewaehlte Sync-Quelle
|
||||||
|
|
||||||
|
Mapping-Bibliotheken werden getrennt vom Projekt gespeichert und koennen in mehreren Projekten wiederverwendet werden.
|
||||||
|
|
||||||
|
## Dateien im Projekt
|
||||||
|
|
||||||
|
| Datei / Ordner | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `main.js` | Electron Main Process |
|
||||||
|
| `preload.js` | Bridge zwischen Renderer und Node / Treibern |
|
||||||
|
| `src/app.js` | Hauptlogik der App |
|
||||||
|
| `src/index.html` | Hauptfenster |
|
||||||
|
| `src/diagnostics.html` | Diagnosefenster |
|
||||||
|
| `src/splash.html` | Start-Splashscreen |
|
||||||
|
| `time4midi.png` | App-Logo |
|
||||||
|
| `JHArtworks2023_wh.png` | Brand-Logo |
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Aktueller Paketstand laut `package.json`:
|
||||||
|
|
||||||
|
- Version `0.3.3`
|
||||||
|
|
||||||
|
Bereits integriert:
|
||||||
|
|
||||||
|
- Art-Net
|
||||||
|
- sACN
|
||||||
|
- KNX
|
||||||
|
- MQTT
|
||||||
|
- OSC
|
||||||
|
- Audio-Waveform und Beat-Snap
|
||||||
|
- gelbe Snap-Linien mit einstellbarer Staerke
|
||||||
|
- Tastatursteuerung fuer Snap-Punkte
|
||||||
|
- Diagnosefenster
|
||||||
|
- Splashscreen
|
||||||
|
|
||||||
|
## Roadmap / Ideen
|
||||||
|
|
||||||
|
- noch praezisere Beat- und Drop-Erkennung
|
||||||
|
- manuelle Wahl der Snap-Quelle bei mehreren Audio-Tracks
|
||||||
|
- mehr Audio-Effekte direkt in der Timeline
|
||||||
|
- Undo / Redo
|
||||||
|
- Copy / Paste fuer Punkte und Tracks
|
||||||
|
- Loop-Bereiche und Cue-Strukturen
|
||||||
|
- bessere Paketierung fuer Release-Builds
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Entwickelt von `J.H. Artworks`.
|
||||||
|
|||||||
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -4,14 +4,162 @@ const path = require("node:path");
|
|||||||
|
|
||||||
app.commandLine.appendSwitch("enable-blink-features", "WebMidi");
|
app.commandLine.appendSwitch("enable-blink-features", "WebMidi");
|
||||||
|
|
||||||
|
let mainWindow = null;
|
||||||
|
let diagnosticsWindow = null;
|
||||||
|
let splashWindow = null;
|
||||||
|
|
||||||
|
const diagnosticsState = {
|
||||||
|
artnet: { universes: {} },
|
||||||
|
sacn: { universes: {} },
|
||||||
|
mqtt: { topics: {} },
|
||||||
|
knx: { groupAddresses: {} },
|
||||||
|
recentEvents: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function rememberRecentEvent(entry) {
|
||||||
|
diagnosticsState.recentEvents.unshift(entry);
|
||||||
|
diagnosticsState.recentEvents = diagnosticsState.recentEvents.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameChannelValues(left, right) {
|
||||||
|
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
if (Number(left[index] || 0) !== Number(right[index] || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDmxDiagnostics(transport, record) {
|
||||||
|
const safeTransport = transport === "artnet" ? "artnet" : "sacn";
|
||||||
|
const previous = diagnosticsState[safeTransport].universes[String(record.universe)];
|
||||||
|
const nextValues = Array.isArray(record.values) ? record.values.slice(0, 512) : [];
|
||||||
|
const changed = !previous || !sameChannelValues(previous.values, nextValues);
|
||||||
|
|
||||||
|
diagnosticsState[safeTransport].universes[String(record.universe)] = {
|
||||||
|
universe: Number(record.universe) || 0,
|
||||||
|
values: nextValues,
|
||||||
|
updatedAt: record.timestamp
|
||||||
|
};
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
rememberRecentEvent({
|
||||||
|
kind: "dmx-frame",
|
||||||
|
transport: safeTransport,
|
||||||
|
universe: Number(record.universe) || 0,
|
||||||
|
activeChannels: nextValues.filter((value) => Number(value) > 0).length,
|
||||||
|
timestamp: record.timestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMqttDiagnostics(record) {
|
||||||
|
diagnosticsState.mqtt.topics[String(record.topic || "")] = {
|
||||||
|
topic: String(record.topic || ""),
|
||||||
|
value: String(record.value ?? ""),
|
||||||
|
updatedAt: record.timestamp
|
||||||
|
};
|
||||||
|
rememberRecentEvent({
|
||||||
|
kind: "mqtt-publish",
|
||||||
|
topic: String(record.topic || ""),
|
||||||
|
value: String(record.value ?? ""),
|
||||||
|
timestamp: record.timestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKnxDiagnostics(record) {
|
||||||
|
const key = `${record.groupAddress || ""}:${record.dpt || ""}`;
|
||||||
|
diagnosticsState.knx.groupAddresses[key] = {
|
||||||
|
groupAddress: String(record.groupAddress || ""),
|
||||||
|
dpt: String(record.dpt || ""),
|
||||||
|
value: record.value,
|
||||||
|
updatedAt: record.timestamp
|
||||||
|
};
|
||||||
|
rememberRecentEvent({
|
||||||
|
kind: "knx-write",
|
||||||
|
groupAddress: String(record.groupAddress || ""),
|
||||||
|
dpt: String(record.dpt || ""),
|
||||||
|
value: record.value,
|
||||||
|
timestamp: record.timestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDiagnosticsRecord(record) {
|
||||||
|
if (!record || typeof record !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.kind === "dmx-frame") {
|
||||||
|
updateDmxDiagnostics(record.transport, record);
|
||||||
|
} else if (record.kind === "mqtt-publish") {
|
||||||
|
updateMqttDiagnostics(record);
|
||||||
|
} else if (record.kind === "knx-write") {
|
||||||
|
updateKnxDiagnostics(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendDiagnosticsUpdate(record) {
|
||||||
|
if (!diagnosticsWindow || diagnosticsWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
diagnosticsWindow.webContents.send("diagnostics:update", record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSplashWindow() {
|
||||||
|
if (!splashWindow || splashWindow.isDestroyed()) {
|
||||||
|
splashWindow = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
splashWindow.close();
|
||||||
|
splashWindow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSplashWindow() {
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
return splashWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
splashWindow = new BrowserWindow({
|
||||||
|
width: 520,
|
||||||
|
height: 420,
|
||||||
|
frame: false,
|
||||||
|
resizable: false,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
movable: true,
|
||||||
|
show: true,
|
||||||
|
center: true,
|
||||||
|
backgroundColor: "#0f1117",
|
||||||
|
icon: path.join(__dirname, "time4midi.png"),
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
splashWindow.on("closed", () => {
|
||||||
|
splashWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
splashWindow.loadFile(path.join(__dirname, "src", "splash.html"));
|
||||||
|
return splashWindow;
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const win = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1600,
|
width: 1600,
|
||||||
height: 980,
|
height: 980,
|
||||||
minWidth: 1200,
|
minWidth: 1200,
|
||||||
minHeight: 760,
|
minHeight: 760,
|
||||||
backgroundColor: "#0f1117",
|
backgroundColor: "#0f1117",
|
||||||
icon: path.join(__dirname, "time4midi.png"),
|
icon: path.join(__dirname, "time4midi.png"),
|
||||||
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
@@ -19,7 +167,49 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
win.loadFile(path.join(__dirname, "src", "index.html"));
|
mainWindow.on("closed", () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.once("ready-to-show", () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
closeSplashWindow();
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
}, 650);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.loadFile(path.join(__dirname, "src", "index.html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDiagnosticsWindow() {
|
||||||
|
if (diagnosticsWindow && !diagnosticsWindow.isDestroyed()) {
|
||||||
|
diagnosticsWindow.focus();
|
||||||
|
return diagnosticsWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnosticsWindow = new BrowserWindow({
|
||||||
|
width: 1100,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 900,
|
||||||
|
minHeight: 640,
|
||||||
|
backgroundColor: "#0f1117",
|
||||||
|
icon: path.join(__dirname, "time4midi.png"),
|
||||||
|
title: "Time4Midi Diagnose",
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false,
|
||||||
|
preload: path.join(__dirname, "preload.js")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
diagnosticsWindow.on("closed", () => {
|
||||||
|
diagnosticsWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
diagnosticsWindow.loadFile(path.join(__dirname, "src", "diagnostics.html"));
|
||||||
|
return diagnosticsWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle("project:save", async (_event, payload) => {
|
ipcMain.handle("project:save", async (_event, payload) => {
|
||||||
@@ -119,17 +309,38 @@ ipcMain.handle("mappingLibrary:pickSaveFile", async (_event, payload) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("diagnostics:openWindow", () => {
|
||||||
|
createDiagnosticsWindow();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("diagnostics:getSnapshot", () => {
|
||||||
|
return diagnosticsState;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on("diagnostics:record", (_event, record) => {
|
||||||
|
const normalizedRecord = {
|
||||||
|
...record,
|
||||||
|
timestamp: Number(record?.timestamp) || Date.now()
|
||||||
|
};
|
||||||
|
applyDiagnosticsRecord(normalizedRecord);
|
||||||
|
sendDiagnosticsUpdate(normalizedRecord);
|
||||||
|
});
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
createSplashWindow();
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
createSplashWindow();
|
||||||
createWindow();
|
createWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
|
closeSplashWindow();
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"main.js",
|
"main.js",
|
||||||
"preload.js",
|
"preload.js",
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
|
"JHArtworks2023_wh.png",
|
||||||
"time4midi.png",
|
"time4midi.png",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
|||||||
+100
-4
@@ -69,6 +69,28 @@ function addStatusApi(target, handler) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDiagnosticsChannelValues(values) {
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.slice(0, 512).map((value) => {
|
||||||
|
const safeValue = Math.round(Number(value) || 0);
|
||||||
|
return Math.max(0, Math.min(255, safeValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordDiagnostics(record) {
|
||||||
|
try {
|
||||||
|
ipcRenderer.send("diagnostics:record", {
|
||||||
|
...record,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore diagnostics forwarding issues to avoid blocking output.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const artnet = {
|
const artnet = {
|
||||||
...createStatusEmitter(),
|
...createStatusEmitter(),
|
||||||
sendSocket: null,
|
sendSocket: null,
|
||||||
@@ -297,7 +319,13 @@ function closeUdpSocket(target, key, signatureKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureUdpSocket(target, key, signatureKey, label, config, options = {}) {
|
async function ensureUdpSocket(target, key, signatureKey, label, config, options = {}) {
|
||||||
const signature = `${config.localAddress || ""}|${options.broadcast ? "1" : "0"}`;
|
const signature = [
|
||||||
|
config.localAddress || "",
|
||||||
|
options.broadcast ? "1" : "0",
|
||||||
|
options.multicast ? "1" : "0",
|
||||||
|
options.multicastInterface || "",
|
||||||
|
Number.isFinite(Number(options.multicastTtl)) ? String(Number(options.multicastTtl)) : ""
|
||||||
|
].join("|");
|
||||||
if (target[key] && target[signatureKey] === signature) {
|
if (target[key] && target[signatureKey] === signature) {
|
||||||
return target[key];
|
return target[key];
|
||||||
}
|
}
|
||||||
@@ -308,8 +336,14 @@ async function ensureUdpSocket(target, key, signatureKey, label, config, options
|
|||||||
target[signatureKey] = signature;
|
target[signatureKey] = signature;
|
||||||
|
|
||||||
const bindAddress = config.localAddress || "0.0.0.0";
|
const bindAddress = config.localAddress || "0.0.0.0";
|
||||||
const requiresBinding = Boolean(config.localAddress || options.broadcast);
|
const requiresBinding = Boolean(
|
||||||
|
config.localAddress
|
||||||
|
|| options.broadcast
|
||||||
|
|| options.multicast
|
||||||
|
|| options.forceBind
|
||||||
|
);
|
||||||
if (!requiresBinding) {
|
if (!requiresBinding) {
|
||||||
|
emitStatus(target, "");
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +380,14 @@ async function ensureUdpSocket(target, key, signatureKey, label, config, options
|
|||||||
if (options.broadcast) {
|
if (options.broadcast) {
|
||||||
socket.setBroadcast(true);
|
socket.setBroadcast(true);
|
||||||
}
|
}
|
||||||
|
if (options.multicast) {
|
||||||
|
if (Number.isFinite(Number(options.multicastTtl))) {
|
||||||
|
socket.setMulticastTTL(Number(options.multicastTtl));
|
||||||
|
}
|
||||||
|
if (options.multicastInterface) {
|
||||||
|
socket.setMulticastInterface(options.multicastInterface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emitStatus(target, "");
|
emitStatus(target, "");
|
||||||
return socket;
|
return socket;
|
||||||
@@ -453,6 +495,7 @@ async function sendArtNetDmx(payload) {
|
|||||||
|
|
||||||
const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0));
|
const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0));
|
||||||
const values = Array.isArray(payload.values) ? payload.values : [];
|
const values = Array.isArray(payload.values) ? payload.values : [];
|
||||||
|
const diagnosticsValues = normalizeDiagnosticsChannelValues(values);
|
||||||
const length = Math.max(2, Math.min(512, values.length || 512));
|
const length = Math.max(2, Math.min(512, values.length || 512));
|
||||||
const evenLength = length % 2 === 0 ? length : length + 1;
|
const evenLength = length % 2 === 0 ? length : length + 1;
|
||||||
const packet = Buffer.alloc(18 + evenLength);
|
const packet = Buffer.alloc(18 + evenLength);
|
||||||
@@ -487,6 +530,12 @@ async function sendArtNetDmx(payload) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emitStatus(artnet, "");
|
emitStatus(artnet, "");
|
||||||
|
recordDiagnostics({
|
||||||
|
kind: "dmx-frame",
|
||||||
|
transport: "artnet",
|
||||||
|
universe,
|
||||||
|
values: diagnosticsValues
|
||||||
|
});
|
||||||
resolve(true);
|
resolve(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -550,8 +599,12 @@ async function sendOsc(payload) {
|
|||||||
|
|
||||||
function configureSacn(nextConfig) {
|
function configureSacn(nextConfig) {
|
||||||
const previousLocalAddress = sacn.config.localAddress;
|
const previousLocalAddress = sacn.config.localAddress;
|
||||||
|
const previousMulticast = sacn.config.multicast;
|
||||||
sacn.config = normalizeSacnConfig(nextConfig);
|
sacn.config = normalizeSacnConfig(nextConfig);
|
||||||
if (previousLocalAddress !== sacn.config.localAddress) {
|
if (
|
||||||
|
previousLocalAddress !== sacn.config.localAddress
|
||||||
|
|| previousMulticast !== sacn.config.multicast
|
||||||
|
) {
|
||||||
closeUdpSocket(sacn, "socket", "socketSignature");
|
closeUdpSocket(sacn, "socket", "socketSignature");
|
||||||
}
|
}
|
||||||
emitStatus(sacn, "");
|
emitStatus(sacn, "");
|
||||||
@@ -588,6 +641,7 @@ async function sendSacnDmx(payload) {
|
|||||||
|
|
||||||
const universe = clampSacnUniverse(payload.universe);
|
const universe = clampSacnUniverse(payload.universe);
|
||||||
const values = Array.isArray(payload.values) ? payload.values : [];
|
const values = Array.isArray(payload.values) ? payload.values : [];
|
||||||
|
const diagnosticsValues = normalizeDiagnosticsChannelValues(values);
|
||||||
const slotCount = Math.max(1, Math.min(512, values.length || 512));
|
const slotCount = Math.max(1, Math.min(512, values.length || 512));
|
||||||
const packetLength = 126 + slotCount;
|
const packetLength = 126 + slotCount;
|
||||||
const propertyValueCount = slotCount + 1;
|
const propertyValueCount = slotCount + 1;
|
||||||
@@ -624,7 +678,19 @@ async function sendSacnDmx(payload) {
|
|||||||
|
|
||||||
sacn.sequence = sacn.sequence >= 255 ? 1 : sacn.sequence + 1;
|
sacn.sequence = sacn.sequence >= 255 ? 1 : sacn.sequence + 1;
|
||||||
const host = sacnDestination(universe);
|
const host = sacnDestination(universe);
|
||||||
const socket = await ensureUdpSocket(sacn, "socket", "socketSignature", "sACN", sacn.config);
|
const socket = await ensureUdpSocket(
|
||||||
|
sacn,
|
||||||
|
"socket",
|
||||||
|
"socketSignature",
|
||||||
|
"sACN",
|
||||||
|
sacn.config,
|
||||||
|
{
|
||||||
|
multicast: sacn.config.multicast,
|
||||||
|
multicastInterface: sacn.config.localAddress || "",
|
||||||
|
multicastTtl: 32,
|
||||||
|
forceBind: sacn.config.multicast
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => {
|
socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => {
|
||||||
@@ -634,6 +700,12 @@ async function sendSacnDmx(payload) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emitStatus(sacn, "");
|
emitStatus(sacn, "");
|
||||||
|
recordDiagnostics({
|
||||||
|
kind: "dmx-frame",
|
||||||
|
transport: "sacn",
|
||||||
|
universe,
|
||||||
|
values: diagnosticsValues
|
||||||
|
});
|
||||||
resolve(true);
|
resolve(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -707,6 +779,11 @@ function publishMqtt(payload) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emitStatus(mqttState, "");
|
emitStatus(mqttState, "");
|
||||||
|
recordDiagnostics({
|
||||||
|
kind: "mqtt-publish",
|
||||||
|
topic: String(payload.topic || ""),
|
||||||
|
value: String(payload.value ?? "")
|
||||||
|
});
|
||||||
resolve(true);
|
resolve(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1104,6 +1181,12 @@ async function sendKnx(payload) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emitStatus(knxState, "");
|
emitStatus(knxState, "");
|
||||||
|
recordDiagnostics({
|
||||||
|
kind: "knx-write",
|
||||||
|
groupAddress: String(payload.groupAddress || "1/1/1"),
|
||||||
|
dpt,
|
||||||
|
value
|
||||||
|
});
|
||||||
resolve(true);
|
resolve(true);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1134,6 +1217,19 @@ contextBridge.exposeInMainWorld("time4midi", {
|
|||||||
importFile: (defaultPath) => importMappingLibrary(defaultPath),
|
importFile: (defaultPath) => importMappingLibrary(defaultPath),
|
||||||
exportFile: (defaultPath, data) => exportMappingLibrary(defaultPath, data)
|
exportFile: (defaultPath, data) => exportMappingLibrary(defaultPath, data)
|
||||||
},
|
},
|
||||||
|
diagnostics: {
|
||||||
|
openWindow: () => ipcRenderer.invoke("diagnostics:openWindow"),
|
||||||
|
getSnapshot: () => ipcRenderer.invoke("diagnostics:getSnapshot"),
|
||||||
|
subscribe: (handler) => {
|
||||||
|
const listener = (_event, record) => {
|
||||||
|
handler(record);
|
||||||
|
};
|
||||||
|
ipcRenderer.on("diagnostics:update", listener);
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener("diagnostics:update", listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
network: {
|
network: {
|
||||||
listInterfaces: () => listNetworkInterfaces()
|
listInterfaces: () => listNetworkInterfaces()
|
||||||
},
|
},
|
||||||
|
|||||||
+861
-66
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Time4Midi Diagnose</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="diagnostics-shell">
|
||||||
|
<section class="panel diagnostics-header">
|
||||||
|
<div class="diagnostics-header-copy">
|
||||||
|
<div class="eyebrow">Live Monitor</div>
|
||||||
|
<h1>Diagnose</h1>
|
||||||
|
<p>Zeigt die letzten gesendeten Werte fuer Art-Net, sACN, MQTT und KNX waehrend die Show laeuft.</p>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-summary">
|
||||||
|
<div class="diagnostics-summary-card">
|
||||||
|
<span class="transport-label">Art-Net Universes</span>
|
||||||
|
<strong id="artnetUniverseCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-summary-card">
|
||||||
|
<span class="transport-label">sACN Universes</span>
|
||||||
|
<strong id="sacnUniverseCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-summary-card">
|
||||||
|
<span class="transport-label">MQTT Topics</span>
|
||||||
|
<strong id="mqttTopicCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-summary-card">
|
||||||
|
<span class="transport-label">KNX Ziele</span>
|
||||||
|
<strong id="knxValueCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-summary-card">
|
||||||
|
<span class="transport-label">Letztes Update</span>
|
||||||
|
<strong id="lastDiagnosticsUpdate">-</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="diagnostics-grid">
|
||||||
|
<section class="panel diagnostics-card">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Art-Net</h2>
|
||||||
|
<span class="diagnostics-transport-pill artnet">DMX</span>
|
||||||
|
</div>
|
||||||
|
<div id="artnetDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel diagnostics-card">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>sACN</h2>
|
||||||
|
<span class="diagnostics-transport-pill sacn">DMX</span>
|
||||||
|
</div>
|
||||||
|
<div id="sacnDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel diagnostics-card">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>MQTT</h2>
|
||||||
|
<span class="diagnostics-transport-pill mqtt">Topics</span>
|
||||||
|
</div>
|
||||||
|
<div id="mqttDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel diagnostics-card">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>KNX</h2>
|
||||||
|
<span class="diagnostics-transport-pill knx">Write</span>
|
||||||
|
</div>
|
||||||
|
<div id="knxDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel diagnostics-card wide">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Letzte Events</h2>
|
||||||
|
</div>
|
||||||
|
<div id="recentDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="./diagnostics.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
(function () {
|
||||||
|
const refs = {
|
||||||
|
artnetUniverseCount: document.getElementById("artnetUniverseCount"),
|
||||||
|
sacnUniverseCount: document.getElementById("sacnUniverseCount"),
|
||||||
|
mqttTopicCount: document.getElementById("mqttTopicCount"),
|
||||||
|
knxValueCount: document.getElementById("knxValueCount"),
|
||||||
|
lastDiagnosticsUpdate: document.getElementById("lastDiagnosticsUpdate"),
|
||||||
|
artnetDiagnostics: document.getElementById("artnetDiagnostics"),
|
||||||
|
sacnDiagnostics: document.getElementById("sacnDiagnostics"),
|
||||||
|
mqttDiagnostics: document.getElementById("mqttDiagnostics"),
|
||||||
|
knxDiagnostics: document.getElementById("knxDiagnostics"),
|
||||||
|
recentDiagnostics: document.getElementById("recentDiagnostics")
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
artnet: { universes: {} },
|
||||||
|
sacn: { universes: {} },
|
||||||
|
mqtt: { topics: {} },
|
||||||
|
knx: { groupAddresses: {} },
|
||||||
|
recentEvents: []
|
||||||
|
};
|
||||||
|
|
||||||
|
let unsubscribe = null;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameChannelValues(left, right) {
|
||||||
|
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
if (Number(left[index] || 0) !== Number(right[index] || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberRecentEvent(entry) {
|
||||||
|
state.recentEvents.unshift(entry);
|
||||||
|
state.recentEvents = state.recentEvents.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDmxDiagnostics(transport, record) {
|
||||||
|
const safeTransport = transport === "artnet" ? "artnet" : "sacn";
|
||||||
|
const previous = state[safeTransport].universes[String(record.universe)];
|
||||||
|
const nextValues = Array.isArray(record.values) ? record.values.slice(0, 512) : [];
|
||||||
|
const changed = !previous || !sameChannelValues(previous.values, nextValues);
|
||||||
|
|
||||||
|
state[safeTransport].universes[String(record.universe)] = {
|
||||||
|
universe: Number(record.universe) || 0,
|
||||||
|
values: nextValues,
|
||||||
|
updatedAt: Number(record.timestamp) || Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
rememberRecentEvent({
|
||||||
|
kind: "dmx-frame",
|
||||||
|
transport: safeTransport,
|
||||||
|
universe: Number(record.universe) || 0,
|
||||||
|
activeChannels: nextValues.filter((value) => Number(value) > 0).length,
|
||||||
|
timestamp: Number(record.timestamp) || Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMqttDiagnostics(record) {
|
||||||
|
state.mqtt.topics[String(record.topic || "")] = {
|
||||||
|
topic: String(record.topic || ""),
|
||||||
|
value: String(record.value ?? ""),
|
||||||
|
updatedAt: Number(record.timestamp) || Date.now()
|
||||||
|
};
|
||||||
|
rememberRecentEvent({
|
||||||
|
kind: "mqtt-publish",
|
||||||
|
topic: String(record.topic || ""),
|
||||||
|
value: String(record.value ?? ""),
|
||||||
|
timestamp: Number(record.timestamp) || Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKnxDiagnostics(record) {
|
||||||
|
const key = `${record.groupAddress || ""}:${record.dpt || ""}`;
|
||||||
|
state.knx.groupAddresses[key] = {
|
||||||
|
groupAddress: String(record.groupAddress || ""),
|
||||||
|
dpt: String(record.dpt || ""),
|
||||||
|
value: record.value,
|
||||||
|
updatedAt: Number(record.timestamp) || Date.now()
|
||||||
|
};
|
||||||
|
rememberRecentEvent({
|
||||||
|
kind: "knx-write",
|
||||||
|
groupAddress: String(record.groupAddress || ""),
|
||||||
|
dpt: String(record.dpt || ""),
|
||||||
|
value: record.value,
|
||||||
|
timestamp: Number(record.timestamp) || Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDiagnosticsRecord(record) {
|
||||||
|
if (!record || typeof record !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.kind === "dmx-frame") {
|
||||||
|
updateDmxDiagnostics(record.transport, record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.kind === "mqtt-publish") {
|
||||||
|
updateMqttDiagnostics(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.kind === "knx-write") {
|
||||||
|
updateKnxDiagnostics(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceState(snapshot) {
|
||||||
|
state.artnet.universes = { ...(snapshot?.artnet?.universes || {}) };
|
||||||
|
state.sacn.universes = { ...(snapshot?.sacn?.universes || {}) };
|
||||||
|
state.mqtt.topics = { ...(snapshot?.mqtt?.topics || {}) };
|
||||||
|
state.knx.groupAddresses = { ...(snapshot?.knx?.groupAddresses || {}) };
|
||||||
|
state.recentEvents = Array.isArray(snapshot?.recentEvents) ? snapshot.recentEvents.slice(0, 200) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
if (!timestamp) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleTimeString("de-DE", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value) {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? "1" : "0";
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||||
|
}
|
||||||
|
return String(value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveChannels(values) {
|
||||||
|
const channels = [];
|
||||||
|
(values || []).forEach((value, index) => {
|
||||||
|
const safeValue = Number(value) || 0;
|
||||||
|
if (safeValue > 0) {
|
||||||
|
channels.push({
|
||||||
|
channel: index + 1,
|
||||||
|
value: safeValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDmxContainer(container, universes, emptyMessage) {
|
||||||
|
const entries = Object.values(universes || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
|
||||||
|
if (!entries.length) {
|
||||||
|
container.innerHTML = `<div class="diagnostics-empty">${escapeHtml(emptyMessage)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="diagnostics-list">
|
||||||
|
${entries.map((entry) => {
|
||||||
|
const activeChannels = getActiveChannels(entry.values);
|
||||||
|
const visibleChannels = activeChannels.slice(0, 48);
|
||||||
|
const hiddenCount = Math.max(0, activeChannels.length - visibleChannels.length);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="diagnostics-entry">
|
||||||
|
<div class="diagnostics-entry-header">
|
||||||
|
<strong>Universe ${entry.universe}</strong>
|
||||||
|
<span>${activeChannels.length} aktive Kanaele</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-entry-meta">
|
||||||
|
<span>Letztes Update ${formatTimestamp(entry.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
${visibleChannels.length ? `
|
||||||
|
<div class="diagnostics-channel-grid">
|
||||||
|
${visibleChannels.map((channel) => `
|
||||||
|
<div class="diagnostics-channel-chip diagnostics-mono">Ch ${channel.channel}: ${channel.value}</div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
${hiddenCount > 0 ? `<div class="diagnostics-entry-meta"><span>+ ${hiddenCount} weitere Kanaele</span></div>` : ""}
|
||||||
|
` : `
|
||||||
|
<div class="diagnostics-entry-meta">
|
||||||
|
<span>Alle Kanaele aktuell auf 0</span>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMqttContainer() {
|
||||||
|
const entries = Object.values(state.mqtt.topics || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
|
||||||
|
if (!entries.length) {
|
||||||
|
refs.mqttDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine MQTT-Nachrichten gesendet.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.mqttDiagnostics.innerHTML = `
|
||||||
|
<div class="diagnostics-list">
|
||||||
|
${entries.map((entry) => `
|
||||||
|
<article class="diagnostics-entry">
|
||||||
|
<div class="diagnostics-entry-header">
|
||||||
|
<strong class="diagnostics-mono">${escapeHtml(entry.topic)}</strong>
|
||||||
|
<span>${formatTimestamp(entry.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-entry-meta">
|
||||||
|
<span>Wert</span>
|
||||||
|
<strong class="diagnostics-mono">${escapeHtml(formatValue(entry.value))}</strong>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKnxContainer() {
|
||||||
|
const entries = Object.values(state.knx.groupAddresses || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
|
||||||
|
if (!entries.length) {
|
||||||
|
refs.knxDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine KNX-Schreibvorgaenge gesendet.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.knxDiagnostics.innerHTML = `
|
||||||
|
<div class="diagnostics-list">
|
||||||
|
${entries.map((entry) => `
|
||||||
|
<article class="diagnostics-entry">
|
||||||
|
<div class="diagnostics-entry-header">
|
||||||
|
<strong class="diagnostics-mono">${escapeHtml(entry.groupAddress)}</strong>
|
||||||
|
<span>${escapeHtml(entry.dpt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-entry-meta">
|
||||||
|
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
|
||||||
|
<span>${formatTimestamp(entry.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeRecentEvent(entry) {
|
||||||
|
if (entry.kind === "dmx-frame") {
|
||||||
|
return `
|
||||||
|
<article class="diagnostics-entry">
|
||||||
|
<div class="diagnostics-entry-header">
|
||||||
|
<strong>${escapeHtml(entry.transport === "artnet" ? "Art-Net" : "sACN")} Universe ${escapeHtml(entry.universe)}</strong>
|
||||||
|
<span class="diagnostics-transport-pill ${escapeHtml(entry.transport)}">${escapeHtml(entry.transport)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-entry-meta">
|
||||||
|
<span>${escapeHtml(entry.activeChannels)} aktive Kanaele</span>
|
||||||
|
<span>${formatTimestamp(entry.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "mqtt-publish") {
|
||||||
|
return `
|
||||||
|
<article class="diagnostics-entry">
|
||||||
|
<div class="diagnostics-entry-header">
|
||||||
|
<strong class="diagnostics-mono">${escapeHtml(entry.topic)}</strong>
|
||||||
|
<span class="diagnostics-transport-pill mqtt">mqtt</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-entry-meta">
|
||||||
|
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
|
||||||
|
<span>${formatTimestamp(entry.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "knx-write") {
|
||||||
|
return `
|
||||||
|
<article class="diagnostics-entry">
|
||||||
|
<div class="diagnostics-entry-header">
|
||||||
|
<strong class="diagnostics-mono">${escapeHtml(entry.groupAddress)}</strong>
|
||||||
|
<span class="diagnostics-transport-pill knx">${escapeHtml(entry.dpt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-entry-meta">
|
||||||
|
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
|
||||||
|
<span>${formatTimestamp(entry.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentEvents() {
|
||||||
|
const entries = state.recentEvents.slice(0, 80);
|
||||||
|
if (!entries.length) {
|
||||||
|
refs.recentDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine Diagnose-Events vorhanden.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.recentDiagnostics.innerHTML = `
|
||||||
|
<div class="diagnostics-history">
|
||||||
|
${entries.map((entry) => describeRecentEvent(entry)).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
const universeCounts = {
|
||||||
|
artnet: Object.keys(state.artnet.universes || {}).length,
|
||||||
|
sacn: Object.keys(state.sacn.universes || {}).length,
|
||||||
|
mqtt: Object.keys(state.mqtt.topics || {}).length,
|
||||||
|
knx: Object.keys(state.knx.groupAddresses || {}).length
|
||||||
|
};
|
||||||
|
const timestamps = [
|
||||||
|
...Object.values(state.artnet.universes || {}).map((entry) => entry.updatedAt || 0),
|
||||||
|
...Object.values(state.sacn.universes || {}).map((entry) => entry.updatedAt || 0),
|
||||||
|
...Object.values(state.mqtt.topics || {}).map((entry) => entry.updatedAt || 0),
|
||||||
|
...Object.values(state.knx.groupAddresses || {}).map((entry) => entry.updatedAt || 0)
|
||||||
|
];
|
||||||
|
const lastUpdate = timestamps.length ? Math.max(...timestamps) : 0;
|
||||||
|
|
||||||
|
refs.artnetUniverseCount.textContent = String(universeCounts.artnet);
|
||||||
|
refs.sacnUniverseCount.textContent = String(universeCounts.sacn);
|
||||||
|
refs.mqttTopicCount.textContent = String(universeCounts.mqtt);
|
||||||
|
refs.knxValueCount.textContent = String(universeCounts.knx);
|
||||||
|
refs.lastDiagnosticsUpdate.textContent = formatTimestamp(lastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderSummary();
|
||||||
|
renderDmxContainer(refs.artnetDiagnostics, state.artnet.universes, "Noch keine Art-Net Frames gesendet.");
|
||||||
|
renderDmxContainer(refs.sacnDiagnostics, state.sacn.universes, "Noch keine sACN Frames gesendet.");
|
||||||
|
renderMqttContainer();
|
||||||
|
renderKnxContainer();
|
||||||
|
renderRecentEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (!window.time4midi?.diagnostics) {
|
||||||
|
document.body.innerHTML = `<main class="diagnostics-shell"><section class="panel"><div class="diagnostics-empty">Diagnose-Bridge nicht verfuegbar. Bitte App neu starten.</div></section></main>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await window.time4midi.diagnostics.getSnapshot();
|
||||||
|
replaceState(snapshot);
|
||||||
|
renderAll();
|
||||||
|
|
||||||
|
unsubscribe = window.time4midi.diagnostics.subscribe((record) => {
|
||||||
|
applyDiagnosticsRecord(record);
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribe = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void init();
|
||||||
|
})();
|
||||||
@@ -86,7 +86,40 @@
|
|||||||
<span class="transport-label">Status</span>
|
<span class="transport-label">Status</span>
|
||||||
<strong id="transportStatusLabel">Idle</strong>
|
<strong id="transportStatusLabel">Idle</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="transport-block transport-field">
|
||||||
|
<span class="transport-label">Timeline Laenge</span>
|
||||||
|
<input id="timelineDurationInput" type="number" min="1" max="86400" step="1" value="120">
|
||||||
|
</label>
|
||||||
|
<label class="transport-block transport-checkbox">
|
||||||
|
<span class="transport-label">Beat Snap</span>
|
||||||
|
<input id="beatSnapEnabledInput" type="checkbox">
|
||||||
|
</label>
|
||||||
|
<label class="transport-block transport-field">
|
||||||
|
<span class="transport-label">Snap Raster</span>
|
||||||
|
<select id="beatSnapGridSelect">
|
||||||
|
<option value="0.25">1/4 Beat</option>
|
||||||
|
<option value="0.5">1/2 Beat</option>
|
||||||
|
<option value="1" selected>1 Beat</option>
|
||||||
|
<option value="2">2 Beats</option>
|
||||||
|
<option value="4">1 Takt</option>
|
||||||
|
<option value="8">2 Takte</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="transport-block transport-field transport-field-small">
|
||||||
|
<span class="transport-label">Linien</span>
|
||||||
|
<select id="beatSnapLineWidthSelect">
|
||||||
|
<option value="1">Duenn</option>
|
||||||
|
<option value="2" selected>Mittel</option>
|
||||||
|
<option value="3">Dick</option>
|
||||||
|
<option value="4">Extra</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="transport-block transport-source">
|
||||||
|
<span class="transport-label">Snap Quelle</span>
|
||||||
|
<strong id="beatSnapSourceLabel">Kein Audio-Raster</strong>
|
||||||
|
</div>
|
||||||
<div class="transport-controls">
|
<div class="transport-controls">
|
||||||
|
<button id="openDiagnosticsButton" class="ghost">Diagnose</button>
|
||||||
<button id="openSettingsButton" class="ghost">Einstellungen</button>
|
<button id="openSettingsButton" class="ghost">Einstellungen</button>
|
||||||
<button id="playButton">Play</button>
|
<button id="playButton">Play</button>
|
||||||
<button id="pauseButton" class="ghost">Pause</button>
|
<button id="pauseButton" class="ghost">Pause</button>
|
||||||
@@ -104,6 +137,7 @@
|
|||||||
<div class="editor-hints">
|
<div class="editor-hints">
|
||||||
<span>Doppelklick: Punkt setzen</span>
|
<span>Doppelklick: Punkt setzen</span>
|
||||||
<span>Ziehen: Zeit/Wert aendern</span>
|
<span>Ziehen: Zeit/Wert aendern</span>
|
||||||
|
<span>Audio ziehen: Clip verschieben</span>
|
||||||
<span>Strg+Mausrad: Zoom</span>
|
<span>Strg+Mausrad: Zoom</span>
|
||||||
<span>Mausrad: horizontal scrollen</span>
|
<span>Mausrad: horizontal scrollen</span>
|
||||||
<span>Entf: Punkt loeschen</span>
|
<span>Entf: Punkt loeschen</span>
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Time4Midi startet</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0b0f15;
|
||||||
|
--panel: rgba(18, 24, 34, 0.96);
|
||||||
|
--line: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: #ecf2ff;
|
||||||
|
--muted: #9ca8bf;
|
||||||
|
--accent: #6ec2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(110, 194, 242, 0.2), transparent 34%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(110, 232, 200, 0.14), transparent 28%),
|
||||||
|
linear-gradient(180deg, #09111b 0%, #101a28 100%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI Variable Text", "Aptos", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 28px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row img:first-child {
|
||||||
|
width: 156px;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row img:last-child {
|
||||||
|
width: 132px;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-bar {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-bar::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 38%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, rgba(110, 194, 242, 0.25), rgba(110, 194, 242, 0.95), rgba(142, 232, 200, 0.55));
|
||||||
|
animation: loading 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(290%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="brand-row">
|
||||||
|
<img src="../time4midi.png" alt="Time4Midi">
|
||||||
|
<img src="../JHArtworks2023_wh.png" alt="J.H. Artworks">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="copy">
|
||||||
|
<div class="eyebrow">Startup</div>
|
||||||
|
<h1>Time4Midi startet</h1>
|
||||||
|
<p>Die Engine, Treiber und das Projekt-Setup werden vorbereitet. Gleich geht es los.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loader">
|
||||||
|
<div class="loader-bar" aria-hidden="true"></div>
|
||||||
|
<div class="loader-meta">
|
||||||
|
<span>Bitte kurz warten</span>
|
||||||
|
<span>Renderer wird geladen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+197
@@ -180,6 +180,43 @@ p {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transport-field {
|
||||||
|
min-width: 132px;
|
||||||
|
max-width: 156px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-field-small {
|
||||||
|
min-width: 96px;
|
||||||
|
max-width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-field input {
|
||||||
|
padding: 0.42rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-field select {
|
||||||
|
padding: 0.42rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-checkbox {
|
||||||
|
min-width: 90px;
|
||||||
|
align-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-checkbox input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-source {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-source strong {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-hints span,
|
.editor-hints span,
|
||||||
.meta-row span,
|
.meta-row span,
|
||||||
.transport-label {
|
.transport-label {
|
||||||
@@ -704,3 +741,163 @@ p {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagnostics-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-header-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-summary-card {
|
||||||
|
min-width: 110px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-summary-card strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-card.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-scroll {
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-list,
|
||||||
|
.diagnostics-history {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-entry {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-entry-header,
|
||||||
|
.diagnostics-entry-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-entry-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-channel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-channel-chip {
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(110, 194, 242, 0.1);
|
||||||
|
border: 1px solid rgba(110, 194, 242, 0.18);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-transport-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.22rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-transport-pill.artnet {
|
||||||
|
background: rgba(255, 179, 92, 0.18);
|
||||||
|
border: 1px solid rgba(255, 179, 92, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-transport-pill.sacn {
|
||||||
|
background: rgba(110, 194, 242, 0.18);
|
||||||
|
border: 1px solid rgba(110, 194, 242, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-transport-pill.mqtt,
|
||||||
|
.diagnostics-transport-pill.knx {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-mono {
|
||||||
|
font-family: "Consolas", "Cascadia Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.diagnostics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user