Skip to content

Commit 70ffa2d

Browse files
nextlevelshitMichael Czechowski
authored andcommitted
feat(blog): 5 more posts (notifications, wake-lock, file system, bluetooth, media session) (#202)
Co-authored-by: Michael Czechowski <mail@dailysh.it> Co-committed-by: Michael Czechowski <mail@dailysh.it>
1 parent 3fd4197 commit 70ffa2d

10 files changed

Lines changed: 741 additions & 0 deletions
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
title: "File System Access API — Native File I/O in the Browser"
3+
description: "Read, write, and persist real files from the user's disk. The download attribute is dead; long live showSaveFilePicker."
4+
date: 2026-05-10
5+
slug: file-system-access
6+
tags: [javascript, ux]
7+
---
8+
9+
For two decades, "save a file from the browser" meant `<a download>` or `URL.createObjectURL`. Both produce one-off downloads with no path, no overwrite, no read-back. The File System Access API replaces all of that with real file handles you can read, write, and re-open across sessions.
10+
11+
## Save a file
12+
13+
```js
14+
const handle = await window.showSaveFilePicker({
15+
suggestedName: "draft.md",
16+
types: [{
17+
description: "Markdown",
18+
accept: { "text/markdown": [".md"] }
19+
}]
20+
});
21+
22+
const writable = await handle.createWritable();
23+
await writable.write("# My Draft\n\nFirst line.");
24+
await writable.close();
25+
```
26+
27+
Native file picker. User picks the location + name. You write to it directly. No DOM hack, no blob URL.
28+
29+
## Open a file
30+
31+
```js
32+
const [handle] = await window.showOpenFilePicker({
33+
types: [{ description: "Markdown", accept: { "text/markdown": [".md"] } }]
34+
});
35+
const file = await handle.getFile();
36+
const text = await file.text();
37+
```
38+
39+
Returns a real `File` object. Same `text()`, `arrayBuffer()`, `stream()` methods as `<input type="file">`, but you also get the **handle**, which you can persist and re-use.
40+
41+
## Persist handles across sessions
42+
43+
```js
44+
import { set, get } from "idb-keyval";
45+
46+
// After save:
47+
await set("draft-handle", handle);
48+
49+
// On next visit:
50+
const saved = await get("draft-handle");
51+
if (saved) {
52+
// User permission granted in this session? Re-request:
53+
const perm = await saved.queryPermission({ mode: "readwrite" });
54+
if (perm === "granted") {
55+
const f = await saved.getFile();
56+
editor.value = await f.text();
57+
} else {
58+
// Need to ask again — must come from user gesture
59+
openButton.addEventListener("click", async () => {
60+
const re = await saved.requestPermission({ mode: "readwrite" });
61+
if (re === "granted") loadFromHandle(saved);
62+
}, { once: true });
63+
}
64+
}
65+
```
66+
67+
Handles ARE serializable to IndexedDB. The user doesn't have to re-pick the file every session — just re-grant permission.
68+
69+
## Directory access
70+
71+
```js
72+
const dir = await window.showDirectoryPicker();
73+
for await (const [name, handle] of dir.entries()) {
74+
console.log(name, handle.kind); // "myfile.md" "file"
75+
}
76+
```
77+
78+
Walk a folder. Read multiple files. Save siblings without re-prompting per file. This is what makes browser-based editors (vscode.dev, stackblitz) feel like real apps.
79+
80+
## Atomic writes
81+
82+
```js
83+
const writable = await handle.createWritable({ keepExistingData: false });
84+
await writable.write(blob);
85+
await writable.close(); // commit
86+
```
87+
88+
Writes go to a temp file; `close()` atomically replaces the original. If the browser crashes mid-write, the original survives. Same guarantee as `rename(2)` on POSIX.
89+
90+
## When NOT to use it
91+
92+
- **Anonymous users** who don't expect to save files — shows scary picker
93+
- **Tiny in-app data** (drafts, settings) — use IndexedDB or OPFS instead
94+
- **Server-side workflows** where the file is just transient before upload
95+
96+
## OPFS vs File System Access
97+
98+
| Origin Private File System (OPFS) | File System Access |
99+
|---|---|
100+
| Hidden, no permission needed | Visible, user picks location |
101+
| Per-origin sandbox | User's actual disk |
102+
| For app's internal data | For user's documents |
103+
| Synchronous in workers | Async only |
104+
105+
Use OPFS for "I need to store stuff that survives reload." Use File System Access for "the user wants to edit their actual file."
106+
107+
## Browser support
108+
109+
- Chrome / Edge 86+ (October 2020)
110+
- Safari 15.2+ partial — `showOpenFilePicker` + `showSaveFilePicker` since 18 (September 2024)
111+
- Firefox: deferred (preference-flagged in 124+, debate ongoing)
112+
113+
For Firefox: fall back to `<input type="file">` + `<a download>`. The handle persistence + directory walks degrade gracefully.
114+
115+
## Security model
116+
117+
- Every picker = explicit user gesture + native dialog
118+
- Permission grants per-handle, not per-origin
119+
- Browser-blocked paths: system directories, browser config, anything that'd let a page read `~/.ssh/`
120+
- Auto-revoke after tab close (configurable per-handle)
121+
122+
## What this enables
123+
124+
- Real text editors in the browser (VSCode-in-browser, monaco-edit-with-real-files)
125+
- Photo/video editors that save back to the source file
126+
- Database-tool web apps (read .sqlite from disk, edit, write back)
127+
- Local-first apps with optional cloud sync — file is the source of truth, sync is a feature
128+
129+
## What it kills
130+
131+
- `<a download>` for "save this file" — picker is better UX
132+
- localStorage for anything bigger than a config flag
133+
- "Drag this PDF onto our site" patterns where the user actually wanted to edit it
134+
- "Re-upload the same file every session" friction
135+
136+
---
137+
138+
Practice JS basics in the [`js-events`](/js-events/0/) module on [Code Crispies](/) — covers async event handling.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
---
2+
title: "Media Session API — Lock-Screen Controls for Web Audio + Video"
3+
description: "Make your podcast / music / video player look native in OS media controls. Two API calls, full lock-screen + Bluetooth-button integration."
4+
date: 2026-05-10
5+
slug: media-session-api
6+
tags: [javascript, ux]
7+
---
8+
9+
When you press play/pause on Bluetooth headphones, the OS routes the keypress to "the app currently playing media." For native apps this is straightforward; for browser tabs it requires the Media Session API. Without it, your podcast site goes silent the moment the user locks their phone. With it, it looks indistinguishable from Spotify on the lock screen.
10+
11+
## The basic setup
12+
13+
```js
14+
if ("mediaSession" in navigator) {
15+
navigator.mediaSession.metadata = new MediaMetadata({
16+
title: "Episode 42 — On Caching",
17+
artist: "Web Crispies Podcast",
18+
album: "Season 3",
19+
artwork: [
20+
{ src: "/cover-96.png", sizes: "96x96", type: "image/png" },
21+
{ src: "/cover-256.png", sizes: "256x256", type: "image/png" },
22+
{ src: "/cover-512.png", sizes: "512x512", type: "image/png" }
23+
]
24+
});
25+
}
26+
```
27+
28+
That's it. As soon as a `<video>` or `<audio>` element on the page starts playing, the OS picks up the metadata and shows it in:
29+
- Lock screen (iOS, Android)
30+
- Notification shade media widget (Android)
31+
- Now Playing in macOS Control Center
32+
- Volume overlay HUD on most platforms
33+
34+
## Action handlers
35+
36+
```js
37+
navigator.mediaSession.setActionHandler("play", () => audio.play());
38+
navigator.mediaSession.setActionHandler("pause", () => audio.pause());
39+
navigator.mediaSession.setActionHandler("previoustrack", () => skipToPrevious());
40+
navigator.mediaSession.setActionHandler("nexttrack", () => skipToNext());
41+
navigator.mediaSession.setActionHandler("seekbackward", (details) => {
42+
audio.currentTime -= details.seekOffset || 10;
43+
});
44+
navigator.mediaSession.setActionHandler("seekforward", (details) => {
45+
audio.currentTime += details.seekOffset || 10;
46+
});
47+
navigator.mediaSession.setActionHandler("seekto", (details) => {
48+
audio.currentTime = details.seekTime;
49+
});
50+
```
51+
52+
Each handler corresponds to a control surface: lock-screen buttons, hardware media keys, Bluetooth headphones, smart-watches, voice assistants ("Hey Siri, skip this track").
53+
54+
## Position state for accurate scrubbing
55+
56+
```js
57+
audio.addEventListener("timeupdate", () => {
58+
navigator.mediaSession.setPositionState({
59+
duration: audio.duration,
60+
playbackRate: audio.playbackRate,
61+
position: audio.currentTime
62+
});
63+
});
64+
```
65+
66+
Without this, the lock-screen scrubber shows a generic progress (or nothing). With it, the scrubber accurately reflects current position + duration, AND seeking from the lock screen calls your `seekto` handler.
67+
68+
## Full minimal podcast player
69+
70+
```js
71+
function setupMedia(episode) {
72+
navigator.mediaSession.metadata = new MediaMetadata({
73+
title: episode.title,
74+
artist: "Web Crispies",
75+
artwork: [
76+
{ src: episode.cover, sizes: "512x512", type: "image/png" }
77+
]
78+
});
79+
80+
const handlers = {
81+
play: () => audio.play(),
82+
pause: () => audio.pause(),
83+
previoustrack: () => loadEpisode(episode.prev),
84+
nexttrack: () => loadEpisode(episode.next),
85+
seekbackward: (e) => audio.currentTime -= (e.seekOffset || 15),
86+
seekforward: (e) => audio.currentTime += (e.seekOffset || 30),
87+
seekto: (e) => audio.currentTime = e.seekTime
88+
};
89+
for (const [action, handler] of Object.entries(handlers)) {
90+
try {
91+
navigator.mediaSession.setActionHandler(action, handler);
92+
} catch (e) {
93+
// Browser may not support all actions
94+
}
95+
}
96+
97+
audio.addEventListener("playing", () => {
98+
navigator.mediaSession.playbackState = "playing";
99+
});
100+
audio.addEventListener("pause", () => {
101+
navigator.mediaSession.playbackState = "paused";
102+
});
103+
audio.addEventListener("timeupdate", () => {
104+
if (audio.duration) {
105+
navigator.mediaSession.setPositionState({
106+
duration: audio.duration,
107+
playbackRate: audio.playbackRate,
108+
position: audio.currentTime
109+
});
110+
}
111+
});
112+
}
113+
```
114+
115+
That's a complete podcast player with full OS integration.
116+
117+
## Why this matters
118+
119+
Without Media Session:
120+
- Lock the phone → audio plays for ~30s, then OS suspends background tabs
121+
- Play/pause buttons on headphones do nothing for the tab
122+
- Lock screen shows generic "browser is playing media"
123+
- User has to unlock + return to tab to control playback
124+
- Bluetooth-car interfaces show nothing
125+
126+
With Media Session:
127+
- Audio survives lock-screen indefinitely
128+
- Headphone buttons work
129+
- Lock screen shows artwork, title, full controls
130+
- Car infotainment shows your podcast as native
131+
- Voice assistants integrate ("hey Siri, pause")
132+
133+
## Browser support
134+
135+
- Chrome / Edge 73+ (March 2019)
136+
- Safari 15+ (September 2021)
137+
- Firefox 82+ (October 2020)
138+
- Universal in 2025+
139+
140+
Per-action support varies (Safari + Firefox don't all support `seekto`); the `try/catch` per `setActionHandler` is intentional.
141+
142+
## When it's worth wiring
143+
144+
- **Podcast / music players** — table stakes; users expect lock-screen controls
145+
- **Video players** — same; bonus is play/pause from car interfaces
146+
- **Live audio** (radio streams) — at minimum metadata + play/pause
147+
- **Audiobook readers**
148+
149+
## When it's overkill
150+
151+
- **Auto-playing background music on a marketing site** — the user didn't opt in to media-session controls; respects them less than they'd like
152+
- **UI sound effects** — short bleeps don't need lock-screen entries
153+
- **Video previews** (auto-pausing when scrolled away) — same
154+
155+
## The ~5-minute integration tax
156+
157+
For any audio/video app, this is the highest-value 5-minute change you can ship. The user experience difference is enormous; the code is short. Most apps skip it because they don't know the API exists.
158+
159+
---
160+
161+
Practice DOM + events in the [`js-events`](/js-events/0/) module on [Code Crispies](/) — covers the patterns the audio element listeners use.

0 commit comments

Comments
 (0)