{"id":50472242,"url":"https://github.com/est/playlet","last_synced_at":"2026-06-01T11:02:36.801Z","repository":{"id":361369381,"uuid":"1254144137","full_name":"est/playlet","owner":"est","description":"Bookmarklet to browse and play DLNA media","archived":false,"fork":false,"pushed_at":"2026-05-30T10:22:18.000Z","size":57,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-30T11:16:51.629Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/est.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-30T07:34:59.000Z","updated_at":"2026-05-30T10:22:22.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/est/playlet","commit_stats":null,"previous_names":["est/playlet"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/est/playlet","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/est%2Fplaylet","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/est%2Fplaylet/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/est%2Fplaylet/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/est%2Fplaylet/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/est","download_url":"https://codeload.github.com/est/playlet/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/est%2Fplaylet/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33771630,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-01T02:00:06.963Z","response_time":115,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-01T11:02:36.675Z","updated_at":"2026-06-01T11:02:36.796Z","avatar_url":"https://github.com/est.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Playlet 💿\n\nPlaylet 💿 plays songs/media on any DLNA server from the browser, without installing a native client.\n\nNo app install, no Electron, no local daemon.\n\n## Story\n\nI have a NAS, it runs a DLNA server and hosts my music collection\n\nBut 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`](https://github.com/est/push2air) 13 years ago but went nowhere.\n\nOn 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.\n\nSuddently 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.\n\nThe rest is vibe coding history.\n\n## How it works\n\n1. Save this to browser bookmark: `javascript:import(\"https://est.github.io/playlet/loader.js\")`\n2. Open DLNA index page, usually http://NAS-IP:8200/\n3. Click the saved bookmark\n\nthe .js interally do these\n\n1. Discover/load device description XML (`rootDesc.xml` or provided URL)\n2. Find `ContentDirectory` `controlURL`\n3. Send `Browse` SOAP requests (`ObjectID`, `BrowseDirectChildren`)\n4. Parse `DIDL-Lite` results into containers/items\n5. Play item `res` URLs in an injected UI panel\n\nNo ads, telemetry. Works offline once loaded.\n\n## Features\n\nFor v1.2\n\n- Tree browser with `+/-` expand/collapse (lazy-loaded by node)\n- Folder hover hint on same line (`XX items`)\n- Folder quick add (`≡+`) adds playable tracks from current folder level only (no recursive deep scan)\n- `Library` tabs: `Tree | Search`\n- Search modes:\n  - `DLNA`: use `ContentDirectory:Search` when server supports it\n  - `Local: Tree`: search only loaded tree nodes\n  - `Local: Full`: crawl full library locally, then search\n- Session playlist: add, remove, play from queue\n- Playlist clear button (`Clear`)\n- Playlist supports drag-drop reorder\n- Shuffle does real in-place playlist reorder (`Shuffle`)\n- Prev/Next follow current playlist order after shuffle\n- Copy media URL from library rows and playlist rows (`⧉`)\n- Search result rows support same actions as tree rows (`▶`, `+`, `☆/★`, `⧉`)\n- Favorites: single-track star (`☆/★`) with localStorage persistence\n- Only favorites/mode are persisted; playlist is session-only\n- Playback modes: all-loop (`∞`), single-loop (`1`)\n- MediaSession track controls wired: `previoustrack` / `nexttrack`\n- Auto-detect `rootDesc.xml`, with hidden advanced URL override\n- Scroll isolation for panel internals (better trackpad behavior on macOS)\n- Native `\u003caudio controls\u003e` player for reliable seek/progress behavior\n- Error bar supports manual dismiss (`×`) and auto-hide for transient failures (play/copy/search/etc.)\n- Runtime reuse on repeated inject with same base/version (avoid full teardown/rebuild)\n- Debug hooks:\n  - `window.__playletDebug.getState()`\n  - `window.__playletDebug.getLastRequest()`\n  - `window.__playletDebug.getLastResponse()`\n  - `window.__playletDebug.getPlaylist()`\n  - `window.__playletDebug.getTreeState()`\n\n## Local debug\n\n```bash\nnpm run dev\n```\n\nOpen:\n\n- `http://127.0.0.1:8788/playlet/index.html?playlet_desc=http://127.0.0.1:8788/playlet/mock/rootDesc.xml`\n\nThen in DevTools console:\n\n```js\nimport(\"http://127.0.0.1:8788/playlet/loader.js\")\n```\n\n### Live NAS proxy debug (same-origin)\n\nProxy a real DLNA host into local same-origin with route split:\n\n- `/playlet/*` -\u003e local debug assets (index, loader, debug iframe helper)\n- `/*` -\u003e reverse proxy to your DLNA server\n\n```bash\nnpm run dev -- --dlna-base http://192.168.1.5:8200/\n```\n\nThen open:\n\n- `http://127.0.0.1:8788/playlet/debug`\n\nThis page uses an iframe + one-click inject button to simulate bookmarklet behavior:\n\n- iframe loads `/` (your real DLNA page via proxy)\n- it sets `?playlet_desc=http://127.0.0.1:8788/rootDesc.xml` on iframe URL\n- then executes `import(\"/playlet/loader.js\")` inside iframe window\n- Debug toolbar includes:\n  - `Inject`: inject loader into iframe page\n\n## Build\n\n```bash\nnpm run build\nnpm run serve:dist\n```\n\nBuild outputs:\n\n- `dist/loader.js`: esbuild bundle + minified (single download path)\n- `dist/index.html`: copied from editable `src/index.html`\n\n`src/index.html` is plain editable source. You can tweak page content/style directly there.\n\n## Architecture\n\nCurrent runtime layering:\n\n- `src/app.js`: runtime orchestrator only (`bootPlaylet`, runtime reuse/dispose, initial state/bootstrap)\n- `src/ui/panel.js`: Playlet panel UI, event wiring, tree/search/playlist rendering\n- `src/ui/styles.js`: injected styles\n- `src/domain/dlna.js`: DLNA SOAP + DIDL parsing/search helpers\n- `src/domain/playlist.js`: playlist/favorites/play-mode domain logic\n- `src/infra/media.js`: media adapter (`HtmlMediaAdapter`)\n- `src/infra/storage.js`: localStorage prefs I/O\n- `src/core/*`: constants and lightweight store\n\nData flow convention:\n\n1. UI event -\u003e domain/infra action\n2. update `state` via orchestrator helpers\n3. render from `state`\n\nRuntime lifecycle:\n\n- First inject builds runtime and auto-connects\n- Re-inject with same `baseUrl + version` reuses runtime and calls `reconnect()`\n- Different version/base disposes old runtime and rebuilds\n\nDebug and smoke checks:\n\n- Runtime/debug hooks: `window.__playletDebug.*`\n- Unit/smoke tests: `npm run test`\n- Structure/boundary checks: `npm run check`\n\n## GitHub Pages deploy\n\nWorkflow: `.github/workflows/pages.yml`\n\n- Trigger: push `main` or manual dispatch\n- Install: `npm ci`\n- Build: `npm run build`\n- Publish artifact: `dist/`\n\nRepository setting required:\n\n- Settings -\u003e Pages -\u003e Source: `GitHub Actions`\n\n\n## Cost\n\n|   Date   |    model      |  input      |     cache     |  output    | total |\n|----------|---------------|-------------|---------------|------------|-------|\n| 20250630 | gpt-5.3-codex | ¥1.75×1.39  | ¥0.175×28.14  | ¥14×0.2    | ¥10.2 |\n| 20250631 | gpt-5.3-codex | ¥1.75×0.5   | ¥0.175×7.64   | ¥14×0.0485 | ¥2.89 |\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fest%2Fplaylet","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fest%2Fplaylet","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fest%2Fplaylet/lists"}