Wake lock persistence, button reposition, docs, dev LAN support

Wake lock (Prevent Sleep) toggles now persist across reloads via
localStorage in both AdminView and DisplayView. Buttons repositioned
inline in AdminView campaigns header bar (was floating overlay causing
overlap on tablets). DisplayView buttons persist localStorage too.

Wake lock acquire failure now shows toast with fix hint (HTTPS or
Chrome flag). Fullscreenchange listener re-acquires wake lock (Android
discards on screen off).

dev-start.sh: auto-detects LAN IP (en0/en1), frontend binds 0.0.0.0,
backend URL inlined as LAN IP so phones reach backend. DANGEROUSLY_DISABLE_HOST_CHECK
for LAN access. Outputs LAN URL + wake lock flag instructions.

Docs: README 'Prevent Sleep (Wake Lock)' section covering secure context
requirement, Android Chrome flag workaround for LAN testing, iOS Safari
standalone PWA bug. DEVELOPMENT.md LAN access + wake lock note.
This commit is contained in:
david raistrick
2026-07-08 13:19:10 -04:00
parent 863e8b3719
commit 41c1e48874
5 changed files with 130 additions and 6 deletions
+20
View File
@@ -307,6 +307,26 @@ ttrpg-initiative-tracker/
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.) * `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
* `TODO.md` — known bugs and backlog * `TODO.md` — known bugs and backlog
## Prevent Sleep (Wake Lock)
DM and Player views have a Coffee/Moon toggle to keep screen awake during combat.
**Requires secure context** (HTTPS or localhost). The Screen Wake Lock API refuses
on plain HTTP.
- **Prod (HTTPS):** works automatically on all devices.
- **Local dev (localhost):** works automatically — localhost is a trusted origin.
- **LAN IP (phones, plain HTTP):** wake lock fails silently. Two options:
1. **Android Chrome flag (dev testing):** `chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add full URL with port, e.g. `http://10.0.0.5:3999`. Enable flag, relaunch.
2. **mkcert local CA:** generate trusted cert for LAN IP. Heavier setup.
**iOS Safari:** Wake Lock API broken in standalone PWA mode (iOS 16.418.4, WebKit bug 254545). Fixed in 18.4+. Works in browser tab (not installed PWA).
**Troubleshooting:** if toggle shows active but screen still sleeps, check:
- Battery saver / power save mode (browser refuses)
- Chrome devtools console for `WakeLockError`
- Secure context: `window.isSecureContext` must be `true`
## Contributing ## Contributing
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change. If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
+1 -1
View File
@@ -5,7 +5,7 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open ## Open
fullscreen and dont lock on main app dm view and the no-game-player view x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
also better vert tab layout - labelt friendly also better vert tab layout - labelt friendly
+8
View File
@@ -63,6 +63,14 @@ git config core.hooksPath .githooks # enable pre-push test gate
Idempotent: if port busy, leaves existing proc as-is. Idempotent: if port busy, leaves existing proc as-is.
**LAN access (phones):** frontend binds 0.0.0.0, LAN IP auto-detected.
Other devices hit `http://<lan-ip>:3999`.
**Wake Lock (Prevent Sleep) on LAN IP:** requires secure context.
Plain HTTP on LAN IP = wake lock silently fails. Android Chrome flag workaround:
`chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add
`http://<lan-ip>:3999` (with port), enable, relaunch. See README for full details.
Smoke check: Smoke check:
```bash ```bash
curl http://127.0.0.1:4001/health # -> {"ok":true} curl http://127.0.0.1:4001/health # -> {"ok":true}
+13 -3
View File
@@ -27,11 +27,15 @@ fi
# frontend: server storage, :3999 # frontend: server storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then if ! lsof -ti :3999 >/dev/null 2>&1; then
echo "starting frontend :3999..." # detect LAN ip so other devices (phones) can reach backend
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1)
echo "starting frontend :3999 (lan ip: $LAN_IP)..."
NODE_ENV=development REACT_APP_DEV_TOOLS=1 \ NODE_ENV=development REACT_APP_DEV_TOOLS=1 \
REACT_APP_STORAGE=server \ REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ REACT_APP_BACKEND_URL=http://${LAN_IP}:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \ REACT_APP_BACKEND_REALTIME_URL=ws://${LAN_IP}:4001/ws \
DANGEROUSLY_DISABLE_HOST_CHECK=true \
HOST=0.0.0.0 \
BROWSER=none PORT=3999 \ BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 & nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid echo $! > tmp/fe.pid
@@ -51,5 +55,11 @@ done
echo "" echo ""
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)" echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
echo "frontend : http://127.0.0.1:3999 (admin / player /display)" echo "frontend : http://127.0.0.1:3999 (admin / player /display)"
echo "lan url : http://${LAN_IP}:3999 (phones/other devices on network)"
echo "logs : tmp/server.log tmp/fe.log" echo "logs : tmp/server.log tmp/fe.log"
echo "stop : ./scripts/dev-stop.sh" echo "stop : ./scripts/dev-stop.sh"
echo ""
echo "NOTE: Prevent Sleep (wake lock) requires secure context (HTTPS/localhost)."
echo " LAN IP (http) won't work unless Android Chrome flag set:"
echo " chrome://flags/#unsafely-treat-insecure-origin-as-secure"
echo " Add: http://${LAN_IP}:3999 (include port)"
+88 -2
View File
@@ -2489,6 +2489,53 @@ function AdminView({ userId }) {
); );
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay()); const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
const [isFullscreen, setIsFullscreen] = useState(false);
const [wakeLockEnabled, setWakeLockEnabled] = useState(() => {
try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; }
catch { return false; }
});
const wakeLockRef = useRef(null);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
useEffect(() => {
if (!wakeLockEnabled) {
wakeLockRef.current?.release();
wakeLockRef.current = null;
return;
}
const acquire = async () => {
try { wakeLockRef.current = await navigator.wakeLock.request('screen'); }
catch (e) {
console.error('Wake lock failed:', e);
showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure');
}
};
acquire();
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
const onFsChange = () => { if (document.fullscreenElement) acquire(); };
document.addEventListener('visibilitychange', onVisChange);
document.addEventListener('fullscreenchange', onFsChange);
return () => {
document.removeEventListener('visibilitychange', onVisChange);
document.removeEventListener('fullscreenchange', onFsChange);
wakeLockRef.current?.release();
wakeLockRef.current = null;
};
}, [wakeLockEnabled, showToast]);
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]); const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [draggedCampaignId, setDraggedCampaignId] = useState(null); const [draggedCampaignId, setDraggedCampaignId] = useState(null);
const [selectedCampaignId, setSelectedCampaignId] = useState(null); const [selectedCampaignId, setSelectedCampaignId] = useState(null);
@@ -2735,6 +2782,22 @@ function AdminView({ userId }) {
> >
<PlusCircle size={20} className="mr-2" /> Create Campaign <PlusCircle size={20} className="mr-2" /> Create Campaign
</button> </button>
<div className="flex gap-1">
<button
onClick={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white'}`}
>
{wakeLockEnabled ? <Coffee size={20} /> : <Moon size={20} />}
</button>
<button
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
className="bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white p-2 rounded-lg transition-all"
>
{isFullscreen ? <Minimize2 size={20} /> : <Maximize2 size={20} />}
</button>
</div>
</div> </div>
{!campaignsCollapsed && ( {!campaignsCollapsed && (
@@ -2963,7 +3026,10 @@ function DisplayView() {
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState(''); const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [wakeLockEnabled, setWakeLockEnabled] = useState(false); const [wakeLockEnabled, setWakeLockEnabled] = useState(() => {
try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; }
catch { return false; }
});
const [displayParticipants, setDisplayParticipants] = useState([]); const [displayParticipants, setDisplayParticipants] = useState([]);
const wakeLockRef = useRef(null); const wakeLockRef = useRef(null);
const currentParticipantRef = useRef(null); const currentParticipantRef = useRef(null);
@@ -3029,8 +3095,12 @@ function DisplayView() {
// Re-acquire after tab becomes visible again (browser auto-releases on hide) // Re-acquire after tab becomes visible again (browser auto-releases on hide)
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); }; const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
document.addEventListener('visibilitychange', onVisChange); document.addEventListener('visibilitychange', onVisChange);
// Re-acquire on fullscreen change (Android discards wakeLock on screen off)
const onFsChange = () => { if (document.fullscreenElement) acquire(); };
document.addEventListener('fullscreenchange', onFsChange);
return () => { return () => {
document.removeEventListener('visibilitychange', onVisChange); document.removeEventListener('visibilitychange', onVisChange);
document.removeEventListener('fullscreenchange', onFsChange);
wakeLockRef.current?.release(); wakeLockRef.current?.release();
wakeLockRef.current = null; wakeLockRef.current = null;
}; };
@@ -3121,6 +3191,22 @@ function DisplayView() {
if (!isPlayerDisplayActive || !activeEncounterData) { if (!isPlayerDisplayActive || !activeEncounterData) {
return ( return (
<div className="min-h-screen bg-black text-stone-400 flex flex-col items-center justify-center p-4 text-center"> <div className="min-h-screen bg-black text-stone-400 flex flex-col items-center justify-center p-4 text-center">
<div className="fixed top-3 right-3 z-50 flex gap-2">
<button
onClick={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white'}`}
>
{wakeLockEnabled ? <Coffee size={20} /> : <Moon size={20} />}
</button>
<button
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
className="bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white p-2 rounded-lg transition-all"
>
{isFullscreen ? <Minimize2 size={20} /> : <Maximize2 size={20} />}
</button>
</div>
<EyeOff size={64} className="mb-4 text-stone-500" /> <EyeOff size={64} className="mb-4 text-stone-500" />
<h2 className="text-3xl font-semibold font-cinzel tracking-wide">Game Session Paused</h2> <h2 className="text-3xl font-semibold font-cinzel tracking-wide">Game Session Paused</h2>
<p className="text-xl mt-2">The Dungeon Master has not activated an encounter for display.</p> <p className="text-xl mt-2">The Dungeon Master has not activated an encounter for display.</p>
@@ -3153,7 +3239,7 @@ function DisplayView() {
> >
<div className="fixed top-3 right-3 z-50 flex gap-2"> <div className="fixed top-3 right-3 z-50 flex gap-2">
<button <button
onClick={() => setWakeLockEnabled(v => !v)} onClick={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'} title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white'}`} className={`p-2 rounded-lg transition-all ${wakeLockEnabled ? 'bg-amber-600 hover:bg-amber-700 text-white' : 'bg-stone-800 bg-opacity-80 hover:bg-opacity-100 text-stone-300 hover:text-white'}`}
> >