Compare commits

...

4 commits

Author SHA1 Message Date
Eddy
73a481fa8f Videos aufnehmen, Emulator-Prüfungen nachgeholt, keine Versalien [deploy]
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>
2026-10-19 23:54:20 +02:00
Eddy
3028ff5731 Kamera: Zoom mit zwei Fingern, Antippen belichtet auch dort [deploy]
- Zwei Finger im Sucher zoomen (nur wenn die Kamera Zoom meldet), Anzeige
  „1×"/„2,5×" unten im Sucher, Antippen der Anzeige setzt auf 1× zurück.
  Während der Geste geht immer nur ein Aufruf an die Kamera; das Ende
  der Geste zählt nicht als Fokus-Tipp.
- Hinweis beim Öffnen: „Antippen stellt dort scharf · zwei Finger zoomen".
- Kein eigener Helligkeitsregler: Chrome setzt mit der Tippstelle auch
  Belichtung und Weißabgleich (im Emulator gemessen).

Im Android-Emulator mit echter Zwei-Finger-Geste geprüft; das Foto bei
2,5× zeigt genau den Sucher-Ausschnitt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:22:04 +02:00
Eddy
7c8a3c4bc0 Kamera: Antippen stellt an der richtigen Stelle scharf, Wechselknopf als Symbol [deploy]
Im Android-Emulator (Chrome 153, echter Camera2-Weg, dumpsys media.camera)
gegengeprüft: Blitz Aus/Auto/An und Licht kommen richtig bei der Kamera an.
Beim Antippen-Fokus waren zwei Fehler:

- Chrome rechnet pointsOfInterest ungedreht auf den quer liegenden Sensor um,
  das Sucherbild steht aber hochkant. Ein Tipp rechts unten landete an
  falscher Stelle. sensorPunkt() dreht die Tippstelle jetzt in Sensorlage
  zurück (hochkant, quer 90° und 270° gemessen).
- focusMode single-shot nimmt Chrome auf Android an, setzt es aber nicht um
  (kein AF-Trigger). Der Hinweis „Fokus fest“ war damit falsch und ist weg;
  Antippen = Dauer-Autofokus auf die Tippstelle.

Knopf „Kamera wechseln“: weißes Symbol statt 🔄-Emoji (blaues Kästchen),
nur sichtbar, wenn das Gerät mindestens zwei Kameras hat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:12:34 +02:00
Eddy
2bf5852147 Doku: Kamera-Gegenprobe am Rechner nicht möglich (Emulator, Webcam)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:26:16 +02:00
10 changed files with 828 additions and 137 deletions

View file

@ -20,20 +20,22 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
- ✅ Auftragsdetail mit Kunde, Adresse, Telefon (Click-to-Call)
- ✅ **Neuer Auftrag** (FAB): Kunde per Suche/Zuletzt-verwendet wählen, dann **Ihr Zeichen** (Pflicht), **Auftragsbeschreibung** (optional), **Geplanter Liefertermin** (Datum + Uhrzeit) und Checkbox **Auftrag direkt freigeben**. Kunden-Defaults (Zahlungsbedingung/Zahlart, Ansprechpartner) werden übernommen. Bei „direkt freigeben" wird der Auftrag **ohne Position** validiert — die Leistungen kommen aus dem Stundenzettel. (Bis Bericht 1.3.1 hängte das Backend hier eine Std-Lohn-Zeile mit Menge 1 an; die verfälschte den Auftragswert, stand als offene Restmenge in der Lieferauflistung und im Kunden-PDF.)
**Foto-Upload**
- ✅ 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, Fehlschuss löschbar
**Foto- und Video-Upload**
- ✅ Live-Kamera-Modal (getUserMedia): bleibt für Serien-Aufnahmen offen — Auslöser + Filmstreifen mit Upload-Status je Foto (💾 gesichert → ⏳ lädt hoch → ✓ fertig), Front-/Rückkamera-Umschaltung (nur bei zwei Kameras), Fehlschuss löschbar
- ✅ **Volle Kameraauflösung**: ausgelöst wird über `ImageCapture.takePhoto()` (native JPEG der Kamera inkl. EXIF) — ein Standbild aus dem Video-Stream hat nur Stream-Auflösung und ist nur noch die Rückfallebene (iOS/Safari, ältere Browser)
- ✅ **Blitz und Fokus** (19.10.2026): Knopf oben schaltet reihum Aus → Auto → An → 🔦 Licht (nur was das Gerät meldet, Auswahl bleibt gemerkt; Blitz wirkt über `takePhoto({fillLightMode})`). Antippen stellt an der Stelle scharf und hält den Fokus („🎯 Fokus fest · zurück auf Auto“). Frontkamera/Tablet-Webcam ohne diese Fähigkeiten: kein Knopf, Antippen ohne Wirkung
- ✅ **Blitz und Fokus** (19.10.2026): Knopf oben schaltet reihum Aus → Auto → An → 🔦 Licht (nur was das Gerät meldet, Auswahl bleibt gemerkt; Blitz wirkt über `takePhoto({fillLightMode})`). Antippen stellt den Autofokus auf diese Stelle (Tippstelle wird in Sensorlage zurückgedreht — Chrome tut das nicht; ein echtes „Fokus festhalten“ bietet Chrome auf Android nicht an). Frontkamera/Tablet-Webcam ohne diese Fähigkeiten: kein Knopf, Antippen ohne Wirkung. Knopf „Kamera wechseln“ (weißes Symbol) nur bei mindestens zwei Kameras. Im Android-Emulator per `dumpsys media.camera` gegengeprüft (ROADMAP Punkt 9)
- ✅ **Videos** (19.10.2026): Umschalter „Foto | Video" in der Kamera, roter Aufnahmeknopf, Laufzeit „● 0:12 / 5:00", Ton (Mikrofon erst beim ersten Video angefragt), Licht/Zoom/Antippen-Fokus auch beim Filmen. MP4 (H.264/AAC) wenn möglich, sonst WebM, 5 Mbit/s; Höchstlänge 5 min bzw. passend zur Server-Grenze (`config.php upload_max`). Schließen oder App-Wechsel während der Aufnahme sichert das Video. Am Auftrag eigener Abschnitt „🎬 Videos" mit erstem Bild, Player (spult per Range), Teilen, Löschen. Galerie und Teilen nehmen Videos; zu große lehnt die App gleich ab. Server: Bericht ≥ 1.8.0 (ROADMAP Punkt 11)
- ✅ **Zoom mit zwei Fingern** im Sucher, Anzeige „1×/2,5×" (Antippen → 1×); Antippen setzt auch Belichtung und Weißabgleich auf die Stelle
- ✅ **Verkleinerte Fotos heißen `.jpg`** und transparente Bilder bekommen beim Verkleinern weißen Grund (vorher behielt ein verkleinertes AVIF/WebP seinen Namen und wurde vom Server abgelehnt)
- ✅ Alternativ Aufnahme aus der Galerie (Mehrfachauswahl: alle Dateien werden sofort nach der Auswahl eingelesen, erst dann hochgeladen, weil Android die Galerie-Dateien nur kurz lesbar hält, siehe ROADMAP Punkt 7)
- ✅ Alternativ Aufnahme aus der Galerie, Fotos und Videos (Mehrfachauswahl: alle Dateien werden sofort nach der Auswahl eingelesen, erst dann hochgeladen, weil Android die Galerie-Dateien nur kurz lesbar hält, siehe ROADMAP Punkt 7)
- ✅ **Fotoqualität im Admin einstellbar** (Bericht-Modul → Fotoqualität, `api/config.php`): Vorgabe ist **1:1, gar nicht verkleinern**; optional längste Seite + JPEG-Qualität. Gesichert wird immer zuerst das Original, die kleine Fassung ersetzt es danach — hängt das Verkleinern (Display aus), bleibt das Original
- ✅ **Wake Lock** während Foto-Serie und Upload: schaltet sich das Display ab, friert der Browser laufende Uploads und das Verkleinern ein
- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg
- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (Zeitlimit 45 s + 1 s je 100 KB, gleiche Formel in `api.js` und `sw.js`) und App-Kill. Gesichert wird der Blob selbst (Chrome legt ihn als Datei ab), nicht mehr ein ArrayBuffer. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg
- ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen)
- ✅ **Background Sync**: der Service Worker lädt die Warteschlange auch dann hoch, wenn die App gar nicht offen ist (Display aus, weggewischt) — Tag `photo-queue`, dazu Periodic Background Sync (15 min), wo der Browser ihn gewährt. Anmeldung läuft über das `awl_sso`-Cookie mit
- ✅ **Wiederholter Upload ist idempotent**: geht die Antwort nach dem Speichern verloren, wird erneut gesendet — der Server vergleicht den Inhalt (md5, seit Bericht 1.6.0), legt nichts doppelt ab und antwortet mit `duplicate: true`
- ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos
- ✅ **Seite und Service Worker laden nichts doppelt hoch**: wer ein Foto sendet, belegt es in IndexedDB (Lease, 90 s); der andere überspringt es und übernimmt erst nach Ablauf. Beim Verkleinern bleibt das Foto belegt, bis die kleine Fassung in der Queue liegt — sonst gingen Original **und** kleine Fassung raus. Logik steht zweimal (`lib/idb.js` + `sw.js`), immer zusammen ändern
- ✅ **Seite und Service Worker laden nichts doppelt hoch**: wer ein Foto sendet, belegt es in IndexedDB (Lease = Zeitlimit + 45 s, mind. 90 s, steht als `lease_ms` im Eintrag); der andere überspringt es und übernimmt erst nach Ablauf. Beim Verkleinern bleibt das Foto belegt, bis die kleine Fassung in der Queue liegt — sonst gingen Original **und** kleine Fassung raus. Logik steht zweimal (`lib/idb.js` + `sw.js`), immer zusammen ändern
- ✅ **App-Update lädt nicht mitten in der Arbeit neu**: nach einem Deploy wartet der Reload, bis kein Dialog (Kamera, Notiz, Unterschrift) offen ist, nichts getippt wird und kein Upload läuft (`window.appBusy()`); der eigene Reload löst keinen Browser-Dialog aus
- ✅ **Wartende Fotos stehen am Auftrag**: Block „⏳ Warten auf Upload (n)" mit Vorschaubildern aus der Warteschlange und Klartext (gesichert auf dem Gerät, geht raus sobald Netz da ist); nach dem Upload zieht die Auftragsseite von selbst nach. Hinweis-Toast beim Schließen der Kamera, wenn noch etwas wartet
- ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304

View file

@ -1,6 +1,6 @@
# ROADMAP — Baustelle PWA
Stand: 2026-10-19 · Gegenstück: `data/bericht` (Modul-Version 1.7.1)
Stand: 2026-10-19 · Gegenstück: `data/bericht` (Modul-Version 1.8.0)
Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine
API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des
@ -10,15 +10,13 @@ Bericht-Moduls.
## Offen
- [ ] `idb.queueAll()` lädt **alle** Foto-Blobs in den Speicher — auch `updateBadge()`, das
nur zählen will. Bei 20 Originalen à 8 MB sind das 160 MB je Badge-Update. Braucht ein
Zähl-Verfahren ohne Werte (Schlüssel + kleiner Meta-Store); Schema-Änderung, weil
`sw.js`/`share.html` die DB mit fester Version 1 öffnen.
- [ ] **Am Handy gegenprüfen** (geht nur auf dem Gerät, am Schreibtisch nicht nachstellbar):
echte Kamera-Hardware mit „Fertig" im Funkloch, Background Sync bei ausgeschaltetem
Display, und der Zwei-Finger-Zoom im PDF-Betrachter (lokal nur per CDP-Touch simuliert). Beides lief lokal nur mit nachgebildeter Kamera (`canvas.captureStream()`).
Wenn etwas komisch aussieht: Tag + ungefähre Uhrzeit reichen, der Rest steht im
Prod-Zugriffslog (Lesart siehe KB #208, Nachtrag 18.09.2026).
- [x] ✅ 19.10.2026 (ohne Schema-Änderung): `idb.queueAll()` lud **alle** Foto-Daten in den Speicher, weil sie
als ArrayBuffer gesichert waren. Neue Einträge tragen den Blob selbst — Chrome legt ihn als Datei ab und liest
ihn erst beim Hochladen; `getAll()` liefert nur den Verweis. Alte ArrayBuffer-Einträge laufen wie bisher ab.
- [x] ✅ 19.10.2026 **im Android-Emulator nachgeholt** (echter Chrome/Camera2-Weg, siehe Punkt 10): Kamera + „Fertig"
im Funkloch, Hochladen bei ausgeschaltetem Display, Zwei-Finger-Zoom im PDF-Betrachter. Was nur das echte
Gerät zeigt (Blitzhelligkeit, Fokus-Treiber, Video-Auflösung), steht bei Punkt 9/11. Wenn etwas komisch
aussieht: Tag + ungefähre Uhrzeit reichen, der Rest steht im Prod-Zugriffslog (Lesart KB #208).
**Bewusst nicht gebaut** (Entscheidungen, nicht vergessen):
@ -31,6 +29,65 @@ Bericht-Moduls.
---
## 10. Kamera vernünftig + offene Handy-Prüfungen im Emulator (Eddy 19.10.2026: „mach das vernünftig")
Anlass: Nach der Blitz/Fokus-Gegenprobe (Punkt 9) meldet Chrome außerdem Zoom 1–10× und Belichtungskorrektur ±3.
Der Emulator läuft jetzt (SVM an) — die bisher „nur am Handy" prüfbaren Punkte gehen dort mit echtem Android-Chrome.
- [x] ✅ 19.10.2026 **Zoom mit zwei Fingern im Sucher** (`applyConstraints({advanced:[{zoom}]})`, nur wenn `caps.zoom`
gemeldet) + Anzeige „1×"/„2,5×" unten im Sucher (gelb, sobald gezoomt), Antippen der Anzeige → zurück auf 1×.
Während der Geste immer nur ein Aufruf an die Kamera, Zwischenwerte verfallen; Ende der Geste löst keinen
Fokus-Tipp aus. Im Emulator mit **echter Zwei-Finger-Geste** (Emulator-Konsole → Touchscreen, siehe unten) geprüft:
Fingerabstand ×2,87 → 2,9×, zusammen → 1×, kein Fokus-Aufruf/Klick während der Gesten; Foto bei 2,5× zeigt genau
den Sucher-Ausschnitt. Chrome 153 zoomt über `control.zoomRatio` (nicht Ausschnitt) — die Fokus-/Belichtungs-
bereiche gelten dann laut AOSP-Definition relativ zum gezoomten Bild, die Tippstelle passt also auch im Zoom.
- [x] ✅ Belichtung: **kein eigener Regler** — Antippen setzt bei Chrome Fokus-, Belichtungs- und Weißabgleich-Bereich
gemeinsam; im Emulator gemessen: `aeRegions` = `afRegions` = `awbRegions` = Tippstelle
→ Display/Typenschild antippen = dort scharf UND richtig belichtet.
- [x] ✅ 19.10.2026 Emulator, **echte Kamera + „Fertig" im Funkloch** (Flugmodus + `adb reverse --remove-all`, Server
nachweislich unerreichbar): 3 Fotos → Warteschlange 3, nach „Fertig" bleibt die App im Auftrag (Fehler aus Punkt 6
bestätigt behoben), Band „Kein Netz — Stand von 23:20 Uhr", 🔴 3, Kasten „Warten auf Upload (3)".
- [x] ✅ 19.10.2026 Emulator, **Hochladen bei ausgeschaltetem Display**: Display aus (`keyevent 223`), dann Netz an
23:23:08 → Server bekam die 3 Fotos 23:26:29–30, Display schlief noch (`mWakefulness=Asleep`). Genau +3, keine
Dubletten. Chrome weckt sich dafür über einen Android-Hintergrundjob — **gut 3 Minuten Verzögerung sind normal**.
- [x] ✅ 19.10.2026 Emulator, **10 Fotos à 8,5 MB (81 MB) auf einmal aus der Galerie** (Android-Fotoauswahl):
alle 10 angekommen, Server genau +10, kein „permission"-Fehler (Fix aus Punkt 7 bestätigt).
- [x] ✅ 19.10.2026 Emulator, **Zwei-Finger-Zoom im PDF-Betrachter** mit echter Mehrfinger-Geste (Konsole, s. u.):
Fingerabstand 200 → 500 px ergibt 100 % → 250 %, Text scharf neu gezeichnet, die Seite selbst zoomt nicht mit.
**Echte Zwei-Finger-Geste im Emulator** (ohne Root, Betriebssystem-Ebene): Emulator-Konsole `telnet localhost 5554`,
`auth <~/.emulator_console_auth_token>`, dann `event send EV_KEY:BTN_TOUCH:1 EV_ABS:ABS_MT_SLOT:0
EV_ABS:ABS_MT_TRACKING_ID:<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)
Eddy: *„bei der App Baustelle ist permanent ein Button relativ weit unten angezeigt über den aufgelisteten
@ -62,14 +119,37 @@ Eddy: *„beim Fotografieren kann man den Blitz nicht einstellen und den Fokus a
klappt das nicht, wird ohne Einstellung ausgelöst) → 🔦 Licht (Dauerlicht, `track.getCapabilities().torch`,
nach dem Auslösen erneut gesetzt). Meldet das Gerät nichts davon, gibt es keinen Knopf. Auswahl bleibt gemerkt
(`localStorage baustelle.kamera.blitz`).
- [x] ✅ 19.10.2026: Antippen stellt dort scharf (`pointsOfInterest` + `focusMode: single-shot`, sonst `continuous`;
ohne Punktwahl nur neu scharfstellen), Ring an der Stelle. Bei `single-shot` bleibt der Fokus fest, oben steht
„🎯 Fokus fest · zurück auf Auto" (Tippen → `continuous`). Einmaliger Hinweis „Antippen stellt dort scharf".
- [x] ✅ 19.10.2026: Antippen stellt dort scharf (`pointsOfInterest` + `focusMode`), Ring an der Stelle, einmaliger
Hinweis „Antippen stellt dort scharf".
- [x] Lokal geprüft mit nachgebildeter Kamera (Canvas-Strom + gemeldete Fähigkeiten): Reihenfolge der Modi,
Licht an/aus, `takePhoto({fillLightMode:"flash"})`, Antippstelle richtig umgerechnet (eingepasste Vorschau,
0,803 statt 0,75 bei seitlichem Rand), „Fokus fest" → continuous; ohne Fähigkeiten kein Knopf, keine Wirkung.
- [ ] **Am Handy gegenprüfen** (am Schreibtisch nicht nachstellbar): blitzt „An" wirklich, geht „Licht" an,
wird nach Antippen die angetippte Stelle scharf.
0,803 statt 0,75 bei seitlichem Rand); ohne Fähigkeiten kein Knopf, keine Wirkung.
- [x] ✅ 19.10.2026 abends: **Gegenprobe im Android-Emulator** (AVD `fluke`, Chrome 153, emulierte Rückkamera mit
Blitz + AF-Bereich) — echter Camera2-Weg, gemessen per `adb shell dumpsys media.camera` („Last request sent"):
| Einstellung | was bei der Kamera ankommt | Ergebnis |
|---|---|---|
| ⚡ Aus | `aeMode ON`, `flash.mode OFF` | ✓ |
| ⚡ Auto, Auslösen | Standbild `aeMode ON_AUTO_FLASH` | ✓ Kamera entscheidet |
| ⚡ An, Auslösen | Standbild `aeMode ON_ALWAYS_FLASH`, `flash.mode SINGLE` | ✓ wie Kamera-Apps |
| 🔦 Licht | `flash.mode TORCH`, bleibt nach dem Auslösen an | ✓ |
| Antippen (alt) | `afRegions` an falscher Stelle, `afMode` bleibt `CONTINUOUS_PICTURE` | ✗ zwei Fehler |
**Fehler 1 — Tippstelle ungedreht:** Chrome rechnet `pointsOfInterest` direkt auf die Sensorfläche um
(`VideoCaptureCamera2.java`, keine Drehung), das Sucherbild kommt aber gedreht an (Sensor quer, Bild hochkant).
Tipp bei 67 % / 82 % → Bereich bei 67 % / 82 % des quer liegenden Sensors statt 82 % / 33 %. Jetzt dreht
`sensorPunkt()` zurück (Rückkamera: Sensor 90° − Bildschirmwinkel; Nothing A059 hat 90°, siehe takePhoto-
Drehmarke 6 in KB #1494). Gemessen: hochkant ✓ (zwei Stellen), quer 90° ✓ (1:1), quer 270° ✓ (180°).
**Fehler 2 — „Fokus fest" log:** `single-shot` nimmt Chrome ohne Fehler an (auch mit `exact`), setzt auf Android
aber nichts um — `AF_TRIGGER_START` kommt im Quelltext nicht vor, der Modus bleibt `CONTINUOUS_PICTURE`, und die
gefundene Entfernung meldet Chrome nicht zurück (nur die eigene Vorgabe). Ein echtes Festhalten geht über die
Web-Schnittstelle also nicht. Jetzt: Antippen = Dauer-Autofokus **auf die Tippstelle**, der Hinweis
„🎯 Fokus fest" ist entfernt.
- [x] ✅ 19.10.2026 (Eddy: „den hässlichen Wechselbutton lässt du da doch nicht"): 🔄-Emoji (erschien als blaues
Kästchen) → weißes Material-Symbol wie ✕; Knopf nur, wenn `enumerateDevices()` mindestens zwei Kameras meldet.
- [ ] **Am Handy beim nächsten Einsatz ansehen** — der Weg bis zur Kamera ist belegt, offen ist nur das Gerät selbst:
blitzt das Nothing bei „An" sichtbar, und springt der Fokus nach dem Antippen sofort auf die Stelle (ohne
AF-Auslöser hängt das vom Kamera-Treiber ab). Wenn nicht: Tag + Uhrzeit reichen.
## 7. Galerie-Auswahl: „permission"-Fehler ab dem 3. Foto (gemeldet 2026-09-30)

137
app.css
View file

@ -268,22 +268,21 @@ body {
}
.detail-section h3 {
margin: 0 0 8px;
font-size: 13px;
text-transform: uppercase;
color: #7aa2f7;
letter-spacing: 0.5px;
font-size: 15px;
font-weight: 700;
color: #7aa2f7; /* fett in normaler Schreibweise — keine Versalien (Eddy) */
}
.detail-section p { margin: 4px 0; font-size: 14px; }
.detail-section .label { opacity: 0.6; font-size: 12px; }
.photo-grid {
.photo-grid, .video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 6px;
margin-top: 10px;
max-width: 600px;
}
.photo-grid .thumb {
.photo-grid .thumb, .video-grid .thumb {
aspect-ratio: 1;
max-width: 160px;
background: #2a2a30;
@ -292,7 +291,7 @@ body {
position: relative;
cursor: pointer;
}
.photo-grid .thumb img {
.photo-grid .thumb img, .photo-grid .thumb video {
width: 100%;
height: 100%;
object-fit: cover;
@ -317,7 +316,23 @@ body {
.pending-section h3 { color: #e0af68; }
.pending-section p { line-height: 1.45; }
.pending-grid .pending-thumb { cursor: pointer; }
.pending-grid .pending-thumb img { opacity: 0.7; }
.pending-grid .pending-thumb img, .pending-grid .pending-thumb video { opacity: 0.7; width: 100%; height: 100%; object-fit: cover; display: block; }
.pending-grid .pending-play, .video-grid .video-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
pointer-events: none;
}
.pending-grid .pending-mark {
position: absolute;
right: 4px;
@ -358,7 +373,7 @@ body {
.btn-small:hover { background: #33333a; }
/* Auswahl-Mode im Photo-Grid */
.photo-grid .thumb .thumb-check {
.photo-grid .thumb .thumb-check, .video-grid .thumb .thumb-check {
position: absolute;
top: 6px;
right: 6px;
@ -375,9 +390,9 @@ body {
border: 2px solid #fff;
pointer-events: none;
}
.photo-grid.selecting .thumb-check { display: flex; opacity: 0.35; }
.photo-grid.selecting .thumb.selected { outline: 3px solid #5ab0ff; outline-offset: -3px; }
.photo-grid.selecting .thumb.selected .thumb-check {
.photo-grid.selecting .thumb-check, .video-grid.selecting .thumb-check { display: flex; opacity: 0.35; }
.photo-grid.selecting .thumb.selected, .video-grid.selecting .thumb.selected { outline: 3px solid #5ab0ff; outline-offset: -3px; }
.photo-grid.selecting .thumb.selected .thumb-check, .video-grid.selecting .thumb.selected .thumb-check {
opacity: 1;
background: #5ab0ff;
border-color: #fff;
@ -1219,6 +1234,7 @@ body {
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none; /* zwei Finger zoomen die Kamera, nicht die Seite */
}
/* contain: die Vorschau zeigt exakt das, was gespeichert wird (wichtig für Doku-Fotos) */
.camera-modal #cam-video {
@ -1310,8 +1326,66 @@ body {
}
.cam-shutter:active { transform: scale(0.92); }
.cam-shutter.flash { background: #ffd34d; }
/* Video: roter Knopf, während der Aufnahme ein rotes Quadrat im Ring */
.cam-shutter.video { background: #e53935; }
.cam-shutter.video.laeuft { background: linear-gradient(#e53935, #e53935) center / 26px 26px no-repeat, #111; }
/* Umschalter Foto | Video über dem Auslöser */
.camera-modal .cam-modus {
display: flex;
justify-content: center;
gap: 8px;
padding: 8px 0 0;
background: #000;
}
.camera-modal .cam-modus-btn {
background: transparent;
border: none;
color: #bbb;
font-size: 15px;
font-weight: 600;
padding: 6px 16px;
border-radius: 16px;
cursor: pointer;
}
.camera-modal .cam-modus-btn.aktiv { color: #ffd34d; background: rgba(255, 255, 255, 0.12); }
.camera-modal .cam-modus.gesperrt .cam-modus-btn { opacity: 0.4; pointer-events: none; }
/* Laufzeit oben im Sucher */
.camera-modal .cam-rec {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 4px 12px;
border-radius: 14px;
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.camera-modal .cam-rec::first-letter { color: #e53935; }
#cam-flip:disabled { opacity: 0.35; }
/* Video im Filmstreifen */
.cam-thumb .cam-thumb-leer {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 22px; background: #222;
}
.cam-thumb .cam-thumb-play {
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 16px;
text-shadow: 0 0 4px #000;
pointer-events: none;
}
/* Blitz-Knopf: Text statt nur Symbol, damit der Zustand lesbar ist (Aus/Auto/An/Licht) */
.camera-modal .cam-blitz { font-size: 15px; white-space: nowrap; }
/* Kamera wechseln: weisses Symbol wie ✕, kein Emoji (das erschien als blaues Kaestchen) */
.camera-modal .cam-flip { display: inline-flex; align-items: center; }
.camera-modal .cam-flip svg { display: block; }
/* Scharfstellen per Antippen */
.camera-modal .cam-fokus-ring {
position: absolute;
@ -1326,19 +1400,24 @@ body {
.camera-modal .cam-fokus-ring.aktiv { animation: cam-fokus 0.35s ease-out; }
.camera-modal .cam-fokus-ring.ok { border-color: #ffd34d; }
@keyframes cam-fokus { from { transform: scale(1.5); opacity: 0.4; } to { transform: scale(1); opacity: 0.9; } }
.camera-modal .cam-fokus-fest {
/* Zoomstufe unten mittig im Sucher; Antippen = zurück auf 1× */
.camera-modal .cam-zoom {
position: absolute;
top: 10px;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #ffd34d;
border: 1px solid #ffd34d;
min-width: 44px;
height: 32px;
padding: 0 10px;
border-radius: 16px;
padding: 6px 12px;
font-size: 13px;
border: 1px solid rgba(255, 255, 255, 0.5);
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.camera-modal .cam-zoom.aktiv { color: #ffd34d; border-color: #ffd34d; }
.camera-modal [hidden] { display: none !important; }
/* ============================================================
@ -1629,3 +1708,23 @@ body.hat-geteiltes .fab { bottom: calc(112px + env(safe-area-inset-bottom, 0px))
.pdfv-btn.pdfv-wide { font-size: 15px; min-width: 84px; }
.pdfv-info { margin-left: auto; color: #bbb; font-size: 13px; }
.pdf-inline-wrap canvas { cursor: zoom-in; }
/* Videos am Auftrag: erstes Bild als Vorschau, ▶ in der Mitte, Datum/Größe unten */
.video-grid .video-poster { width: 100%; height: 100%; object-fit: cover; display: block; background: #111; pointer-events: none; }
.video-grid .video-meta {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 3px 5px;
font-size: 11px;
color: #fff;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
/* Video-Player (Vollbild) */
.video-modal .fs-body { background: #000; }
.video-modal video { width: 100%; height: 100%; object-fit: contain; background: #000; }

567
app.js
View file

@ -817,10 +817,16 @@ router.on('/orders/:id', async (args) => {
const notesCount = notes.notes.length;
title(data.order.ref);
// Nach MIME-Type aufteilen
// Nach MIME-Type aufteilen. Videos am Namen: der Server nennt sie video_… (mp4/m4v/mov/webm).
// Ein WebM ohne video_ ist eine Sprachnotiz (voice_….webm) — Dolibarr meldet fuer .webm
// immer video/webm, am MIME-Typ allein liessen sich beide nicht trennen.
const istVideo = (p) => /^video_/i.test(p.filename) || /\.(mp4|m4v|mov)$/i.test(p.filename);
const videoFiles = photos.photos.filter(istVideo);
const imagePhotos = photos.photos.filter(p => (p.mime || '').startsWith('image/'));
const audioFiles = photos.photos.filter(p => (p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
const audioFiles = photos.photos.filter(p => !istVideo(p)
&& ((p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename)));
const otherDocs = photos.photos.filter(p =>
!istVideo(p) &&
!(p.mime || '').startsWith('image/') &&
!(p.mime || '').startsWith('audio/') &&
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename) &&
@ -845,7 +851,7 @@ router.on('/orders/:id', async (args) => {
<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>
<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-notes">📝 Notizen${notesCount ? ' (' + notesCount + ')' : ''}</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="photo-section-head">
<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 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('')}
</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 ? `
<div class="detail-section">
<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)
const photoGrid = document.getElementById('photo-grid');
const videoGrid = document.getElementById('video-grid');
const docList = document.querySelector('.doc-list');
photoGrid.querySelectorAll('.thumb').forEach(t => {
t.addEventListener('click', () => {
@ -905,6 +924,18 @@ router.on('/orders/:id', async (args) => {
}
});
});
// Videos: Tap = Abspielen (oder Auswahl im Select-Modus)
if (videoGrid) videoVorschau(videoGrid);
if (videoGrid) videoGrid.querySelectorAll('.thumb').forEach(t => {
t.addEventListener('click', () => {
if (videoGrid.classList.contains('selecting')) {
t.classList.toggle('selected');
updateSelectBar();
} else {
openVideoPlayer(args.id, t.dataset.relpath, t.dataset.filename);
}
});
});
// Mehrfachauswahl-Modus
const selectBtn = document.getElementById('btn-select-mode');
@ -912,12 +943,14 @@ router.on('/orders/:id', async (args) => {
function allSelectables() {
const thumbs = Array.from(photoGrid.querySelectorAll('.thumb[data-relpath]'));
const vids = videoGrid ? Array.from(videoGrid.querySelectorAll('.thumb[data-relpath]')) : [];
const docs = docList ? Array.from(docList.querySelectorAll('.doc-item[data-relpath]')) : [];
return thumbs.concat(docs);
return thumbs.concat(vids, docs);
}
function startSelectMode() {
if (photoGrid.classList.contains('selecting')) return;
photoGrid.classList.add('selecting');
if (videoGrid) videoGrid.classList.add('selecting');
if (docList) docList.classList.add('selecting');
renderSelectBar();
updateSelectBar();
@ -928,6 +961,7 @@ router.on('/orders/:id', async (args) => {
function endSelectMode() {
selectModeCleanup = null;
photoGrid.classList.remove('selecting');
if (videoGrid) videoGrid.classList.remove('selecting');
if (docList) docList.classList.remove('selecting');
allSelectables().forEach(x => x.classList.remove('selected'));
const bar = document.getElementById('select-bar');
@ -1100,10 +1134,18 @@ router.on('/orders/:id', async (args) => {
// Prod-Log: nur 2 upload_photo-POSTs). Eine File aus dem Speicher bleibt lesbar.
const copies = [];
for (const f of Array.from(files)) {
// Zu grosses Video gleich ablehnen — es bliebe sonst fuer immer in der
// Warteschlange haengen. Es liegt ja weiter in der Galerie.
const max = photoCfg.upload_max || 0;
if (/^video\//.test(f.type || '') && max && f.size > max) {
showToast('Video ' + (f.name || '') + ' ist zu groß (' + Math.round(f.size / 1048576)
+ ' MB) — der Server nimmt höchstens ' + Math.round(max / 1048576) + ' MB', 'error');
continue;
}
try {
copies.push(await readFileNow(f));
} catch (e) {
showToast('Foto ' + (f.name || '') + ' konnte nicht gelesen werden — bitte nochmal auswählen', 'error');
showToast('Datei ' + (f.name || '') + ' konnte nicht gelesen werden — bitte nochmal auswählen', 'error');
}
}
for (const f of copies) {
@ -1111,7 +1153,7 @@ router.on('/orders/:id', async (args) => {
try {
await uploadPhoto(args.id, f);
} catch (e) {
showToast('Foto ' + (f.name || '') + ' fehlgeschlagen: ' + ((e && e.message) || ''), 'error');
showToast('Datei ' + (f.name || '') + ' fehlgeschlagen: ' + ((e && e.message) || ''), 'error');
}
}
} finally {
@ -1130,6 +1172,57 @@ router.on('/orders/:id', async (args) => {
}
});
/* ============================================================
* VIDEO ABSPIELEN
*
* Direkt vom Server: photo.php liefert seit Bericht 1.8.0 Teilbereiche (Range), das Video
* spielt also sofort und laesst sich spulen, ohne vorher komplett geladen zu werden.
* Teilen laedt es einmal ganz. Kann der Browser das Format nicht (z. B. HEVC-.mov vom
* iPhone), bleibt Teilen bzw. Dolibarr.
* ============================================================ */
function openVideoPlayer(orderId, relpath, filename) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal video-modal';
modal.innerHTML = `
<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
*
@ -1161,25 +1254,29 @@ async function renderPendingPhotos() {
const failed = items.filter(i => i.failed).length;
const waiting = items.length - failed;
const fotos = (n) => n + (n === 1 ? ' Foto' : ' Fotos');
const wartende = items.filter(i => !i.failed);
const lines = [];
if (waiting) {
lines.push(navigator.onLine === false
? '📴 Kein Netz — ' + fotos(waiting) + (waiting === 1 ? ' ist' : ' sind')
? '📴 Kein Netz — ' + medienText(wartende) + (waiting === 1 ? ' ist' : ' sind')
+ ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'wird' : 'werden')
+ ' gesendet, sobald wieder Netz da ist.'
: fotos(waiting) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und '
: medienText(wartende) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und '
+ (waiting === 1 ? 'geht' : 'gehen') + ' raus, sobald die Verbindung steht.');
}
if (failed) lines.push('⚠️ ' + fotos(failed) + ' konnte' + (failed === 1 ? '' : 'n')
if (failed) lines.push('⚠️ ' + medienText(items.filter(i => i.failed)) + ' konnte' + (failed === 1 ? '' : 'n')
+ ' nicht hochgeladen werden — antippen zum Prüfen.');
const shown = items.slice(0, PENDING_PREVIEW_MAX);
const thumbs = shown.map(it => {
const url = URL.createObjectURL(new Blob([it.data], { type: it.mime || 'image/jpeg' }));
pendingPhotoUrls.push(url);
// Video: erstes Bild direkt aus dem Geraetespeicher (kein Netz noetig)
const bild = istQueueVideo(it)
? '<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' : '') + '">'
+ '<img src="' + url + '" alt="" loading="lazy">'
+ bild
+ '<span class="pending-mark">' + (it.failed ? '⚠️' : '⏳') + '</span></div>';
}).join('');
const more = items.length - shown.length;
@ -1190,21 +1287,54 @@ async function renderPendingPhotos() {
+ '<div class="photo-grid pending-grid">' + thumbs
+ (more > 0 ? '<div class="thumb pending-thumb pending-more">+' + more + '</div>' : '')
+ '</div></div>';
videoVorschau(box);
box.querySelector('.pending-section').onclick = () => openUploadQueueModal();
}
/* Nach dem Schliessen der Kamera sagen, was mit den Fotos ist. Ohne den Satz sieht
* "gesichert, aber noch nicht gesendet" genauso aus wie "alles erledigt". */
async function announcePendingPhotos(orderId) {
const n = (await offline.listQueue().catch(() => []))
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId)).length;
const items = (await offline.listQueue().catch(() => []))
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId));
const n = items.length;
if (!n) return;
const fotos = n + (n === 1 ? ' Foto' : ' Fotos');
const fotos = medienText(items);
showToast(navigator.onLine === false
? '📴 Kein Netz — ' + fotos + ' auf dem Gerät gesichert, Upload folgt automatisch'
: '⏳ ' + fotos + (n === 1 ? ' wartet' : ' warten') + ' noch auf den Upload', 'warn');
}
/* Vorschaubild eines Videos (Kachel): Chrome auf Android malt ein <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() {
if (!document.getElementById('pending-photos')) return;
clearTimeout(pendingRenderTimer);
@ -1244,13 +1374,15 @@ window.addEventListener('offline', renderPendingPhotosSoon);
const PHOTO_CFG_KEY = 'baustelle.photo_cfg';
let photoCfg = (function () {
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() {
try {
const c = await api.getConfig();
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));
}
} 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
// mehr passieren, was das Foto kostet.
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
// Service Worker das Original hoch, waehrend hier die kleine Fassung entsteht, und im
// Auftrag liegen zwei verschiedene Dateien.
const willShrink = photoCfg.photo_maxside > 0;
const qid = await offline.enqueuePhoto(orderId, file, name, { hold: willShrink });
// Auftrag liegen zwei verschiedene Dateien. Videos werden nie verkleinert; ihre
// Aufnahmezeit ist das Dateidatum aus der Galerie (der Server sortiert danach).
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
// schiefgeht oder haengt, bleibt schlicht das Original in der Queue.
@ -3618,15 +3753,22 @@ async function openCameraModal(orderId) {
<button class="icon-btn" id="cam-close">✕</button>
<div class="fs-title">📷 Kamera</div>
<button class="icon-btn cam-blitz" id="cam-blitz" title="Blitz" hidden></button>
<button class="icon-btn" id="cam-flip" title="Kamera wechseln">🔄</button>
<button class="icon-btn cam-flip" id="cam-flip" title="Kamera wechseln" aria-label="Kamera wechseln" hidden>
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" aria-hidden="true"><path d="M16 7h-1l-1-1h-4L9 7H8c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm-4 7c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/><path d="M8.57.51l4.48 4.48V2.04c4.72.47 8.48 4.23 8.95 8.95h2C23.34 3.02 15.49-1.59 8.57.51zm2.38 21.45c-4.72-.47-8.48-4.23-8.95-8.95H0c.66 7.97 8.51 12.58 15.43 10.48l-4.48-4.48v2.95z"/></svg>
</button>
</div>
<div class="cam-stage" id="cam-stage">
<video id="cam-video" playsinline autoplay muted></video>
<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>
<button class="cam-fokus-fest" id="cam-fokus-fest" hidden>🎯 Fokus fest · zurück auf Auto</button>
<button class="cam-zoom" id="cam-zoom" title="Zoom zurück auf 1×" hidden>1×</button>
</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-count" id="cam-count"></div>
<button class="cam-shutter" id="cam-shutter" aria-label="Auslösen"></button>
@ -3643,7 +3785,10 @@ async function openCameraModal(orderId) {
const stage = modal.querySelector('#cam-stage');
const blitzBtn = modal.querySelector('#cam-blitz');
const fokusRing = modal.querySelector('#cam-fokus-ring');
const fokusFest = modal.querySelector('#cam-fokus-fest');
const flipBtn = modal.querySelector('#cam-flip');
const zoomBtn = modal.querySelector('#cam-zoom');
const recEl = modal.querySelector('#cam-rec');
const modusLeiste = modal.querySelector('#cam-modus');
let stream = null;
let facing = 'environment';
@ -3672,6 +3817,12 @@ async function openCameraModal(orderId) {
// Auftragsseite neu laden (zeigt die frisch hochgeladenen Fotos).
pushModal(modal, () => {
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();
window.removeEventListener('photo-uploaded', onUploaded);
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
@ -3687,13 +3838,18 @@ async function openCameraModal(orderId) {
modal.querySelector('#cam-close').onclick = () => closeModal(modal);
modal.querySelector('#cam-done').onclick = () => closeModal(modal);
modal.querySelector('#cam-flip').onclick = () => {
flipBtn.onclick = () => {
facing = (facing === 'environment') ? 'user' : 'environment';
startStream();
};
let videos = 0; // davon Videos (shots zaehlt alle Aufnahmen)
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() {
@ -3716,6 +3872,11 @@ async function openCameraModal(orderId) {
video.srcObject = stream;
await video.play().catch(() => {});
hint.style.display = 'none';
// Wechselknopf nur, wenn es eine zweite Kamera gibt (Emulator, Tablet-Webcam: nur eine).
// Erst nach der Freigabe ist die Geraeteliste vollstaendig.
navigator.mediaDevices.enumerateDevices().then(l => {
if (my === gen) flipBtn.hidden = l.filter(d => d.kind === 'videoinput').length < 2;
}).catch(() => {});
await kameraExtras(my);
} catch (err) {
if (my !== gen || !document.body.contains(modal)) return; // abgebrochen, nicht mehr relevant
@ -3727,13 +3888,15 @@ async function openCameraModal(orderId) {
}
}
function addThumb(url) {
function addThumb(url, istVideo) {
const t = document.createElement('div');
t.className = 'cam-thumb saving';
t.innerHTML = '<img src="' + url + '" alt=""><span class="cam-thumb-badge"></span>'
t.className = 'cam-thumb saving' + (istVideo ? ' is-video' : '');
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>';
// 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); };
strip.prepend(t);
return t;
@ -3743,12 +3906,13 @@ async function openCameraModal(orderId) {
if (t.dataset.deleting === '1') return; // Doppel-Tap-Schutz
const relpath = t.dataset.relpath;
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
if (!relpath && qid) {
if (!await confirmDialog({
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',
danger: true,
})) return;
@ -3766,8 +3930,9 @@ async function openCameraModal(orderId) {
try { URL.revokeObjectURL(t.querySelector('img').src); } catch (_) {}
t.remove();
shots = Math.max(0, shots - 1);
if (was === 'Video') videos = Math.max(0, videos - 1);
updateCount();
showToast('Foto gelöscht');
showToast(was + ' gelöscht');
} catch (e) {
t.dataset.deleting = '';
if (delBtn) delBtn.disabled = false;
@ -3781,17 +3946,27 @@ async function openCameraModal(orderId) {
* - Blitz beim Ausloesen: ImageCapture.getPhotoCapabilities().fillLightMode (auto/flash),
* wirkt nur ueber takePhoto({ fillLightMode }) — nicht beim Standbild aus dem Sucher.
* - Licht (Dauerlicht): track.getCapabilities().torch, per applyConstraints.
* - Scharfstellen per Antippen: focusMode 'single-shot' (stellt einmal scharf und haelt den
* Fokus fest) bzw. 'continuous', jeweils mit pointsOfInterest = angetippte Stelle (0..1).
* - Scharfstellen per Antippen: pointsOfInterest = angetippte Stelle, focusMode 'continuous'
* → der Autofokus stellt laufend auf diese Stelle scharf. 'single-shot' meldet Chrome zwar,
* loest es auf Android aber nicht ein (kein AF-Trigger in VideoCaptureCamera2.java, der
* Modus bleibt CONTINUOUS_PICTURE — im Emulator per dumpsys gemessen, 19.10.2026).
* Chrome setzt mit dem Punkt zugleich Belichtungs- und Weissabgleich-Bereich (AE/AWB_REGIONS):
* Display oder Typenschild antippen = dort auch richtig belichtet, kein eigener Regler noetig.
* - Zoom mit zwei Fingern: track.getCapabilities().zoom, Chrome schneidet den Sensor aus
* (SCALER_CROP_REGION, gilt auch fuer takePhoto). Beim Zoomen verwirft Chrome den Tippbereich.
* Frontkamera, Webcam am Tablet (X12) usw. melden meist nichts davon → kein Knopf, kein Antippen.
*/
const BLITZ_TEXT = { off: '⚡ Aus', auto: '⚡ Auto', flash: '⚡ An', torch: '🔦 Licht' };
const BLITZ_KEY = 'baustelle.kamera.blitz';
let imageCap = null;
let blitzModi = [];
let blitz = 'off';
let blitzModi = []; // Modi des aktuellen Kamera-Modus (Foto bzw. Video)
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 fokusModus = ''; // 'single-shot' | 'continuous' | '' (kein Antippen)
let fokusModus = ''; // 'continuous' | 'single-shot' | '' (kein Antippen)
let zoomBereich = null; // { min, max, step } aus getCapabilities, sonst kein Zoom
let zoom = 1;
let tippHinweis = false;
function videoTrack() {
@ -3799,8 +3974,8 @@ async function openCameraModal(orderId) {
}
async function kameraExtras(my) {
imageCap = null; blitzModi = []; fotoBlitz = false; fokusModus = '';
blitzBtn.hidden = true; fokusFest.hidden = true; fokusRing.hidden = true;
imageCap = null; blitzModi = []; fotoBlitz = false; fokusModus = ''; zoomBereich = null; zoom = 1;
blitzBtn.hidden = true; fokusRing.hidden = true; zoomBtn.hidden = true;
const track = videoTrack();
if (!track) return;
let caps = {};
@ -3821,26 +3996,80 @@ async function openCameraModal(orderId) {
if (licht.includes('auto')) modi.push('auto');
if (licht.includes('flash')) modi.push('flash');
if (caps.torch) modi.push('torch');
blitzModi = modi;
blitzFotoModi = modi;
blitzVideoModi = caps.torch ? ['off', 'torch'] : []; // beim Filmen gibt es nur Dauerlicht
let gemerkt = 'off';
try { gemerkt = localStorage.getItem(BLITZ_KEY) || 'off'; } catch (_) {}
blitz = modi.includes(gemerkt) ? gemerkt : 'off';
if (modi.length > 1) {
blitzBtn.hidden = false;
blitzBtn.textContent = BLITZ_TEXT[blitz];
if (blitz === 'torch') lichtSchalten(true);
}
blitzFoto = modi.includes(gemerkt) ? gemerkt : 'off';
blitz = 'off'; // frischer Strom: Licht ist aus
blitzFuerModus();
const fm = Array.isArray(caps.focusMode) ? caps.focusMode : [];
fokusModus = fm.includes('single-shot') ? 'single-shot' : (fm.includes('continuous') ? 'continuous' : '');
if (fokusModus && !tippHinweis) {
fokusModus = fm.includes('continuous') ? 'continuous' : (fm.includes('single-shot') ? 'single-shot' : '');
const zb = caps.zoom;
if (zb && typeof zb.max === 'number' && zb.max > (zb.min || 1)) {
zoomBereich = { min: zb.min || 1, max: zb.max, step: zb.step || 0.1 };
try { zoom = track.getSettings().zoom || zoomBereich.min; } catch (_) { zoom = zoomBereich.min; }
zoomAnzeigen();
zoomBtn.hidden = false;
}
const tipps = [];
if (fokusModus) tipps.push('Antippen stellt dort scharf');
if (zoomBereich) tipps.push('zwei Finger zoomen');
if (tipps.length && !tippHinweis) {
tippHinweis = true;
hint.textContent = 'Antippen stellt dort scharf';
const text = tipps.join(' · ');
hint.textContent = text;
hint.style.display = '';
setTimeout(() => { if (hint.textContent === 'Antippen stellt dort scharf') hint.style.display = 'none'; }, 2500);
setTimeout(() => { if (hint.textContent === text) hint.style.display = 'none'; }, 2500);
}
}
function zoomAnzeigen() {
const z = Math.round(zoom * 10) / 10;
zoomBtn.textContent = (Number.isInteger(z) ? String(z) : z.toFixed(1).replace('.', ',')) + '×';
zoomBtn.classList.toggle('aktiv', z > (zoomBereich ? zoomBereich.min : 1));
}
// Zoom setzen: waehrend der Geste kommen viele Werte — immer nur einen Aufruf gleichzeitig
// an die Kamera schicken, danach den zuletzt gewuenschten (Zwischenwerte verfallen).
let zoomLaeuft = false, zoomZiel = null;
async function zoomSetzen(z) {
if (!zoomBereich) return;
const { min, max, step } = zoomBereich;
zoomZiel = Math.min(max, Math.max(min, Math.round(Math.round(z / step) * step * 100) / 100));
zoom = zoomZiel;
zoomAnzeigen();
if (zoomLaeuft) return;
zoomLaeuft = true;
while (zoomZiel !== null) {
const ziel = zoomZiel; zoomZiel = null;
const track = videoTrack();
if (!track) break;
try { await track.applyConstraints({ advanced: [{ zoom: ziel }] }); } catch (_) {}
}
zoomLaeuft = false;
}
zoomBtn.onclick = (ev) => { ev.stopPropagation(); if (zoomBereich) zoomSetzen(zoomBereich.min); };
// Zwei Finger auf dem Sucher = zoomen (Abstand der Finger relativ zum Start)
let pinch = null, pinchEnde = 0;
const fingerAbstand = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
stage.addEventListener('touchstart', (e) => {
if (e.touches.length === 2 && zoomBereich) pinch = { d0: fingerAbstand(e.touches), z0: zoom };
}, { passive: true });
stage.addEventListener('touchmove', (e) => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
zoomSetzen(pinch.z0 * fingerAbstand(e.touches) / pinch.d0);
}, { passive: false });
const pinchAus = (e) => { if (pinch && (!e.touches || e.touches.length < 2)) { pinch = null; pinchEnde = Date.now(); } };
stage.addEventListener('touchend', pinchAus);
stage.addEventListener('touchcancel', pinchAus);
async function lichtSchalten(an) {
const track = videoTrack();
if (!track) return false;
@ -3859,12 +4088,223 @@ async function openCameraModal(orderId) {
}
}
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,
* 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 %
* des QUER liegenden Sensors scharf (Emulator, dumpsys afRegions) — also an falscher Stelle.
* Drehung wie in Chrome: Rueckkamera Sensor − Bildschirmwinkel, Frontkamera Sensor + Winkel.
* Sensorlage Rueckkamera 90° (Nothing A059: takePhoto-Drehmarke 6, KB #1494), Frontkamera 270°;
* passt das nicht zum Bildformat (Tablet mit 0°-Sensor), ohne 90°-Anteil. */
function sensorPunkt(x, y) {
const winkel = (screen.orientation && typeof screen.orientation.angle === 'number') ? screen.orientation.angle : 0;
const drehung = (sensor) => (facing === 'user' ? sensor + winkel : sensor - winkel + 360) % 360;
let d = drehung(facing === 'user' ? 270 : 90);
if ((d % 180 === 90) !== (video.videoHeight > video.videoWidth)) d = drehung(0);
switch (d) {
case 90: return { x: y, y: 1 - x };
case 180: return { x: 1 - x, y: 1 - y };
case 270: return { x: 1 - y, y: x };
default: return { x, y };
}
}
stage.addEventListener('click', async (ev) => {
const track = videoTrack();
if (!track || !fokusModus || !video.videoWidth || ev.target === fokusFest) return;
if (!track || !fokusModus || !video.videoWidth) return;
if (pinch || Date.now() - pinchEnde < 400) return; // Ende einer Zoom-Geste ist kein Tipp
// Antippstelle in Bildkoordinaten 0..1 — die Vorschau ist eingepasst (object-fit: contain)
const r = video.getBoundingClientRect();
const sk = Math.min(r.width / video.videoWidth, r.height / video.videoHeight);
@ -3872,6 +4312,7 @@ async function openCameraModal(orderId) {
const x = (ev.clientX - r.left - (r.width - w) / 2) / w;
const y = (ev.clientY - r.top - (r.height - h) / 2) / h;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
const punkt = sensorPunkt(x, y);
const sr = stage.getBoundingClientRect();
fokusRing.style.left = (ev.clientX - sr.left) + 'px';
@ -3883,7 +4324,7 @@ async function openCameraModal(orderId) {
let ok = false;
try {
await track.applyConstraints({ advanced: [{ pointsOfInterest: [{ x, y }], focusMode: fokusModus }] });
await track.applyConstraints({ advanced: [{ pointsOfInterest: [punkt], focusMode: fokusModus }] });
ok = true;
} catch (_) {
// Ohne Punktwahl wenigstens neu scharfstellen
@ -3891,16 +4332,8 @@ async function openCameraModal(orderId) {
}
fokusRing.classList.toggle('ok', ok);
setTimeout(() => { fokusRing.hidden = true; }, 900);
if (ok && fokusModus === 'single-shot') fokusFest.hidden = false;
});
fokusFest.onclick = async (ev) => {
ev.stopPropagation();
const track = videoTrack();
fokusFest.hidden = true;
if (track) { try { await track.applyConstraints({ advanced: [{ focusMode: 'continuous' }] }); } catch (_) {} }
};
/* Echtes Kamerafoto in voller Sensoraufloesung.
*
* Ein Standbild aus dem Video-Stream hat hoechstens Stream-Aufloesung (hier bis 4K
@ -3976,7 +4409,11 @@ async function openCameraModal(orderId) {
}
}
shutter.onclick = capture;
shutter.onclick = () => {
if (modus !== 'video') return capture();
if (aufnahme) videoStoppen();
else videoStarten();
};
startStream();
}
@ -3989,7 +4426,7 @@ async function openCameraModal(orderId) {
async function openUploadQueueModal() {
const items = await offline.listQueue().catch(() => []);
if (!items.length) {
showToast(navigator.onLine ? 'Alle Fotos hochgeladen ✓' : 'Keine offenen Uploads');
showToast(navigator.onLine ? 'Alle Fotos und Videos hochgeladen ✓' : 'Keine offenen Uploads');
return;
}
const pending = items.filter(i => !i.failed);
@ -4008,7 +4445,7 @@ async function openUploadQueueModal() {
<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-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>
`;
document.body.appendChild(modal);
@ -4034,7 +4471,7 @@ async function openUploadQueueModal() {
mime: it.mime || 'image/jpeg',
}));
if (!files.length) return;
await shareFiles(files, 'Nicht hochgeladene Baustellen-Fotos');
await shareFiles(files, 'Nicht hochgeladene Baustellen-Fotos und -Videos');
};
}

View file

@ -145,13 +145,24 @@
// er antwortet dann mit duplicate:true und dem relpath der vorhandenen Datei.
// Die Wiederholung ist damit idempotent statt duplikaterzeugend; die Regel „lieber
// 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();
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', {
method: 'POST',
body: fd,
timeoutMs: 45000,
timeoutMs: uploadTimeoutMs(fileBlob && fileBlob.size),
});
// 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).
@ -410,7 +421,7 @@
request,
getToken, setToken, clearToken,
login, logout,
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, createOrder,
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, uploadTimeoutMs, createOrder,
listCustomers, getCustomer,
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,

View file

@ -108,7 +108,8 @@
}
// 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() {
const db = await open();
return new Promise((res, rej) => {
@ -149,15 +150,19 @@
* 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.
* 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). */
function queueClaim(id, owner, leaseMs) {
const now = Date.now();
return queuePatch(id, (it) => {
const lease = typeof leaseMs === 'function' ? leaseMs(it) : leaseMs;
if (it.failed) return false;
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_by = owner;
it.lease_ms = lease;
});
}
@ -165,10 +170,18 @@
return queuePatch(id, (it) => {
delete it.uploading_since;
delete it.uploading_by;
delete it.lease_ms;
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,
queueKeys, queuePatch, queueClaim, queueRelease };
queueKeys, queuePatch, queueClaim, queueRelease, dataSize };
})();

View file

@ -18,31 +18,40 @@
* 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
* 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.
* Der Besitzer ist je Seiten-Instanz eindeutig: zwei offene Fenster sind zwei Besitzer. */
const OWNER = 'page-' + Math.random().toString(36).slice(2, 10);
const HOLD_OWNER = OWNER + ':resize';
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
* 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
* 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. */
/* 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) {
// Blob → ArrayBuffer für IndexedDB-Speicherung
const buf = await fileBlob.arrayBuffer();
const item = {
type: 'photo',
order_id: orderId,
filename,
mime: fileBlob.type || 'image/jpeg',
data: buf,
data: fileBlob,
attempts: 0,
failed: false,
created: Date.now(),
};
if (opts && opts.takenAt) item.taken_at = opts.takenAt;
if (opts && opts.hold) {
item.uploading_since = Date.now();
item.uploading_by = HOLD_OWNER;
@ -70,9 +79,8 @@
// Per Patch in einer Transaktion: das fruehere "alles lesen, ganzes Item zurueckschreiben"
// ueberschrieb, was ein anderer in der Zwischenzeit am Eintrag geaendert hatte.
async function replaceQueuedBlob(id, fileBlob) {
const buf = await fileBlob.arrayBuffer();
const it = await idb.queuePatch(id, (x) => {
x.data = buf;
x.data = fileBlob;
x.mime = fileBlob.type || x.mime || 'image/jpeg';
// Verkleinert = JPEG aus dem Canvas: Endung angleichen. Sonst laege JPEG-Inhalt unter
// „.avif"/„.webp" in der Queue, und der Server lehnt die Endung ab.
@ -185,12 +193,12 @@
for (const id of ids) {
// null: schon weg, in Quarantaene — oder gerade vom Service Worker bzw.
// 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.type !== 'photo') { await idb.queueDelete(it.id); continue; }
try {
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);
try {
window.dispatchEvent(new CustomEvent('photo-uploaded', {
@ -214,12 +222,14 @@
}
// Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne,
// 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';
try {
await idb.queueRelease(it.id, (x) => {
x.attempts = (x.attempts || 0) + 1;
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 (_) {}
}

View file

@ -27,8 +27,8 @@
"text": "text",
"url": "url",
"files": [
{ "name": "photos", "accept": ["image/jpeg", "image/png", "image/webp", "image/*"] },
{ "name": "file", "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/*", "video/mp4", "video/quicktime", "video/webm", "video/*"] }
]
}
}

View file

@ -296,10 +296,17 @@ async function renderPicker() {
payload.files.forEach(f => {
const d = document.createElement('div');
d.className = 'thumb';
const img = document.createElement('img');
img.src = URL.createObjectURL(f.blob);
img.alt = f.name;
d.appendChild(img);
// Videos (seit 19.10.2026): erstes Bild als Vorschau
const istVideo = /^video\//.test(f.type || f.blob.type || '');
const el = document.createElement(istVideo ? 'video' : '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);
});
}

52
sw.js
View file

@ -306,12 +306,28 @@ function queueIds(db) {
* hintereinander ausfuehrt. DIESELBE LOGIK STEHT IN lib/idb.js (queueClaim) — beide
* Stellen zusammen aendern.
*
* Die Lease haelt laenger als der laengste Upload-Timeout (60 s, unten), damit 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. */
* Die Lease haelt laenger als der Upload-Timeout (waechst mit der Dateigroesse, unten), damit
* 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. 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 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
* (Seite laedt hoch oder verkleinert noch), sonst {} (weg, Quarantaene, kein Foto). */
function queueClaim(db, id) {
@ -324,10 +340,12 @@ function queueClaim(db, id) {
const it = req.result;
if (!it || it.failed || it.type !== 'photo') return;
const now = Date.now();
const lease = leaseFuer(it);
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_by = SW_OWNER;
it.lease_ms = lease;
store.put(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) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
const store = tx.objectStore(QUEUE_STORE);
@ -347,6 +367,12 @@ function queueRelease(db, id) {
if (!it || it.uploading_by !== SW_OWNER) return;
delete it.uploading_since;
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);
};
tx.oncomplete = () => resolve();
@ -369,15 +395,17 @@ async function tellClients(msg) {
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. */
async function uploadQueued(item) {
const blob = new Blob([item.data], { type: item.mime || 'image/jpeg' });
const fd = new FormData();
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 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;
try {
r = await fetch(UPLOAD_BASE + '?id=' + encodeURIComponent(item.order_id) + '&action=upload_photo', {
@ -390,7 +418,7 @@ async function uploadQueued(item) {
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.
const ct = (r.headers.get('content-type') || '').toLowerCase();
@ -425,9 +453,13 @@ async function drainQueue() {
filename: it.filename,
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
await queueRelease(db, it.id); // sofort freigeben, sonst wartet die Seite 90 s
await queueRelease(db, it.id); // sofort freigeben, sonst wartet die Seite
}
}