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


Ignore:
Files:
1 added
5 edited

Legend:

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

    r4cc4bcf r7b03c35  
    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
    450524  // Native snappen uit: twee mechanismen op dezelfde scroller vechten.
    451525  document.documentElement.style.scrollSnapType = 'none';
  • src/routes/posts.js

    r4cc4bcf r7b03c35  
    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.
     841export 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
    824851function postNeighbors(site, post) {
    825852  const ordered = db.prepare(`
     
    15731600  if (post.paid && !canEditThis && !_unlocked) {
    15741601    const { newerPost, olderPost } = postNeighbors(site, post);
     1602    const pgAudio = paidOpenAudioHtml(site, post, req);
    15751603    return renderPage(req, res, 'pages/paid-gate', {
    1576     pageJs: 'paid-gate',
     1604    pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
    15771605      pageTitle: post.title || 'Voor supporters',
    15781606      bodyClass: 'on-special',
    15791607      pgTitle: post.title || '',
    15801608      pgTeaser: paidTeaser(post),
     1609      pgAudio,
    15811610      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
    15821611      pgSlug: post.slug,
  • src/services/ActivityPubService.js

    r4cc4bcf r7b03c35  
    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.
     505function 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.
     542function 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
    498553// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
    499554export function buildNote(base, site, post, opts = {}) {
     
    565620
    566621  // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
    567   // content, so nothing leaks past the paywall. No media attachments either.
     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.
    568633  if (post.paid) {
    569634    const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
     
    571636    const rawTeaser = String(post.excerpt || '').trim()
    572637      || _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);
    573643    return {
    574644      '@context': AP_CONTEXT,
     
    579649      url: human,
    580650      published: toISO(post.published_at || post.created_at || Date.now()),
     651      ...(openBijlagen.length ? { attachment: openBijlagen } : {}),
    581652      to: [PUBLIC],
    582653      cc: [`${aId}/followers`],
     
    705776    }
    706777  } catch { /* non-fatal */ }
    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   }
     778  const openAudio = openAudioAttachments(base, site, post);
    731779  // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt:
    732780  // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een
     
    795843  for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
    796844
    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 */ }
     845  // Het bandje als bijlage — zie mixtapeAttachment() voor het waarom.
     846  const tape = mixtapeAttachment(base, site, post);
     847  if (tape) attachment.push(tape);
    818848
    819849  // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
  • src/views/pages/paid-gate.ejs

    r4cc4bcf r7b03c35  
    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>
    1018  <% } %>
    1119
     
    3947  .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95;
    4048    -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; }
    4150  .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; }
    4251  .pg-lock { font-size: 38px; margin-bottom: 6px; }
  • src/views/shell.ejs

    r4cc4bcf r7b03c35  
    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 = 55;
     507  var MOD_V = 59;
    508508
    509509  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
Note: See TracChangeset for help on using the changeset viewer.