From 4817f99974c493a9ad03f91b984c4b36f8abd904 Mon Sep 17 00:00:00 2001 From: Rekushi <170186554+RekuNote@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:22:31 +0100 Subject: [PATCH] [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. --- web/static/flipnote-player.js | 182 ++++++++++++++++++++++++++++++++++ web/static/site.css | 5 + webapp.py | 29 +++++- 3 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 web/static/flipnote-player.js diff --git a/web/static/flipnote-player.js b/web/static/flipnote-player.js new file mode 100644 index 0000000..fb76e02 --- /dev/null +++ b/web/static/flipnote-player.js @@ -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.'); + }); +}()); diff --git a/web/static/site.css b/web/static/site.css index 22db9ad..5fbd72e 100644 --- a/web/static/site.css +++ b/web/static/site.css @@ -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: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}} + +/* 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}} diff --git a/webapp.py b/webapp.py index 78c1506..a9d2dc8 100644 --- a/webapp.py +++ b/webapp.py @@ -119,6 +119,7 @@ class WebRoot(resource.Resource): '
The original open-source Flipnote Hatena replacement server and the main foundation for the older public server code.
Utilities for PPM/TMB, UGO, NTFT and related Nintendo DSi and Flipnote formats. The bundled copy has been ported to Python 3.
Browser-side PPM decoding, animation rendering and audio playback on Flipnote pages.
The language and runtime used by RexiMemo OSE.
The networking and HTTP framework used by the server.
Used by the legacy Flipnote media and format tools.
by %(creator_name)s · %(posted)s