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

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):
@ -46,10 +44,49 @@ Der Emulator läuft jetzt (SVM an) — die bisher „nur am Handy" prüfbaren Pu
- [x] ✅ Belichtung: **kein eigener Regler** — Antippen setzt bei Chrome Fokus-, Belichtungs- und Weißabgleich-Bereich
gemeinsam; im Emulator gemessen: `aeRegions` = `afRegions` = `awbRegions` = Tippstelle
→ Display/Typenschild antippen = dort scharf UND richtig belichtet.
- [ ] Emulator: echte Kamera + „Fertig" im Funkloch (Fotos gesichert, nach Netz-Rückkehr hochgeladen, Server zählt genau +n)
- [ ] Emulator: Hochladen bei ausgeschaltetem Display (Background Sync)
- [ ] Emulator: 8–10 Fotos auf einmal aus der Galerie (Fix aus Punkt 7)
- [ ] Emulator: Zwei-Finger-Zoom im PDF-Betrachter mit echtem Mehrfinger-Touch (statt CDP-Nachbildung)
- [x] ✅ 19.10.2026 Emulator, **echte Kamera + „Fertig" im Funkloch** (Flugmodus + `adb reverse --remove-all`, Server
nachweislich unerreichbar): 3 Fotos → Warteschlange 3, nach „Fertig" bleibt die App im Auftrag (Fehler aus Punkt 6
bestätigt behoben), Band „Kein Netz — Stand von 23:20 Uhr", 🔴 3, Kasten „Warten auf Upload (3)".
- [x] ✅ 19.10.2026 Emulator, **Hochladen bei ausgeschaltetem Display**: Display aus (`keyevent 223`), dann Netz an
23:23:08 → Server bekam die 3 Fotos 23:26:29–30, Display schlief noch (`mWakefulness=Asleep`). Genau +3, keine
Dubletten. Chrome weckt sich dafür über einen Android-Hintergrundjob — **gut 3 Minuten Verzögerung sind normal**.
- [x] ✅ 19.10.2026 Emulator, **10 Fotos à 8,5 MB (81 MB) auf einmal aus der Galerie** (Android-Fotoauswahl):
alle 10 angekommen, Server genau +10, kein „permission"-Fehler (Fix aus Punkt 7 bestätigt).
- [x] ✅ 19.10.2026 Emulator, **Zwei-Finger-Zoom im PDF-Betrachter** mit echter Mehrfinger-Geste (Konsole, s. u.):
Fingerabstand 200 → 500 px ergibt 100 % → 250 %, Text scharf neu gezeichnet, die Seite selbst zoomt nicht mit.
**Echte Zwei-Finger-Geste im Emulator** (ohne Root, Betriebssystem-Ebene): Emulator-Konsole `telnet localhost 5554`,
`auth <~/.emulator_console_auth_token>`, dann `event send EV_KEY:BTN_TOUCH:1 EV_ABS:ABS_MT_SLOT:0
EV_ABS:ABS_MT_TRACKING_ID:<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)

114
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;
@ -1311,6 +1326,61 @@ body {
}
.cam-shutter:active { transform: scale(0.92); }
.cam-shutter.flash { background: #ffd34d; }
/* Video: roter Knopf, während der Aufnahme ein rotes Quadrat im Ring */
.cam-shutter.video { background: #e53935; }
.cam-shutter.video.laeuft { background: linear-gradient(#e53935, #e53935) center / 26px 26px no-repeat, #111; }
/* Umschalter Foto | Video über dem Auslöser */
.camera-modal .cam-modus {
display: flex;
justify-content: center;
gap: 8px;
padding: 8px 0 0;
background: #000;
}
.camera-modal .cam-modus-btn {
background: transparent;
border: none;
color: #bbb;
font-size: 15px;
font-weight: 600;
padding: 6px 16px;
border-radius: 16px;
cursor: pointer;
}
.camera-modal .cam-modus-btn.aktiv { color: #ffd34d; background: rgba(255, 255, 255, 0.12); }
.camera-modal .cam-modus.gesperrt .cam-modus-btn { opacity: 0.4; pointer-events: none; }
/* Laufzeit oben im Sucher */
.camera-modal .cam-rec {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 4px 12px;
border-radius: 14px;
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.camera-modal .cam-rec::first-letter { color: #e53935; }
#cam-flip:disabled { opacity: 0.35; }
/* Video im Filmstreifen */
.cam-thumb .cam-thumb-leer {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 22px; background: #222;
}
.cam-thumb .cam-thumb-play {
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 16px;
text-shadow: 0 0 4px #000;
pointer-events: none;
}
/* Blitz-Knopf: Text statt nur Symbol, damit der Zustand lesbar ist (Aus/Auto/An/Licht) */
.camera-modal .cam-blitz { font-size: 15px; white-space: nowrap; }
/* Kamera wechseln: weisses Symbol wie ✕, kein Emoji (das erschien als blaues Kaestchen) */
@ -1638,3 +1708,23 @@ body.hat-geteiltes .fab { bottom: calc(112px + env(safe-area-inset-bottom, 0px))
.pdfv-btn.pdfv-wide { font-size: 15px; min-width: 84px; }
.pdfv-info { margin-left: auto; color: #bbb; font-size: 13px; }
.pdf-inline-wrap canvas { cursor: zoom-in; }
/* Videos am Auftrag: erstes Bild als Vorschau, ▶ in der Mitte, Datum/Größe unten */
.video-grid .video-poster { width: 100%; height: 100%; object-fit: cover; display: block; background: #111; pointer-events: none; }
.video-grid .video-meta {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 3px 5px;
font-size: 11px;
color: #fff;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
/* Video-Player (Vollbild) */
.video-modal .fs-body { background: #000; }
.video-modal video { width: 100%; height: 100%; object-fit: contain; background: #000; }

435
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.
@ -3625,10 +3760,15 @@ async function openCameraModal(orderId) {
<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-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>
@ -3647,6 +3787,8 @@ async function openCameraModal(orderId) {
const fokusRing = modal.querySelector('#cam-fokus-ring');
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';
@ -3675,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 (_) {} });
@ -3695,8 +3843,13 @@ async function openCameraModal(orderId) {
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() {
@ -3735,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;
@ -3751,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;
@ -3774,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;
@ -3802,8 +3959,10 @@ async function openCameraModal(orderId) {
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 = ''; // 'continuous' | 'single-shot' | '' (kein Antippen)
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('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('continuous') ? 'continuous' : (fm.includes('single-shot') ? 'single-shot' : '');
@ -3931,9 +4088,199 @@ 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 %
@ -4062,7 +4409,11 @@ async function openCameraModal(orderId) {
}
}
shutter.onclick = capture;
shutter.onclick = () => {
if (modus !== 'video') return capture();
if (aufnahme) videoStoppen();
else videoStarten();
};
startStream();
}
@ -4075,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);
@ -4094,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);
@ -4120,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
}
}