flipoff-split-flap-display
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFlipOff Split-Flap Display Emulator
FlipOff 翻页显示屏模拟器
Skill by ara.so — Daily 2026 Skills collection.
FlipOff is a pure vanilla HTML/CSS/JS web app that emulates classic mechanical split-flap (flip-board) airport displays. No frameworks, no npm, no build step — open and you have a full-screen retro display with authentic scramble animations and clacking sounds.
index.html由ara.so开发的技能——属于Daily 2026 Skills系列。
FlipOff是一款纯原生HTML/CSS/JS开发的Web应用,用于模拟经典的机械翻页式(翻板)机场显示屏。无需框架、无需npm、无需构建步骤——只需打开,即可获得带有真实打乱动画和咔哒音效的全屏复古显示屏。
index.htmlInstallation
安装步骤
bash
git clone https://github.com/magnum6actual/flipoff.git
cd flipoffbash
git clone https://github.com/magnum6actual/flipoff.git
cd flipoffOption 1: Open directly
选项1:直接打开
open index.html
open index.html
Option 2: Serve locally (recommended for audio)
选项2:本地部署(推荐用于音频功能)
python3 -m http.server 8080
python3 -m http.server 8080
> **Audio note:** Browsers block autoplay. The user must click once to enable the Web Audio API context. After that, sound plays automatically on each message transition.
---
> **音频说明:** 浏览器会阻止自动播放。用户必须点击一次以启用Web Audio API上下文。之后,每次消息切换时音效会自动播放。
---File Structure
文件结构
flipoff/
index.html — Single-page app entry point
css/
reset.css — CSS reset
layout.css — Header, hero, page layout
board.css — Board container and accent bars
tile.css — Tile styling and 3D flip animation
responsive.css — Media queries (mobile → 4K)
js/
main.js — Entry point, wires everything together
Board.js — Grid manager, transition orchestration
Tile.js — Per-tile animation logic
SoundEngine.js — Web Audio API playback
MessageRotator.js — Auto-rotate timer
KeyboardController.js — Keyboard shortcut handling
constants.js — All configuration lives here
flapAudio.js — Base64-encoded audio dataflipoff/
index.html — 单页应用入口
css/
reset.css — CSS重置样式
layout.css — 页头、核心区域、页面布局
board.css — 显示屏容器和装饰条
tile.css — 翻页方块样式和3D翻转动画
responsive.css — 媒体查询(适配移动端至4K屏)
js/
main.js — 入口文件,整合所有功能
Board.js — 网格管理器,过渡动画协调
Tile.js — 单个方块的动画逻辑
SoundEngine.js — Web Audio API 播放控制
MessageRotator.js — 消息自动轮播计时器
KeyboardController.js — 键盘快捷键处理
constants.js — 所有配置项集中存放
flapAudio.js — Base64编码的音频数据Key Configuration — js/constants.js
js/constants.js关键配置 — js/constants.js
js/constants.jsEverything you'd want to change lives in one file:
js
// js/constants.js (representative structure)
export const GRID_COLS = 26; // Characters per row
export const GRID_ROWS = 8; // Number of rows
export const SCRAMBLE_DURATION = 600; // ms each tile scrambles before settling
export const STAGGER_DELAY = 18; // ms between each tile starting its scramble
export const AUTO_ROTATE_INTERVAL = 8000; // ms between auto-advancing messages
export const SCRAMBLE_COLORS = [
'#FF6B35', '#F7C59F', '#EFEFD0',
'#004E89', '#1A936F', '#C6E0F5'
];
export const ACCENT_COLORS = ['#FF6B35', '#004E89', '#1A936F'];
export const MESSAGES = [
"HAVE A NICE DAY",
"ALL FLIGHTS ON TIME",
"WELCOME TO THE FUTURE",
// Add your own here
];所有你想要修改的设置都集中在一个文件中:
js
// js/constants.js(示例结构)
export const GRID_COLS = 26; // 每行字符数
export const GRID_ROWS = 8; // 行数
export const SCRAMBLE_DURATION = 600; // 每个方块打乱后稳定的时长(毫秒)
export const STAGGER_DELAY = 18; // 每个方块开始打乱的间隔时长(毫秒)
export const AUTO_ROTATE_INTERVAL = 8000; // 消息自动切换的间隔时长(毫秒)
export const SCRAMBLE_COLORS = [
'#FF6B35', '#F7C59F', '#EFEFD0',
'#004E89', '#1A936F', '#C6E0F5'
];
export const ACCENT_COLORS = ['#FF6B35', '#004E89', '#1A936F'];
export const MESSAGES = [
"HAVE A NICE DAY",
"ALL FLIGHTS ON TIME",
"WELCOME TO THE FUTURE",
// 在此添加自定义消息
];Adding Custom Messages
添加自定义消息
Edit in . Each message is a plain string. The board wraps text across the grid automatically.
MESSAGESjs/constants.jsjs
export const MESSAGES = [
"DEPARTING GATE 7",
"YOUR COFFEE IS READY",
"BUILD THINGS THAT MATTER",
"FLIGHT AA 404 NOT FOUND", // max GRID_COLS * GRID_ROWS chars
];Padding rules: Messages shorter than the grid are padded with spaces. Messages longer than the grid are truncated. Keep messages at or under characters.
GRID_COLS × GRID_ROWS编辑中的数组。每条消息为纯字符串,显示屏会自动将文本在网格中换行。
js/constants.jsMESSAGESjs
export const MESSAGES = [
"DEPARTING GATE 7",
"YOUR COFFEE IS READY",
"BUILD THINGS THAT MATTER",
"FLIGHT AA 404 NOT FOUND", // 最大长度为 GRID_COLS × GRID_ROWS 个字符
];填充规则: 短于网格容量的消息会自动用空格填充;超过网格容量的消息会被截断。请确保消息长度不超过个字符。
GRID_COLS × GRID_ROWSChanging Grid Size
更改网格尺寸
js
// Compact 16×4 ticker-style board
export const GRID_COLS = 16;
export const GRID_ROWS = 4;
// Wide cinema board
export const GRID_COLS = 40;
export const GRID_ROWS = 6;
// Tall info kiosk
export const GRID_COLS = 20;
export const GRID_ROWS = 12;After changing grid dimensions, tiles re-render automatically on next page load.
js
// 紧凑的16×4滚动条样式显示屏
export const GRID_COLS = 16;
export const GRID_ROWS = 4;
// 宽屏影院样式显示屏
export const GRID_COLS = 40;
export const GRID_ROWS = 6;
// 竖版信息亭样式显示屏
export const GRID_COLS = 20;
export const GRID_ROWS = 12;修改网格尺寸后,下次页面加载时方块会自动重新渲染。
Keyboard Shortcuts
键盘快捷键
| Key | Action |
|---|---|
| Next message |
| Previous message |
| Next message |
| Toggle fullscreen |
| Toggle mute |
| Exit fullscreen |
| 按键 | 操作 |
|---|---|
| 切换到下一条消息 |
| 切换到上一条消息 |
| 切换到下一条消息 |
| 切换全屏模式 |
| 切换静音状态 |
| 退出全屏模式 |
Programmatic API
编程式API
Board
Board 类
js
// Board.js exposes a class you can instantiate directly
import Board from './js/Board.js';
const board = new Board(document.getElementById('board-container'));
// Display a specific string
board.setMessage('GATE CHANGE B12');
// Advance to next message in the rotation
board.next();
// Go back
board.previous();js
// Board.js 暴露了可直接实例化的类
import Board from './js/Board.js';
const board = new Board(document.getElementById('board-container'));
// 显示指定字符串
board.setMessage('GATE CHANGE B12');
// 切换到轮播中的下一条消息
board.next();
// 切换到上一条消息
board.previous();MessageRotator
MessageRotator 类
js
import MessageRotator from './js/MessageRotator.js';
const rotator = new MessageRotator(board, messages, AUTO_ROTATE_INTERVAL);
rotator.start(); // begin auto-advancing
rotator.stop(); // pause rotation
rotator.next(); // manual advance
rotator.previous(); // manual backjs
import MessageRotator from './js/MessageRotator.js';
const rotator = new MessageRotator(board, messages, AUTO_ROTATE_INTERVAL);
rotator.start(); // 开始自动轮播
rotator.stop(); // 暂停轮播
rotator.next(); // 手动切换到下一条
rotator.previous(); // 手动切换到上一条SoundEngine
SoundEngine 类
js
import SoundEngine from './js/SoundEngine.js';
const sound = new SoundEngine();
// Must call after a user gesture (click/keypress)
await sound.init();
sound.play(); // play the flap transition sound
sound.mute(); // silence
sound.unmute();
sound.toggle(); // flip mute statejs
import SoundEngine from './js/SoundEngine.js';
const sound = new SoundEngine();
// 必须在用户交互(点击/按键)后调用
await sound.init();
sound.play(); // 播放翻页过渡音效
sound.mute(); // 静音
sound.unmute(); // 取消静音
sound.toggle(); // 切换静音状态KeyboardController
KeyboardController 类
js
import KeyboardController from './js/KeyboardController.js';
const kb = new KeyboardController({
onNext: () => rotator.next(),
onPrevious: () => rotator.previous(),
onFullscreen: () => toggleFullscreen(),
onMute: () => sound.toggle(),
});
kb.attach(); // start listening
kb.detach(); // stop listeningjs
import KeyboardController from './js/KeyboardController.js';
const kb = new KeyboardController({
onNext: () => rotator.next(),
onPrevious: () => rotator.previous(),
onFullscreen: () => toggleFullscreen(),
onMute: () => sound.toggle(),
});
kb.attach(); // 开始监听键盘事件
kb.detach(); // 停止监听Embedding FlipOff in Another Page
在其他页面中嵌入FlipOff
As an iframe
以iframe形式嵌入
html
<iframe
src="/flipoff/index.html"
width="1280"
height="400"
style="border:none; background:#000;"
allowfullscreen
></iframe>html
<iframe
src="/flipoff/index.html"
width="1280"
height="400"
style="border:none; background:#000;"
allowfullscreen
></iframe>Inline embed (pull in just the board)
内联嵌入(仅引入显示屏)
html
<!-- In your page -->
<div id="flip-board"></div>
<script type="module">
import Board from '/flipoff/js/Board.js';
import SoundEngine from '/flipoff/js/SoundEngine.js';
import { MESSAGES, AUTO_ROTATE_INTERVAL } from '/flipoff/js/constants.js';
const board = new Board(document.getElementById('flip-board'));
const sound = new SoundEngine();
let idx = 0;
board.setMessage(MESSAGES[idx]);
document.addEventListener('click', async () => {
await sound.init();
}, { once: true });
setInterval(() => {
idx = (idx + 1) % MESSAGES.length;
board.setMessage(MESSAGES[idx]);
sound.play();
}, AUTO_ROTATE_INTERVAL);
</script>html
<!-- 在你的页面中 -->
<div id="flip-board"></div>
<script type="module">
import Board from '/flipoff/js/Board.js';
import SoundEngine from '/flipoff/js/SoundEngine.js';
import { MESSAGES, AUTO_ROTATE_INTERVAL } from '/flipoff/js/constants.js';
const board = new Board(document.getElementById('flip-board'));
const sound = new SoundEngine();
let idx = 0;
board.setMessage(MESSAGES[idx]);
document.addEventListener('click', async () => {
await sound.init();
}, { once: true });
setInterval(() => {
idx = (idx + 1) % MESSAGES.length;
board.setMessage(MESSAGES[idx]);
sound.play();
}, AUTO_ROTATE_INTERVAL);
</script>Custom Color Themes
自定义颜色主题
js
// js/constants.js — dark blue terminal theme
export const SCRAMBLE_COLORS = [
'#0D1B2A', '#1B2838', '#00FF41',
'#003459', '#007EA7', '#00A8E8'
];
export const ACCENT_COLORS = ['#00FF41', '#007EA7', '#00A8E8'];css
/* css/board.css — override tile background */
.tile {
background-color: #0D1B2A;
color: #00FF41;
border-color: #003459;
}js
// js/constants.js — 深蓝色终端主题
export const SCRAMBLE_COLORS = [
'#0D1B2A', '#1B2838', '#00FF41',
'#003459', '#007EA7', '#00A8E8'
];
export const ACCENT_COLORS = ['#00FF41', '#007EA7', '#00A8E8'];css
/* css/board.css — 覆盖方块背景色 */
.tile {
background-color: #0D1B2A;
color: #00FF41;
border-color: #003459;
}Common Patterns
常见使用场景
Show real-time data (e.g., a flight API)
展示实时数据(如航班API)
js
import Board from './js/Board.js';
import SoundEngine from './js/SoundEngine.js';
const board = new Board(document.getElementById('board'));
const sound = new SoundEngine();
async function fetchAndDisplay() {
const res = await fetch('/api/departures');
const data = await res.json();
const message = `${data.flight} ${data.destination} ${data.gate}`;
board.setMessage(message.toUpperCase());
sound.play();
}
document.addEventListener('click', () => sound.init(), { once: true });
setInterval(fetchAndDisplay, 30_000);
fetchAndDisplay();js
import Board from './js/Board.js';
import SoundEngine from './js/SoundEngine.js';
const board = new Board(document.getElementById('board'));
const sound = new SoundEngine();
async function fetchAndDisplay() {
const res = await fetch('/api/departures');
const data = await res.json();
const message = `${data.flight} ${data.destination} ${data.gate}`;
board.setMessage(message.toUpperCase());
sound.play();
}
document.addEventListener('click', () => sound.init(), { once: true });
setInterval(fetchAndDisplay, 30_000);
fetchAndDisplay();Cycle through a custom message list
循环播放自定义消息列表
js
const promos = [
"SALE ENDS SUNDAY",
"FREE SHIPPING OVER $50",
"NEW ARRIVALS THIS WEEK",
];
let i = 0;
setInterval(() => {
board.setMessage(promos[i % promos.length]);
sound.play();
i++;
}, 5000);js
const promos = [
"SALE ENDS SUNDAY",
"FREE SHIPPING OVER $50",
"NEW ARRIVALS THIS WEEK",
];
let i = 0;
setInterval(() => {
board.setMessage(promos[i % promos.length]);
sound.play();
i++;
}, 5000);React/Vue wrapper (import as a module)
React/Vue 封装(作为模块导入)
jsx
// FlipBoard.jsx
import { useEffect, useRef } from 'react';
import Board from '../flipoff/js/Board.js';
import { MESSAGES } from '../flipoff/js/constants.js';
export default function FlipBoard({ messages = MESSAGES, interval = 8000 }) {
const containerRef = useRef(null);
const boardRef = useRef(null);
useEffect(() => {
boardRef.current = new Board(containerRef.current);
let idx = 0;
boardRef.current.setMessage(messages[idx]);
const timer = setInterval(() => {
idx = (idx + 1) % messages.length;
boardRef.current.setMessage(messages[idx]);
}, interval);
return () => clearInterval(timer);
}, []);
return <div ref={containerRef} className="flip-board-container" />;
}jsx
// FlipBoard.jsx
import { useEffect, useRef } from 'react';
import Board from '../flipoff/js/Board.js';
import { MESSAGES } from '../flipoff/js/constants.js';
export default function FlipBoard({ messages = MESSAGES, interval = 8000 }) {
const containerRef = useRef(null);
const boardRef = useRef(null);
useEffect(() => {
boardRef.current = new Board(containerRef.current);
let idx = 0;
boardRef.current.setMessage(messages[idx]);
const timer = setInterval(() => {
idx = (idx + 1) % messages.length;
boardRef.current.setMessage(messages[idx]);
}, interval);
return () => clearInterval(timer);
}, []);
return <div ref={containerRef} className="flip-board-container" />;
}Troubleshooting
故障排除
| Problem | Fix |
|---|---|
| No sound | User must click/interact first; Web Audio requires a user gesture |
| Sound works locally but not deployed | Ensure |
| Tiles don't animate | Verify CSS |
| Grid overflows on small screens | Reduce |
| Fullscreen not working | |
| Messages cut off | String length exceeds |
| Audio blocked by CSP | Add |
| CORS error loading modules | Serve with a local server ( |
| 问题 | 解决方法 |
|---|---|
| 无音效 | 用户必须先进行点击/交互操作;Web Audio API 需要用户触发才能启用 |
| 本地音效正常但部署后失效 | 确保 |
| 方块无动画效果 | 确认 |
| 网格在小屏幕上溢出 | 在 |
| 全屏功能无法使用 | |
| 消息被截断 | 字符串长度超过 |
| 音频被CSP策略阻止 | 在内容安全策略中添加 |
| 加载模块时出现CORS错误 | 使用本地服务器部署(如 |
Tips for TV / Kiosk Deployment
电视/自助终端部署技巧
bash
undefinedbash
undefinedServe with a simple static server
使用简单的静态服务器部署
npx serve . # Node
python3 -m http.server # Python
npx serve . # Node.js 环境
python3 -m http.server # Python 环境
Auto-launch fullscreen in Chromium kiosk mode
在Chromium kiosk模式下自动启动全屏
chromium-browser --kiosk --app=http://localhost:8080
chromium-browser --kiosk --app=http://localhost:8080
Hide cursor after idle (add to index.html)
闲置后隐藏光标(添加到index.html中)
document.addEventListener('mousemove', () => {
document.body.style.cursor = 'default';
clearTimeout(window._cursorTimer);
window._cursorTimer = setTimeout(() => {
document.body.style.cursor = 'none';
}, 3000);
});
undefineddocument.addEventListener('mousemove', () => {
document.body.style.cursor = 'default';
clearTimeout(window._cursorTimer);
window._cursorTimer = setTimeout(() => {
document.body.style.cursor = 'none';
}, 3000);
});
undefined