Changes in / [7b03c35:4cc4bcf] in Klonkt


Ignore:
Files:
1 deleted
5 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/mod/read.js

    r7b03c35 r4cc4bcf  
    448448    if (punten[vooruit].value - nu <= vangZone()) echtGaNaar(vooruit);
    449449  };
    450 
    451   // OP TOUCH DOET DE MODULE-EIGEN TRIGGER HET NIET, en dat is meetbaar in de
    452   // vendor-bron, geen vermoeden. Drie feiten op een rij:
    453   //
    454   //   1. lenis geeft 'virtual-scroll' door met de RUWE vingerdelta, VOOR hij
    455   //      de uitloop uitrekent (teken * |snelheid|^touchInertiaExponent).
    456   //   2. lenis-snap negeert elke touchmove en beoordeelt dus EEN keer per
    457   //      gebaar, op touchend, met `scroll + rawDelta` -- de plek waar je
    458   //      vinger LOSLIET.
    459   //   3. de uitloop draagt daarna nog honderden pixels verder.
    460   //
    461   // De snapbeslissing valt dus op een positie die de scroll meteen verlaat.
    462   // Op desktop klopt dezelfde som wel: wieldelta's zijn klein en de
    463   // smoothWheel-uitloop is kort, dus voorspelling en landing liggen bijeen.
    464   // Vandaar: op touch de module-trigger eraf en zelf beoordelen tegen
    465   // lenis.targetScroll -- die wordt synchroon bij touchend gezet en IS het
    466   // exacte landingspunt van de uitloop.
    467   if (OP_TOUCH.matches) {
    468     // Hoe lang het richten naar de lijn duurt, in seconden. De ene knop voor
    469     // "snapperiger": 0.18 is direct, 0.3 is landen, 0.5 is zweven.
    470     const SNAP_DUUR = 0.22;
    471     lenis.off('virtual-scroll', snap.onSnapDebounced);
    472     let raakTimer = null;
    473     let vertrek = 0;   // scrollpositie bij loslaten: punten daarachter zijn
    474                        // de bovenkant van het HUIDIGE bericht, nooit grijpen
    475     lenis.on('virtual-scroll', (e) => {
    476       const soort = e.event && e.event.type;
    477       if (soort === 'touchstart' || soort === 'touchmove') { clearTimeout(raakTimer); return; }
    478       if (soort !== 'touchend') return;
    479       vertrek = lenis.scroll;
    480       clearTimeout(raakTimer);
    481       // TIMEOUT 0, geen 150ms. Dit event komt binnen VOOR lenis zelf de
    482       // touchend verwerkt (de emit staat in de bron boven de verwerking), dus
    483       // targetScroll is hier nog het oude doel. Een tik later is de uitloop
    484       // gestart en staat targetScroll op de echte landing -- en dan sturen we
    485       // hem meteen bij, in plaats van de uitloop 150ms te laten lopen en er
    486       // dan een aparte snap-animatie overheen te zetten.
    487       raakTimer = setTimeout(() => {
    488         if (!laatsteRichtingOmlaag || snap.isStopped) return;
    489         const doel = lenis.targetScroll;   // de echte landing, niet de raakdelta
    490         const punten = snap.computeSnaps();
    491         // De EERSTE grens voorbij het loslaatpunt, niet de dichtstbijzijnde bij
    492         // de landing. Robins regel (24-8): wordt het target voorbijgescrolld,
    493         // dan landen we alsnog OP het target. De zone werkt dus alleen aan de
    494         // korte kant -- kom je er niet eens bij in de buurt, dan lees je gewoon
    495         // binnen een lang bericht en blijft alles vrij. Eroverheen, hoe ver
    496         // ook, betekent grijpen. Een harde veeg komt daarmee altijd precies
    497         // een bericht verder, nooit twee: dat is het bladeren.
    498         let eerste = -1;
    499         punten.forEach((punt, i) => {
    500           if (punt.value > vertrek + 2 && (eerste < 0 || punt.value < punten[eerste].value)) eerste = i;
    501         });
    502         if (eerste < 0) return;            // niets meer voor je: vrij uitscrollen
    503         // HET RICHTEN IS DE SNAP (Robins regel, 24-8): de lopende uitloop
    504         // krijgt het nieuwe doel, er komt geen tweede animatie overheen.
    505         //
    506         // MET EEN VASTE DUUR, niet met de lerp van de uitloop. Die lerp-vorm
    507         // (0.09) is boterzacht maar heeft een exponentiele staart van ruim een
    508         // halve seconde -- Robin wilde het korter en beslister (24-8). Een
    509         // duur eindigt ECHT, op een instelbaar moment, zoals iOS-paging: vlot
    510         // weg, stevig landen. Zelfde easing-familie als de desktop-snap.
    511         // SNAP_DUUR is de enige knop: korter is snapperiger, langer is zachter.
    512         if (doel >= punten[eerste].value - vangZone()) {
    513           lenis.scrollTo(punten[eerste].value, {
    514             duration: SNAP_DUUR,
    515             easing: (t) => 1 - Math.pow(1 - t, 4),
    516             force: true,
    517             userData: { initiator: 'snap' },
    518           });
    519         }
    520       }, 0);
    521     });
    522   }
    523 
    524450  // Native snappen uit: twee mechanismen op dezelfde scroller vechten.
    525451  document.documentElement.style.scrollSnapType = 'none';
  • src/routes/posts.js

    r7b03c35 r4cc4bcf  
    822822}
    823823
    824 // De muziek van een betaalde post op de poortpagina zelf.
    825 //
    826 // WAAROM DIE DAAR HOORT. Zodra een nummer `fedi_open` is, federeert het als
    827 // eigen Audio-object en speelt het bij iedereen die de post in een hub of in
    828 // Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal
    829 // beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde
    830 // kant op (Robin, 24-8). De muur staat om de tekst.
    831 //
    832 // ALLES OF NIETS. Alleen als ELK nummer waar de post naar wijst open staat.
    833 // Een shortcode rendert zijn hele lijst, dus bij een half-open bandje zou de
    834 // speler ook de gesloten nummers krijgen -- en /audio/stream laat een
    835 // gelijke-oorsprong-fetch door, dus dat is geen theoretisch lek maar een echt.
    836 // Half open is hier dus dicht.
    837 //
    838 // De TEKST komt hier niet langs: we geven renderPostBodyHtml een post mee die
    839 // alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook
    840 // niet lekken -- dezelfde regel als bij readerItems.
    841 export function paidOpenAudioHtml(site, post, req) {
    842   if (!postAudioFediOpen(site.id, post.content)) return '';
    843   const codes = String(post.content || '').match(/\[\[(?:track|album|playlist):[^\]]+\]\]/gi) || [];
    844   if (!codes.length) return '';
    845   const alleenMuziek = codes.join('\n');
    846   try {
    847     return renderPostBodyHtml(site, { ...post, content: alleenMuziek, content_rendered: alleenMuziek }, req);
    848   } catch { return ''; /* geen speler is geen kapotte poort */ }
    849 }
    850 
    851824function postNeighbors(site, post) {
    852825  const ordered = db.prepare(`
     
    16001573  if (post.paid && !canEditThis && !_unlocked) {
    16011574    const { newerPost, olderPost } = postNeighbors(site, post);
    1602     const pgAudio = paidOpenAudioHtml(site, post, req);
    16031575    return renderPage(req, res, 'pages/paid-gate', {
    1604     pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
     1576    pageJs: 'paid-gate',
    16051577      pageTitle: post.title || 'Voor supporters',
    16061578      bodyClass: 'on-special',
    16071579      pgTitle: post.title || '',
    16081580      pgTeaser: paidTeaser(post),
    1609       pgAudio,
    16101581      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
    16111582      pgSlug: post.slug,
  • src/services/ActivityPubService.js

    r7b03c35 r4cc4bcf  
    496496}
    497497
    498 // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
    499 // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
    500 // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
    501 // later body mutation can't affect it.
    502 //
    503 // Staat apart en niet meer midden in buildNote, omdat een BETAALDE post hem ook
    504 // nodig heeft: daar staat de muur om de TEKST en niet om de muziek.
    505 function openAudioAttachments(base, site, post) {
    506   const openAudio = [];
    507   if (!/\[\[(track|album|playlist):/i.test(post.content || '')) return openAudio;
    508   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
    509   const seenA = new Set();
    510   const addRow = (r) => {
    511     const fn = r.filename || (r.storage_path || '').split('/').pop();
    512     if (!fn || seenA.has(fn)) return; seenA.add(fn);
    513     const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
    514     // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
    515     // Mastodon renders it as the artwork thumbnail on its native audio player.
    516     const art = abs(r.cover_url || post.cover_image_url || null);
    517     if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
    518     openAudio.push(a);
    519   };
    520   const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
    521   try {
    522     for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
    523     for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
    524     for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
    525   } catch { /* non-fatal */ }
    526   return openAudio;
    527 }
    528 
    529 // HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
    530 // eigen code: de playlist-collectie blijft namelijk een OrderedCollection
    531 // (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
    532 // object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
    533 // Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
    534 // niet voor, en de hub gooide zo'n bandje daarom stil weg.
    535 //
    536 // Als bijlage en niet als het object zelf: de post blijft een Note, zodat
    537 // Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
    538 // ziet. Wie het type wel kent, vindt het bandje als geheel.
    539 //
    540 // Het bandje draagt alleen wat al open staat: playlistOpenTracks filtert op
    541 // fedi_open. Daarom is het veilig om hem ook aan een betaalde teaser te hangen.
    542 function mixtapeAttachment(base, site, post) {
    543   try {
    544     const soort = postMusicType(post.content || '', site.id);
    545     if (!soort || soort.type !== 'mixtape' || !soort.collectie || !soort.collectie.id) return null;
    546     const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
    547       .get(soort.collectie.id, site.id);
    548     if (!pl) return null;
    549     return buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id)) || null;
    550   } catch { return null; /* een bandje minder is geen kapotte post */ }
    551 }
    552 
    553498// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
    554499export function buildNote(base, site, post, opts = {}) {
     
    620565
    621566  // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
    622   // content, so nothing leaks past the paywall. Images stay home too.
    623   //
    624   // MAAR DE OPENGEZETTE AUDIO REIST WEL MEE (Robin, 24-8, naar aanleiding van
    625   // boiert.eu/introducing-this-machine). De muur staat om de TEKST. `fedi_open`
    626   // is een aparte, eenrichtings, per nummer bewust gezette vlag van de eigenaar,
    627   // en die nummers federeren toch al los als eigen Audio-objecten met hun
    628   // `context` naar deze post. Hield deze tak het bandje tegen, dan hield hij
    629   // niets geheim -- alleen de VOLGORDE en het feit dat het een cassette is. In
    630   // de hub viel het bandje daardoor uiteen in vier losse nummers onder een kale
    631   // teaserkaart. Een cassette die terugwijst naar "lees verder (supporters)"
    632   // dient de betaalde post beter dan vier weesnummers.
     567  // content, so nothing leaks past the paywall. No media attachments either.
    633568  if (post.paid) {
    634569    const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
     
    636571    const rawTeaser = String(post.excerpt || '').trim()
    637572      || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
    638     const openBijlagen = openAudioAttachments(base, site, post);
    639     const band = mixtapeAttachment(base, site, post);
    640     // Het bandje alleen als er ook echt iets open in zit: een cassette waarvan
    641     // elk nummer gesloten is, is een lege doos met een titel erop.
    642     if (band && openBijlagen.length) openBijlagen.push(band);
    643573    return {
    644574      '@context': AP_CONTEXT,
     
    649579      url: human,
    650580      published: toISO(post.published_at || post.created_at || Date.now()),
    651       ...(openBijlagen.length ? { attachment: openBijlagen } : {}),
    652581      to: [PUBLIC],
    653582      cc: [`${aId}/followers`],
     
    776705    }
    777706  } catch { /* non-fatal */ }
    778   const openAudio = openAudioAttachments(base, site, post);
     707  // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
     708  // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
     709  // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
     710  // later body mutation can't affect it.
     711  const openAudio = [];
     712  if (hadAudio) {
     713    const seenA = new Set();
     714    const addRow = (r) => {
     715      const fn = r.filename || (r.storage_path || '').split('/').pop();
     716      if (!fn || seenA.has(fn)) return; seenA.add(fn);
     717      const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
     718      // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
     719      // Mastodon renders it as the artwork thumbnail on its native audio player.
     720      const art = abs(r.cover_url || post.cover_image_url || null);
     721      if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
     722      openAudio.push(a);
     723    };
     724    const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
     725    try {
     726      for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
     727      for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
     728      for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
     729    } catch { /* non-fatal */ }
     730  }
    779731  // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt:
    780732  // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een
     
    843795  for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
    844796
    845   // Het bandje als bijlage — zie mixtapeAttachment() voor het waarom.
    846   const tape = mixtapeAttachment(base, site, post);
    847   if (tape) attachment.push(tape);
     797  // HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
     798  // eigen code: de playlist-collectie blijft namelijk een OrderedCollection
     799  // (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
     800  // object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
     801  // Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
     802  // niet voor, en de hub gooide zo'n bandje daarom stil weg.
     803  //
     804  // Als bijlage en niet als het object zelf: de post blijft een Note, zodat
     805  // Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
     806  // ziet. Wie het type wel kent, vindt het bandje als geheel.
     807  try {
     808    const soort = postMusicType(post.content || '', site.id);
     809    if (soort && soort.type === 'mixtape' && soort.collectie && soort.collectie.id) {
     810      const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
     811        .get(soort.collectie.id, site.id);
     812      if (pl) {
     813        const tape = buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id));
     814        if (tape) attachment.push(tape);
     815      }
     816    }
     817  } catch { /* een bandje minder is geen kapotte post */ }
    848818
    849819  // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
  • src/views/pages/paid-gate.ejs

    r7b03c35 r4cc4bcf  
    88  <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %>
    99    <div class="pg-teaser"><p><%= pgTeaser %></p></div>
    10   <% } %>
    11 
    12   <%# De opengezette muziek staat VOOR de poort en niet erachter: wie hem in een
    13       hub of in Mastodon al kan afspelen, hoort hem ook hier te kunnen afspelen.
    14       Alleen de tekst zit achter de poort, en die staat hier niet -- de route
    15       geeft alleen de audio-shortcodes mee (paidOpenAudioHtml). %>
    16   <% if (typeof pgAudio !== 'undefined' && pgAudio) { %>
    17     <div class="pg-audio post-content"><%- pgAudio %></div>
    1810  <% } %>
    1911
     
    4739  .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95;
    4840    -webkit-mask-image: linear-gradient(180deg, #000 55%, transparent); mask-image: linear-gradient(180deg, #000 55%, transparent); }
    49   .pg-audio { margin: 1.5rem 0 0; }
    5041  .pg-card { margin: 1.5rem 0 0; border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); border-radius: 18px; padding: 30px 26px; text-align: center; }
    5142  .pg-lock { font-size: 38px; margin-bottom: 6px; }
  • src/views/shell.ejs

    r7b03c35 r4cc4bcf  
    505505  // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
    506506  // bumpen kost een bugfix die nooit aankomt.
    507   var MOD_V = 59;
     507  var MOD_V = 55;
    508508
    509509  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
Note: See TracChangeset for help on using the changeset viewer.