discovery-media-player 0.1.62 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
package/server/handler.js CHANGED
@@ -337,9 +337,21 @@ var Live=(function(){
337
337
  // conversations de TOUTES les présentations, pas seulement la sienne. C'était le dernier
338
338
  // obstacle avant de pouvoir fermer cette lecture.
339
339
  // L'émetteur diffuse la ligne que le serveur vient d'écrire ; les autres l'ajoutent chez eux.
340
- // ⚠️ La diffusion ne revient pas à son émetteur : il ajoute donc SA copie lui-même.
341
- function sendMsg(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg',payload:m});}catch(e){}}
342
- function sendMsgUpd(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg-upd',payload:m});}catch(e){}}
340
+ // ⚠️ La diffusion ne revient pas à son émetteur : il ajoute donc SA copie lui-même — depuis la
341
+ // RÉPONSE du serveur, jamais depuis le canal.
342
+ //
343
+ // ⚠️ LA CHARGE EST VIDE, ET C'EST CE QUI REND LA PROPRIÉTÉ STRUCTURELLE. Elle transportait la
344
+ // ligne projetée — que le récepteur IGNORAIT (il relit par HTTP, cf. le commentaire du
345
+ // récepteur : sans relecture, une notification pourrait afficher un texte forgé). Un contenu
346
+ // que personne ne consomme n'est pas neutre : la description « un signal, jamais un contenu »
347
+ // était plus forte que le code, et tenait lieu de garde sans en être une — le jour où un
348
+ // récepteur nouveau aurait lu la charge « puisqu'elle est là », la projection serait devenue
349
+ // optionnelle sans que rien ne le dise. Le second hôte a levé l'écart ; sendState et
350
+ // sendMap avaient déjà la bonne forme. Un commentaire ici même affirmait l'ancien monde
351
+ // (« les autres l'ajoutent chez eux ») : il a fait dériver sa lecture, pas la nôtre — un
352
+ // commentaire n'est pas du code, et il vieillit sans essai pour le contredire.
353
+ function sendMsg(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg',payload:{}});}catch(e){}}
354
+ function sendMsgUpd(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg-upd',payload:{}});}catch(e){}}
343
355
  // Édition, suppression, réaction : le serveur renvoie la ligne à jour, on l'applique chez soi
344
356
  // puis on la diffuse. Même chemin pour les trois — une seule façon de se tromper.
345
357
  function majDiffusee(r){return r.json().then(function(d){if(d&&d.ok&&d.message){updateMsg(d.message);sendMsgUpd(d.message);}}).catch(function(){});}
@@ -671,25 +671,62 @@ async function recordAttendance(slug, { key, name, email, avatar, isMember, isPr
671
671
  // resté ouvert, longtemps après la fin.
672
672
  if (pres.active === false && pres.control_hash == null) return REFUS_ARCHIVE;
673
673
  const page = Math.max(1, Math.trunc(Number(pres.current_page) || 1));
674
- const now = Date.now();
675
- const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&select=*&limit=1`);
676
- const cur = Array.isArray(rows) && rows[0];
677
- if (!cur) {
678
- const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter: !!isPresenter, first_seen: new Date(now).toISOString(), last_seen: new Date(now).toISOString(), total_ms: 0, pages: [page] };
679
- await PLAYER.db.request("doc_presentation_attendees", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
680
- return { ok: true };
674
+ // ⚠️ LE DERNIER LIRE-MODIFIER-RÉÉCRIRE DU DÉPÔT, fermé comme les autres — mais SANS migration :
675
+ // `last_seen` change à chaque battement accepté, c'est un verrou optimiste gratuit. L'écriture
676
+ // est conditionnée à la valeur LUE ; zéro ligne = quelqu'un d'autre a battu entre-temps (l'autre
677
+ // onglet du même participant) — on relit et on rejoue. Sans ça, deux onglets qui battent dans la
678
+ // même seconde perdaient une page vue : la seconde réécriture emportait la première.
679
+ let cur = null;
680
+ for (let essai = 0; essai < 4; essai += 1) {
681
+ if (cur === null) {
682
+ const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&select=*&limit=1`);
683
+ cur = (Array.isArray(rows) && rows[0]) || false;
684
+ }
685
+ if (!cur) {
686
+ const now = Date.now();
687
+ const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter: !!isPresenter, first_seen: new Date(now).toISOString(), last_seen: new Date(now).toISOString(), total_ms: 0, pages: [page] };
688
+ try {
689
+ await PLAYER.db.request("doc_presentation_attendees", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
690
+ return { ok: true };
691
+ } catch (erreur) {
692
+ // ⚠️ La clé primaire (slug, attendee_key) fait son travail : deux PREMIERS battements
693
+ // simultanés (deux onglets ouverts ensemble), et le second recevait un 409 que personne ne
694
+ // rattrapait — un 500 pour un battement, bénin mais faux. Le conflit dit « la ligne existe
695
+ // maintenant » : on la relit et on continue en mise à jour. Tout autre échec remonte.
696
+ if (!String((erreur && erreur.message) || "").includes("409")) throw erreur;
697
+ // Journalisé comme bénin : deux onglets qui arrivent ensemble sont une information, pas
698
+ // une panne — et la garde des écritures muettes exige que tout rattrapage parle.
699
+ try { PLAYER.errors.capture(new Error("présence déjà ouverte (second onglet) : " + String(slug)), { route: "present-attend", benin: true }); } catch { /* jamais bloquant */ }
700
+ cur = null;
701
+ continue;
702
+ }
703
+ }
704
+ // ⚠️ UN `last_seen` ACCEPTÉ EST STRICTEMENT CROISSANT — sans ça, la serrure est aveugle dans
705
+ // la milliseconde : deux battements dans la même ms écrivent la MÊME valeur, la condition du
706
+ // suivant matche encore, et l'écrasement revient par la fenêtre qu'on venait de fermer. Vu au
707
+ // banc (trois écritures, trois conditions vraies) avant d'être vu nulle part ailleurs. Le prix :
708
+ // une milliseconde d'avance sur l'horloge dans les rafales, invisible pour des statistiques.
709
+ const lu = new Date(cur.last_seen || 0).getTime();
710
+ const now = Math.max(Date.now(), lu + 1);
711
+ const gap = now - lu;
712
+ const addMs = gap > 0 && gap <= ATTEND_MAX_GAP_MS ? gap : 0;
713
+ const pages = Array.isArray(cur.pages) ? cur.pages.slice() : [];
714
+ if (!pages.includes(page)) pages.push(page);
715
+ const ecrit = await ecrireSiEncoreVrai(
716
+ `doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&last_seen=eq.${enc(String(cur.last_seen))}`,
717
+ { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null,
718
+ // ⚠️ Les deux drapeaux se remettent à jour à chaque battement, ils ne sont plus figés à la
719
+ // première ligne : un transfert de présentation change qui porte le titre, et une session qui
720
+ // s'authentifie en cours de route devient un membre. Figés, ils décriraient l'instant de
721
+ // l'arrivée et non la réalité — et le premier arrivé aurait raison pour toujours.
722
+ is_member: !!isMember, is_presenter: !!isPresenter },
723
+ );
724
+ if (ecrit) return { ok: true };
725
+ cur = null; // battu en vol : on relira l'état frais au tour suivant
681
726
  }
682
- const gap = now - new Date(cur.last_seen || now).getTime();
683
- const addMs = gap > 0 && gap <= ATTEND_MAX_GAP_MS ? gap : 0;
684
- const pages = Array.isArray(cur.pages) ? cur.pages.slice() : [];
685
- if (!pages.includes(page)) pages.push(page);
686
- await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null,
687
- // ⚠️ Les deux drapeaux se remettent à jour à chaque battement, ils ne sont plus figés à la
688
- // première ligne : un transfert de présentation change qui porte le titre, et une session qui
689
- // s'authentifie en cours de route devient un membre. Figés, ils décriraient l'instant de
690
- // l'arrivée et non la réalité — et le premier arrivé aurait raison pour toujours.
691
- is_member: !!isMember, is_presenter: !!isPresenter } });
692
- return { ok: true };
727
+ // Quatre tours perdus : un battement de présence se reperd sans conséquence — le suivant arrive
728
+ // dans quelques secondes. Refuser net plutôt qu'écraser ce que les gagnants viennent d'écrire.
729
+ return { ok: false, status: 409 };
693
730
  }
694
731
 
695
732
  // Détail d'une présentation : entête + participants + nombre de messages par participant.