Playlet 💿 plays songs/media on any DLNA server from the browser, without installing a native client.
No app install, no Electron, no local daemon.
I have a NAS, it runs a DLNA server and hosts my music collection
But I hate installing a DLNA compatible app on phone/macOS. It's hard to find a good one. I even tried to build a Chrome App with chrome.socket 13 years ago but went nowhere.
On a beautiful Saturday afternoon, I decided to build a client again. While evaluating tech stack and distribution options, I talked to ChatGPT whether browsers provide SSDP/uPNP natively, turns out no. You have to choose native UI, electron (boo!), or some nerd command line utility. They are very boring.
Suddently I had an idea: a DLNA client involves speaking HTTP anyway, and the DLNA server already has a web server. There's an ancient lesser-known trick called bookmarklet: Inject a small .js into the ugly DLNA index page, then do fetch() calls SOAP shit and render a nice player inline. No UDP, no CORS, no bullshit.
The rest is vibe coding history.
- Save this to browser bookmark:
javascript:import("https://est.github.io/playlet/loader.js") - Open DLNA index page, usually http://NAS-IP:8200/
- Click the saved bookmark
the .js interally do these
- Discover/load device description XML (
rootDesc.xmlor provided URL) - Find
ContentDirectorycontrolURL - Send
BrowseSOAP requests (ObjectID,BrowseDirectChildren) - Parse
DIDL-Literesults into containers/items - Play item
resURLs in an injected UI panel
No ads, telemetry. Works offline once loaded.
For v1.2
- Tree browser with
+/-expand/collapse (lazy-loaded by node) - Folder hover hint on same line (
XX items) - Folder quick add (
≡+) adds playable tracks from current folder level only (no recursive deep scan) Librarytabs:Tree | Search- Search modes:
DLNA: useContentDirectory:Searchwhen server supports itLocal: Tree: search only loaded tree nodesLocal: Full: crawl full library locally, then search
- Session playlist: add, remove, play from queue
- Playlist clear button (
Clear) - Playlist supports drag-drop reorder
- Shuffle does real in-place playlist reorder (
Shuffle) - Prev/Next follow current playlist order after shuffle
- Copy media URL from library rows and playlist rows (
⧉) - Search result rows support same actions as tree rows (
▶,+,☆/★,⧉) - Favorites: single-track star (
☆/★) with localStorage persistence - Only favorites/mode are persisted; playlist is session-only
- Playback modes: all-loop (
∞), single-loop (1) - MediaSession track controls wired:
previoustrack/nexttrack - Auto-detect
rootDesc.xml, with hidden advanced URL override - Scroll isolation for panel internals (better trackpad behavior on macOS)
- Native
<audio controls>player for reliable seek/progress behavior - Error bar supports manual dismiss (
×) and auto-hide for transient failures (play/copy/search/etc.) - Runtime reuse on repeated inject with same base/version (avoid full teardown/rebuild)
- Debug hooks:
window.__playletDebug.getState()window.__playletDebug.getLastRequest()window.__playletDebug.getLastResponse()window.__playletDebug.getPlaylist()window.__playletDebug.getTreeState()
npm run devOpen:
http://127.0.0.1:8788/playlet/index.html?playlet_desc=http://127.0.0.1:8788/playlet/mock/rootDesc.xml
Then in DevTools console:
import("http://127.0.0.1:8788/playlet/loader.js")Proxy a real DLNA host into local same-origin with route split:
/playlet/*-> local debug assets (index, loader, debug iframe helper)/*-> reverse proxy to your DLNA server
npm run dev -- --dlna-base http://192.168.1.5:8200/Then open:
http://127.0.0.1:8788/playlet/debug
This page uses an iframe + one-click inject button to simulate bookmarklet behavior:
- iframe loads
/(your real DLNA page via proxy) - it sets
?playlet_desc=http://127.0.0.1:8788/rootDesc.xmlon iframe URL - then executes
import("/playlet/loader.js")inside iframe window - Debug toolbar includes:
Inject: inject loader into iframe page
npm run build
npm run serve:distBuild outputs:
dist/loader.js: esbuild bundle + minified (single download path)dist/index.html: copied from editablesrc/index.html
src/index.html is plain editable source. You can tweak page content/style directly there.
Current runtime layering:
src/app.js: runtime orchestrator only (bootPlaylet, runtime reuse/dispose, initial state/bootstrap)src/ui/panel.js: Playlet panel UI, event wiring, tree/search/playlist renderingsrc/ui/styles.js: injected stylessrc/domain/dlna.js: DLNA SOAP + DIDL parsing/search helperssrc/domain/playlist.js: playlist/favorites/play-mode domain logicsrc/infra/media.js: media adapter (HtmlMediaAdapter)src/infra/storage.js: localStorage prefs I/Osrc/core/*: constants and lightweight store
Data flow convention:
- UI event -> domain/infra action
- update
statevia orchestrator helpers - render from
state
Runtime lifecycle:
- First inject builds runtime and auto-connects
- Re-inject with same
baseUrl + versionreuses runtime and callsreconnect() - Different version/base disposes old runtime and rebuilds
Debug and smoke checks:
- Runtime/debug hooks:
window.__playletDebug.* - Unit/smoke tests:
npm run test - Structure/boundary checks:
npm run check
Workflow: .github/workflows/pages.yml
- Trigger: push
mainor manual dispatch - Install:
npm ci - Build:
npm run build - Publish artifact:
dist/
Repository setting required:
- Settings -> Pages -> Source:
GitHub Actions
| Date | model | input | cache | output | total |
|---|---|---|---|---|---|
| 20250630 | gpt-5.3-codex | ¥1.75×1.39 | ¥0.175×28.14 | ¥14×0.2 | ¥10.2 |
| 20250631 | gpt-5.3-codex | ¥1.75×0.5 | ¥0.175×7.64 | ¥14×0.0485 | ¥2.89 |