Add fullscreen button and PWA support for iOS standalone mode

This commit is contained in:
Roni Laukkarinen
2026-02-02 00:08:42 +02:00
parent b2df50749d
commit d6ca3ce040
6 changed files with 153 additions and 3 deletions
+6
View File
@@ -1,3 +1,9 @@
### 2026-02-02: 1.0.5
* Add fullscreen button for desktop browsers
* Add PWA support with manifest for standalone mode
* Add iOS install banner prompting Add to Home Screen for fullscreen experience
### 2026-02-01: 1.0.4 ### 2026-02-01: 1.0.4
* Improve mobile responsiveness and book metadata layout * Improve mobile responsiveness and book metadata layout
+6
View File
@@ -4,7 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Speed reader</title> <title>Speed reader</title>
<meta name="theme-color" content="#0a0a0a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Speed reader">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cstyle%3Epath%7Bfill:none;stroke:%23666;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round%7D@media(prefers-color-scheme:dark)%7Bpath%7Bstroke:%23fff%7D%7D%3C/style%3E%3Cpath d='M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20'/%3E%3Cpath d='M8 7h6'/%3E%3Cpath d='M8 11h8'/%3E%3C/svg%3E"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cstyle%3Epath%7Bfill:none;stroke:%23666;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round%7D@media(prefers-color-scheme:dark)%7Bpath%7Bstroke:%23fff%7D%7D%3C/style%3E%3Cpath d='M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20'/%3E%3Cpath d='M8 7h6'/%3E%3Cpath d='M8 11h8'/%3E%3C/svg%3E">
<link rel="apple-touch-icon" href="/icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rsvp-speed-reader", "name": "rsvp-speed-reader",
"version": "1.0.0", "version": "1.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rsvp-speed-reader", "name": "rsvp-speed-reader",
"version": "1.0.0", "version": "1.0.4",
"dependencies": { "dependencies": {
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rsvp-speed-reader", "name": "rsvp-speed-reader",
"private": true, "private": true,
"version": "1.0.4", "version": "1.0.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Speed reader",
"short_name": "Speed reader",
"description": "A web-based speed reading tool using RSVP with ORP alignment",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+117
View File
@@ -12,6 +12,9 @@ import {
Settings, Settings,
Link, Link,
Check, Check,
Maximize,
Minimize,
Share,
} from "lucide-react"; } from "lucide-react";
import JSZip from "jszip"; import JSZip from "jszip";
@@ -32,6 +35,21 @@ const PauseSolid = ({ size = 24 }) => (
const DEFAULT_TEXT = `Welcome to the RSVP Speed Reader! This tool uses Rapid Serial Visual Presentation to help you read faster. Click the text icon in the top left to paste text or load an EPUB file. The reader displays one word at a time at a fixed focal point, reducing eye movement and allowing for faster reading speeds. Research suggests that RSVP can help readers achieve speeds of 500 words per minute or more with practice. Try starting at a comfortable pace and gradually increase the speed as you become more accustomed to the technique. Happy reading!`; const DEFAULT_TEXT = `Welcome to the RSVP Speed Reader! This tool uses Rapid Serial Visual Presentation to help you read faster. Click the text icon in the top left to paste text or load an EPUB file. The reader displays one word at a time at a fixed focal point, reducing eye movement and allowing for faster reading speeds. Research suggests that RSVP can help readers achieve speeds of 500 words per minute or more with practice. Try starting at a comfortable pace and gradually increase the speed as you become more accustomed to the technique. Happy reading!`;
const STORAGE_KEY = "rsvp-reader-settings"; const STORAGE_KEY = "rsvp-reader-settings";
const IOS_BANNER_DISMISSED_KEY = "rsvp-ios-banner-dismissed";
// Detect iOS Safari (not in standalone mode)
function isIOSSafari() {
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|OPiOS|mercury/.test(ua);
const isStandalone = window.navigator.standalone === true;
return isIOS && isSafari && !isStandalone;
}
// Check if fullscreen API is supported
function isFullscreenSupported() {
return document.documentElement.requestFullscreen !== undefined;
}
// Simple hash for text to use as key for positions // Simple hash for text to use as key for positions
function hashText(text) { function hashText(text) {
@@ -320,6 +338,15 @@ function App() {
() => savedSettings?.fetchMetadataOnline ?? false, () => savedSettings?.fetchMetadataOnline ?? false,
); );
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showIOSBanner, setShowIOSBanner] = useState(() => {
if (!isIOSSafari()) return false;
try {
return localStorage.getItem(IOS_BANNER_DISMISSED_KEY) !== "true";
} catch {
return true;
}
});
const timeoutRef = useRef(null); const timeoutRef = useRef(null);
const prevTextRef = useRef(text); const prevTextRef = useRef(text);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
@@ -517,6 +544,36 @@ function App() {
} }
}; };
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (e) {
console.error("Fullscreen error:", e);
}
};
const dismissIOSBanner = () => {
setShowIOSBanner(false);
try {
localStorage.setItem(IOS_BANNER_DISMISSED_KEY, "true");
} catch {
// Ignore storage errors
}
};
// Listen for fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
const handleProgressClick = (e) => { const handleProgressClick = (e) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
@@ -576,6 +633,16 @@ function App() {
onChange={handleFileUpload} onChange={handleFileUpload}
style={{ display: "none" }} style={{ display: "none" }}
/> />
{isFullscreenSupported() && (
<button
onClick={toggleFullscreen}
style={styles.textBtn}
className="icon-btn"
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
>
{isFullscreen ? <Minimize size={16} /> : <Maximize size={16} />}
</button>
)}
</div> </div>
<div style={styles.topCenter}> <div style={styles.topCenter}>
<div style={styles.wpmControl} className="wpm-control"> <div style={styles.wpmControl} className="wpm-control">
@@ -964,6 +1031,23 @@ function App() {
</div> </div>
)} )}
{/* iOS install banner */}
{showIOSBanner && (
<div style={styles.iosBanner}>
<div style={styles.iosBannerContent}>
<Share size={16} style={{ flexShrink: 0 }} />
<span>For fullscreen, tap Share then "Add to Home Screen"</span>
</div>
<button
onClick={dismissIOSBanner}
style={styles.iosBannerClose}
aria-label="Dismiss"
>
<X size={16} />
</button>
</div>
)}
{/* Settings modal */} {/* Settings modal */}
{showSettings && ( {showSettings && (
<div style={styles.modalOverlay} onClick={() => setShowSettings(false)} role="presentation"> <div style={styles.modalOverlay} onClick={() => setShowSettings(false)} role="presentation">
@@ -1538,6 +1622,39 @@ const styles = {
margin: 0, margin: 0,
marginTop: "2px", marginTop: "2px",
}, },
// iOS install banner
iosBanner: {
position: "fixed",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "#1a1a1a",
borderTop: "1px solid #333",
padding: "12px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
zIndex: 1000,
},
iosBannerContent: {
display: "flex",
alignItems: "center",
gap: "10px",
color: "#999",
fontSize: "0.8rem",
},
iosBannerClose: {
background: "transparent",
border: "none",
color: "#666",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
}; };
export default App; export default App;