[FEATURE] Add flipnote.js web playback
Adds in-browser Flipnote playback with audio, seeking, mute and looping using flipnote.js. Introduces a separate media route for playback so loading a Flipnote does not increment download counts, while retaining the existing first-frame preview as a fallback.
This commit is contained in:
1 parent
b6d610d15f
commit
4817f99974
3 files changed
+213
-3
No files matched your search
@@ -0,0 +1,182 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var stage = document.querySelector('[data-flipnote-player]');
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
var source = stage.getAttribute('data-source');
|
||||||
|
var mount = stage.querySelector('.flipnote-player-canvas');
|
||||||
|
var fallback = stage.querySelector('.flipnote-player-fallback');
|
||||||
|
var status = stage.querySelector('[data-display="status"]');
|
||||||
|
var bigPlay = stage.querySelector('[data-action="big-play"]');
|
||||||
|
var controls = stage.parentNode.querySelector('.flipnote-controls');
|
||||||
|
var playButton = controls.querySelector('[data-action="play"]');
|
||||||
|
var muteButton = controls.querySelector('[data-action="mute"]');
|
||||||
|
var loopButton = controls.querySelector('[data-action="loop"]');
|
||||||
|
var progress = controls.querySelector('[data-control="progress"]');
|
||||||
|
var time = controls.querySelector('[data-display="time"]');
|
||||||
|
var controlButtons = controls.querySelectorAll('button');
|
||||||
|
var seeking = false;
|
||||||
|
var hasPlayed = false;
|
||||||
|
var player;
|
||||||
|
|
||||||
|
function setControlsEnabled(enabled) {
|
||||||
|
for (var i = 0; i < controlButtons.length; i++) {
|
||||||
|
controlButtons[i].disabled = !enabled;
|
||||||
|
}
|
||||||
|
progress.disabled = !enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayState(playing) {
|
||||||
|
playButton.textContent = playing ? 'Pause' : 'Play';
|
||||||
|
bigPlay.hidden = playing;
|
||||||
|
stage.classList.toggle('is-playing', playing);
|
||||||
|
if (playing) hasPlayed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTime() {
|
||||||
|
if (!player || !player.isNoteLoaded) return;
|
||||||
|
time.textContent = player.getTimeCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(value) {
|
||||||
|
if (seeking) return;
|
||||||
|
var amount = Number(value);
|
||||||
|
if (!isFinite(amount)) amount = player && player.isNoteLoaded ? player.getProgress() : 0;
|
||||||
|
progress.value = String(Math.max(0, Math.min(1000, Math.round(amount * 10))));
|
||||||
|
updateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVolume() {
|
||||||
|
if (!player || !player.isNoteLoaded) return;
|
||||||
|
var muted = player.getMuted();
|
||||||
|
muteButton.textContent = muted ? 'Muted' : 'Sound';
|
||||||
|
muteButton.setAttribute('aria-pressed', muted ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLoop() {
|
||||||
|
if (!player || !player.isNoteLoaded) return;
|
||||||
|
var looped = player.getLoop();
|
||||||
|
loopButton.classList.toggle('is-active', looped);
|
||||||
|
loopButton.setAttribute('aria-pressed', looped ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
stage.classList.add('has-player-error');
|
||||||
|
stage.classList.remove('is-ready');
|
||||||
|
fallback.hidden = false;
|
||||||
|
bigPlay.hidden = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = message;
|
||||||
|
setControlsEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.flipnote || !window.flipnote.Player) {
|
||||||
|
showError('Playback unavailable — showing the first frame.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
player = new window.flipnote.Player(mount, 320, 240);
|
||||||
|
} catch (err) {
|
||||||
|
showError('Playback is not supported by this browser.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.on('ready', function () {
|
||||||
|
stage.classList.add('is-ready');
|
||||||
|
stage.classList.remove('has-player-error');
|
||||||
|
fallback.hidden = true;
|
||||||
|
status.hidden = true;
|
||||||
|
bigPlay.hidden = false;
|
||||||
|
setControlsEnabled(true);
|
||||||
|
setPlayState(false);
|
||||||
|
updateProgress(0);
|
||||||
|
updateVolume();
|
||||||
|
updateLoop();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('play', function () {
|
||||||
|
setPlayState(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('pause', function () {
|
||||||
|
setPlayState(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('ended', function () {
|
||||||
|
setPlayState(false);
|
||||||
|
updateProgress(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('progress', function (value) {
|
||||||
|
updateProgress(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('timeupdate', function () {
|
||||||
|
updateTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('volumechange', function () {
|
||||||
|
updateVolume();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('error', function () {
|
||||||
|
showError('Could not play this Flipnote — showing the first frame.');
|
||||||
|
});
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
if (!player || !player.isNoteLoaded) return;
|
||||||
|
player.togglePlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
playButton.addEventListener('click', togglePlay);
|
||||||
|
bigPlay.addEventListener('click', togglePlay);
|
||||||
|
|
||||||
|
mount.addEventListener('click', function () {
|
||||||
|
if (hasPlayed) togglePlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
muteButton.addEventListener('click', function () {
|
||||||
|
if (!player || !player.isNoteLoaded) return;
|
||||||
|
player.toggleMuted();
|
||||||
|
updateVolume();
|
||||||
|
});
|
||||||
|
|
||||||
|
loopButton.addEventListener('click', function () {
|
||||||
|
if (!player || !player.isNoteLoaded) return;
|
||||||
|
player.toggleLoop();
|
||||||
|
updateLoop();
|
||||||
|
});
|
||||||
|
|
||||||
|
function beginSeek() {
|
||||||
|
if (!player || !player.isNoteLoaded || seeking) return;
|
||||||
|
seeking = true;
|
||||||
|
player.startSeek();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSeek() {
|
||||||
|
if (!player || !player.isNoteLoaded) return;
|
||||||
|
if (!seeking) beginSeek();
|
||||||
|
player.seek(Number(progress.value) / 1000);
|
||||||
|
updateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSeek() {
|
||||||
|
if (!player || !player.isNoteLoaded || !seeking) return;
|
||||||
|
player.seek(Number(progress.value) / 1000);
|
||||||
|
player.endSeek();
|
||||||
|
seeking = false;
|
||||||
|
updateProgress(player.getProgress());
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.addEventListener('pointerdown', beginSeek);
|
||||||
|
progress.addEventListener('input', moveSeek);
|
||||||
|
progress.addEventListener('change', finishSeek);
|
||||||
|
progress.addEventListener('pointerup', finishSeek);
|
||||||
|
progress.addEventListener('keyup', finishSeek);
|
||||||
|
|
||||||
|
player.load(source).catch(function () {
|
||||||
|
showError('Could not play this Flipnote — showing the first frame.');
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -81,3 +81,8 @@ input,textarea,select{width:100%;border:1px solid var(--line);border-radius:3px;
|
|||||||
@media(max-width:1050px){.flipnote-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.watch-layout{grid-template-columns:minmax(0,1fr) 270px}.docs-layout{grid-template-columns:205px minmax(0,1fr)}}
|
@media(max-width:1050px){.flipnote-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.watch-layout{grid-template-columns:minmax(0,1fr) 270px}.docs-layout{grid-template-columns:205px minmax(0,1fr)}}
|
||||||
@media(max-width:820px){.credit-list{grid-template-columns:1fr}.docs-layout{grid-template-columns:1fr}.docs-nav{position:static}.docs-nav nav{grid-template-columns:repeat(3,minmax(0,1fr))}.watch-head{align-items:flex-start}.related-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.header-inner{height:auto;min-height:68px;flex-wrap:wrap;gap:0 12px;padding-top:6px;padding-bottom:6px}.brand{width:164px;height:54px}.brand img{width:164px}.main-nav{order:3;width:100%;height:36px}.nav-link{padding:0 10px}.header-actions{margin-left:auto}.flipnote-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.watch-layout{grid-template-columns:1fr}.detail-panel{position:static}.comment{grid-template-columns:120px 1fr}.admin-ban-form{grid-template-columns:1fr}.page-shell{padding-top:20px}}
|
@media(max-width:820px){.credit-list{grid-template-columns:1fr}.docs-layout{grid-template-columns:1fr}.docs-nav{position:static}.docs-nav nav{grid-template-columns:repeat(3,minmax(0,1fr))}.watch-head{align-items:flex-start}.related-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.header-inner{height:auto;min-height:68px;flex-wrap:wrap;gap:0 12px;padding-top:6px;padding-bottom:6px}.brand{width:164px;height:54px}.brand img{width:164px}.main-nav{order:3;width:100%;height:36px}.nav-link{padding:0 10px}.header-actions{margin-left:auto}.flipnote-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.watch-layout{grid-template-columns:1fr}.detail-panel{position:static}.comment{grid-template-columns:120px 1fr}.admin-ban-form{grid-template-columns:1fr}.page-shell{padding-top:20px}}
|
||||||
@media(max-width:580px){.docs-nav nav{grid-template-columns:repeat(2,minmax(0,1fr))}.watch-head{flex-direction:column;align-items:stretch}.preview-stage{min-height:220px}.preview-footer{align-items:flex-start;flex-direction:column}.related-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.header-inner{padding-left:12px;padding-right:12px}.brand{width:150px}.brand img{width:150px}.nav-action{padding:0 8px;font-size:12px}.nav-action:not(.ghost){display:none}.page-shell{padding:16px 12px 50px}.page-head,.page-head.split,.home-intro-card,.site-footer{align-items:flex-start;flex-direction:column}.page-head h1{font-size:25px}.tabs{width:100%;justify-content:space-between}.flipnote-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.comment{grid-template-columns:1fr;gap:4px}.comment-form{align-items:stretch;flex-direction:column}.site-footer{padding-left:12px;padding-right:12px}.site-footer>div{flex-wrap:wrap}}
|
@media(max-width:580px){.docs-nav nav{grid-template-columns:repeat(2,minmax(0,1fr))}.watch-head{flex-direction:column;align-items:stretch}.preview-stage{min-height:220px}.preview-footer{align-items:flex-start;flex-direction:column}.related-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.header-inner{padding-left:12px;padding-right:12px}.brand{width:150px}.brand img{width:150px}.nav-action{padding:0 8px;font-size:12px}.nav-action:not(.ghost){display:none}.page-shell{padding:16px 12px 50px}.page-head,.page-head.split,.home-intro-card,.site-footer{align-items:flex-start;flex-direction:column}.page-head h1{font-size:25px}.tabs{width:100%;justify-content:space-between}.flipnote-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.comment{grid-template-columns:1fr;gap:4px}.comment-form{align-items:stretch;flex-direction:column}.site-footer{padding-left:12px;padding-right:12px}.site-footer>div{flex-wrap:wrap}}
|
||||||
|
|
||||||
|
/* flipnote.js web player */
|
||||||
|
.flipnote-stage{position:relative;overflow:hidden;min-height:0;aspect-ratio:4/3}.flipnote-player-canvas{display:none;width:100%;height:100%;align-items:center;justify-content:center;background:#080708;cursor:pointer}.flipnote-stage.is-ready .flipnote-player-canvas{display:flex}.flipnote-player-canvas canvas{display:block;width:100%!important;height:100%!important;max-width:100%;object-fit:contain;background:#080708;image-rendering:auto}.flipnote-player-fallback{display:block;width:100%;height:100%;object-fit:contain;background:#080708}.flipnote-stage.is-ready .flipnote-player-fallback,.flipnote-player-fallback[hidden]{display:none}.flipnote-player-status{position:absolute;left:12px;bottom:12px;padding:5px 8px;border:1px solid rgba(255,255,255,.12);border-radius:3px;background:rgba(8,7,8,.84);color:#d7cfcd;font-size:11px;font-weight:700}.flipnote-big-play{position:absolute;left:50%;top:50%;width:64px;height:64px;transform:translate(-50%,-50%);border:1px solid rgba(255,255,255,.28);border-radius:50%;background:rgba(8,7,8,.78);color:#fff;font:700 25px/1 system-ui,sans-serif;cursor:pointer;padding:0 0 1px 4px;box-shadow:0 3px 18px rgba(0,0,0,.35)}.flipnote-big-play:hover{background:rgba(177,59,41,.9)}.flipnote-big-play[hidden]{display:none}.flipnote-stage.has-player-error .flipnote-player-status{border-color:#70423b;background:rgba(33,21,19,.94)}
|
||||||
|
.flipnote-controls{display:grid;grid-template-columns:auto minmax(110px,1fr) auto auto auto;gap:8px;align-items:center;padding:8px 10px;border-top:1px solid var(--line);background:#111012}.flipnote-control-button{min-width:58px;height:30px;padding:0 9px;border:1px solid var(--line);border-radius:3px;background:var(--surface-raised);color:#ddd5d3;font:700 11px/1 system-ui,sans-serif;cursor:pointer}.flipnote-control-button:hover:not(:disabled),.flipnote-control-button.is-active{border-color:#7e463e;background:#2a1916;color:#fff}.flipnote-control-button:disabled{opacity:.45;cursor:default}.flipnote-progress{width:100%;min-width:70px;accent-color:var(--accent);cursor:pointer}.flipnote-progress:disabled{cursor:default;opacity:.45}.flipnote-time{min-width:82px;color:var(--muted);font:700 10px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace;text-align:center;white-space:nowrap}
|
||||||
|
@media(max-width:580px){.flipnote-controls{grid-template-columns:auto minmax(90px,1fr) auto}.flipnote-time{grid-column:2;text-align:left}.flipnote-control-button[data-action="mute"]{grid-column:3}.flipnote-control-button[data-action="loop"]{grid-column:3}.flipnote-big-play{width:56px;height:56px;font-size:22px}}
|
||||||
@@ -119,6 +119,7 @@ class WebRoot(resource.Resource):
|
|||||||
'<div class="credit-list">'
|
'<div class="credit-list">'
|
||||||
'<div class="credit-item"><strong><a href="https://github.com/pbsds/hatena-server">pbsds / hatena-server</a></strong><small>AGPL-3.0</small><p>The original open-source Flipnote Hatena replacement server and the main foundation for the older public server code.</p></div>'
|
'<div class="credit-item"><strong><a href="https://github.com/pbsds/hatena-server">pbsds / hatena-server</a></strong><small>AGPL-3.0</small><p>The original open-source Flipnote Hatena replacement server and the main foundation for the older public server code.</p></div>'
|
||||||
'<div class="credit-item"><strong><a href="https://github.com/pbsds/Hatenatools">Hatenatools</a></strong><small>AGPL-3.0 · pbsds and contributors</small><p>Utilities for PPM/TMB, UGO, NTFT and related Nintendo DSi and Flipnote formats. The bundled copy has been ported to Python 3.</p></div>'
|
'<div class="credit-item"><strong><a href="https://github.com/pbsds/Hatenatools">Hatenatools</a></strong><small>AGPL-3.0 · pbsds and contributors</small><p>Utilities for PPM/TMB, UGO, NTFT and related Nintendo DSi and Flipnote formats. The bundled copy has been ported to Python 3.</p></div>'
|
||||||
|
'<div class="credit-item"><strong><a href="https://flipnote.js.org/">flipnote.js</a></strong><small>MIT · James Daniel</small><p>Browser-side PPM decoding, animation rendering and audio playback on Flipnote pages.</p></div>'
|
||||||
'<div class="credit-item"><strong><a href="https://www.python.org/">Python</a></strong><small>PSF License</small><p>The language and runtime used by RexiMemo OSE.</p></div>'
|
'<div class="credit-item"><strong><a href="https://www.python.org/">Python</a></strong><small>PSF License</small><p>The language and runtime used by RexiMemo OSE.</p></div>'
|
||||||
'<div class="credit-item"><strong><a href="https://twisted.org/">Twisted</a></strong><small>MIT</small><p>The networking and HTTP framework used by the server.</p></div>'
|
'<div class="credit-item"><strong><a href="https://twisted.org/">Twisted</a></strong><small>MIT</small><p>The networking and HTTP framework used by the server.</p></div>'
|
||||||
'<div class="credit-item"><strong><a href="https://numpy.org/">NumPy</a></strong><small>BSD-3-Clause</small><p>Used by the legacy Flipnote media and format tools.</p></div>'
|
'<div class="credit-item"><strong><a href="https://numpy.org/">NumPy</a></strong><small>BSD-3-Clause</small><p>Used by the legacy Flipnote media and format tools.</p></div>'
|
||||||
@@ -180,6 +181,16 @@ class WebRoot(resource.Resource):
|
|||||||
request.setHeader(b"Cache-Control", b"public, max-age=3600")
|
request.setHeader(b"Cache-Control", b"public, max-age=3600")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
if path.startswith("/media/") and path.endswith(".ppm"):
|
||||||
|
public_id = path[len("/media/"):-4]
|
||||||
|
fn = Database.get_flipnote_by_public_id(public_id)
|
||||||
|
if not fn:
|
||||||
|
request.setResponseCode(404); return b""
|
||||||
|
request.setHeader(b"content-type", b"application/octet-stream")
|
||||||
|
request.setHeader(b"content-disposition", b"inline")
|
||||||
|
request.setHeader(b"Cache-Control", b"public, max-age=3600")
|
||||||
|
return Database.GetFlipnotePPM(fn["creator_fsid"], fn["filename"])
|
||||||
|
|
||||||
if path.startswith("/flipnote/") and path.endswith(".ppm"):
|
if path.startswith("/flipnote/") and path.endswith(".ppm"):
|
||||||
public_id = path[len("/flipnote/"):-4]
|
public_id = path[len("/flipnote/"):-4]
|
||||||
fn = Database.get_flipnote_by_public_id(public_id)
|
fn = Database.get_flipnote_by_public_id(public_id)
|
||||||
@@ -232,9 +243,19 @@ class WebRoot(resource.Resource):
|
|||||||
'<p>by <a href="/creator/%(creator)s">%(creator_name)s</a> <span>·</span> %(posted)s</p></div>'
|
'<p>by <a href="/creator/%(creator)s">%(creator_name)s</a> <span>·</span> %(posted)s</p></div>'
|
||||||
'<a class="button subtle" href="/creator/%(creator)s">Creator\'s Room</a></header>'
|
'<a class="button subtle" href="/creator/%(creator)s">Creator\'s Room</a></header>'
|
||||||
'<div class="watch-layout watch-layout-rich"><section class="player-card watch-preview">'
|
'<div class="watch-layout watch-layout-rich"><section class="player-card watch-preview">'
|
||||||
'<div class="preview-bar"><span>First-frame preview</span><span>PPM</span></div>'
|
'<div class="preview-bar"><span>Flipnote player</span><span>PPM · flipnote.js</span></div>'
|
||||||
'<div class="preview-stage"><img src="/thumb/%(public)s.png" alt="First frame of %(title_attr)s"></div>'
|
'<div class="preview-stage flipnote-stage" data-flipnote-player data-source="/media/%(public)s.ppm">'
|
||||||
'<div class="preview-footer"><span>The original Flipnote is kept as a PPM file.</span><a href="/flipnote/%(public)s.ppm">Download original</a></div>'
|
'<div class="flipnote-player-canvas" aria-label="Flipnote playback canvas"></div>'
|
||||||
|
'<img class="flipnote-player-fallback" data-fallback-label="First-frame preview" src="/thumb/%(public)s.png" alt="First frame of %(title_attr)s">'
|
||||||
|
'<button class="flipnote-big-play" type="button" data-action="big-play" aria-label="Play Flipnote" hidden>▶</button>'
|
||||||
|
'<div class="flipnote-player-status" data-display="status" role="status">Loading Flipnote…</div></div>'
|
||||||
|
'<div class="flipnote-controls" aria-label="Flipnote playback controls">'
|
||||||
|
'<button class="flipnote-control-button play-control" type="button" data-action="play" disabled>Play</button>'
|
||||||
|
'<input class="flipnote-progress" data-control="progress" type="range" min="0" max="1000" value="0" step="1" aria-label="Playback position" disabled>'
|
||||||
|
'<span class="flipnote-time" data-display="time">0:00 / 0:00</span>'
|
||||||
|
'<button class="flipnote-control-button" type="button" data-action="mute" disabled>Sound</button>'
|
||||||
|
'<button class="flipnote-control-button" type="button" data-action="loop" aria-pressed="false" disabled>Loop</button></div>'
|
||||||
|
'<div class="preview-footer"><span>Played in-browser with flipnote.js.</span><a href="/flipnote/%(public)s.ppm">Download original</a></div>'
|
||||||
'</section><aside class="detail-panel watch-details"><div class="creator-summary"><span class="creator-label">Creator</span>'
|
'</section><aside class="detail-panel watch-details"><div class="creator-summary"><span class="creator-label">Creator</span>'
|
||||||
'<a class="creator-name" href="/creator/%(creator)s">%(creator_name)s</a><span>%(identity)s</span></div>'
|
'<a class="creator-name" href="/creator/%(creator)s">%(creator_name)s</a><span>%(identity)s</span></div>'
|
||||||
'<div class="watch-stat-grid"><div><strong>%(stars)s</strong><span>Stars</span></div><div><strong>%(views)s</strong><span>Views</span></div>'
|
'<div class="watch-stat-grid"><div><strong>%(stars)s</strong><span>Stars</span></div><div><strong>%(views)s</strong><span>Views</span></div>'
|
||||||
@@ -246,6 +267,8 @@ class WebRoot(resource.Resource):
|
|||||||
'<section class="comments-panel section"><div class="panel-title comments-title"><span>Comments</span><span>%(comment_count)s</span></div>'
|
'<section class="comments-panel section"><div class="panel-title comments-title"><span>Comments</span><span>%(comment_count)s</span></div>'
|
||||||
'<div class="comments-body">%(form)s<div class="comments">%(comments)s</div></div></section>'
|
'<div class="comments-body">%(form)s<div class="comments">%(comments)s</div></div></section>'
|
||||||
'%(related_section)s'
|
'%(related_section)s'
|
||||||
|
'<script src="https://cdn.jsdelivr.net/npm/flipnote.js@6.3.1/dist/flipnote.min.js"></script>'
|
||||||
|
'<script src="/static/flipnote-player.js"></script>'
|
||||||
) % {
|
) % {
|
||||||
"public": html.escape(fn["public_id"], quote=True),
|
"public": html.escape(fn["public_id"], quote=True),
|
||||||
"title": html.escape(fn.get("title") or "Untitled Flipnote"),
|
"title": html.escape(fn.get("title") or "Untitled Flipnote"),
|
||||||
|
|||||||
Reference in new issue
Block a user
