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:
@@ -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.4–18.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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user