Videos aufnehmen, Emulator-Prüfungen nachgeholt, keine Versalien [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
Eddy: „Bei Fotos aufnehmen auch Videos unterstützen bitte". - Kamera: Umschalter Foto | Video, roter Aufnahmeknopf, Laufzeit mit Höchstlänge (5 min bzw. passend zur Servergrenze), Ton, Licht/Zoom/ Antippen-Fokus auch beim Filmen; MP4 (H.264/AAC), sonst WebM. Schließen oder App-Wechsel während der Aufnahme sichert das Video. - Warteschlange: Videos wie Fotos (Persist-First). Gesichert wird der Blob statt eines ArrayBuffers; Zeitlimit und Lease wachsen mit der Größe (api.js/sw.js gleiche Formel); 413 → sofort Quarantäne. - Auftrag: Abschnitt „Videos" mit erstem Bild, Player (spult per Range), Teilen, Löschen; „Warten auf Upload" zeigt Videos mit. - Galerie und Teilen nehmen Videos, zu große lehnt die App gleich ab. - Überschriften fett in normaler Schreibweise statt Versalien. - ROADMAP: Funkloch, Display aus, 10 Fotos aus der Galerie und PDF-Zoom im Emulator geprüft und abgehakt. Braucht Bericht 1.8.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3028ff5731
commit
73a481fa8f
10 changed files with 656 additions and 103 deletions
10
README.md
10
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)
|
- ✅ 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.)
|
- ✅ **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
|
- ✅ 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)
|
- ✅ **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)
|
- ✅ **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)
|
- ✅ **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
|
- ✅ **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
|
- ✅ **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)
|
- ✅ 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
|
- ✅ **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`
|
- ✅ **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
|
- ✅ 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
|
- ✅ **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
|
- ✅ **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
|
- ✅ 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
|
||||||
|
|
|
||||||
65
ROADMAP.md
65
ROADMAP.md
|
|
@ -1,6 +1,6 @@
|
||||||
# ROADMAP — Baustelle PWA
|
# 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
|
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
|
API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des
|
||||||
|
|
@ -10,15 +10,13 @@ Bericht-Moduls.
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
- [ ] `idb.queueAll()` lädt **alle** Foto-Blobs in den Speicher — auch `updateBadge()`, das
|
- [x] ✅ 19.10.2026 (ohne Schema-Änderung): `idb.queueAll()` lud **alle** Foto-Daten in den Speicher, weil sie
|
||||||
nur zählen will. Bei 20 Originalen à 8 MB sind das 160 MB je Badge-Update. Braucht ein
|
als ArrayBuffer gesichert waren. Neue Einträge tragen den Blob selbst — Chrome legt ihn als Datei ab und liest
|
||||||
Zähl-Verfahren ohne Werte (Schlüssel + kleiner Meta-Store); Schema-Änderung, weil
|
ihn erst beim Hochladen; `getAll()` liefert nur den Verweis. Alte ArrayBuffer-Einträge laufen wie bisher ab.
|
||||||
`sw.js`/`share.html` die DB mit fester Version 1 öffnen.
|
- [x] ✅ 19.10.2026 **im Android-Emulator nachgeholt** (echter Chrome/Camera2-Weg, siehe Punkt 10): Kamera + „Fertig"
|
||||||
- [ ] **Am Handy gegenprüfen** (geht nur auf dem Gerät, am Schreibtisch nicht nachstellbar):
|
im Funkloch, Hochladen bei ausgeschaltetem Display, Zwei-Finger-Zoom im PDF-Betrachter. Was nur das echte
|
||||||
echte Kamera-Hardware mit „Fertig" im Funkloch, Background Sync bei ausgeschaltetem
|
Gerät zeigt (Blitzhelligkeit, Fokus-Treiber, Video-Auflösung), steht bei Punkt 9/11. Wenn etwas komisch
|
||||||
Display, und der Zwei-Finger-Zoom im PDF-Betrachter (lokal nur per CDP-Touch simuliert). Beides lief lokal nur mit nachgebildeter Kamera (`canvas.captureStream()`).
|
aussieht: Tag + ungefähre Uhrzeit reichen, der Rest steht im Prod-Zugriffslog (Lesart KB #208).
|
||||||
Wenn etwas komisch aussieht: Tag + ungefähre Uhrzeit reichen, der Rest steht im
|
|
||||||
Prod-Zugriffslog (Lesart siehe KB #208, Nachtrag 18.09.2026).
|
|
||||||
|
|
||||||
**Bewusst nicht gebaut** (Entscheidungen, nicht vergessen):
|
**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
|
- [x] ✅ Belichtung: **kein eigener Regler** — Antippen setzt bei Chrome Fokus-, Belichtungs- und Weißabgleich-Bereich
|
||||||
gemeinsam; im Emulator gemessen: `aeRegions` = `afRegions` = `awbRegions` = Tippstelle
|
gemeinsam; im Emulator gemessen: `aeRegions` = `afRegions` = `awbRegions` = Tippstelle
|
||||||
→ Display/Typenschild antippen = dort scharf UND richtig belichtet.
|
→ 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)
|
- [x] ✅ 19.10.2026 Emulator, **echte Kamera + „Fertig" im Funkloch** (Flugmodus + `adb reverse --remove-all`, Server
|
||||||
- [ ] Emulator: Hochladen bei ausgeschaltetem Display (Background Sync)
|
nachweislich unerreichbar): 3 Fotos → Warteschlange 3, nach „Fertig" bleibt die App im Auftrag (Fehler aus Punkt 6
|
||||||
- [ ] Emulator: 8–10 Fotos auf einmal aus der Galerie (Fix aus Punkt 7)
|
bestätigt behoben), Band „Kein Netz — Stand von 23:20 Uhr", 🔴 3, Kasten „Warten auf Upload (3)".
|
||||||
- [ ] Emulator: Zwei-Finger-Zoom im PDF-Betrachter mit echtem Mehrfinger-Touch (statt CDP-Nachbildung)
|
- [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:<neu> 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)
|
## 8. Knopf „Geteiltes ablegen" bleibt dauerhaft stehen (gemeldet 2026-10-19)
|
||||||
|
|
||||||
|
|
|
||||||
114
app.css
114
app.css
|
|
@ -268,22 +268,21 @@ body {
|
||||||
}
|
}
|
||||||
.detail-section h3 {
|
.detail-section h3 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
text-transform: uppercase;
|
font-weight: 700;
|
||||||
color: #7aa2f7;
|
color: #7aa2f7; /* fett in normaler Schreibweise — keine Versalien (Eddy) */
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
.detail-section p { margin: 4px 0; font-size: 14px; }
|
.detail-section p { margin: 4px 0; font-size: 14px; }
|
||||||
.detail-section .label { opacity: 0.6; font-size: 12px; }
|
.detail-section .label { opacity: 0.6; font-size: 12px; }
|
||||||
|
|
||||||
.photo-grid {
|
.photo-grid, .video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
.photo-grid .thumb {
|
.photo-grid .thumb, .video-grid .thumb {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
max-width: 160px;
|
max-width: 160px;
|
||||||
background: #2a2a30;
|
background: #2a2a30;
|
||||||
|
|
@ -292,7 +291,7 @@ body {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.photo-grid .thumb img {
|
.photo-grid .thumb img, .photo-grid .thumb video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
@ -317,7 +316,23 @@ body {
|
||||||
.pending-section h3 { color: #e0af68; }
|
.pending-section h3 { color: #e0af68; }
|
||||||
.pending-section p { line-height: 1.45; }
|
.pending-section p { line-height: 1.45; }
|
||||||
.pending-grid .pending-thumb { cursor: pointer; }
|
.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 {
|
.pending-grid .pending-mark {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
|
|
@ -358,7 +373,7 @@ body {
|
||||||
.btn-small:hover { background: #33333a; }
|
.btn-small:hover { background: #33333a; }
|
||||||
|
|
||||||
/* Auswahl-Mode im Photo-Grid */
|
/* Auswahl-Mode im Photo-Grid */
|
||||||
.photo-grid .thumb .thumb-check {
|
.photo-grid .thumb .thumb-check, .video-grid .thumb .thumb-check {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
|
|
@ -375,9 +390,9 @@ body {
|
||||||
border: 2px solid #fff;
|
border: 2px solid #fff;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.photo-grid.selecting .thumb-check { display: flex; opacity: 0.35; }
|
.photo-grid.selecting .thumb-check, .video-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, .video-grid.selecting .thumb.selected { outline: 3px solid #5ab0ff; outline-offset: -3px; }
|
||||||
.photo-grid.selecting .thumb.selected .thumb-check {
|
.photo-grid.selecting .thumb.selected .thumb-check, .video-grid.selecting .thumb.selected .thumb-check {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: #5ab0ff;
|
background: #5ab0ff;
|
||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
|
|
@ -1311,6 +1326,61 @@ body {
|
||||||
}
|
}
|
||||||
.cam-shutter:active { transform: scale(0.92); }
|
.cam-shutter:active { transform: scale(0.92); }
|
||||||
.cam-shutter.flash { background: #ffd34d; }
|
.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) */
|
/* 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; }
|
.camera-modal .cam-blitz { font-size: 15px; white-space: nowrap; }
|
||||||
/* Kamera wechseln: weisses Symbol wie ✕, kein Emoji (das erschien als blaues Kaestchen) */
|
/* 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-btn.pdfv-wide { font-size: 15px; min-width: 84px; }
|
||||||
.pdfv-info { margin-left: auto; color: #bbb; font-size: 13px; }
|
.pdfv-info { margin-left: auto; color: #bbb; font-size: 13px; }
|
||||||
.pdf-inline-wrap canvas { cursor: zoom-in; }
|
.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; }
|
||||||
|
|
|
||||||
435
app.js
435
app.js
|
|
@ -817,10 +817,16 @@ router.on('/orders/:id', async (args) => {
|
||||||
const notesCount = notes.notes.length;
|
const notesCount = notes.notes.length;
|
||||||
title(data.order.ref);
|
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 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 =>
|
const otherDocs = photos.photos.filter(p =>
|
||||||
|
!istVideo(p) &&
|
||||||
!(p.mime || '').startsWith('image/') &&
|
!(p.mime || '').startsWith('image/') &&
|
||||||
!(p.mime || '').startsWith('audio/') &&
|
!(p.mime || '').startsWith('audio/') &&
|
||||||
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename) &&
|
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename) &&
|
||||||
|
|
@ -845,7 +851,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
<button class="btn btn-large" id="btn-take-photo">📷 Foto aufnehmen</button>
|
<button class="btn btn-large" id="btn-take-photo">📷 Foto aufnehmen</button>
|
||||||
<input type="file" id="camera-input" class="hidden-input" accept="image/*" capture="environment" multiple>
|
<input type="file" id="camera-input" class="hidden-input" accept="image/*" capture="environment" multiple>
|
||||||
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
|
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
|
||||||
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
|
<input type="file" id="gallery-input" class="hidden-input" accept="image/*,video/*" multiple>
|
||||||
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
|
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
|
||||||
<button class="btn btn-secondary" id="btn-notes">📝 Notizen${notesCount ? ' (' + notesCount + ')' : ''}</button>
|
<button class="btn btn-secondary" id="btn-notes">📝 Notizen${notesCount ? ' (' + notesCount + ')' : ''}</button>
|
||||||
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</button>
|
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</button>
|
||||||
|
|
@ -857,12 +863,24 @@ router.on('/orders/:id', async (args) => {
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<div class="photo-section-head">
|
<div class="photo-section-head">
|
||||||
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
|
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
|
||||||
${(imagePhotos.length + otherDocs.length) ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
|
${(imagePhotos.length + videoFiles.length + otherDocs.length) ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-grid" id="photo-grid">
|
<div class="photo-grid" id="photo-grid">
|
||||||
${imagePhotos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div><div class="thumb-check">✓</div></div>`).join('')}
|
${imagePhotos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div><div class="thumb-check">✓</div></div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${videoFiles.length ? `
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>🎬 Videos (${videoFiles.length})</h3>
|
||||||
|
<div class="video-grid" id="video-grid">
|
||||||
|
${videoFiles.map(v => `<div class="thumb video-thumb" data-relpath="${escapeHtml(v.relpath)}" data-filename="${escapeHtml(v.filename)}">
|
||||||
|
<video class="video-poster" muted playsinline preload="metadata" data-vorschau src="${escapeHtml(api.photoUrl(v.relpath))}#t=0.1"></video>
|
||||||
|
<span class="video-play">▶</span>
|
||||||
|
<span class="video-meta">${escapeHtml(formatShortDate(v.taken_at || v.date))} · ${escapeHtml(formatFileSize(v.size))}</span>
|
||||||
|
<div class="thumb-check">✓</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
${audioFiles.length ? `
|
${audioFiles.length ? `
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>🎙 Sprachnotizen (${audioFiles.length})</h3>
|
<h3>🎙 Sprachnotizen (${audioFiles.length})</h3>
|
||||||
|
|
@ -894,6 +912,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
|
|
||||||
// Foto-Thumbnails: Tap = Vollbild-Modal (oder Auswahl im Select-Modus)
|
// Foto-Thumbnails: Tap = Vollbild-Modal (oder Auswahl im Select-Modus)
|
||||||
const photoGrid = document.getElementById('photo-grid');
|
const photoGrid = document.getElementById('photo-grid');
|
||||||
|
const videoGrid = document.getElementById('video-grid');
|
||||||
const docList = document.querySelector('.doc-list');
|
const docList = document.querySelector('.doc-list');
|
||||||
photoGrid.querySelectorAll('.thumb').forEach(t => {
|
photoGrid.querySelectorAll('.thumb').forEach(t => {
|
||||||
t.addEventListener('click', () => {
|
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
|
// Mehrfachauswahl-Modus
|
||||||
const selectBtn = document.getElementById('btn-select-mode');
|
const selectBtn = document.getElementById('btn-select-mode');
|
||||||
|
|
@ -912,12 +943,14 @@ router.on('/orders/:id', async (args) => {
|
||||||
|
|
||||||
function allSelectables() {
|
function allSelectables() {
|
||||||
const thumbs = Array.from(photoGrid.querySelectorAll('.thumb[data-relpath]'));
|
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]')) : [];
|
const docs = docList ? Array.from(docList.querySelectorAll('.doc-item[data-relpath]')) : [];
|
||||||
return thumbs.concat(docs);
|
return thumbs.concat(vids, docs);
|
||||||
}
|
}
|
||||||
function startSelectMode() {
|
function startSelectMode() {
|
||||||
if (photoGrid.classList.contains('selecting')) return;
|
if (photoGrid.classList.contains('selecting')) return;
|
||||||
photoGrid.classList.add('selecting');
|
photoGrid.classList.add('selecting');
|
||||||
|
if (videoGrid) videoGrid.classList.add('selecting');
|
||||||
if (docList) docList.classList.add('selecting');
|
if (docList) docList.classList.add('selecting');
|
||||||
renderSelectBar();
|
renderSelectBar();
|
||||||
updateSelectBar();
|
updateSelectBar();
|
||||||
|
|
@ -928,6 +961,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
function endSelectMode() {
|
function endSelectMode() {
|
||||||
selectModeCleanup = null;
|
selectModeCleanup = null;
|
||||||
photoGrid.classList.remove('selecting');
|
photoGrid.classList.remove('selecting');
|
||||||
|
if (videoGrid) videoGrid.classList.remove('selecting');
|
||||||
if (docList) docList.classList.remove('selecting');
|
if (docList) docList.classList.remove('selecting');
|
||||||
allSelectables().forEach(x => x.classList.remove('selected'));
|
allSelectables().forEach(x => x.classList.remove('selected'));
|
||||||
const bar = document.getElementById('select-bar');
|
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.
|
// Prod-Log: nur 2 upload_photo-POSTs). Eine File aus dem Speicher bleibt lesbar.
|
||||||
const copies = [];
|
const copies = [];
|
||||||
for (const f of Array.from(files)) {
|
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 {
|
try {
|
||||||
copies.push(await readFileNow(f));
|
copies.push(await readFileNow(f));
|
||||||
} catch (e) {
|
} 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) {
|
for (const f of copies) {
|
||||||
|
|
@ -1111,7 +1153,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
try {
|
try {
|
||||||
await uploadPhoto(args.id, f);
|
await uploadPhoto(args.id, f);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Foto ' + (f.name || '') + ' fehlgeschlagen: ' + ((e && e.message) || ''), 'error');
|
showToast('Datei ' + (f.name || '') + ' fehlgeschlagen: ' + ((e && e.message) || ''), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} 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 = `
|
||||||
|
<div class="fs-header">
|
||||||
|
<button class="icon-btn" id="vp-close">✕</button>
|
||||||
|
<div class="fs-title">🎬 ${escapeHtml(filename || 'Video')}</div>
|
||||||
|
<button class="icon-btn" id="vp-share" title="Teilen">📤</button>
|
||||||
|
<button class="icon-btn" id="vp-delete" title="Löschen">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div class="fs-body"><video controls playsinline autoplay preload="auto"></video></div>
|
||||||
|
`;
|
||||||
|
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
|
* WARTENDE FOTOS AM AUFTRAG
|
||||||
*
|
*
|
||||||
|
|
@ -1161,25 +1254,29 @@ async function renderPendingPhotos() {
|
||||||
|
|
||||||
const failed = items.filter(i => i.failed).length;
|
const failed = items.filter(i => i.failed).length;
|
||||||
const waiting = items.length - failed;
|
const waiting = items.length - failed;
|
||||||
const fotos = (n) => n + (n === 1 ? ' Foto' : ' Fotos');
|
const wartende = items.filter(i => !i.failed);
|
||||||
const lines = [];
|
const lines = [];
|
||||||
if (waiting) {
|
if (waiting) {
|
||||||
lines.push(navigator.onLine === false
|
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')
|
+ ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'wird' : 'werden')
|
||||||
+ ' gesendet, sobald wieder Netz da ist.'
|
+ ' 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.');
|
+ (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.');
|
+ ' nicht hochgeladen werden — antippen zum Prüfen.');
|
||||||
|
|
||||||
const shown = items.slice(0, PENDING_PREVIEW_MAX);
|
const shown = items.slice(0, PENDING_PREVIEW_MAX);
|
||||||
const thumbs = shown.map(it => {
|
const thumbs = shown.map(it => {
|
||||||
const url = URL.createObjectURL(new Blob([it.data], { type: it.mime || 'image/jpeg' }));
|
const url = URL.createObjectURL(new Blob([it.data], { type: it.mime || 'image/jpeg' }));
|
||||||
pendingPhotoUrls.push(url);
|
pendingPhotoUrls.push(url);
|
||||||
|
// Video: erstes Bild direkt aus dem Geraetespeicher (kein Netz noetig)
|
||||||
|
const bild = istQueueVideo(it)
|
||||||
|
? '<video src="' + url + '#t=0.1" muted playsinline preload="metadata" data-vorschau></video><span class="pending-play">▶</span>'
|
||||||
|
: '<img src="' + url + '" alt="" loading="lazy">';
|
||||||
return '<div class="thumb pending-thumb' + (it.failed ? ' is-failed' : '') + '">'
|
return '<div class="thumb pending-thumb' + (it.failed ? ' is-failed' : '') + '">'
|
||||||
+ '<img src="' + url + '" alt="" loading="lazy">'
|
+ bild
|
||||||
+ '<span class="pending-mark">' + (it.failed ? '⚠️' : '⏳') + '</span></div>';
|
+ '<span class="pending-mark">' + (it.failed ? '⚠️' : '⏳') + '</span></div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
const more = items.length - shown.length;
|
const more = items.length - shown.length;
|
||||||
|
|
@ -1190,21 +1287,54 @@ async function renderPendingPhotos() {
|
||||||
+ '<div class="photo-grid pending-grid">' + thumbs
|
+ '<div class="photo-grid pending-grid">' + thumbs
|
||||||
+ (more > 0 ? '<div class="thumb pending-thumb pending-more">+' + more + '</div>' : '')
|
+ (more > 0 ? '<div class="thumb pending-thumb pending-more">+' + more + '</div>' : '')
|
||||||
+ '</div></div>';
|
+ '</div></div>';
|
||||||
|
videoVorschau(box);
|
||||||
box.querySelector('.pending-section').onclick = () => openUploadQueueModal();
|
box.querySelector('.pending-section').onclick = () => openUploadQueueModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nach dem Schliessen der Kamera sagen, was mit den Fotos ist. Ohne den Satz sieht
|
/* 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". */
|
* "gesichert, aber noch nicht gesendet" genauso aus wie "alles erledigt". */
|
||||||
async function announcePendingPhotos(orderId) {
|
async function announcePendingPhotos(orderId) {
|
||||||
const n = (await offline.listQueue().catch(() => []))
|
const items = (await offline.listQueue().catch(() => []))
|
||||||
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId)).length;
|
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId));
|
||||||
|
const n = items.length;
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
const fotos = n + (n === 1 ? ' Foto' : ' Fotos');
|
const fotos = medienText(items);
|
||||||
showToast(navigator.onLine === false
|
showToast(navigator.onLine === false
|
||||||
? '📴 Kein Netz — ' + fotos + ' auf dem Gerät gesichert, Upload folgt automatisch'
|
? '📴 Kein Netz — ' + fotos + ' auf dem Gerät gesichert, Upload folgt automatisch'
|
||||||
: '⏳ ' + fotos + (n === 1 ? ' wartet' : ' warten') + ' noch auf den Upload', 'warn');
|
: '⏳ ' + fotos + (n === 1 ? ' wartet' : ' warten') + ' noch auf den Upload', 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Vorschaubild eines Videos (Kachel): Chrome auf Android malt ein <video preload="metadata">
|
||||||
|
* erst nach einem Sprung — der Zeitanker #t=0.1 in der Adresse allein laesst die Kachel schwarz
|
||||||
|
* (Emulator, 19.10.2026: das Bild hatte Helligkeit 231, angezeigt wurde Schwarz). Also nach den
|
||||||
|
* Metadaten einmal ausdruecklich auf 0,1 s springen. */
|
||||||
|
function videoVorschau(scope) {
|
||||||
|
scope.querySelectorAll('video[data-vorschau]').forEach(v => {
|
||||||
|
v.removeAttribute('data-vorschau');
|
||||||
|
const springen = () => {
|
||||||
|
try { v.currentTime = Math.min(0.1, (isFinite(v.duration) && v.duration) || 0.1); } catch (_) {}
|
||||||
|
};
|
||||||
|
if (v.readyState >= 1) springen(); // Metadaten schon da (Cache)
|
||||||
|
else v.addEventListener('loadedmetadata', springen, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Videos laufen als type 'photo' durch die Warteschlange (siehe offline.enqueuePhoto) —
|
||||||
|
* erkannt werden sie am MIME-Typ bzw. am Namen. */
|
||||||
|
function istQueueVideo(it) {
|
||||||
|
return /^video\//.test(it.mime || '') || /^video_/i.test(it.filename || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// „3 Fotos", „1 Video", „2 Fotos und 1 Video"
|
||||||
|
function medienText(items) {
|
||||||
|
const v = items.filter(istQueueVideo).length;
|
||||||
|
const f = items.length - v;
|
||||||
|
const teile = [];
|
||||||
|
if (f) teile.push(f + (f === 1 ? ' Foto' : ' Fotos'));
|
||||||
|
if (v) teile.push(v + (v === 1 ? ' Video' : ' Videos'));
|
||||||
|
return teile.join(' und ');
|
||||||
|
}
|
||||||
|
|
||||||
function renderPendingPhotosSoon() {
|
function renderPendingPhotosSoon() {
|
||||||
if (!document.getElementById('pending-photos')) return;
|
if (!document.getElementById('pending-photos')) return;
|
||||||
clearTimeout(pendingRenderTimer);
|
clearTimeout(pendingRenderTimer);
|
||||||
|
|
@ -1244,13 +1374,15 @@ window.addEventListener('offline', renderPendingPhotosSoon);
|
||||||
const PHOTO_CFG_KEY = 'baustelle.photo_cfg';
|
const PHOTO_CFG_KEY = 'baustelle.photo_cfg';
|
||||||
let photoCfg = (function () {
|
let photoCfg = (function () {
|
||||||
try { return JSON.parse(localStorage.getItem(PHOTO_CFG_KEY)) || null; } catch (_) { return null; }
|
try { return JSON.parse(localStorage.getItem(PHOTO_CFG_KEY)) || null; } catch (_) { return null; }
|
||||||
})() || { photo_maxside: 0, photo_quality: 100 };
|
})() || { photo_maxside: 0, photo_quality: 100, upload_max: 0 };
|
||||||
|
|
||||||
async function refreshPhotoConfig() {
|
async function refreshPhotoConfig() {
|
||||||
try {
|
try {
|
||||||
const c = await api.getConfig();
|
const c = await api.getConfig();
|
||||||
if (c && typeof c.photo_maxside === 'number') {
|
if (c && typeof c.photo_maxside === 'number') {
|
||||||
photoCfg = { photo_maxside: c.photo_maxside, photo_quality: c.photo_quality || 100 };
|
// upload_max (Bytes, Bericht ≥ 1.8.0): bestimmt die Hoechstlaenge eines Videos
|
||||||
|
photoCfg = { photo_maxside: c.photo_maxside, photo_quality: c.photo_quality || 100,
|
||||||
|
upload_max: c.upload_max || 0 };
|
||||||
localStorage.setItem(PHOTO_CFG_KEY, JSON.stringify(photoCfg));
|
localStorage.setItem(PHOTO_CFG_KEY, JSON.stringify(photoCfg));
|
||||||
}
|
}
|
||||||
} catch (_) { /* offline o.ae. — der zwischengespeicherte Wert gilt weiter */ }
|
} catch (_) { /* offline o.ae. — der zwischengespeicherte Wert gilt weiter */ }
|
||||||
|
|
@ -1279,11 +1411,14 @@ async function uploadPhoto(orderId, file) {
|
||||||
// gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts
|
// gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts
|
||||||
// mehr passieren, was das Foto kostet.
|
// mehr passieren, was das Foto kostet.
|
||||||
const name = file.name || ('foto_' + Date.now() + '.jpg');
|
const name = file.name || ('foto_' + Date.now() + '.jpg');
|
||||||
|
const istVideo = /^video\//.test(file.type || '');
|
||||||
// Wird gleich verkleinert, kommt das Foto BELEGT in die Queue (hold) — sonst laedt der
|
// Wird gleich verkleinert, kommt das Foto BELEGT in die Queue (hold) — sonst laedt der
|
||||||
// Service Worker das Original hoch, waehrend hier die kleine Fassung entsteht, und im
|
// Service Worker das Original hoch, waehrend hier die kleine Fassung entsteht, und im
|
||||||
// Auftrag liegen zwei verschiedene Dateien.
|
// Auftrag liegen zwei verschiedene Dateien. Videos werden nie verkleinert; ihre
|
||||||
const willShrink = photoCfg.photo_maxside > 0;
|
// Aufnahmezeit ist das Dateidatum aus der Galerie (der Server sortiert danach).
|
||||||
const qid = await offline.enqueuePhoto(orderId, file, name, { hold: willShrink });
|
const willShrink = photoCfg.photo_maxside > 0 && !istVideo;
|
||||||
|
const qid = await offline.enqueuePhoto(orderId, file, name,
|
||||||
|
{ hold: willShrink, takenAt: istVideo ? (file.lastModified || 0) : 0 });
|
||||||
|
|
||||||
// Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es
|
// Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es
|
||||||
// schiefgeht oder haengt, bleibt schlicht das Original in der Queue.
|
// schiefgeht oder haengt, bleibt schlicht das Original in der Queue.
|
||||||
|
|
@ -3625,10 +3760,15 @@ async function openCameraModal(orderId) {
|
||||||
<div class="cam-stage" id="cam-stage">
|
<div class="cam-stage" id="cam-stage">
|
||||||
<video id="cam-video" playsinline autoplay muted></video>
|
<video id="cam-video" playsinline autoplay muted></video>
|
||||||
<div class="cam-hint" id="cam-hint">Kamera wird gestartet…</div>
|
<div class="cam-hint" id="cam-hint">Kamera wird gestartet…</div>
|
||||||
|
<div class="cam-rec" id="cam-rec" hidden></div>
|
||||||
<div class="cam-fokus-ring" id="cam-fokus-ring" hidden></div>
|
<div class="cam-fokus-ring" id="cam-fokus-ring" hidden></div>
|
||||||
<button class="cam-zoom" id="cam-zoom" title="Zoom zurück auf 1×" hidden>1×</button>
|
<button class="cam-zoom" id="cam-zoom" title="Zoom zurück auf 1×" hidden>1×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cam-filmstrip" id="cam-strip"></div>
|
<div class="cam-filmstrip" id="cam-strip"></div>
|
||||||
|
<div class="cam-modus" id="cam-modus" hidden>
|
||||||
|
<button class="cam-modus-btn aktiv" data-modus="foto">Foto</button>
|
||||||
|
<button class="cam-modus-btn" data-modus="video">Video</button>
|
||||||
|
</div>
|
||||||
<div class="cam-controls">
|
<div class="cam-controls">
|
||||||
<div class="cam-count" id="cam-count"></div>
|
<div class="cam-count" id="cam-count"></div>
|
||||||
<button class="cam-shutter" id="cam-shutter" aria-label="Auslösen"></button>
|
<button class="cam-shutter" id="cam-shutter" aria-label="Auslösen"></button>
|
||||||
|
|
@ -3647,6 +3787,8 @@ async function openCameraModal(orderId) {
|
||||||
const fokusRing = modal.querySelector('#cam-fokus-ring');
|
const fokusRing = modal.querySelector('#cam-fokus-ring');
|
||||||
const flipBtn = modal.querySelector('#cam-flip');
|
const flipBtn = modal.querySelector('#cam-flip');
|
||||||
const zoomBtn = modal.querySelector('#cam-zoom');
|
const zoomBtn = modal.querySelector('#cam-zoom');
|
||||||
|
const recEl = modal.querySelector('#cam-rec');
|
||||||
|
const modusLeiste = modal.querySelector('#cam-modus');
|
||||||
|
|
||||||
let stream = null;
|
let stream = null;
|
||||||
let facing = 'environment';
|
let facing = 'environment';
|
||||||
|
|
@ -3675,6 +3817,12 @@ async function openCameraModal(orderId) {
|
||||||
// Auftragsseite neu laden (zeigt die frisch hochgeladenen Fotos).
|
// Auftragsseite neu laden (zeigt die frisch hochgeladenen Fotos).
|
||||||
pushModal(modal, () => {
|
pushModal(modal, () => {
|
||||||
gen++; // entwertet einen evtl. noch laufenden getUserMedia-Aufruf
|
gen++; // entwertet einen evtl. noch laufenden getUserMedia-Aufruf
|
||||||
|
// Laeuft gerade ein Video? Schliessen (✕, Fertig, Zurueck-Geste) beendet die Aufnahme und
|
||||||
|
// SICHERT sie — das Video kommt danach wie jedes Foto in die Warteschlange.
|
||||||
|
// gezaehlt: das Video zaehlt schon jetzt als Aufnahme (Auftrag wird unten neu geladen),
|
||||||
|
// videoSichern zaehlt es dann nicht noch einmal.
|
||||||
|
if (aufnahme) { aufnahme.gezaehlt = true; shots++; videoStoppen(); }
|
||||||
|
document.removeEventListener('visibilitychange', beiHintergrund);
|
||||||
stopStream();
|
stopStream();
|
||||||
window.removeEventListener('photo-uploaded', onUploaded);
|
window.removeEventListener('photo-uploaded', onUploaded);
|
||||||
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
|
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
|
||||||
|
|
@ -3695,8 +3843,13 @@ async function openCameraModal(orderId) {
|
||||||
startStream();
|
startStream();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let videos = 0; // davon Videos (shots zaehlt alle Aufnahmen)
|
||||||
function updateCount() {
|
function updateCount() {
|
||||||
countEl.textContent = shots ? (shots + (shots === 1 ? ' Foto' : ' Fotos')) : '';
|
const fotos = shots - videos;
|
||||||
|
const teile = [];
|
||||||
|
if (fotos > 0) teile.push(fotos + (fotos === 1 ? ' Foto' : ' Fotos'));
|
||||||
|
if (videos > 0) teile.push(videos + (videos === 1 ? ' Video' : ' Videos'));
|
||||||
|
countEl.textContent = teile.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startStream() {
|
async function startStream() {
|
||||||
|
|
@ -3735,13 +3888,15 @@ async function openCameraModal(orderId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addThumb(url) {
|
function addThumb(url, istVideo) {
|
||||||
const t = document.createElement('div');
|
const t = document.createElement('div');
|
||||||
t.className = 'cam-thumb saving';
|
t.className = 'cam-thumb saving' + (istVideo ? ' is-video' : '');
|
||||||
t.innerHTML = '<img src="' + url + '" alt=""><span class="cam-thumb-badge"></span>'
|
t.innerHTML = (url ? '<img src="' + url + '" alt="">' : '<div class="cam-thumb-leer">🎬</div>')
|
||||||
|
+ (istVideo ? '<span class="cam-thumb-play">▶</span>' : '')
|
||||||
|
+ '<span class="cam-thumb-badge"></span>'
|
||||||
+ '<button class="cam-thumb-del" title="Löschen">🗑</button>';
|
+ '<button class="cam-thumb-del" title="Löschen">🗑</button>';
|
||||||
// Tipp auf Miniatur → Löschen-Overlay ein/aus
|
// Tipp auf Miniatur → Löschen-Overlay ein/aus
|
||||||
t.querySelector('img').onclick = () => t.classList.toggle('confirm-del');
|
t.firstElementChild.onclick = () => t.classList.toggle('confirm-del');
|
||||||
t.querySelector('.cam-thumb-del').onclick = (ev) => { ev.stopPropagation(); deleteThumb(t); };
|
t.querySelector('.cam-thumb-del').onclick = (ev) => { ev.stopPropagation(); deleteThumb(t); };
|
||||||
strip.prepend(t);
|
strip.prepend(t);
|
||||||
return t;
|
return t;
|
||||||
|
|
@ -3751,12 +3906,13 @@ async function openCameraModal(orderId) {
|
||||||
if (t.dataset.deleting === '1') return; // Doppel-Tap-Schutz
|
if (t.dataset.deleting === '1') return; // Doppel-Tap-Schutz
|
||||||
const relpath = t.dataset.relpath;
|
const relpath = t.dataset.relpath;
|
||||||
const qid = t.dataset.qid;
|
const qid = t.dataset.qid;
|
||||||
if (!relpath && !qid) { showToast('Foto wird noch gesichert…', 'warn'); return; }
|
const was = t.classList.contains('is-video') ? 'Video' : 'Foto';
|
||||||
|
if (!relpath && !qid) { showToast(was + ' wird noch gesichert…', 'warn'); return; }
|
||||||
// Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich
|
// Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich
|
||||||
if (!relpath && qid) {
|
if (!relpath && qid) {
|
||||||
if (!await confirmDialog({
|
if (!await confirmDialog({
|
||||||
title: 'Noch nicht hochgeladen',
|
title: 'Noch nicht hochgeladen',
|
||||||
text: 'Dieses Foto liegt nur auf dem Gerät und wurde noch nicht übertragen. Beim Löschen ist es weg.',
|
text: 'Dieses ' + was + ' liegt nur auf dem Gerät und wurde noch nicht übertragen. Beim Löschen ist es weg.',
|
||||||
confirmLabel: 'Trotzdem löschen',
|
confirmLabel: 'Trotzdem löschen',
|
||||||
danger: true,
|
danger: true,
|
||||||
})) return;
|
})) return;
|
||||||
|
|
@ -3774,8 +3930,9 @@ async function openCameraModal(orderId) {
|
||||||
try { URL.revokeObjectURL(t.querySelector('img').src); } catch (_) {}
|
try { URL.revokeObjectURL(t.querySelector('img').src); } catch (_) {}
|
||||||
t.remove();
|
t.remove();
|
||||||
shots = Math.max(0, shots - 1);
|
shots = Math.max(0, shots - 1);
|
||||||
|
if (was === 'Video') videos = Math.max(0, videos - 1);
|
||||||
updateCount();
|
updateCount();
|
||||||
showToast('Foto gelöscht');
|
showToast(was + ' gelöscht');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
t.dataset.deleting = '';
|
t.dataset.deleting = '';
|
||||||
if (delBtn) delBtn.disabled = false;
|
if (delBtn) delBtn.disabled = false;
|
||||||
|
|
@ -3802,8 +3959,10 @@ async function openCameraModal(orderId) {
|
||||||
const BLITZ_TEXT = { off: '⚡ Aus', auto: '⚡ Auto', flash: '⚡ An', torch: '🔦 Licht' };
|
const BLITZ_TEXT = { off: '⚡ Aus', auto: '⚡ Auto', flash: '⚡ An', torch: '🔦 Licht' };
|
||||||
const BLITZ_KEY = 'baustelle.kamera.blitz';
|
const BLITZ_KEY = 'baustelle.kamera.blitz';
|
||||||
let imageCap = null;
|
let imageCap = null;
|
||||||
let blitzModi = [];
|
let blitzModi = []; // Modi des aktuellen Kamera-Modus (Foto bzw. Video)
|
||||||
let blitz = 'off';
|
let blitz = 'off'; // aktuell gewaehlt
|
||||||
|
let blitzFotoModi = [], blitzVideoModi = [];
|
||||||
|
let blitzFoto = 'off', blitzVideo = 'off'; // Auswahl je Modus (Foto wird gemerkt)
|
||||||
let fotoBlitz = false; // kann takePhoto() blitzen (fillLightMode)?
|
let fotoBlitz = false; // kann takePhoto() blitzen (fillLightMode)?
|
||||||
let fokusModus = ''; // 'continuous' | 'single-shot' | '' (kein Antippen)
|
let fokusModus = ''; // 'continuous' | 'single-shot' | '' (kein Antippen)
|
||||||
let zoomBereich = null; // { min, max, step } aus getCapabilities, sonst kein Zoom
|
let zoomBereich = null; // { min, max, step } aus getCapabilities, sonst kein Zoom
|
||||||
|
|
@ -3837,15 +3996,13 @@ async function openCameraModal(orderId) {
|
||||||
if (licht.includes('auto')) modi.push('auto');
|
if (licht.includes('auto')) modi.push('auto');
|
||||||
if (licht.includes('flash')) modi.push('flash');
|
if (licht.includes('flash')) modi.push('flash');
|
||||||
if (caps.torch) modi.push('torch');
|
if (caps.torch) modi.push('torch');
|
||||||
blitzModi = modi;
|
blitzFotoModi = modi;
|
||||||
|
blitzVideoModi = caps.torch ? ['off', 'torch'] : []; // beim Filmen gibt es nur Dauerlicht
|
||||||
let gemerkt = 'off';
|
let gemerkt = 'off';
|
||||||
try { gemerkt = localStorage.getItem(BLITZ_KEY) || 'off'; } catch (_) {}
|
try { gemerkt = localStorage.getItem(BLITZ_KEY) || 'off'; } catch (_) {}
|
||||||
blitz = modi.includes(gemerkt) ? gemerkt : 'off';
|
blitzFoto = modi.includes(gemerkt) ? gemerkt : 'off';
|
||||||
if (modi.length > 1) {
|
blitz = 'off'; // frischer Strom: Licht ist aus
|
||||||
blitzBtn.hidden = false;
|
blitzFuerModus();
|
||||||
blitzBtn.textContent = BLITZ_TEXT[blitz];
|
|
||||||
if (blitz === 'torch') lichtSchalten(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fm = Array.isArray(caps.focusMode) ? caps.focusMode : [];
|
const fm = Array.isArray(caps.focusMode) ? caps.focusMode : [];
|
||||||
fokusModus = fm.includes('continuous') ? 'continuous' : (fm.includes('single-shot') ? 'single-shot' : '');
|
fokusModus = fm.includes('continuous') ? 'continuous' : (fm.includes('single-shot') ? 'single-shot' : '');
|
||||||
|
|
@ -3931,9 +4088,199 @@ async function openCameraModal(orderId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blitzBtn.textContent = BLITZ_TEXT[blitz];
|
blitzBtn.textContent = BLITZ_TEXT[blitz];
|
||||||
try { localStorage.setItem(BLITZ_KEY, blitz); } catch (_) {}
|
if (modus === 'video') {
|
||||||
|
blitzVideo = blitz;
|
||||||
|
} else {
|
||||||
|
blitzFoto = blitz;
|
||||||
|
try { localStorage.setItem(BLITZ_KEY, blitz); } catch (_) {}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Blitz-Knopf passend zum Modus: beim Filmen gibt es nur Aus/Licht (ein Blitz wirkt nur
|
||||||
|
* beim Standbild). Das Licht folgt der Auswahl des jeweiligen Modus. */
|
||||||
|
function blitzFuerModus() {
|
||||||
|
const vorher = blitz;
|
||||||
|
blitzModi = modus === 'video' ? blitzVideoModi : blitzFotoModi;
|
||||||
|
blitz = modus === 'video' ? blitzVideo : blitzFoto;
|
||||||
|
if (!blitzModi.includes(blitz)) blitz = 'off';
|
||||||
|
blitzBtn.hidden = blitzModi.length < 2;
|
||||||
|
blitzBtn.textContent = BLITZ_TEXT[blitz];
|
||||||
|
if ((vorher === 'torch') !== (blitz === 'torch')) lichtSchalten(blitz === 'torch');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Video ----
|
||||||
|
* MediaRecorder auf derselben Kameraspur wie der Sucher (Licht, Zoom und Antippen-Fokus
|
||||||
|
* wirken also auch beim Filmen), dazu das Mikrofon — erst beim ersten Video angefragt.
|
||||||
|
* MP4 (H.264/AAC), wenn der Browser es kann: spielt ueberall (Dolibarr, Windows, iPhone);
|
||||||
|
* Chrome auf Android kann es (im Emulator geprueft, 19.10.2026), sonst WebM.
|
||||||
|
* 5 Mbit/s ≈ 38 MB pro Minute. Hoechstlaenge 5 Minuten, kuerzer, wenn der Server weniger
|
||||||
|
* annimmt (config.php upload_max) — das Video muss am Ende auch hochgehen. */
|
||||||
|
const VIDEO_BPS = 5000000;
|
||||||
|
const TON_BPS = 128000;
|
||||||
|
const VIDEO_MAX_SEK = 300;
|
||||||
|
let modus = 'foto';
|
||||||
|
let aufnahme = null; // laufende Aufnahme { rec, ton, start, stuecke, max, uhr, poster }
|
||||||
|
let videoStartet = false;
|
||||||
|
|
||||||
|
function videoHoechstSek() {
|
||||||
|
const max = photoCfg.upload_max || 0;
|
||||||
|
if (!max) return VIDEO_MAX_SEK;
|
||||||
|
// 85 % der Servergrenze — MediaRecorder haelt die Zielrate nur ungefaehr
|
||||||
|
return Math.max(15, Math.min(VIDEO_MAX_SEK, Math.floor(max * 0.85 / ((VIDEO_BPS + TON_BPS) / 8))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoFormat(mitTon) {
|
||||||
|
const kandidaten = mitTon
|
||||||
|
? ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm']
|
||||||
|
: ['video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm'];
|
||||||
|
for (const k of kandidaten) {
|
||||||
|
try { if (MediaRecorder.isTypeSupported(k)) return k; } catch (_) {}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeitText = (s) => Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||||
|
|
||||||
|
// Kleines Standbild aus dem Sucher als Vorschau im Filmstreifen
|
||||||
|
function sucherVorschau() {
|
||||||
|
return new Promise((res) => {
|
||||||
|
try {
|
||||||
|
if (!video.videoWidth) return res('');
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 240;
|
||||||
|
c.height = Math.round(240 * video.videoHeight / video.videoWidth);
|
||||||
|
c.getContext('2d').drawImage(video, 0, 0, c.width, c.height);
|
||||||
|
c.toBlob(b => res(b ? URL.createObjectURL(b) : ''), 'image/jpeg', 0.7);
|
||||||
|
} catch (_) { res(''); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function modusSetzen(m) {
|
||||||
|
if (aufnahme || videoStartet || m === modus) return;
|
||||||
|
modus = m;
|
||||||
|
modusLeiste.querySelectorAll('.cam-modus-btn').forEach(b => b.classList.toggle('aktiv', b.dataset.modus === m));
|
||||||
|
shutter.classList.toggle('video', m === 'video');
|
||||||
|
shutter.setAttribute('aria-label', m === 'video' ? 'Aufnahme starten' : 'Auslösen');
|
||||||
|
blitzFuerModus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function videoStarten() {
|
||||||
|
const track = videoTrack();
|
||||||
|
if (!track || aufnahme || videoStartet) return;
|
||||||
|
videoStartet = true;
|
||||||
|
try {
|
||||||
|
// Ton dazu — verweigert der Nutzer das Mikrofon, wird stumm gefilmt
|
||||||
|
let ton = null;
|
||||||
|
try {
|
||||||
|
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
ton = s.getAudioTracks()[0] || null;
|
||||||
|
} catch (_) {
|
||||||
|
showToast('Kein Mikrofon freigegeben — Video ohne Ton', 'warn');
|
||||||
|
}
|
||||||
|
if (videoTrack() !== track || !document.body.contains(modal) || modus !== 'video') {
|
||||||
|
if (ton) ton.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const format = videoFormat(!!ton);
|
||||||
|
const opts = { videoBitsPerSecond: VIDEO_BPS };
|
||||||
|
if (ton) opts.audioBitsPerSecond = TON_BPS;
|
||||||
|
if (format) opts.mimeType = format;
|
||||||
|
let rec;
|
||||||
|
try {
|
||||||
|
rec = new MediaRecorder(new MediaStream(ton ? [track, ton] : [track]), opts);
|
||||||
|
} catch (e) {
|
||||||
|
if (ton) ton.stop();
|
||||||
|
showToast('Video-Aufnahme nicht möglich: ' + (e.message || e.name), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = { rec, ton, start: Date.now(), stuecke: [], max: videoHoechstSek(), uhr: null, poster: '' };
|
||||||
|
a.poster = await sucherVorschau();
|
||||||
|
rec.ondataavailable = (ev) => { if (ev.data && ev.data.size) a.stuecke.push(ev.data); };
|
||||||
|
rec.onstop = () => videoSichern(a);
|
||||||
|
rec.onerror = (ev) => {
|
||||||
|
showToast('Video-Aufnahme abgebrochen: ' + ((ev.error && ev.error.message) || ''), 'error');
|
||||||
|
if (aufnahme === a) videoStoppen();
|
||||||
|
};
|
||||||
|
rec.start(1000); // jede Sekunde ein Stueck — bricht etwas ab, ist das Bisherige da
|
||||||
|
aufnahme = a;
|
||||||
|
shutter.classList.add('laeuft');
|
||||||
|
shutter.setAttribute('aria-label', 'Aufnahme beenden');
|
||||||
|
modusLeiste.classList.add('gesperrt');
|
||||||
|
flipBtn.disabled = true;
|
||||||
|
recEl.hidden = false;
|
||||||
|
uhrStellen();
|
||||||
|
a.uhr = setInterval(uhrStellen, 500);
|
||||||
|
} finally {
|
||||||
|
videoStartet = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uhrStellen() {
|
||||||
|
const a = aufnahme;
|
||||||
|
if (!a) return;
|
||||||
|
const sek = Math.floor((Date.now() - a.start) / 1000);
|
||||||
|
recEl.textContent = '● ' + zeitText(Math.min(sek, a.max)) + ' / ' + zeitText(a.max);
|
||||||
|
if (sek >= a.max) {
|
||||||
|
videoStoppen();
|
||||||
|
showToast('Höchstlänge erreicht — Video gespeichert', 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoStoppen() {
|
||||||
|
const a = aufnahme;
|
||||||
|
if (!a) return;
|
||||||
|
aufnahme = null;
|
||||||
|
clearInterval(a.uhr);
|
||||||
|
try {
|
||||||
|
if (a.rec.state !== 'inactive') a.rec.stop(); // onstop → videoSichern
|
||||||
|
else videoSichern(a);
|
||||||
|
} catch (_) { videoSichern(a); }
|
||||||
|
shutter.classList.remove('laeuft');
|
||||||
|
shutter.setAttribute('aria-label', 'Aufnahme starten');
|
||||||
|
modusLeiste.classList.remove('gesperrt');
|
||||||
|
flipBtn.disabled = false;
|
||||||
|
recEl.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist-First wie beim Foto: das Video geht als Erstes in die Warteschlange
|
||||||
|
async function videoSichern(a) {
|
||||||
|
if (a.gesichert) return;
|
||||||
|
a.gesichert = true;
|
||||||
|
if (a.ton) { try { a.ton.stop(); } catch (_) {} }
|
||||||
|
const typ = ((a.rec.mimeType || 'video/webm').split(';')[0]) || 'video/webm';
|
||||||
|
const blob = new Blob(a.stuecke, { type: typ });
|
||||||
|
a.stuecke = [];
|
||||||
|
if (!blob.size) { showToast('Video leer — es wurde nichts aufgenommen', 'error'); return; }
|
||||||
|
if (!a.gezaehlt) { shots++; videos++; updateCount(); }
|
||||||
|
const thumb = addThumb(a.poster, true);
|
||||||
|
try {
|
||||||
|
const name = 'video_' + a.start + '.' + (typ.indexOf('mp4') >= 0 ? 'mp4' : 'webm');
|
||||||
|
const qid = await offline.enqueuePhoto(orderId, blob, name, { takenAt: a.start });
|
||||||
|
thumb.dataset.qid = qid;
|
||||||
|
thumb.classList.remove('saving');
|
||||||
|
thumb.classList.add('pending');
|
||||||
|
offline.syncQueue();
|
||||||
|
} catch (e) {
|
||||||
|
thumb.classList.remove('saving');
|
||||||
|
thumb.classList.add('error');
|
||||||
|
const quota = e && (e.name === 'QuotaExceededError' || /quota/i.test(e.message || ''));
|
||||||
|
showToast(quota
|
||||||
|
? '⚠ Speicher voll — bitte erst vorhandene Fotos und Videos hochladen'
|
||||||
|
: ('Video sichern fehlgeschlagen: ' + (e.message || '')), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// App in den Hintergrund (Anruf, App-Wechsel): Chrome haelt die Kamera an — Video sichern
|
||||||
|
function beiHintergrund() {
|
||||||
|
if (document.visibilityState === 'hidden' && aufnahme) videoStoppen();
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', beiHintergrund);
|
||||||
|
|
||||||
|
if (typeof MediaRecorder === 'function') {
|
||||||
|
modusLeiste.hidden = false;
|
||||||
|
modusLeiste.querySelectorAll('.cam-modus-btn').forEach(b => { b.onclick = () => modusSetzen(b.dataset.modus); });
|
||||||
|
}
|
||||||
|
|
||||||
/* Chrome gibt pointsOfInterest UNGEDREHT an den Sensor weiter (Punkt × Sensorflaeche,
|
/* Chrome gibt pointsOfInterest UNGEDREHT an den Sensor weiter (Punkt × Sensorflaeche,
|
||||||
* VideoCaptureCamera2.java) — das Sucherbild kommt aber schon gedreht an: Sensor quer,
|
* VideoCaptureCamera2.java) — das Sucherbild kommt aber schon gedreht an: Sensor quer,
|
||||||
* Bild am Handy hochkant. Ohne Rueckdrehung stellte ein Tipp bei 67 % / 82 % auf 67 % / 82 %
|
* Bild am Handy hochkant. Ohne Rueckdrehung stellte ein Tipp bei 67 % / 82 % auf 67 % / 82 %
|
||||||
|
|
@ -4062,7 +4409,11 @@ async function openCameraModal(orderId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shutter.onclick = capture;
|
shutter.onclick = () => {
|
||||||
|
if (modus !== 'video') return capture();
|
||||||
|
if (aufnahme) videoStoppen();
|
||||||
|
else videoStarten();
|
||||||
|
};
|
||||||
startStream();
|
startStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4075,7 +4426,7 @@ async function openCameraModal(orderId) {
|
||||||
async function openUploadQueueModal() {
|
async function openUploadQueueModal() {
|
||||||
const items = await offline.listQueue().catch(() => []);
|
const items = await offline.listQueue().catch(() => []);
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
showToast(navigator.onLine ? 'Alle Fotos hochgeladen ✓' : 'Keine offenen Uploads');
|
showToast(navigator.onLine ? 'Alle Fotos und Videos hochgeladen ✓' : 'Keine offenen Uploads');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pending = items.filter(i => !i.failed);
|
const pending = items.filter(i => !i.failed);
|
||||||
|
|
@ -4094,7 +4445,7 @@ async function openUploadQueueModal() {
|
||||||
<button class="btn" id="q-sync">🔄 Jetzt synchronisieren</button>
|
<button class="btn" id="q-sync">🔄 Jetzt synchronisieren</button>
|
||||||
${failed.length ? '<button class="btn btn-secondary" id="q-retry">↻ Fehlgeschlagene erneut senden</button>' : ''}
|
${failed.length ? '<button class="btn btn-secondary" id="q-retry">↻ Fehlgeschlagene erneut senden</button>' : ''}
|
||||||
${failed.length ? '<button class="btn btn-secondary" id="q-share">📤 Fehlgeschlagene sichern / teilen</button>' : ''}
|
${failed.length ? '<button class="btn btn-secondary" id="q-share">📤 Fehlgeschlagene sichern / teilen</button>' : ''}
|
||||||
<p style="margin:0;font-size:12px;color:#aaa;">Fotos bleiben gespeichert, bis der Upload bestätigt ist. Schließe die App nicht, solange hier noch Einträge stehen.</p>
|
<p style="margin:0;font-size:12px;color:#aaa;">Fotos und Videos bleiben gespeichert, bis der Upload bestätigt ist. Schließe die App nicht, solange hier noch Einträge stehen.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
|
@ -4120,7 +4471,7 @@ async function openUploadQueueModal() {
|
||||||
mime: it.mime || 'image/jpeg',
|
mime: it.mime || 'image/jpeg',
|
||||||
}));
|
}));
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
await shareFiles(files, 'Nicht hochgeladene Baustellen-Fotos');
|
await shareFiles(files, 'Nicht hochgeladene Baustellen-Fotos und -Videos');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
17
lib/api.js
17
lib/api.js
|
|
@ -145,13 +145,24 @@
|
||||||
// er antwortet dann mit duplicate:true und dem relpath der vorhandenen Datei.
|
// er antwortet dann mit duplicate:true und dem relpath der vorhandenen Datei.
|
||||||
// Die Wiederholung ist damit idempotent statt duplikaterzeugend; die Regel „lieber
|
// Die Wiederholung ist damit idempotent statt duplikaterzeugend; die Regel „lieber
|
||||||
// ein Duplikat als ein verlorenes Foto" gilt weiterhin als Rückfallebene.
|
// ein Duplikat als ein verlorenes Foto" gilt weiterhin als Rückfallebene.
|
||||||
async function uploadOrderPhoto(orderId, fileBlob, filename) {
|
// Zeitlimit nach Dateigroesse: 45 s Grundzeit + 1 s je 100 KB (rund 0,8 Mbit/s Upload).
|
||||||
|
// Ein Foto bleibt praktisch bei 45 s, ein 100-MB-Video bekommt gut 17 Minuten — mit
|
||||||
|
// festen 45 s brach jeder groessere Video-Upload im Mobilfunk ab und begann von vorn.
|
||||||
|
// GLEICHE FORMEL in sw.js (uploadTimeoutMs) — beide zusammen aendern.
|
||||||
|
function uploadTimeoutMs(bytes) {
|
||||||
|
return 45000 + Math.ceil((bytes || 0) / 102400) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extra.takenAt (ms): Aufnahmezeit — der Server setzt sie bei Videos als Dateidatum,
|
||||||
|
// damit ein im Funkloch gefilmtes Video nicht unter der spaeteren Sendezeit einsortiert wird.
|
||||||
|
async function uploadOrderPhoto(orderId, fileBlob, filename, extra) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', fileBlob, filename || 'photo.jpg');
|
fd.append('file', fileBlob, filename || 'photo.jpg');
|
||||||
|
if (extra && extra.takenAt) fd.append('taken_at', String(Math.round(extra.takenAt / 1000)));
|
||||||
const res = await request('/orders.php?id=' + orderId + '&action=upload_photo', {
|
const res = await request('/orders.php?id=' + orderId + '&action=upload_photo', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: fd,
|
body: fd,
|
||||||
timeoutMs: 45000,
|
timeoutMs: uploadTimeoutMs(fileBlob && fileBlob.size),
|
||||||
});
|
});
|
||||||
// Der Server MUSS einen relpath liefern, wenn er die Datei wirklich gespeichert hat.
|
// Der Server MUSS einen relpath liefern, wenn er die Datei wirklich gespeichert hat.
|
||||||
// Fehlt er, gilt der Upload als NICHT bestätigt → Foto bleibt in der Queue (Retry).
|
// Fehlt er, gilt der Upload als NICHT bestätigt → Foto bleibt in der Queue (Retry).
|
||||||
|
|
@ -410,7 +421,7 @@
|
||||||
request,
|
request,
|
||||||
getToken, setToken, clearToken,
|
getToken, setToken, clearToken,
|
||||||
login, logout,
|
login, logout,
|
||||||
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, createOrder,
|
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, uploadTimeoutMs, createOrder,
|
||||||
listCustomers, getCustomer,
|
listCustomers, getCustomer,
|
||||||
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
||||||
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
||||||
|
|
|
||||||
19
lib/idb.js
19
lib/idb.js
|
|
@ -108,7 +108,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur die Ids — wer die Queue abarbeitet, holt die Eintraege danach einzeln. queueAll()
|
// Nur die Ids — wer die Queue abarbeitet, holt die Eintraege danach einzeln. queueAll()
|
||||||
// zieht dagegen jedes Foto komplett in den Speicher.
|
// zieht bei aelteren Eintraegen (ArrayBuffer) jedes Foto komplett in den Speicher; neue
|
||||||
|
// Eintraege tragen einen Blob, den der Browser erst beim Lesen der Daten holt.
|
||||||
async function queueKeys() {
|
async function queueKeys() {
|
||||||
const db = await open();
|
const db = await open();
|
||||||
return new Promise((res, rej) => {
|
return new Promise((res, rej) => {
|
||||||
|
|
@ -149,15 +150,19 @@
|
||||||
* reihenweise duplicate:true). Belegt wird nur, was frei ist oder dessen Lease abgelaufen
|
* reihenweise duplicate:true). Belegt wird nur, was frei ist oder dessen Lease abgelaufen
|
||||||
* ist — eine eingefrorene oder abgeschossene Seite blockiert ihr Foto also nicht ewig.
|
* ist — eine eingefrorene oder abgeschossene Seite blockiert ihr Foto also nicht ewig.
|
||||||
* DIESELBE LOGIK STEHT IN sw.js (queueClaim) — beide Stellen zusammen aendern.
|
* DIESELBE LOGIK STEHT IN sw.js (queueClaim) — beide Stellen zusammen aendern.
|
||||||
|
* leaseMs: Zahl oder Funktion(eintrag) — ein Video braucht laenger als ein Foto. Die Dauer
|
||||||
|
* steht im Eintrag (lease_ms), damit der andere weiss, wie lange er warten muss.
|
||||||
* Rueckgabe: der belegte Eintrag, oder null (weg, fehlgeschlagen-markiert oder belegt). */
|
* Rueckgabe: der belegte Eintrag, oder null (weg, fehlgeschlagen-markiert oder belegt). */
|
||||||
function queueClaim(id, owner, leaseMs) {
|
function queueClaim(id, owner, leaseMs) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return queuePatch(id, (it) => {
|
return queuePatch(id, (it) => {
|
||||||
|
const lease = typeof leaseMs === 'function' ? leaseMs(it) : leaseMs;
|
||||||
if (it.failed) return false;
|
if (it.failed) return false;
|
||||||
if (it.uploading_since && it.uploading_by !== owner
|
if (it.uploading_since && it.uploading_by !== owner
|
||||||
&& (now - it.uploading_since) < leaseMs) return false;
|
&& (now - it.uploading_since) < (it.lease_ms || lease)) return false;
|
||||||
it.uploading_since = now;
|
it.uploading_since = now;
|
||||||
it.uploading_by = owner;
|
it.uploading_by = owner;
|
||||||
|
it.lease_ms = lease;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,10 +170,18 @@
|
||||||
return queuePatch(id, (it) => {
|
return queuePatch(id, (it) => {
|
||||||
delete it.uploading_since;
|
delete it.uploading_since;
|
||||||
delete it.uploading_by;
|
delete it.uploading_by;
|
||||||
|
delete it.lease_ms;
|
||||||
if (extra) extra(it);
|
if (extra) extra(it);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Groesse der gesicherten Daten — aeltere Eintraege tragen einen ArrayBuffer, neue einen Blob
|
||||||
|
function dataSize(it) {
|
||||||
|
const d = it && it.data;
|
||||||
|
if (!d) return 0;
|
||||||
|
return typeof d.size === 'number' ? d.size : (d.byteLength || 0);
|
||||||
|
}
|
||||||
|
|
||||||
window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate,
|
window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate,
|
||||||
queueKeys, queuePatch, queueClaim, queueRelease };
|
queueKeys, queuePatch, queueClaim, queueRelease, dataSize };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -18,31 +18,40 @@
|
||||||
* Worker (Background Sync) arbeiten dieselbe Queue ab — ohne Absprache luden beide
|
* Worker (Background Sync) arbeiten dieselbe Queue ab — ohne Absprache luden beide
|
||||||
* dasselbe Foto hoch (Prod-Log 17.09.2026: reihenweise duplicate:true, im Mobilfunk das
|
* dasselbe Foto hoch (Prod-Log 17.09.2026: reihenweise duplicate:true, im Mobilfunk das
|
||||||
* doppelte Datenvolumen). Die Lease muss laenger halten als der laengste Upload-Timeout
|
* doppelte Datenvolumen). Die Lease muss laenger halten als der laengste Upload-Timeout
|
||||||
* (Seite 45 s, Service Worker 60 s), sonst uebernimmt der andere einen LAUFENDEN Upload.
|
* (wächst mit der Dateigroesse, siehe leaseFuer), sonst uebernimmt der andere einen LAUFENDEN Upload.
|
||||||
* Nach Ablauf darf er uebernehmen — eine eingefrorene Seite blockiert ihr Foto nicht ewig.
|
* Nach Ablauf darf er uebernehmen — eine eingefrorene Seite blockiert ihr Foto nicht ewig.
|
||||||
* Der Besitzer ist je Seiten-Instanz eindeutig: zwei offene Fenster sind zwei Besitzer. */
|
* Der Besitzer ist je Seiten-Instanz eindeutig: zwei offene Fenster sind zwei Besitzer. */
|
||||||
const OWNER = 'page-' + Math.random().toString(36).slice(2, 10);
|
const OWNER = 'page-' + Math.random().toString(36).slice(2, 10);
|
||||||
const HOLD_OWNER = OWNER + ':resize';
|
const HOLD_OWNER = OWNER + ':resize';
|
||||||
const LEASE_MS = 90000;
|
const LEASE_MS = 90000;
|
||||||
|
// Belegen nach Groesse: Upload-Zeitlimit (api.uploadTimeoutMs) + 45 s Puffer. Fuer ein Foto
|
||||||
|
// sind das die bisherigen 90 s, ein Video haelt die Lease so lange, wie sein Upload darf.
|
||||||
|
const leaseFuer = (it) => Math.max(LEASE_MS, api.uploadTimeoutMs(idb.dataSize(it)) + 45000);
|
||||||
|
|
||||||
/* opts.hold: Das Foto kommt BELEGT in die Queue. Noetig, wenn es gleich noch verkleinert
|
/* opts.hold: Das Foto kommt BELEGT in die Queue. Noetig, wenn es gleich noch verkleinert
|
||||||
* wird — sonst greift sich der Service Worker das Original, waehrend die Seite die kleine
|
* wird — sonst greift sich der Service Worker das Original, waehrend die Seite die kleine
|
||||||
* Fassung nachschiebt, und im Auftrag liegen zwei verschiedene Dateien (der md5-Abgleich
|
* Fassung nachschiebt, und im Auftrag liegen zwei verschiedene Dateien (der md5-Abgleich
|
||||||
* des Servers erkennt sie nicht als Duplikat). Der Aufrufer MUSS danach releaseHold()
|
* des Servers erkennt sie nicht als Duplikat). Der Aufrufer MUSS danach releaseHold()
|
||||||
* rufen; vergisst er es oder stirbt die Seite, laeuft die Lease nach 90 s von selbst ab. */
|
* rufen; vergisst er es oder stirbt die Seite, laeuft die Lease nach 90 s von selbst ab. */
|
||||||
|
/* Videos laufen durch dieselbe Queue und denselben Endpunkt (type bleibt 'photo': aeltere
|
||||||
|
* Fassungen von Seite und Service Worker loeschen Eintraege anderen Typs). opts.takenAt (ms)
|
||||||
|
* = Aufnahmezeit, geht beim Upload mit.
|
||||||
|
* Gesichert wird der Blob selbst, nicht mehr ein ArrayBuffer: Chrome legt ihn als Datei ab
|
||||||
|
* und liest die Daten erst beim Hochladen. Ein ArrayBuffer stand bei jedem Belegen, Freigeben
|
||||||
|
* und Zaehlen komplett im Speicher — bei einem 100-MB-Video nicht tragbar. Alte Eintraege
|
||||||
|
* (ArrayBuffer) lesen alle Stellen weiter ueber new Blob([it.data]). */
|
||||||
async function enqueuePhoto(orderId, fileBlob, filename, opts) {
|
async function enqueuePhoto(orderId, fileBlob, filename, opts) {
|
||||||
// Blob → ArrayBuffer für IndexedDB-Speicherung
|
|
||||||
const buf = await fileBlob.arrayBuffer();
|
|
||||||
const item = {
|
const item = {
|
||||||
type: 'photo',
|
type: 'photo',
|
||||||
order_id: orderId,
|
order_id: orderId,
|
||||||
filename,
|
filename,
|
||||||
mime: fileBlob.type || 'image/jpeg',
|
mime: fileBlob.type || 'image/jpeg',
|
||||||
data: buf,
|
data: fileBlob,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
failed: false,
|
failed: false,
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
};
|
};
|
||||||
|
if (opts && opts.takenAt) item.taken_at = opts.takenAt;
|
||||||
if (opts && opts.hold) {
|
if (opts && opts.hold) {
|
||||||
item.uploading_since = Date.now();
|
item.uploading_since = Date.now();
|
||||||
item.uploading_by = HOLD_OWNER;
|
item.uploading_by = HOLD_OWNER;
|
||||||
|
|
@ -70,9 +79,8 @@
|
||||||
// Per Patch in einer Transaktion: das fruehere "alles lesen, ganzes Item zurueckschreiben"
|
// Per Patch in einer Transaktion: das fruehere "alles lesen, ganzes Item zurueckschreiben"
|
||||||
// ueberschrieb, was ein anderer in der Zwischenzeit am Eintrag geaendert hatte.
|
// ueberschrieb, was ein anderer in der Zwischenzeit am Eintrag geaendert hatte.
|
||||||
async function replaceQueuedBlob(id, fileBlob) {
|
async function replaceQueuedBlob(id, fileBlob) {
|
||||||
const buf = await fileBlob.arrayBuffer();
|
|
||||||
const it = await idb.queuePatch(id, (x) => {
|
const it = await idb.queuePatch(id, (x) => {
|
||||||
x.data = buf;
|
x.data = fileBlob;
|
||||||
x.mime = fileBlob.type || x.mime || 'image/jpeg';
|
x.mime = fileBlob.type || x.mime || 'image/jpeg';
|
||||||
// Verkleinert = JPEG aus dem Canvas: Endung angleichen. Sonst laege JPEG-Inhalt unter
|
// Verkleinert = JPEG aus dem Canvas: Endung angleichen. Sonst laege JPEG-Inhalt unter
|
||||||
// „.avif"/„.webp" in der Queue, und der Server lehnt die Endung ab.
|
// „.avif"/„.webp" in der Queue, und der Server lehnt die Endung ab.
|
||||||
|
|
@ -185,12 +193,12 @@
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
// null: schon weg, in Quarantaene — oder gerade vom Service Worker bzw.
|
// null: schon weg, in Quarantaene — oder gerade vom Service Worker bzw.
|
||||||
// vom Verkleinern belegt. Dann nicht anfassen, der naechste Lauf sieht es wieder.
|
// vom Verkleinern belegt. Dann nicht anfassen, der naechste Lauf sieht es wieder.
|
||||||
const it = await idb.queueClaim(id, OWNER, LEASE_MS);
|
const it = await idb.queueClaim(id, OWNER, leaseFuer);
|
||||||
if (!it) continue;
|
if (!it) continue;
|
||||||
if (it.type !== 'photo') { await idb.queueDelete(it.id); continue; }
|
if (it.type !== 'photo') { await idb.queueDelete(it.id); continue; }
|
||||||
try {
|
try {
|
||||||
const blob = new Blob([it.data], { type: it.mime || 'image/jpeg' });
|
const blob = new Blob([it.data], { type: it.mime || 'image/jpeg' });
|
||||||
const res = await api.uploadOrderPhoto(it.order_id, blob, it.filename);
|
const res = await api.uploadOrderPhoto(it.order_id, blob, it.filename, { takenAt: it.taken_at });
|
||||||
await idb.queueDelete(it.id);
|
await idb.queueDelete(it.id);
|
||||||
try {
|
try {
|
||||||
window.dispatchEvent(new CustomEvent('photo-uploaded', {
|
window.dispatchEvent(new CustomEvent('photo-uploaded', {
|
||||||
|
|
@ -214,12 +222,14 @@
|
||||||
}
|
}
|
||||||
// Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne,
|
// Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne,
|
||||||
// aber weiter mit den nächsten Items (ein „Poison-Item" blockiert nicht mehr die Queue).
|
// aber weiter mit den nächsten Items (ein „Poison-Item" blockiert nicht mehr die Queue).
|
||||||
|
// 413 (zu gross fuer den Server) wird nie besser — sofort in die Quarantaene,
|
||||||
|
// statt ein grosses Video sechsmal ueber den Mobilfunk zu schicken.
|
||||||
const msg = (e && e.message) || 'Fehler';
|
const msg = (e && e.message) || 'Fehler';
|
||||||
try {
|
try {
|
||||||
await idb.queueRelease(it.id, (x) => {
|
await idb.queueRelease(it.id, (x) => {
|
||||||
x.attempts = (x.attempts || 0) + 1;
|
x.attempts = (x.attempts || 0) + 1;
|
||||||
x.last_error = msg;
|
x.last_error = msg;
|
||||||
if (x.attempts >= MAX_ATTEMPTS) x.failed = true;
|
if (x.attempts >= MAX_ATTEMPTS || (e && e.status === 413)) x.failed = true;
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@
|
||||||
"text": "text",
|
"text": "text",
|
||||||
"url": "url",
|
"url": "url",
|
||||||
"files": [
|
"files": [
|
||||||
{ "name": "photos", "accept": ["image/jpeg", "image/png", "image/webp", "image/*"] },
|
{ "name": "photos", "accept": ["image/jpeg", "image/png", "image/webp", "image/*", "video/mp4", "video/quicktime", "video/webm", "video/*"] },
|
||||||
{ "name": "file", "accept": ["image/jpeg", "image/png", "image/webp", "image/*"] }
|
{ "name": "file", "accept": ["image/jpeg", "image/png", "image/webp", "image/*", "video/mp4", "video/quicktime", "video/webm", "video/*"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
share.html
15
share.html
|
|
@ -296,10 +296,17 @@ async function renderPicker() {
|
||||||
payload.files.forEach(f => {
|
payload.files.forEach(f => {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.className = 'thumb';
|
d.className = 'thumb';
|
||||||
const img = document.createElement('img');
|
// Videos (seit 19.10.2026): erstes Bild als Vorschau
|
||||||
img.src = URL.createObjectURL(f.blob);
|
const istVideo = /^video\//.test(f.type || f.blob.type || '');
|
||||||
img.alt = f.name;
|
const el = document.createElement(istVideo ? 'video' : 'img');
|
||||||
d.appendChild(img);
|
el.src = URL.createObjectURL(f.blob) + (istVideo ? '#t=0.1' : '');
|
||||||
|
if (istVideo) {
|
||||||
|
el.muted = true; el.playsInline = true; el.preload = 'metadata';
|
||||||
|
// Android-Chrome malt das Bild erst nach einem Sprung (siehe videoVorschau in app.js)
|
||||||
|
el.addEventListener('loadedmetadata', () => { try { el.currentTime = 0.1; } catch (_) {} }, { once: true });
|
||||||
|
}
|
||||||
|
else el.alt = f.name;
|
||||||
|
d.appendChild(el);
|
||||||
grid.appendChild(d);
|
grid.appendChild(d);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
sw.js
52
sw.js
|
|
@ -306,12 +306,28 @@ function queueIds(db) {
|
||||||
* hintereinander ausfuehrt. DIESELBE LOGIK STEHT IN lib/idb.js (queueClaim) — beide
|
* hintereinander ausfuehrt. DIESELBE LOGIK STEHT IN lib/idb.js (queueClaim) — beide
|
||||||
* Stellen zusammen aendern.
|
* Stellen zusammen aendern.
|
||||||
*
|
*
|
||||||
* Die Lease haelt laenger als der laengste Upload-Timeout (60 s, unten), damit niemand
|
* Die Lease haelt laenger als der Upload-Timeout (waechst mit der Dateigroesse, unten), damit
|
||||||
* einen LAUFENDEN Upload uebernimmt; nach Ablauf ist der Eintrag wieder frei — fuer die
|
* niemand einen LAUFENDEN Upload uebernimmt; nach Ablauf ist der Eintrag wieder frei — fuer die
|
||||||
* eingefrorene oder abgeschossene Seite gibt es den Background Sync ja gerade. */
|
* eingefrorene oder abgeschossene Seite gibt es den Background Sync ja gerade. Die Dauer steht
|
||||||
|
* im Eintrag (lease_ms), damit die Seite weiss, wie lange sie warten muss. */
|
||||||
const SW_OWNER = 'sw-' + Math.random().toString(36).slice(2, 10);
|
const SW_OWNER = 'sw-' + Math.random().toString(36).slice(2, 10);
|
||||||
const LEASE_MS = 90000;
|
const LEASE_MS = 90000;
|
||||||
|
|
||||||
|
/* Zeitlimit nach Dateigroesse — GLEICHE FORMEL wie api.uploadTimeoutMs (lib/api.js):
|
||||||
|
* 45 s + 1 s je 100 KB. Die Lease haelt das Zeitlimit + 45 s, fuer ein Foto wie bisher 90 s.
|
||||||
|
* Videos (seit 19.10.2026) laufen durch dieselbe Queue. */
|
||||||
|
function uploadTimeoutMs(bytes) {
|
||||||
|
return 45000 + Math.ceil((bytes || 0) / 102400) * 1000;
|
||||||
|
}
|
||||||
|
function dataSize(it) {
|
||||||
|
const d = it && it.data;
|
||||||
|
if (!d) return 0;
|
||||||
|
return typeof d.size === 'number' ? d.size : (d.byteLength || 0); // Blob (neu) oder ArrayBuffer (alt)
|
||||||
|
}
|
||||||
|
function leaseFuer(it) {
|
||||||
|
return Math.max(LEASE_MS, uploadTimeoutMs(dataSize(it)) + 45000);
|
||||||
|
}
|
||||||
|
|
||||||
/* Rueckgabe: { item } wenn belegt, { busy:true } wenn gerade ein anderer dran ist
|
/* Rueckgabe: { item } wenn belegt, { busy:true } wenn gerade ein anderer dran ist
|
||||||
* (Seite laedt hoch oder verkleinert noch), sonst {} (weg, Quarantaene, kein Foto). */
|
* (Seite laedt hoch oder verkleinert noch), sonst {} (weg, Quarantaene, kein Foto). */
|
||||||
function queueClaim(db, id) {
|
function queueClaim(db, id) {
|
||||||
|
|
@ -324,10 +340,12 @@ function queueClaim(db, id) {
|
||||||
const it = req.result;
|
const it = req.result;
|
||||||
if (!it || it.failed || it.type !== 'photo') return;
|
if (!it || it.failed || it.type !== 'photo') return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const lease = leaseFuer(it);
|
||||||
if (it.uploading_since && it.uploading_by !== SW_OWNER
|
if (it.uploading_since && it.uploading_by !== SW_OWNER
|
||||||
&& (now - it.uploading_since) < LEASE_MS) { out.busy = true; return; }
|
&& (now - it.uploading_since) < (it.lease_ms || lease)) { out.busy = true; return; }
|
||||||
it.uploading_since = now;
|
it.uploading_since = now;
|
||||||
it.uploading_by = SW_OWNER;
|
it.uploading_by = SW_OWNER;
|
||||||
|
it.lease_ms = lease;
|
||||||
store.put(it);
|
store.put(it);
|
||||||
out.item = it;
|
out.item = it;
|
||||||
};
|
};
|
||||||
|
|
@ -337,7 +355,9 @@ function queueClaim(db, id) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueRelease(db, id) {
|
/* fehler gesetzt = dauerhafter Fehler (413: zu gross) → Quarantaene wie auf der Seite. Sonst
|
||||||
|
* schickte der Hintergrund-Sync ein zu grosses Video immer wieder ueber den Mobilfunk. */
|
||||||
|
function queueRelease(db, id, fehler) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const tx = db.transaction(QUEUE_STORE, 'readwrite');
|
const tx = db.transaction(QUEUE_STORE, 'readwrite');
|
||||||
const store = tx.objectStore(QUEUE_STORE);
|
const store = tx.objectStore(QUEUE_STORE);
|
||||||
|
|
@ -347,6 +367,12 @@ function queueRelease(db, id) {
|
||||||
if (!it || it.uploading_by !== SW_OWNER) return;
|
if (!it || it.uploading_by !== SW_OWNER) return;
|
||||||
delete it.uploading_since;
|
delete it.uploading_since;
|
||||||
delete it.uploading_by;
|
delete it.uploading_by;
|
||||||
|
delete it.lease_ms;
|
||||||
|
if (fehler) {
|
||||||
|
it.attempts = (it.attempts || 0) + 1;
|
||||||
|
it.last_error = fehler;
|
||||||
|
it.failed = true;
|
||||||
|
}
|
||||||
store.put(it);
|
store.put(it);
|
||||||
};
|
};
|
||||||
tx.oncomplete = () => resolve();
|
tx.oncomplete = () => resolve();
|
||||||
|
|
@ -369,15 +395,17 @@ async function tellClients(msg) {
|
||||||
list.forEach(c => { try { c.postMessage(msg); } catch (_) {} });
|
list.forEach(c => { try { c.postMessage(msg); } catch (_) {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ein Foto hochladen. Rückgabe: die Serverantwort bei bestätigtem Upload,
|
/* Ein Foto oder Video hochladen. Rückgabe: die Serverantwort bei bestätigtem Upload,
|
||||||
* sonst wird geworfen — der Aufrufer lässt den Eintrag dann in der Queue. */
|
* sonst wird geworfen — der Aufrufer lässt den Eintrag dann in der Queue. */
|
||||||
async function uploadQueued(item) {
|
async function uploadQueued(item) {
|
||||||
const blob = new Blob([item.data], { type: item.mime || 'image/jpeg' });
|
const blob = new Blob([item.data], { type: item.mime || 'image/jpeg' });
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', blob, item.filename || 'foto.jpg');
|
fd.append('file', blob, item.filename || 'foto.jpg');
|
||||||
|
if (item.taken_at) fd.append('taken_at', String(Math.round(item.taken_at / 1000)));
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 60000); // haengende Verbindung nicht ewig halten
|
// haengende Verbindung nicht ewig halten — aber einem grossen Video genug Zeit geben
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), uploadTimeoutMs(blob.size) + 15000);
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await fetch(UPLOAD_BASE + '?id=' + encodeURIComponent(item.order_id) + '&action=upload_photo', {
|
r = await fetch(UPLOAD_BASE + '?id=' + encodeURIComponent(item.order_id) + '&action=upload_photo', {
|
||||||
|
|
@ -390,7 +418,7 @@ async function uploadQueued(item) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) { const e = new Error('HTTP ' + r.status); e.status = r.status; throw e; }
|
||||||
|
|
||||||
// 2xx mit HTML-Body heisst: Proxy oder abgelaufene Anmeldung, NICHT gespeichert.
|
// 2xx mit HTML-Body heisst: Proxy oder abgelaufene Anmeldung, NICHT gespeichert.
|
||||||
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||||
|
|
@ -425,9 +453,13 @@ async function drainQueue() {
|
||||||
filename: it.filename,
|
filename: it.filename,
|
||||||
duplicate: !!res.duplicate,
|
duplicate: !!res.duplicate,
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
if (e && e.status === 413) { // zu gross — wird nie besser
|
||||||
|
await queueRelease(db, it.id, 'Datei zu groß für den Server');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
left++; // bleibt in der Queue, naechster Sync versucht es erneut
|
left++; // bleibt in der Queue, naechster Sync versucht es erneut
|
||||||
await queueRelease(db, it.id); // sofort freigeben, sonst wartet die Seite 90 s
|
await queueRelease(db, it.id); // sofort freigeben, sonst wartet die Seite
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue