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): '
' '
pbsds / hatena-serverAGPL-3.0

The original open-source Flipnote Hatena replacement server and the main foundation for the older public server code.

' '
HatenatoolsAGPL-3.0 · pbsds and contributors

Utilities for PPM/TMB, UGO, NTFT and related Nintendo DSi and Flipnote formats. The bundled copy has been ported to Python 3.

' + '
flipnote.jsMIT · James Daniel

Browser-side PPM decoding, animation rendering and audio playback on Flipnote pages.

' '
PythonPSF License

The language and runtime used by RexiMemo OSE.

' '
TwistedMIT

The networking and HTTP framework used by the server.

' '
NumPyBSD-3-Clause

Used by the legacy Flipnote media and format tools.

' @@ -180,6 +181,16 @@ class WebRoot(resource.Resource): request.setHeader(b"Cache-Control", b"public, max-age=3600") 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"): public_id = path[len("/flipnote/"):-4] fn = Database.get_flipnote_by_public_id(public_id) @@ -232,9 +243,19 @@ class WebRoot(resource.Resource): '

by %(creator_name)s · %(posted)s

' 'Creator\'s Room' '
' - '
First-frame previewPPM
' - '
First frame of %(title_attr)s
' - '' + '
Flipnote playerPPM · flipnote.js
' + '
' + '
' + 'First frame of %(title_attr)s' + '' + '
Loading Flipnote…
' + '
' + '' + '' + '0:00 / 0:00' + '' + '
' + '' '