diff --git a/README.md b/README.md index ea32c84..252c183 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,22 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und - ✅ Auftragsdetail mit Kunde, Adresse, Telefon (Click-to-Call) - ✅ **Neuer Auftrag** (FAB): Kunde per Suche/Zuletzt-verwendet wählen, dann **Ihr Zeichen** (Pflicht), **Auftragsbeschreibung** (optional), **Geplanter Liefertermin** (Datum + Uhrzeit) und Checkbox **Auftrag direkt freigeben**. Kunden-Defaults (Zahlungsbedingung/Zahlart, Ansprechpartner) werden übernommen. Bei „direkt freigeben" wird der Auftrag **ohne Position** validiert — die Leistungen kommen aus dem Stundenzettel. (Bis Bericht 1.3.1 hängte das Backend hier eine Std-Lohn-Zeile mit Menge 1 an; die verfälschte den Auftragswert, stand als offene Restmenge in der Lieferauflistung und im Kunden-PDF.) -**Foto-Upload** +**Foto- und Video-Upload** - ✅ Live-Kamera-Modal (getUserMedia): bleibt für Serien-Aufnahmen offen — Auslöser + Filmstreifen mit Upload-Status je Foto (💾 gesichert → ⏳ lädt hoch → ✓ fertig), Front-/Rückkamera-Umschaltung (nur bei zwei Kameras), Fehlschuss löschbar - ✅ **Volle Kameraauflösung**: ausgelöst wird über `ImageCapture.takePhoto()` (native JPEG der Kamera inkl. EXIF) — ein Standbild aus dem Video-Stream hat nur Stream-Auflösung und ist nur noch die Rückfallebene (iOS/Safari, ältere Browser) - ✅ **Blitz und Fokus** (19.10.2026): Knopf oben schaltet reihum Aus → Auto → An → 🔦 Licht (nur was das Gerät meldet, Auswahl bleibt gemerkt; Blitz wirkt über `takePhoto({fillLightMode})`). Antippen stellt den Autofokus auf diese Stelle (Tippstelle wird in Sensorlage zurückgedreht — Chrome tut das nicht; ein echtes „Fokus festhalten“ bietet Chrome auf Android nicht an). Frontkamera/Tablet-Webcam ohne diese Fähigkeiten: kein Knopf, Antippen ohne Wirkung. Knopf „Kamera wechseln“ (weißes Symbol) nur bei mindestens zwei Kameras. Im Android-Emulator per `dumpsys media.camera` gegengeprüft (ROADMAP Punkt 9) +- ✅ **Videos** (19.10.2026): Umschalter „Foto | Video" in der Kamera, roter Aufnahmeknopf, Laufzeit „● 0:12 / 5:00", Ton (Mikrofon erst beim ersten Video angefragt), Licht/Zoom/Antippen-Fokus auch beim Filmen. MP4 (H.264/AAC) wenn möglich, sonst WebM, 5 Mbit/s; Höchstlänge 5 min bzw. passend zur Server-Grenze (`config.php upload_max`). Schließen oder App-Wechsel während der Aufnahme sichert das Video. Am Auftrag eigener Abschnitt „🎬 Videos" mit erstem Bild, Player (spult per Range), Teilen, Löschen. Galerie und Teilen nehmen Videos; zu große lehnt die App gleich ab. Server: Bericht ≥ 1.8.0 (ROADMAP Punkt 11) +- ✅ **Zoom mit zwei Fingern** im Sucher, Anzeige „1×/2,5×" (Antippen → 1×); Antippen setzt auch Belichtung und Weißabgleich auf die Stelle - ✅ **Verkleinerte Fotos heißen `.jpg`** und transparente Bilder bekommen beim Verkleinern weißen Grund (vorher behielt ein verkleinertes AVIF/WebP seinen Namen und wurde vom Server abgelehnt) -- ✅ Alternativ Aufnahme aus der Galerie (Mehrfachauswahl: alle Dateien werden sofort nach der Auswahl eingelesen, erst dann hochgeladen, weil Android die Galerie-Dateien nur kurz lesbar hält, siehe ROADMAP Punkt 7) +- ✅ Alternativ Aufnahme aus der Galerie, Fotos und Videos (Mehrfachauswahl: alle Dateien werden sofort nach der Auswahl eingelesen, erst dann hochgeladen, weil Android die Galerie-Dateien nur kurz lesbar hält, siehe ROADMAP Punkt 7) - ✅ **Fotoqualität im Admin einstellbar** (Bericht-Modul → Fotoqualität, `api/config.php`): Vorgabe ist **1:1, gar nicht verkleinern**; optional längste Seite + JPEG-Qualität. Gesichert wird immer zuerst das Original, die kleine Fassung ersetzt es danach — hängt das Verkleinern (Display aus), bleibt das Original - ✅ **Wake Lock** während Foto-Serie und Upload: schaltet sich das Display ab, friert der Browser laufende Uploads und das Verkleinern ein -- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg +- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (Zeitlimit 45 s + 1 s je 100 KB, gleiche Formel in `api.js` und `sw.js`) und App-Kill. Gesichert wird der Blob selbst (Chrome legt ihn als Datei ab), nicht mehr ein ArrayBuffer. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg - ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen) - ✅ **Background Sync**: der Service Worker lädt die Warteschlange auch dann hoch, wenn die App gar nicht offen ist (Display aus, weggewischt) — Tag `photo-queue`, dazu Periodic Background Sync (15 min), wo der Browser ihn gewährt. Anmeldung läuft über das `awl_sso`-Cookie mit - ✅ **Wiederholter Upload ist idempotent**: geht die Antwort nach dem Speichern verloren, wird erneut gesendet — der Server vergleicht den Inhalt (md5, seit Bericht 1.6.0), legt nichts doppelt ab und antwortet mit `duplicate: true` - ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos -- ✅ **Seite und Service Worker laden nichts doppelt hoch**: wer ein Foto sendet, belegt es in IndexedDB (Lease, 90 s); der andere überspringt es und übernimmt erst nach Ablauf. Beim Verkleinern bleibt das Foto belegt, bis die kleine Fassung in der Queue liegt — sonst gingen Original **und** kleine Fassung raus. Logik steht zweimal (`lib/idb.js` + `sw.js`), immer zusammen ändern +- ✅ **Seite und Service Worker laden nichts doppelt hoch**: wer ein Foto sendet, belegt es in IndexedDB (Lease = Zeitlimit + 45 s, mind. 90 s, steht als `lease_ms` im Eintrag); der andere überspringt es und übernimmt erst nach Ablauf. Beim Verkleinern bleibt das Foto belegt, bis die kleine Fassung in der Queue liegt — sonst gingen Original **und** kleine Fassung raus. Logik steht zweimal (`lib/idb.js` + `sw.js`), immer zusammen ändern - ✅ **App-Update lädt nicht mitten in der Arbeit neu**: nach einem Deploy wartet der Reload, bis kein Dialog (Kamera, Notiz, Unterschrift) offen ist, nichts getippt wird und kein Upload läuft (`window.appBusy()`); der eigene Reload löst keinen Browser-Dialog aus - ✅ **Wartende Fotos stehen am Auftrag**: Block „⏳ Warten auf Upload (n)" mit Vorschaubildern aus der Warteschlange und Klartext (gesichert auf dem Gerät, geht raus sobald Netz da ist); nach dem Upload zieht die Auftragsseite von selbst nach. Hinweis-Toast beim Schließen der Kamera, wenn noch etwas wartet - ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304 diff --git a/ROADMAP.md b/ROADMAP.md index 6d1d2ba..80d4db3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP — Baustelle PWA -Stand: 2026-10-19 · Gegenstück: `data/bericht` (Modul-Version 1.7.1) +Stand: 2026-10-19 · Gegenstück: `data/bericht` (Modul-Version 1.8.0) Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des @@ -10,15 +10,13 @@ Bericht-Moduls. ## Offen -- [ ] `idb.queueAll()` lädt **alle** Foto-Blobs in den Speicher — auch `updateBadge()`, das - nur zählen will. Bei 20 Originalen à 8 MB sind das 160 MB je Badge-Update. Braucht ein - Zähl-Verfahren ohne Werte (Schlüssel + kleiner Meta-Store); Schema-Änderung, weil - `sw.js`/`share.html` die DB mit fester Version 1 öffnen. -- [ ] **Am Handy gegenprüfen** (geht nur auf dem Gerät, am Schreibtisch nicht nachstellbar): - echte Kamera-Hardware mit „Fertig" im Funkloch, Background Sync bei ausgeschaltetem - Display, und der Zwei-Finger-Zoom im PDF-Betrachter (lokal nur per CDP-Touch simuliert). Beides lief lokal nur mit nachgebildeter Kamera (`canvas.captureStream()`). - Wenn etwas komisch aussieht: Tag + ungefähre Uhrzeit reichen, der Rest steht im - Prod-Zugriffslog (Lesart siehe KB #208, Nachtrag 18.09.2026). +- [x] ✅ 19.10.2026 (ohne Schema-Änderung): `idb.queueAll()` lud **alle** Foto-Daten in den Speicher, weil sie + als ArrayBuffer gesichert waren. Neue Einträge tragen den Blob selbst — Chrome legt ihn als Datei ab und liest + ihn erst beim Hochladen; `getAll()` liefert nur den Verweis. Alte ArrayBuffer-Einträge laufen wie bisher ab. +- [x] ✅ 19.10.2026 **im Android-Emulator nachgeholt** (echter Chrome/Camera2-Weg, siehe Punkt 10): Kamera + „Fertig" + im Funkloch, Hochladen bei ausgeschaltetem Display, Zwei-Finger-Zoom im PDF-Betrachter. Was nur das echte + Gerät zeigt (Blitzhelligkeit, Fokus-Treiber, Video-Auflösung), steht bei Punkt 9/11. Wenn etwas komisch + aussieht: Tag + ungefähre Uhrzeit reichen, der Rest steht im Prod-Zugriffslog (Lesart KB #208). **Bewusst nicht gebaut** (Entscheidungen, nicht vergessen): @@ -46,10 +44,49 @@ Der Emulator läuft jetzt (SVM an) — die bisher „nur am Handy" prüfbaren Pu - [x] ✅ Belichtung: **kein eigener Regler** — Antippen setzt bei Chrome Fokus-, Belichtungs- und Weißabgleich-Bereich gemeinsam; im Emulator gemessen: `aeRegions` = `afRegions` = `awbRegions` = Tippstelle → Display/Typenschild antippen = dort scharf UND richtig belichtet. -- [ ] Emulator: echte Kamera + „Fertig" im Funkloch (Fotos gesichert, nach Netz-Rückkehr hochgeladen, Server zählt genau +n) -- [ ] Emulator: Hochladen bei ausgeschaltetem Display (Background Sync) -- [ ] Emulator: 8–10 Fotos auf einmal aus der Galerie (Fix aus Punkt 7) -- [ ] Emulator: Zwei-Finger-Zoom im PDF-Betrachter mit echtem Mehrfinger-Touch (statt CDP-Nachbildung) +- [x] ✅ 19.10.2026 Emulator, **echte Kamera + „Fertig" im Funkloch** (Flugmodus + `adb reverse --remove-all`, Server + nachweislich unerreichbar): 3 Fotos → Warteschlange 3, nach „Fertig" bleibt die App im Auftrag (Fehler aus Punkt 6 + bestätigt behoben), Band „Kein Netz — Stand von 23:20 Uhr", 🔴 3, Kasten „Warten auf Upload (3)". +- [x] ✅ 19.10.2026 Emulator, **Hochladen bei ausgeschaltetem Display**: Display aus (`keyevent 223`), dann Netz an + 23:23:08 → Server bekam die 3 Fotos 23:26:29–30, Display schlief noch (`mWakefulness=Asleep`). Genau +3, keine + Dubletten. Chrome weckt sich dafür über einen Android-Hintergrundjob — **gut 3 Minuten Verzögerung sind normal**. +- [x] ✅ 19.10.2026 Emulator, **10 Fotos à 8,5 MB (81 MB) auf einmal aus der Galerie** (Android-Fotoauswahl): + alle 10 angekommen, Server genau +10, kein „permission"-Fehler (Fix aus Punkt 7 bestätigt). +- [x] ✅ 19.10.2026 Emulator, **Zwei-Finger-Zoom im PDF-Betrachter** mit echter Mehrfinger-Geste (Konsole, s. u.): + Fingerabstand 200 → 500 px ergibt 100 % → 250 %, Text scharf neu gezeichnet, die Seite selbst zoomt nicht mit. + +**Echte Zwei-Finger-Geste im Emulator** (ohne Root, Betriebssystem-Ebene): Emulator-Konsole `telnet localhost 5554`, +`auth <~/.emulator_console_auth_token>`, dann `event send EV_KEY:BTN_TOUCH:1 EV_ABS:ABS_MT_SLOT:0 +EV_ABS:ABS_MT_TRACKING_ID: EV_ABS:ABS_MT_POSITION_X:<0..32767> … EV_SYN:0:0` (EV_SYN nur als Zahl; +`BTN_TOUCH` und frische Tracking-IDs sind Pflicht, sonst meldet Android keine Berührung bzw. alte Finger „hängen"). +Skript: KB-Eintrag zur Baustellen-Kamera. + +## 11. Videos aufnehmen (gewünscht 2026-10-19) + +Eddy: *„Bei Fotos aufnehmen auch Videos unterstützen bitte"*. +- [x] ✅ 19.10.2026 **Kamera:** Umschalter „Foto | Video" über dem Auslöser, roter Aufnahmeknopf (läuft: rotes Quadrat), + Laufzeit oben „● 0:12 / 5:00", Ton mit (Mikrofon erst beim ersten Video angefragt; verweigert → stumm), Licht + (Blitz-Knopf zeigt beim Filmen nur Aus/Licht), Zoom und Antippen-Fokus auch beim Filmen. MP4 (H.264/AAC), sonst WebM; + 5 Mbit/s ≈ 38 MB/min; Höchstlänge 5 min, kürzer, wenn der Server weniger annimmt (`config.php upload_max`: lokal + 100 MB → 2:19). Schließen (✕/Fertig/Zurück) oder App-Wechsel während der Aufnahme **sichert** das Video. +- [x] ✅ Warteschlange: Videos laufen wie Fotos (Persist-First, `type` bleibt `'photo'`, sonst löschten ältere + Fassungen den Eintrag). Gesichert wird jetzt der **Blob** statt eines ArrayBuffers (auch bei Fotos) — damit + erledigt sich auch „`queueAll()` lädt alle Blobs" für neue Einträge. Upload-Zeitlimit und Lease wachsen mit der + Größe (45 s + 1 s je 100 KB; gleiche Formel in `api.js` und `sw.js`, Lease steht im Eintrag `lease_ms`). + 413 (zu groß) → sofort Quarantäne, auch im Service Worker. +- [x] ✅ **[API]** Bericht 1.8.0: `orders.php` nimmt mp4/m4v/mov/webm (Inhalt geprüft: `ftyp` ohne Bildmarke bzw. + EBML), Name `video_…`, `taken_at` → Dateidatum; Aufnahmezeit sonst aus dem MP4 (`mvhd`); `photo.php` mit Range + (206); `config.php` meldet `upload_max`; zu groß → 413 mit Meldung. Bericht-Editor: 🎬-Kachel, Übernahme in einen + Bericht lehnt `add_attachment.php` ab (nur Bild/PDF) — das PDF kann daran nicht scheitern. +- [x] ✅ Auftrag: eigener Abschnitt „🎬 Videos (n)" mit erstem Bild, Datum, Größe; Antippen = Player (spult dank Range), + 📤 Teilen, 🗑 Löschen; Mehrfachauswahl/Teilen schließt Videos ein. „Warten auf Upload" zeigt Videos mit erstem + Bild aus dem Gerätespeicher und zählt „2 Fotos und 1 Video". +- [x] ✅ Galerie und „Teilen" nehmen Videos; ein Video über `upload_max` wird gleich mit Meldung abgelehnt. +- [x] ✅ Im Emulator geprüft: Aufnahme mit Ton, Zoom und Licht → MP4 H.264/AAC auf dem Server; im Funkloch filmen + + Kamera mitten in der Aufnahme schließen → gesichert, nach Netz-Rückkehr hochgeladen, nach Aufnahmezeit + einsortiert; Galerie: 1,9-MB-Video hoch, 124-MB-Video mit Meldung abgelehnt; Player spielt (206), Löschen geht. +- [ ] **Am Handy beim nächsten Einsatz:** Auflösung/Bildrate eines echten Videos (der Sucher fordert bis 4K an; bei 4K + verteilt sich die 5-Mbit/s-Rate auf mehr Pixel) — Wert an der ersten Datei auf Prod ablesen (ffprobe). ## 8. Knopf „Geteiltes ablegen" bleibt dauerhaft stehen (gemeldet 2026-10-19) diff --git a/app.css b/app.css index 128cb4e..dfd8034 100644 --- a/app.css +++ b/app.css @@ -268,22 +268,21 @@ body { } .detail-section h3 { margin: 0 0 8px; - font-size: 13px; - text-transform: uppercase; - color: #7aa2f7; - letter-spacing: 0.5px; + font-size: 15px; + font-weight: 700; + color: #7aa2f7; /* fett in normaler Schreibweise — keine Versalien (Eddy) */ } .detail-section p { margin: 4px 0; font-size: 14px; } .detail-section .label { opacity: 0.6; font-size: 12px; } -.photo-grid { +.photo-grid, .video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; margin-top: 10px; max-width: 600px; } -.photo-grid .thumb { +.photo-grid .thumb, .video-grid .thumb { aspect-ratio: 1; max-width: 160px; background: #2a2a30; @@ -292,7 +291,7 @@ body { position: relative; cursor: pointer; } -.photo-grid .thumb img { +.photo-grid .thumb img, .photo-grid .thumb video { width: 100%; height: 100%; object-fit: cover; @@ -317,7 +316,23 @@ body { .pending-section h3 { color: #e0af68; } .pending-section p { line-height: 1.45; } .pending-grid .pending-thumb { cursor: pointer; } -.pending-grid .pending-thumb img { opacity: 0.7; } +.pending-grid .pending-thumb img, .pending-grid .pending-thumb video { opacity: 0.7; width: 100%; height: 100%; object-fit: cover; display: block; } +.pending-grid .pending-play, .video-grid .video-play { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + pointer-events: none; +} .pending-grid .pending-mark { position: absolute; right: 4px; @@ -358,7 +373,7 @@ body { .btn-small:hover { background: #33333a; } /* Auswahl-Mode im Photo-Grid */ -.photo-grid .thumb .thumb-check { +.photo-grid .thumb .thumb-check, .video-grid .thumb .thumb-check { position: absolute; top: 6px; right: 6px; @@ -375,9 +390,9 @@ body { border: 2px solid #fff; pointer-events: none; } -.photo-grid.selecting .thumb-check { display: flex; opacity: 0.35; } -.photo-grid.selecting .thumb.selected { outline: 3px solid #5ab0ff; outline-offset: -3px; } -.photo-grid.selecting .thumb.selected .thumb-check { +.photo-grid.selecting .thumb-check, .video-grid.selecting .thumb-check { display: flex; opacity: 0.35; } +.photo-grid.selecting .thumb.selected, .video-grid.selecting .thumb.selected { outline: 3px solid #5ab0ff; outline-offset: -3px; } +.photo-grid.selecting .thumb.selected .thumb-check, .video-grid.selecting .thumb.selected .thumb-check { opacity: 1; background: #5ab0ff; border-color: #fff; @@ -1311,6 +1326,61 @@ body { } .cam-shutter:active { transform: scale(0.92); } .cam-shutter.flash { background: #ffd34d; } +/* Video: roter Knopf, während der Aufnahme ein rotes Quadrat im Ring */ +.cam-shutter.video { background: #e53935; } +.cam-shutter.video.laeuft { background: linear-gradient(#e53935, #e53935) center / 26px 26px no-repeat, #111; } +/* Umschalter Foto | Video über dem Auslöser */ +.camera-modal .cam-modus { + display: flex; + justify-content: center; + gap: 8px; + padding: 8px 0 0; + background: #000; +} +.camera-modal .cam-modus-btn { + background: transparent; + border: none; + color: #bbb; + font-size: 15px; + font-weight: 600; + padding: 6px 16px; + border-radius: 16px; + cursor: pointer; +} +.camera-modal .cam-modus-btn.aktiv { color: #ffd34d; background: rgba(255, 255, 255, 0.12); } +.camera-modal .cam-modus.gesperrt .cam-modus-btn { opacity: 0.4; pointer-events: none; } +/* Laufzeit oben im Sucher */ +.camera-modal .cam-rec { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.6); + color: #fff; + padding: 4px 12px; + border-radius: 14px; + font-size: 14px; + font-weight: 600; + font-variant-numeric: tabular-nums; + pointer-events: none; +} +.camera-modal .cam-rec::first-letter { color: #e53935; } +#cam-flip:disabled { opacity: 0.35; } +/* Video im Filmstreifen */ +.cam-thumb .cam-thumb-leer { + width: 100%; height: 100%; + display: flex; align-items: center; justify-content: center; + font-size: 22px; background: #222; +} +.cam-thumb .cam-thumb-play { + position: absolute; + left: 50%; top: 50%; + transform: translate(-50%, -50%); + color: #fff; + font-size: 16px; + text-shadow: 0 0 4px #000; + pointer-events: none; +} /* Blitz-Knopf: Text statt nur Symbol, damit der Zustand lesbar ist (Aus/Auto/An/Licht) */ .camera-modal .cam-blitz { font-size: 15px; white-space: nowrap; } /* Kamera wechseln: weisses Symbol wie ✕, kein Emoji (das erschien als blaues Kaestchen) */ @@ -1638,3 +1708,23 @@ body.hat-geteiltes .fab { bottom: calc(112px + env(safe-area-inset-bottom, 0px)) .pdfv-btn.pdfv-wide { font-size: 15px; min-width: 84px; } .pdfv-info { margin-left: auto; color: #bbb; font-size: 13px; } .pdf-inline-wrap canvas { cursor: zoom-in; } + +/* Videos am Auftrag: erstes Bild als Vorschau, ▶ in der Mitte, Datum/Größe unten */ +.video-grid .video-poster { width: 100%; height: 100%; object-fit: cover; display: block; background: #111; pointer-events: none; } +.video-grid .video-meta { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 3px 5px; + font-size: 11px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} +/* Video-Player (Vollbild) */ +.video-modal .fs-body { background: #000; } +.video-modal video { width: 100%; height: 100%; object-fit: contain; background: #000; } diff --git a/app.js b/app.js index 07f1f3e..a21f6f7 100644 --- a/app.js +++ b/app.js @@ -817,10 +817,16 @@ router.on('/orders/:id', async (args) => { const notesCount = notes.notes.length; title(data.order.ref); - // Nach MIME-Type aufteilen + // Nach MIME-Type aufteilen. Videos am Namen: der Server nennt sie video_… (mp4/m4v/mov/webm). + // Ein WebM ohne video_ ist eine Sprachnotiz (voice_….webm) — Dolibarr meldet fuer .webm + // immer video/webm, am MIME-Typ allein liessen sich beide nicht trennen. + const istVideo = (p) => /^video_/i.test(p.filename) || /\.(mp4|m4v|mov)$/i.test(p.filename); + const videoFiles = photos.photos.filter(istVideo); const imagePhotos = photos.photos.filter(p => (p.mime || '').startsWith('image/')); - const audioFiles = photos.photos.filter(p => (p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename)); + const audioFiles = photos.photos.filter(p => !istVideo(p) + && ((p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename))); const otherDocs = photos.photos.filter(p => + !istVideo(p) && !(p.mime || '').startsWith('image/') && !(p.mime || '').startsWith('audio/') && !/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename) && @@ -845,7 +851,7 @@ router.on('/orders/:id', async (args) => { - + @@ -857,12 +863,24 @@ router.on('/orders/:id', async (args) => {

Hochgeladene Fotos (${imagePhotos.length})

- ${(imagePhotos.length + otherDocs.length) ? '' : ''} + ${(imagePhotos.length + videoFiles.length + otherDocs.length) ? '' : ''}
${imagePhotos.map(p => `
⏳
✓
`).join('')}
+ ${videoFiles.length ? ` +
+

🎬 Videos (${videoFiles.length})

+
+ ${videoFiles.map(v => `
+ + ▶ + ${escapeHtml(formatShortDate(v.taken_at || v.date))} · ${escapeHtml(formatFileSize(v.size))} +
✓
+
`).join('')} +
+
` : ''} ${audioFiles.length ? `

🎙 Sprachnotizen (${audioFiles.length})

@@ -894,6 +912,7 @@ router.on('/orders/:id', async (args) => { // Foto-Thumbnails: Tap = Vollbild-Modal (oder Auswahl im Select-Modus) const photoGrid = document.getElementById('photo-grid'); + const videoGrid = document.getElementById('video-grid'); const docList = document.querySelector('.doc-list'); photoGrid.querySelectorAll('.thumb').forEach(t => { t.addEventListener('click', () => { @@ -905,6 +924,18 @@ router.on('/orders/:id', async (args) => { } }); }); + // Videos: Tap = Abspielen (oder Auswahl im Select-Modus) + if (videoGrid) videoVorschau(videoGrid); + if (videoGrid) videoGrid.querySelectorAll('.thumb').forEach(t => { + t.addEventListener('click', () => { + if (videoGrid.classList.contains('selecting')) { + t.classList.toggle('selected'); + updateSelectBar(); + } else { + openVideoPlayer(args.id, t.dataset.relpath, t.dataset.filename); + } + }); + }); // Mehrfachauswahl-Modus const selectBtn = document.getElementById('btn-select-mode'); @@ -912,12 +943,14 @@ router.on('/orders/:id', async (args) => { function allSelectables() { const thumbs = Array.from(photoGrid.querySelectorAll('.thumb[data-relpath]')); + const vids = videoGrid ? Array.from(videoGrid.querySelectorAll('.thumb[data-relpath]')) : []; const docs = docList ? Array.from(docList.querySelectorAll('.doc-item[data-relpath]')) : []; - return thumbs.concat(docs); + return thumbs.concat(vids, docs); } function startSelectMode() { if (photoGrid.classList.contains('selecting')) return; photoGrid.classList.add('selecting'); + if (videoGrid) videoGrid.classList.add('selecting'); if (docList) docList.classList.add('selecting'); renderSelectBar(); updateSelectBar(); @@ -928,6 +961,7 @@ router.on('/orders/:id', async (args) => { function endSelectMode() { selectModeCleanup = null; photoGrid.classList.remove('selecting'); + if (videoGrid) videoGrid.classList.remove('selecting'); if (docList) docList.classList.remove('selecting'); allSelectables().forEach(x => x.classList.remove('selected')); const bar = document.getElementById('select-bar'); @@ -1100,10 +1134,18 @@ router.on('/orders/:id', async (args) => { // Prod-Log: nur 2 upload_photo-POSTs). Eine File aus dem Speicher bleibt lesbar. const copies = []; for (const f of Array.from(files)) { + // Zu grosses Video gleich ablehnen — es bliebe sonst fuer immer in der + // Warteschlange haengen. Es liegt ja weiter in der Galerie. + const max = photoCfg.upload_max || 0; + if (/^video\//.test(f.type || '') && max && f.size > max) { + showToast('Video ' + (f.name || '') + ' ist zu groß (' + Math.round(f.size / 1048576) + + ' MB) — der Server nimmt höchstens ' + Math.round(max / 1048576) + ' MB', 'error'); + continue; + } try { copies.push(await readFileNow(f)); } catch (e) { - showToast('Foto ' + (f.name || '') + ' konnte nicht gelesen werden — bitte nochmal auswählen', 'error'); + showToast('Datei ' + (f.name || '') + ' konnte nicht gelesen werden — bitte nochmal auswählen', 'error'); } } for (const f of copies) { @@ -1111,7 +1153,7 @@ router.on('/orders/:id', async (args) => { try { await uploadPhoto(args.id, f); } catch (e) { - showToast('Foto ' + (f.name || '') + ' fehlgeschlagen: ' + ((e && e.message) || ''), 'error'); + showToast('Datei ' + (f.name || '') + ' fehlgeschlagen: ' + ((e && e.message) || ''), 'error'); } } } finally { @@ -1130,6 +1172,57 @@ router.on('/orders/:id', async (args) => { } }); +/* ============================================================ + * VIDEO ABSPIELEN + * + * Direkt vom Server: photo.php liefert seit Bericht 1.8.0 Teilbereiche (Range), das Video + * spielt also sofort und laesst sich spulen, ohne vorher komplett geladen zu werden. + * Teilen laedt es einmal ganz. Kann der Browser das Format nicht (z. B. HEVC-.mov vom + * iPhone), bleibt Teilen bzw. Dolibarr. + * ============================================================ */ +function openVideoPlayer(orderId, relpath, filename) { + const modal = document.createElement('div'); + modal.className = 'fullscreen-modal video-modal'; + modal.innerHTML = ` +
+ +
🎬 ${escapeHtml(filename || 'Video')}
+ + +
+
+ `; + document.body.appendChild(modal); + const v = modal.querySelector('video'); + v.onerror = () => showToast('Dieses Video kann der Browser nicht abspielen — über 📤 teilen oder in Dolibarr öffnen', 'warn'); + v.src = api.photoUrl(relpath); + pushModal(modal, () => { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (_) {} }); + + modal.querySelector('#vp-close').onclick = () => closeModal(modal); + modal.querySelector('#vp-share').onclick = async () => { + showToast('Lade Video…'); + try { + const f = await api.getFileBlobUrl(relpath); + if (!f) { showToast('Video konnte nicht geladen werden', 'error'); return; } + await shareFile(f.blob, filename || relpath.split('/').pop(), f.mime, 'Video vom Auftrag'); + } catch (e) { showToast('Fehler: ' + e.message, 'error'); } + }; + modal.querySelector('#vp-delete').onclick = async () => { + if (!await confirmDialog({ + title: 'Video löschen', + text: 'Das Video wird endgültig aus den Dokumenten des Auftrags entfernt.', + confirmLabel: 'Löschen', + danger: true, + })) return; + try { + await api.deletePhoto(relpath); + showToast('✓ Gelöscht'); + closeModal(modal); + router.go('#/orders/' + orderId); // ausdruecklich diesen Auftrag (History-Race, siehe Kamera) + } catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); } + }; +} + /* ============================================================ * WARTENDE FOTOS AM AUFTRAG * @@ -1161,25 +1254,29 @@ async function renderPendingPhotos() { const failed = items.filter(i => i.failed).length; const waiting = items.length - failed; - const fotos = (n) => n + (n === 1 ? ' Foto' : ' Fotos'); + const wartende = items.filter(i => !i.failed); const lines = []; if (waiting) { lines.push(navigator.onLine === false - ? '📴 Kein Netz — ' + fotos(waiting) + (waiting === 1 ? ' ist' : ' sind') + ? '📴 Kein Netz — ' + medienText(wartende) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'wird' : 'werden') + ' gesendet, sobald wieder Netz da ist.' - : fotos(waiting) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und ' + : medienText(wartende) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'geht' : 'gehen') + ' raus, sobald die Verbindung steht.'); } - if (failed) lines.push('⚠️ ' + fotos(failed) + ' konnte' + (failed === 1 ? '' : 'n') + if (failed) lines.push('⚠️ ' + medienText(items.filter(i => i.failed)) + ' konnte' + (failed === 1 ? '' : 'n') + ' nicht hochgeladen werden — antippen zum Prüfen.'); const shown = items.slice(0, PENDING_PREVIEW_MAX); const thumbs = shown.map(it => { const url = URL.createObjectURL(new Blob([it.data], { type: it.mime || 'image/jpeg' })); pendingPhotoUrls.push(url); + // Video: erstes Bild direkt aus dem Geraetespeicher (kein Netz noetig) + const bild = istQueueVideo(it) + ? '▶' + : ''; return '
' - + '' + + bild + '' + (it.failed ? '⚠️' : '⏳') + '
'; }).join(''); const more = items.length - shown.length; @@ -1190,21 +1287,54 @@ async function renderPendingPhotos() { + '
' + thumbs + (more > 0 ? '
+' + more + '
' : '') + '
'; + videoVorschau(box); box.querySelector('.pending-section').onclick = () => openUploadQueueModal(); } /* Nach dem Schliessen der Kamera sagen, was mit den Fotos ist. Ohne den Satz sieht * "gesichert, aber noch nicht gesendet" genauso aus wie "alles erledigt". */ async function announcePendingPhotos(orderId) { - const n = (await offline.listQueue().catch(() => [])) - .filter(i => i.type === 'photo' && String(i.order_id) === String(orderId)).length; + const items = (await offline.listQueue().catch(() => [])) + .filter(i => i.type === 'photo' && String(i.order_id) === String(orderId)); + const n = items.length; if (!n) return; - const fotos = n + (n === 1 ? ' Foto' : ' Fotos'); + const fotos = medienText(items); showToast(navigator.onLine === false ? '📴 Kein Netz — ' + fotos + ' auf dem Gerät gesichert, Upload folgt automatisch' : '⏳ ' + fotos + (n === 1 ? ' wartet' : ' warten') + ' noch auf den Upload', 'warn'); } +/* Vorschaubild eines Videos (Kachel): Chrome auf Android malt ein