Changes in / [7b03c35:4cc4bcf] in Klonkt
- Files:
-
- 1 deleted
- 5 edited
-
src/assets/js/mod/read.js (modified) (1 diff)
-
src/routes/posts.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (6 diffs)
-
src/views/pages/paid-gate.ejs (modified) (2 diffs)
-
src/views/shell.ejs (modified) (1 diff)
-
test/paid-open-audio.test.js (deleted)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/js/mod/read.js
r7b03c35 r4cc4bcf 448 448 if (punten[vooruit].value - nu <= vangZone()) echtGaNaar(vooruit); 449 449 }; 450 451 // OP TOUCH DOET DE MODULE-EIGEN TRIGGER HET NIET, en dat is meetbaar in de452 // vendor-bron, geen vermoeden. Drie feiten op een rij:453 //454 // 1. lenis geeft 'virtual-scroll' door met de RUWE vingerdelta, VOOR hij455 // de uitloop uitrekent (teken * |snelheid|^touchInertiaExponent).456 // 2. lenis-snap negeert elke touchmove en beoordeelt dus EEN keer per457 // gebaar, op touchend, met `scroll + rawDelta` -- de plek waar je458 // 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 de463 // smoothWheel-uitloop is kort, dus voorspelling en landing liggen bijeen.464 // Vandaar: op touch de module-trigger eraf en zelf beoordelen tegen465 // lenis.targetScroll -- die wordt synchroon bij touchend gezet en IS het466 // exacte landingspunt van de uitloop.467 if (OP_TOUCH.matches) {468 // Hoe lang het richten naar de lijn duurt, in seconden. De ene knop voor469 // "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 zijn474 // de bovenkant van het HUIDIGE bericht, nooit grijpen475 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 de482 // touchend verwerkt (de emit staat in de bron boven de verwerking), dus483 // targetScroll is hier nog het oude doel. Een tik later is de uitloop484 // gestart en staat targetScroll op de echte landing -- en dan sturen we485 // hem meteen bij, in plaats van de uitloop 150ms te laten lopen en er486 // 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 raakdelta490 const punten = snap.computeSnaps();491 // De EERSTE grens voorbij het loslaatpunt, niet de dichtstbijzijnde bij492 // de landing. Robins regel (24-8): wordt het target voorbijgescrolld,493 // dan landen we alsnog OP het target. De zone werkt dus alleen aan de494 // korte kant -- kom je er niet eens bij in de buurt, dan lees je gewoon495 // binnen een lang bericht en blijft alles vrij. Eroverheen, hoe ver496 // ook, betekent grijpen. Een harde veeg komt daarmee altijd precies497 // 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 uitscrollen503 // HET RICHTEN IS DE SNAP (Robins regel, 24-8): de lopende uitloop504 // 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-vorm507 // (0.09) is boterzacht maar heeft een exponentiele staart van ruim een508 // halve seconde -- Robin wilde het korter en beslister (24-8). Een509 // duur eindigt ECHT, op een instelbaar moment, zoals iOS-paging: vlot510 // 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 524 450 // Native snappen uit: twee mechanismen op dezelfde scroller vechten. 525 451 document.documentElement.style.scrollSnapType = 'none'; -
src/routes/posts.js
r7b03c35 r4cc4bcf 822 822 } 823 823 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 als827 // eigen Audio-object en speelt het bij iedereen die de post in een hub of in828 // Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal829 // beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde830 // 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 de834 // speler ook de gesloten nummers krijgen -- en /audio/stream laat een835 // 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 die839 // alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook840 // 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 851 824 function postNeighbors(site, post) { 852 825 const ordered = db.prepare(` … … 1600 1573 if (post.paid && !canEditThis && !_unlocked) { 1601 1574 const { newerPost, olderPost } = postNeighbors(site, post); 1602 const pgAudio = paidOpenAudioHtml(site, post, req);1603 1575 return renderPage(req, res, 'pages/paid-gate', { 1604 pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),1576 pageJs: 'paid-gate', 1605 1577 pageTitle: post.title || 'Voor supporters', 1606 1578 bodyClass: 'on-special', 1607 1579 pgTitle: post.title || '', 1608 1580 pgTeaser: paidTeaser(post), 1609 pgAudio,1610 1581 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id), 1611 1582 pgSlug: post.slug, -
src/services/ActivityPubService.js
r7b03c35 r4cc4bcf 496 496 } 497 497 498 // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so499 // 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 a501 // later body mutation can't affect it.502 //503 // Staat apart en niet meer midden in buildNote, omdat een BETAALDE post hem ook504 // 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 onze530 // eigen code: de playlist-collectie blijft namelijk een OrderedCollection531 // (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele532 // 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 Mixtape534 // 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, zodat537 // Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio538 // ziet. Wie het type wel kent, vindt het bandje als geheel.539 //540 // Het bandje draagt alleen wat al open staat: playlistOpenTracks filtert op541 // 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 553 498 // A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery). 554 499 export function buildNote(base, site, post, opts = {}) { … … 620 565 621 566 // 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. 633 568 if (post.paid) { 634 569 const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); … … 636 571 const rawTeaser = String(post.excerpt || '').trim() 637 572 || _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 waarvan641 // elk nummer gesloten is, is een lege doos met een titel erop.642 if (band && openBijlagen.length) openBijlagen.push(band);643 573 return { 644 574 '@context': AP_CONTEXT, … … 649 579 url: human, 650 580 published: toISO(post.published_at || post.created_at || Date.now()), 651 ...(openBijlagen.length ? { attachment: openBijlagen } : {}),652 581 to: [PUBLIC], 653 582 cc: [`${aId}/followers`], … … 776 705 } 777 706 } 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 } 779 731 // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt: 780 732 // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een … … 843 795 for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players 844 796 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 */ } 848 818 849 819 // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only -
src/views/pages/paid-gate.ejs
r7b03c35 r4cc4bcf 8 8 <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %> 9 9 <div class="pg-teaser"><p><%= pgTeaser %></p></div> 10 <% } %>11 12 <%# De opengezette muziek staat VOOR de poort en niet erachter: wie hem in een13 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 route15 geeft alleen de audio-shortcodes mee (paidOpenAudioHtml). %>16 <% if (typeof pgAudio !== 'undefined' && pgAudio) { %>17 <div class="pg-audio post-content"><%- pgAudio %></div>18 10 <% } %> 19 11 … … 47 39 .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95; 48 40 -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; }50 41 .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; } 51 42 .pg-lock { font-size: 38px; margin-bottom: 6px; } -
src/views/shell.ejs
r7b03c35 r4cc4bcf 505 505 // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig 506 506 // bumpen kost een bugfix die nooit aankomt. 507 var MOD_V = 5 9;507 var MOD_V = 55; 508 508 509 509 // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)