dither-player 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erick Donado
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # dither-player
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small music player for personal sites. Your own tracks play with a 1-bit dithered visualizer, and an optional second tab holds a playlist of songs you like from Apple Music, Spotify, YouTube or SoundCloud.
4
+
5
+ - **A tag for any web page**: `<dither-player>`, one script, no build step. Works on Neocities, Nekoweb, GitHub Pages or anywhere else.
6
+ - **A React component**: `<DitherPlayer />`, client-only, works in Next.js.
7
+ - **English and Spanish built in**, following the page's language. Every word can be changed.
8
+
9
+ The visualizer uses the same ordered dithering as [dither-moon](https://github.com/KanuSaru/dither-moon), so the two sit well together.
10
+
11
+ ## Any web page
12
+
13
+ ```html
14
+ <script src="https://cdn.jsdelivr.net/npm/dither-player@1/dist/dither-player.js"></script>
15
+
16
+ <dither-player playlist="https://music.apple.com/co/playlist/…" artist="Your name">
17
+ <a href="music/night-bus.mp3">Night bus</a>
18
+ <a href="music/tide.mp3">Tide</a>
19
+ </dither-player>
20
+ ```
21
+
22
+ The tracks are ordinary links, so without JavaScript the page still lists them and they still play or download.
23
+
24
+ | Attribute | What it does |
25
+ | --- | --- |
26
+ | `playlist` | Share link of a public playlist; adds a **Faves** tab |
27
+ | `artist` | Shown on phone lock screens (per track: `data-artist` on the link) |
28
+ | `lang` | `en` or `es`. Without it, the page's language, including later changes |
29
+ | `tab` | `mine` or `faves`, the tab to open on |
30
+ | `visualizer` | `false` to hide it |
31
+ | `pixel-size`, `bars` | Visualizer pixel size (default 3) and height in its pixels (default 12) |
32
+ | `label-…` | Any of the words, e.g. `label-faves="Songs I like"`, `label-faves-text="On repeat lately."` |
33
+
34
+ ## React
35
+
36
+ ```bash
37
+ npm install dither-player
38
+ ```
39
+
40
+ ```jsx
41
+ import { DitherPlayer } from "dither-player/react";
42
+
43
+ <DitherPlayer
44
+ tracks={[{ title: "Night bus", src: "/music/night-bus.mp3" }]}
45
+ playlist="https://open.spotify.com/playlist/…"
46
+ lang="es"
47
+ labels={{ faves: "Favoritas", favesText: "Lo que más escucho." }}
48
+ />
49
+ ```
50
+
51
+ It keeps playing while it stays mounted. In Next.js, put it in a layout (a sidebar, a footer) and the music carries on as visitors move between pages.
52
+
53
+ ## Without a framework
54
+
55
+ ```js
56
+ import { createPlayer } from "dither-player";
57
+
58
+ const player = createPlayer(document.querySelector("#music"), { tracks, playlist, lang: "en" });
59
+ player.update({ lang: "es" }); // any option; a playing track keeps playing
60
+ player.state; // { playing, index, track, time, duration }
61
+ player.destroy();
62
+ ```
63
+
64
+ ## Options
65
+
66
+ | Option | Default | |
67
+ | --- | --- | --- |
68
+ | `tracks` | `[]` | `{ title, src, artist? }`, in order. Files on your own site. |
69
+ | `playlist` | `""` | Apple Music, Spotify, YouTube (playlist) or SoundCloud share link |
70
+ | `lang` | page language | `"en"` or `"es"` (others fall back to English) |
71
+ | `labels` | | Replace words: `region mine faves play pause prev next seek empty noPlaylist favesText favesTitle load loadNote`. `{x}` is the service's name. |
72
+ | `artist` | `""` | For lock screens |
73
+ | `visualizer` | `true` | |
74
+ | `pixelSize` | `3` | Screen pixels per visualizer pixel |
75
+ | `bars` | `12` | Visualizer height |
76
+ | `tab` | `"mine"` if there are tracks | |
77
+
78
+ ## Looks
79
+
80
+ It takes the text color and font of wherever you put it. Fine-tune with CSS variables on the player or any parent:
81
+
82
+ ```css
83
+ dither-player {
84
+ --dp-ink: #e7e2d6; /* lines, icons, bars, filled buttons */
85
+ --dp-muted: #918c81; /* secondary text */
86
+ --dp-line: #2b2a26; /* borders */
87
+ --dp-accent: #7390ff; /* hover and focus */
88
+ --dp-on-ink: #0d0d0c; /* text and icons on filled buttons */
89
+ --dp-mono: "VT323", monospace;
90
+ }
91
+ ```
92
+
93
+ ## Good to know
94
+
95
+ - **Browsers don't allow sound before a click**, so nothing autoplays. The visualizer starts with the first press of play.
96
+ - **The playlist's player only loads when a visitor opens it**, so the services don't load scripts or cookies on every page view. Apple Music and Spotify play full songs to their own members and previews to everyone else; that's their rule.
97
+ - **The visualizer needs the files on your own site.** Browsers mute audio from other sites when a page analyses it, so if any track lives elsewhere, the player plays without the visualizer.
98
+ - **Media keys and lock screens** work: play, pause, previous, next.
99
+ - It respects `prefers-reduced-motion`: the bars stay still.
100
+ - Only share music you have the rights to.
101
+
102
+ ## License
103
+
104
+ MIT, Erick Donado. The demo loop in `demo/` is synthesized for this project and free to use.
@@ -0,0 +1,54 @@
1
+ (()=>{var Q=Object.defineProperty;var X=e=>{throw TypeError(e)};var ce=(e,n,t)=>n in e?Q(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t;var de=(e,n)=>{for(var t in n)Q(e,t,{get:n[t],enumerable:!0})};var ee=(e,n,t)=>ce(e,typeof n!="symbol"?n+"":n,t),pe=(e,n,t)=>n.has(e)||X("Cannot "+t);var te=(e,n,t)=>n.has(e)?X("Cannot add the same private member more than once"):n instanceof WeakSet?n.add(e):n.set(e,t);var q=(e,n,t)=>(pe(e,n,"access private method"),t);var J={};de(J,{CSS:()=>K,DitherPlayerElement:()=>T,LABELS:()=>E,LANGUAGES:()=>ne,createPlayer:()=>V,defineDitherPlayer:()=>W,embedFor:()=>P,labelsFor:()=>B});function P(e){let n;try{n=new URL(String(e||"").trim())}catch{return null}let t=n.hostname.replace(/^www\./,"");return t==="music.apple.com"||t==="embed.music.apple.com"?{src:`https://embed.music.apple.com${n.pathname}${n.search}`,service:"Apple Music",height:450}:t==="open.spotify.com"?{src:`https://open.spotify.com/embed${n.pathname.replace(/^\/intl-[a-z-]+/i,"").replace(/^\/embed/,"")}`,service:"Spotify",height:352}:(t==="youtube.com"||t==="music.youtube.com"||t==="m.youtube.com")&&n.searchParams.get("list")?{src:`https://www.youtube-nocookie.com/embed/videoseries?list=${encodeURIComponent(n.searchParams.get("list"))}`,service:"YouTube",height:200}:t==="soundcloud.com"?{src:`https://w.soundcloud.com/player/?url=${encodeURIComponent(n.origin+n.pathname)}&visual=false&show_comments=false`,service:"SoundCloud",height:360}:null}var E={en:{region:"Music player",mine:"Mine",faves:"Faves",play:"Play",pause:"Pause",prev:"Previous track",next:"Next track",seek:"Position in the track",empty:"No tracks yet.",noPlaylist:"No playlist yet.",favesText:"",favesTitle:"Playlist",load:"Open the playlist on {x}",loadNote:"Loads {x}'s player. It plays full songs for its members and previews for everyone else."},es:{region:"Reproductor de m\xFAsica",mine:"M\xEDas",faves:"Favoritas",play:"Reproducir",pause:"Pausar",prev:"Pista anterior",next:"Siguiente pista",seek:"Posici\xF3n en la pista",empty:"A\xFAn no hay pistas.",noPlaylist:"A\xFAn no hay playlist.",favesText:"",favesTitle:"Playlist",load:"Abrir la playlist en {x}",loadNote:"Carga el reproductor de {x}. Reproduce canciones completas a sus usuarios y fragmentos al resto."}},ne=Object.keys(E);function B(e,n){return{...E[String(e||"en").slice(0,2).toLowerCase()]??E.en,...n||{}}}var L="var(--dp-ink, currentColor)",O="var(--dp-muted, color-mix(in srgb, currentColor 62%, transparent))",N="var(--dp-line, color-mix(in srgb, currentColor 22%, transparent))",U="var(--dp-accent, var(--dp-ink, currentColor))",re="var(--dp-on-ink, Canvas)",ae="var(--dp-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)",K=`
2
+ .dp{display:grid;grid-template-columns:minmax(0,1fr);gap:.6rem;min-width:0}
3
+ .dp [hidden]{display:none!important}
4
+ .dp button{font:inherit;color:inherit;cursor:pointer}
5
+ .dp button:focus-visible,.dp input:focus-visible{outline:2px solid ${U};outline-offset:2px}
6
+ .dp-tabs{display:flex}
7
+ .dp-tabs button{flex:1;font-family:${ae};font-size:.95em;line-height:1;padding:.35em .5em;color:${O};background:transparent;border:1px solid ${N}}
8
+ .dp-tabs button+button{border-left:none}
9
+ .dp-tabs button[aria-selected=true]{color:${L};border-color:${L};box-shadow:inset 0 0 0 3em ${L};-webkit-text-fill-color:${re}}
10
+ .dp-panel{display:grid;grid-template-columns:minmax(0,1fr);gap:.5rem;min-width:0}
11
+ .dp-viz{min-width:0;overflow:hidden;color:${L};border-bottom:1px solid ${N};padding-bottom:2px}
12
+ .dp-viz canvas{display:block;image-rendering:pixelated}
13
+ .dp-now{display:grid;gap:.1rem;min-width:0}
14
+ .dp-title{margin:0;font-weight:700;line-height:1.25;overflow-wrap:anywhere}
15
+ .dp-time{margin:0;font-family:${ae};font-size:.95em;line-height:1.1;color:${O};font-variant-numeric:tabular-nums;white-space:pre}
16
+ .dp-seek{-webkit-appearance:none;appearance:none;width:100%;height:14px;margin:0;color:${L};background:transparent;cursor:pointer}
17
+ .dp-seek::-webkit-slider-runnable-track{height:2px;background:${N}}
18
+ .dp-seek::-moz-range-track{height:2px;background:${N}}
19
+ .dp-seek::-moz-range-progress{height:2px;background:currentColor}
20
+ .dp-seek::-webkit-slider-thumb{-webkit-appearance:none;width:10px;height:10px;margin-top:-4px;background:currentColor;border:0;border-radius:0}
21
+ .dp-seek::-moz-range-thumb{width:10px;height:10px;background:currentColor;border:0;border-radius:0}
22
+ .dp-buttons{display:flex;gap:.4rem}
23
+ .dp-buttons button{display:grid;place-items:center;width:2.25rem;height:2.25rem;padding:0;color:${L};background:transparent;border:1px solid ${N}}
24
+ .dp-buttons button.dp-main{border-color:${L};box-shadow:inset 0 0 0 3em ${L}}
25
+ .dp-buttons button.dp-main svg{color:${re}}
26
+ .dp-buttons button:hover{border-color:${U}}
27
+ .dp-buttons svg{width:13px;height:13px}
28
+ .dp-list{margin:0;padding:.35rem 0 0;list-style:none;display:grid;border-top:1px dashed ${N}}
29
+ .dp-list button{width:100%;text-align:left;font-size:.875em;color:${O};background:none;border:0;padding:.15rem 0}
30
+ .dp-list button[aria-current=true]{color:inherit}
31
+ .dp-list button[aria-current=true]::before{content:"\\25B8\\00A0";color:${U}}
32
+ .dp-note{margin:0;font-size:.875em;color:${O}}
33
+ .dp-small{margin:0;font-size:.8em;color:${O}}
34
+ .dp-load{justify-self:start;font-size:.95em;background:transparent;border:1px solid ${N};padding:.35em .6em}
35
+ .dp-load:hover{border-color:${U}}
36
+ .dp-embed{display:block;width:100%;border:0}
37
+ `;function ie(e){if(typeof document>"u")return;let n=e.getRootNode?.()??document,t=n instanceof Document?n.head:n;if(!t||t.querySelector?.("style[data-dither-player]"))return;let i=document.createElement("style");i.setAttribute("data-dither-player",""),i.textContent=K,t.prepend(i)}var ue=[0,8,2,10,12,4,14,6,3,11,1,9,15,7,13,5].map(e=>(e+.5)/16);function me(e){let n=getComputedStyle(e).color.match(/[\d.]+/g);return n?n.slice(0,3).map(Number):[230,226,214]}function oe(e,n){let t=e.getContext("2d"),i=typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches,l=null,p=new Uint8Array(0),f=[],h=!1,z=0,A=!0;function b(){let c=e.parentElement,g=Math.max(8,Math.floor((c?.clientWidth||120)/n.pixelSize)),w=Math.max(3,n.height);(e.width!==g||e.height!==w)&&(e.width=g,e.height=w,e.style.width=`${g*n.pixelSize}px`,e.style.height=`${w*n.pixelSize}px`),f.length!==g&&(f=new Array(g).fill(0))}function a(){b();let c=e.width,g=e.height,w=h&&l&&!i;w&&l.getByteFrequencyData(p);let x=me(e),C=t.createImageData(c,g),k=!1;for(let v=0;v<c;v++){let R=Math.min(p.length-1,Math.floor(Math.pow(p.length||1,v/c))),y=w&&R>=0?p[R]/255:0;f[v]+=(y-f[v])*(y>f[v]?.6:.15),f[v]>.01&&(k=!0);let I=Math.max(1/g,.9*f[v])*g;for(let $=0;$<g;$++)if(Math.min(1,Math.max(0,I-$))*(1-.6*$/g)>ue[($&3)*4+(v&3)]){let r=((g-1-$)*c+v)*4;C.data[r]=x[0],C.data[r+1]=x[1],C.data[r+2]=x[2],C.data[r+3]=255}}return t.putImageData(C,0,0),k}function o(){z=0;let c=A?a():!0;(h||c)&&(z=requestAnimationFrame(o))}let M=typeof ResizeObserver<"u"?new ResizeObserver(()=>a()):null;e.parentElement&&M?.observe(e.parentElement);let F=typeof IntersectionObserver<"u"?new IntersectionObserver(([c])=>{A=c.isIntersecting}):null;return F?.observe(e),a(),{connect(c){l=c,p=new Uint8Array(c.frequencyBinCount)},setRunning(c){h=c,z||(z=requestAnimationFrame(o))},resize(c){Object.assign(n,c),a()},redraw:a,destroy(){cancelAnimationFrame(z),M?.disconnect(),F?.disconnect()}}}var he={tracks:[],playlist:"",lang:null,labels:null,artist:"",visualizer:!0,pixelSize:3,bars:12,tab:null},j={play:"M1 0h1v1h1v1h1v1h1v1h1v1H5v1H4v1H3v1H2v1H1z",pause:"M1 0h2v9H1zM5 0h2v9H5z",prev:"M0 0h2v9H0zM6 0h1v9H6V8H5V7H4V6H3V3h1V2h1V1h1z",next:"M7 0h2v9H7zM1 0h1v1h1v1h1v1h1v3H4v1H3v1H2v1H1z"},Y=e=>`<svg viewBox="0 0 9 9" aria-hidden="true" shape-rendering="crispEdges" fill="currentColor"><path d="${e}"/></svg>`,D=e=>!Number.isFinite(e)||e<0?"0:00":`${Math.floor(e/60)}:${String(Math.floor(e%60)).padStart(2,"0")}`,se=(e,n)=>String(e).replace("{x}",n),Z=e=>typeof location<"u"&&e.every(n=>{try{return new URL(n.src,location.href).origin===location.origin}catch{return!1}}),be=(e,n)=>e.length===n.length&&e.every((t,i)=>t.src===n[i].src&&t.title===n[i].title&&t.artist===n[i].artist);function V(e,n={}){let t={...he,...n},i=(t.tracks||[]).filter(r=>r&&r.src),l=0,p=t.tab||(i.length||!t.playlist?"mine":"faves"),f=!1,h=null,z=!1;ie(e);let A=document.createElement("div");A.className="dp",A.setAttribute("role","region"),A.innerHTML=`
38
+ <div class="dp-tabs" role="tablist">
39
+ <button type="button" role="tab" data-tab="mine"></button>
40
+ <button type="button" role="tab" data-tab="faves"></button>
41
+ </div>
42
+ <div class="dp-panel" role="tabpanel" data-panel="mine">
43
+ <div class="dp-viz"><canvas aria-hidden="true"></canvas></div>
44
+ <div class="dp-now"><p class="dp-title"></p><p class="dp-time"></p></div>
45
+ <input class="dp-seek" type="range" min="0" max="0" step="0.1" value="0">
46
+ <div class="dp-buttons">
47
+ <button type="button" data-act="prev">${Y(j.prev)}</button>
48
+ <button type="button" data-act="toggle" class="dp-main"></button>
49
+ <button type="button" data-act="next">${Y(j.next)}</button>
50
+ </div>
51
+ <ol class="dp-list"></ol>
52
+ <p class="dp-note dp-empty"></p>
53
+ </div>
54
+ <div class="dp-panel" role="tabpanel" data-panel="faves"></div>`,e.appendChild(A);let b=r=>A.querySelector(r),a={tabs:b(".dp-tabs"),tabMine:b('[data-tab="mine"]'),tabFaves:b('[data-tab="faves"]'),mine:b('[data-panel="mine"]'),faves:b('[data-panel="faves"]'),viz:b(".dp-viz"),title:b(".dp-title"),time:b(".dp-time"),seek:b(".dp-seek"),prev:b('[data-act="prev"]'),toggle:b('[data-act="toggle"]'),next:b('[data-act="next"]'),list:b(".dp-list"),empty:b(".dp-empty"),now:b(".dp-now"),buttons:b(".dp-buttons")},o=new Audio;o.preload="metadata";let M=oe(a.viz.querySelector("canvas"),{pixelSize:t.pixelSize,height:t.bars}),F=()=>B(t.lang||(e.host??e).closest?.("[lang]")?.getAttribute("lang")||document.documentElement.lang,t.labels);function c(){let r=o.currentTime,s=o.duration,m=i.length>1?` \xB7 ${l+1}/${i.length}`:"";a.time.textContent=`${D(r)} / ${D(s)}${m}`,a.seek.max=Number.isFinite(s)?String(s):"0",a.seek.value=String(r),a.seek.setAttribute("aria-valuetext",`${D(r)} / ${D(s)}`)}function g(){a.list.textContent="",!(i.length<2)&&i.forEach((r,s)=>{let m=document.createElement("li"),u=document.createElement("button");u.type="button",u.textContent=r.title||`Track ${s+1}`,u.addEventListener("click",()=>k(s,!0)),m.appendChild(u),a.list.appendChild(m)})}function w(){let r=F(),s=P(t.playlist),m=a.faves.querySelector("iframe");if(f&&s&&m?.src===s.src){m.title=r.favesTitle;return}if(a.faves.textContent="",!s){let d=document.createElement("p");d.className="dp-note",d.textContent=r.noPlaylist,a.faves.appendChild(d);return}if(f){let d=document.createElement("iframe");d.className="dp-embed",d.src=s.src,d.height=String(s.height),d.title=r.favesTitle,d.loading="lazy",d.allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write",a.faves.appendChild(d);return}if(r.favesText){let d=document.createElement("p");d.className="dp-note",d.textContent=r.favesText,a.faves.appendChild(d)}let u=document.createElement("button");u.type="button",u.className="dp-load",u.textContent=se(r.load,s.service),u.addEventListener("click",()=>{f=!0,w()});let S=document.createElement("p");S.className="dp-small",S.textContent=se(r.loadNote,s.service),a.faves.append(u,S)}function x(){let r=F(),s=i.length>0&&!!P(t.playlist);A.setAttribute("aria-label",r.region),a.tabs.hidden=!s,a.tabMine.textContent=r.mine,a.tabFaves.textContent=r.faves,a.tabMine.setAttribute("aria-selected",String(p==="mine")),a.tabFaves.setAttribute("aria-selected",String(p==="faves"));let m=s?p==="mine":i.length>0||!t.playlist;a.mine.hidden=!m,a.faves.hidden=m;let u=i.length>0;for(let d of[a.viz,a.now,a.seek,a.buttons])d.hidden=!u;a.viz.hidden=!u||!t.visualizer,a.empty.hidden=u,a.empty.textContent=r.empty,a.prev.hidden=a.next.hidden=a.list.hidden=i.length<2,a.prev.setAttribute("aria-label",r.prev),a.next.setAttribute("aria-label",r.next),a.toggle.setAttribute("aria-label",o.paused?r.play:r.pause),a.toggle.innerHTML=Y(o.paused?j.play:j.pause),a.seek.setAttribute("aria-label",r.seek);let S=i[l];a.title.textContent=S&&S.title||"",[...a.list.querySelectorAll("button")].forEach((d,le)=>{le===l?d.setAttribute("aria-current","true"):d.removeAttribute("aria-current")}),c()}function C(){let r=i[l];if(!(!r||typeof navigator>"u"||!("mediaSession"in navigator)))try{navigator.mediaSession.metadata=new MediaMetadata({title:r.title||"",artist:r.artist||t.artist||""})}catch{}}function k(r,s){if(!i.length)return;l=(r%i.length+i.length)%i.length;let m=s??!o.paused;o.src=i[l].src,C(),x(),m&&v()}function v(){if(i.length){if(o.src||k(l,!1),t.visualizer&&!h&&typeof AudioContext<"u"&&Z(i))try{h=new AudioContext;let r=h.createMediaElementSource(o),s=h.createAnalyser();s.fftSize=256,s.smoothingTimeConstant=.75,r.connect(s),s.connect(h.destination),M.connect(s)}catch{}h?.resume?.(),o.play().catch(()=>{})}}let R=()=>o.paused?v():o.pause(),y=(r,s,m)=>r.addEventListener(s,m);y(a.tabMine,"click",()=>(p="mine",x())),y(a.tabFaves,"click",()=>(p="faves",x())),y(a.toggle,"click",R),y(a.prev,"click",()=>k(l-1)),y(a.next,"click",()=>k(l+1)),y(a.seek,"input",()=>{o.currentTime=Number(a.seek.value),c()}),y(o,"timeupdate",c),y(o,"loadedmetadata",c),y(o,"durationchange",c),y(o,"play",()=>{M.setRunning(!0),x()}),y(o,"pause",()=>{M.setRunning(!1),x()}),y(o,"ended",()=>{i.length>1&&l<i.length-1?k(l+1,!0):i.length>1&&k(0,!1)});let I=typeof navigator<"u"&&"mediaSession"in navigator?navigator.mediaSession:null,$={play:()=>v(),pause:()=>o.pause(),previoustrack:()=>k(l-1),nexttrack:()=>k(l+1)};for(let[r,s]of Object.entries($))try{I?.setActionHandler(r,s)}catch{}let _=typeof MutationObserver<"u"?new MutationObserver(()=>{t.lang||(x(),w())}):null;return _?.observe(document.documentElement,{attributes:!0,attributeFilter:["lang"]}),g(),i.length&&(o.src=i[0].src,C()),w(),x(),{update(r={}){if(z)return;let s=t.playlist;t={...t,...r};let m=(t.tracks||[]).filter(u=>u&&u.src);if(h&&!Z(m)&&(m=m.filter(u=>Z([u]))),!be(i,m)){let u=i[l]?.src;i=m;let S=i.findIndex(d=>d.src===u);g(),S>=0?l=S:(l=0,o.pause(),o.removeAttribute("src"),i.length&&(o.src=i[0].src),C())}t.playlist!==s&&(f=!1),r.tab&&(p=r.tab),M.resize({pixelSize:t.pixelSize,height:t.bars}),w(),x()},play:v,pause:()=>o.pause(),select:r=>k(r,!0),get state(){return{playing:!o.paused,index:l,track:i[l]??null,time:o.currentTime,duration:Number.isFinite(o.duration)?o.duration:0}},audio:o,destroy(){z=!0,o.pause(),o.removeAttribute("src"),M.destroy(),_?.disconnect(),h?.close?.();for(let r of Object.keys($))try{I?.setActionHandler(r,null)}catch{}A.remove()}}}var fe=typeof HTMLElement>"u"?class{}:HTMLElement,ge=Object.keys(E.en).map(e=>`label-${e.replace(/[A-Z]/g,n=>`-${n.toLowerCase()}`)}`),H,G,T=class extends fe{constructor(){super(...arguments);te(this,H)}connectedCallback(){if(!this.shadowRoot){let t=this.attachShadow({mode:"open"});t.innerHTML="<style>:host{display:block}</style>"}this.player=V(this.shadowRoot,q(this,H,G).call(this)),this.watch=new MutationObserver(()=>this.player?.update(q(this,H,G).call(this))),this.watch.observe(this,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["href","data-artist"]})}disconnectedCallback(){this.watch?.disconnect(),this.player?.destroy(),this.player=null}attributeChangedCallback(){this.player?.update(q(this,H,G).call(this))}get state(){return this.player?.state}play(){this.player?.play()}pause(){this.player?.pause()}};H=new WeakSet,G=function(){let t=[...this.querySelectorAll("a[href]")].map(p=>({title:p.textContent.trim(),src:p.href,artist:p.dataset.artist||void 0})),i={};for(let p of Object.keys(E.en)){let f=this.getAttribute(`label-${p.replace(/[A-Z]/g,h=>`-${h.toLowerCase()}`)}`);f!=null&&(i[p]=f)}let l=(p,f)=>{let h=Number(this.getAttribute(p));return Number.isFinite(h)&&h>0?h:f};return{tracks:t,playlist:this.getAttribute("playlist")||"",artist:this.getAttribute("artist")||"",lang:this.getAttribute("lang")||null,tab:this.getAttribute("tab")||null,pixelSize:l("pixel-size",3),bars:l("bars",12),visualizer:this.getAttribute("visualizer")!=="false",labels:i}},ee(T,"observedAttributes",["playlist","artist","lang","tab","pixel-size","bars","visualizer",...ge]);function W(e="dither-player"){typeof customElements<"u"&&!customElements.get(e)&&customElements.define(e,class extends T{})}W();globalThis.DitherPlayer=J;})();
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "dither-player",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "A small music player for personal sites: your own tracks with a 1-bit dithered visualizer, plus a tab for a playlist from Apple Music, Spotify, YouTube or SoundCloud. A custom element and a React component.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./react": {
14
+ "types": "./src/react.d.ts",
15
+ "default": "./src/react.js"
16
+ },
17
+ "./dist/dither-player.js": "./dist/dither-player.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "dist"
23
+ ],
24
+ "sideEffects": [
25
+ "./src/browser.js",
26
+ "./dist/dither-player.js"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test test/*.test.js",
30
+ "build": "esbuild src/browser.js --bundle --format=iife --minify --target=es2020 --legal-comments=none --outfile=dist/dither-player.js"
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=18"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "react": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "esbuild": "^0.24.0"
42
+ },
43
+ "keywords": [
44
+ "music-player",
45
+ "audio-player",
46
+ "visualizer",
47
+ "dither",
48
+ "pixel-art",
49
+ "1-bit",
50
+ "indieweb",
51
+ "web-component",
52
+ "react"
53
+ ],
54
+ "author": "Erick Donado (https://github.com/KanuSaru)",
55
+ "license": "MIT",
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/KanuSaru/dither-player.git"
59
+ },
60
+ "homepage": "https://github.com/KanuSaru/dither-player#readme"
61
+ }
package/src/browser.js ADDED
@@ -0,0 +1,5 @@
1
+ // The script-tag build: registers <dither-player> and exposes the API as window.DitherPlayer.
2
+ import * as api from "./index.js";
3
+
4
+ api.defineDitherPlayer();
5
+ globalThis.DitherPlayer = api;
package/src/element.js ADDED
@@ -0,0 +1,88 @@
1
+ // <dither-player> custom element. The tracks are plain links inside it, so without
2
+ // JavaScript the page still shows them as links to the files:
3
+ //
4
+ // <dither-player playlist="https://music.apple.com/…" artist="Kanu">
5
+ // <a href="music/night-bus.mp3">Night bus</a>
6
+ // <a href="music/tide.mp3" data-artist="Kanu & friends">Tide</a>
7
+ // </dither-player>
8
+ //
9
+ // Attributes (all optional): playlist, artist, lang, tab, pixel-size, bars,
10
+ // visualizer ("false" turns it off), and label-<name> to change any of the words,
11
+ // e.g. label-faves="Songs I like".
12
+
13
+ import { createPlayer } from "./player.js";
14
+ import { LABELS } from "./labels.js";
15
+
16
+ const Base = typeof HTMLElement === "undefined" ? class {} : HTMLElement;
17
+ const LABEL_ATTRS = Object.keys(LABELS.en).map((k) => `label-${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`);
18
+
19
+ export class DitherPlayerElement extends Base {
20
+ static observedAttributes = ["playlist", "artist", "lang", "tab", "pixel-size", "bars", "visualizer", ...LABEL_ATTRS];
21
+
22
+ connectedCallback() {
23
+ if (!this.shadowRoot) {
24
+ const root = this.attachShadow({ mode: "open" });
25
+ root.innerHTML = "<style>:host{display:block}</style>";
26
+ }
27
+ this.player = createPlayer(this.shadowRoot, this.#options());
28
+ // tracks added or removed later are picked up too
29
+ this.watch = new MutationObserver(() => this.player?.update(this.#options()));
30
+ this.watch.observe(this, { childList: true, subtree: true, attributes: true, attributeFilter: ["href", "data-artist"] });
31
+ }
32
+
33
+ disconnectedCallback() {
34
+ this.watch?.disconnect();
35
+ this.player?.destroy();
36
+ this.player = null;
37
+ }
38
+
39
+ attributeChangedCallback() {
40
+ this.player?.update(this.#options());
41
+ }
42
+
43
+ /** The playing state: { playing, index, track, time, duration }. */
44
+ get state() {
45
+ return this.player?.state;
46
+ }
47
+ play() {
48
+ this.player?.play();
49
+ }
50
+ pause() {
51
+ this.player?.pause();
52
+ }
53
+
54
+ #options() {
55
+ const tracks = [...this.querySelectorAll("a[href]")].map((a) => ({
56
+ title: a.textContent.trim(),
57
+ src: a.href,
58
+ artist: a.dataset.artist || undefined,
59
+ }));
60
+ const labels = {};
61
+ for (const k of Object.keys(LABELS.en)) {
62
+ const v = this.getAttribute(`label-${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`);
63
+ if (v != null) labels[k] = v;
64
+ }
65
+ const num = (name, fallback) => {
66
+ const v = Number(this.getAttribute(name));
67
+ return Number.isFinite(v) && v > 0 ? v : fallback;
68
+ };
69
+ return {
70
+ tracks,
71
+ playlist: this.getAttribute("playlist") || "",
72
+ artist: this.getAttribute("artist") || "",
73
+ lang: this.getAttribute("lang") || null, // without it, the page's language (and its changes)
74
+ tab: this.getAttribute("tab") || null,
75
+ pixelSize: num("pixel-size", 3),
76
+ bars: num("bars", 12),
77
+ visualizer: this.getAttribute("visualizer") !== "false",
78
+ labels,
79
+ };
80
+ }
81
+ }
82
+
83
+ /** Register the element (once). */
84
+ export function defineDitherPlayer(name = "dither-player") {
85
+ if (typeof customElements !== "undefined" && !customElements.get(name)) {
86
+ customElements.define(name, class extends DitherPlayerElement {});
87
+ }
88
+ }
package/src/embed.js ADDED
@@ -0,0 +1,40 @@
1
+ // Turns a playlist's share link into the address of that service's own player.
2
+ // Supported: Apple Music, Spotify, YouTube (playlists) and SoundCloud.
3
+
4
+ /**
5
+ * @param {string} link the link from the service's Share button
6
+ * @returns {{ src: string, service: string, height: number } | null}
7
+ */
8
+ export function embedFor(link) {
9
+ let u;
10
+ try {
11
+ u = new URL(String(link || "").trim());
12
+ } catch {
13
+ return null;
14
+ }
15
+ const host = u.hostname.replace(/^www\./, "");
16
+
17
+ if (host === "music.apple.com" || host === "embed.music.apple.com") {
18
+ return { src: `https://embed.music.apple.com${u.pathname}${u.search}`, service: "Apple Music", height: 450 };
19
+ }
20
+ if (host === "open.spotify.com") {
21
+ // links can carry a locale ("/intl-es/playlist/…") that the embed doesn't take
22
+ const path = u.pathname.replace(/^\/intl-[a-z-]+/i, "").replace(/^\/embed/, "");
23
+ return { src: `https://open.spotify.com/embed${path}`, service: "Spotify", height: 352 };
24
+ }
25
+ if ((host === "youtube.com" || host === "music.youtube.com" || host === "m.youtube.com") && u.searchParams.get("list")) {
26
+ return {
27
+ src: `https://www.youtube-nocookie.com/embed/videoseries?list=${encodeURIComponent(u.searchParams.get("list"))}`,
28
+ service: "YouTube",
29
+ height: 200,
30
+ };
31
+ }
32
+ if (host === "soundcloud.com") {
33
+ return {
34
+ src: `https://w.soundcloud.com/player/?url=${encodeURIComponent(u.origin + u.pathname)}&visual=false&show_comments=false`,
35
+ service: "SoundCloud",
36
+ height: 360,
37
+ };
38
+ }
39
+ return null;
40
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ export interface Track {
2
+ /** Shown in the player and on lock screens. */
3
+ title: string;
4
+ /** Address of the audio file (mp3, m4a, ogg…), on the same site. */
5
+ src: string;
6
+ /** Default: the player's `artist`. */
7
+ artist?: string;
8
+ }
9
+
10
+ export type Labels = typeof import("./labels.js").LABELS.en;
11
+
12
+ export interface PlayerOptions {
13
+ /** Your own tracks, in order. */
14
+ tracks?: Track[];
15
+ /** Share link of a playlist (Apple Music, Spotify, YouTube or SoundCloud); adds a second tab. */
16
+ playlist?: string;
17
+ /** "en" or "es". Default: the page's language, and it follows changes to it. */
18
+ lang?: string | null;
19
+ /** Replace any of the words. `{x}` is the playlist's service. */
20
+ labels?: Partial<Labels> | null;
21
+ /** Shown on lock screens for tracks without their own artist. */
22
+ artist?: string;
23
+ /** The 1-bit spectrum above the controls. Default true. */
24
+ visualizer?: boolean;
25
+ /** Screen pixels per visualizer pixel. Default 3. */
26
+ pixelSize?: number;
27
+ /** Visualizer height, in its own pixels. Default 12. */
28
+ bars?: number;
29
+ /** Tab to open on. Default: "mine" when there are tracks. */
30
+ tab?: "mine" | "faves" | null;
31
+ }
32
+
33
+ export interface PlayerState {
34
+ playing: boolean;
35
+ index: number;
36
+ track: Track | null;
37
+ time: number;
38
+ duration: number;
39
+ }
40
+
41
+ export interface PlayerController {
42
+ update(options: PlayerOptions): void;
43
+ play(): void;
44
+ pause(): void;
45
+ select(index: number): void;
46
+ readonly state: PlayerState;
47
+ readonly audio: HTMLAudioElement;
48
+ destroy(): void;
49
+ }
50
+
51
+ export declare function createPlayer(container: HTMLElement | ShadowRoot, options?: PlayerOptions): PlayerController;
52
+
53
+ export declare function embedFor(link: string): { src: string; service: string; height: number } | null;
54
+
55
+ export declare const LABELS: { en: Labels; es: Labels };
56
+ export declare const LANGUAGES: readonly string[];
57
+ export declare function labelsFor(lang?: string | null, overrides?: Partial<Labels> | null): Labels;
58
+ export declare const CSS: string;
59
+
60
+ export declare class DitherPlayerElement extends HTMLElement {
61
+ readonly state: PlayerState | undefined;
62
+ play(): void;
63
+ pause(): void;
64
+ }
65
+ export declare function defineDitherPlayer(name?: string): void;
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createPlayer } from "./player.js";
2
+ export { embedFor } from "./embed.js";
3
+ export { LABELS, LANGUAGES, labelsFor } from "./labels.js";
4
+ export { CSS } from "./styles.js";
5
+ export { DitherPlayerElement, defineDitherPlayer } from "./element.js";
@@ -0,0 +1,7 @@
1
+ declare const en: {
2
+ region: string; mine: string; faves: string; play: string; pause: string; prev: string; next: string;
3
+ seek: string; empty: string; noPlaylist: string; favesText: string; favesTitle: string; load: string; loadNote: string;
4
+ };
5
+ export declare const LABELS: { en: typeof en; es: typeof en };
6
+ export declare const LANGUAGES: readonly string[];
7
+ export declare function labelsFor(lang?: string | null, overrides?: Partial<typeof en> | null): typeof en;
package/src/labels.js ADDED
@@ -0,0 +1,45 @@
1
+ // The player's words, in English and Spanish. Any of them can be replaced with the
2
+ // `labels` option. {x} is filled in with the name of the playlist's service.
3
+
4
+ export const LABELS = {
5
+ en: {
6
+ region: "Music player",
7
+ mine: "Mine",
8
+ faves: "Faves",
9
+ play: "Play",
10
+ pause: "Pause",
11
+ prev: "Previous track",
12
+ next: "Next track",
13
+ seek: "Position in the track",
14
+ empty: "No tracks yet.",
15
+ noPlaylist: "No playlist yet.",
16
+ favesText: "",
17
+ favesTitle: "Playlist",
18
+ load: "Open the playlist on {x}",
19
+ loadNote: "Loads {x}'s player. It plays full songs for its members and previews for everyone else.",
20
+ },
21
+ es: {
22
+ region: "Reproductor de música",
23
+ mine: "Mías",
24
+ faves: "Favoritas",
25
+ play: "Reproducir",
26
+ pause: "Pausar",
27
+ prev: "Pista anterior",
28
+ next: "Siguiente pista",
29
+ seek: "Posición en la pista",
30
+ empty: "Aún no hay pistas.",
31
+ noPlaylist: "Aún no hay playlist.",
32
+ favesText: "",
33
+ favesTitle: "Playlist",
34
+ load: "Abrir la playlist en {x}",
35
+ loadNote: "Carga el reproductor de {x}. Reproduce canciones completas a sus usuarios y fragmentos al resto.",
36
+ },
37
+ };
38
+
39
+ export const LANGUAGES = Object.keys(LABELS);
40
+
41
+ /** Words for a language ("es", "es-CO"…, anything else is English), with overrides on top. */
42
+ export function labelsFor(lang, overrides) {
43
+ const base = LABELS[String(lang || "en").slice(0, 2).toLowerCase()] ?? LABELS.en;
44
+ return { ...base, ...(overrides || {}) };
45
+ }
package/src/player.js ADDED
@@ -0,0 +1,399 @@
1
+ // The player: your own tracks with a 1-bit visualizer, plus an optional tab with a
2
+ // playlist from Apple Music, Spotify, YouTube or SoundCloud. Plain DOM, no framework.
3
+
4
+ import { embedFor } from "./embed.js";
5
+ import { labelsFor } from "./labels.js";
6
+ import { injectStyles } from "./styles.js";
7
+ import { createVisualizer } from "./visualizer.js";
8
+
9
+ const DEFAULTS = {
10
+ tracks: [], // [{ title, src, artist? }]
11
+ playlist: "", // share link of a playlist; adds the second tab
12
+ lang: null, // "en" | "es"; null = the page's language
13
+ labels: null, // replace any of the words (see labels.js)
14
+ artist: "", // shown on lock screens when a track has no artist of its own
15
+ visualizer: true,
16
+ pixelSize: 3, // screen pixels per visualizer pixel
17
+ bars: 12, // visualizer height, in its own pixels
18
+ tab: null, // "mine" | "faves" to open on; null = mine if there are tracks
19
+ };
20
+
21
+ // pixel icons on a 9×9 grid
22
+ const ICONS = {
23
+ play: "M1 0h1v1h1v1h1v1h1v1h1v1H5v1H4v1H3v1H2v1H1z",
24
+ pause: "M1 0h2v9H1zM5 0h2v9H5z",
25
+ prev: "M0 0h2v9H0zM6 0h1v9H6V8H5V7H4V6H3V3h1V2h1V1h1z",
26
+ next: "M7 0h2v9H7zM1 0h1v1h1v1h1v1h1v3H4v1H3v1H2v1H1z",
27
+ };
28
+ const icon = (d) =>
29
+ `<svg viewBox="0 0 9 9" aria-hidden="true" shape-rendering="crispEdges" fill="currentColor"><path d="${d}"/></svg>`;
30
+
31
+ const clock = (s) =>
32
+ !Number.isFinite(s) || s < 0 ? "0:00" : `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
33
+
34
+ const fill = (text, x) => String(text).replace("{x}", x);
35
+
36
+ // Browsers silence audio from other sites once a page analyses it, so the
37
+ // visualizer only connects when every track is on this site.
38
+ const allLocal = (list) =>
39
+ typeof location !== "undefined" &&
40
+ list.every((t) => {
41
+ try {
42
+ return new URL(t.src, location.href).origin === location.origin;
43
+ } catch {
44
+ return false;
45
+ }
46
+ });
47
+
48
+ const sameTracks = (a, b) =>
49
+ a.length === b.length && a.every((t, i) => t.src === b[i].src && t.title === b[i].title && t.artist === b[i].artist);
50
+
51
+ /**
52
+ * Builds the player inside `container`.
53
+ * @param {HTMLElement} container
54
+ * @param {Partial<typeof DEFAULTS>} [options]
55
+ */
56
+ export function createPlayer(container, options = {}) {
57
+ let opts = { ...DEFAULTS, ...options };
58
+ let tracks = (opts.tracks || []).filter((t) => t && t.src);
59
+ let index = 0;
60
+ let tab = opts.tab || (tracks.length || !opts.playlist ? "mine" : "faves");
61
+ let embedOpen = false;
62
+ let audioCtx = null;
63
+ let destroyed = false;
64
+
65
+ injectStyles(container);
66
+ const root = document.createElement("div");
67
+ root.className = "dp";
68
+ root.setAttribute("role", "region");
69
+ root.innerHTML = `
70
+ <div class="dp-tabs" role="tablist">
71
+ <button type="button" role="tab" data-tab="mine"></button>
72
+ <button type="button" role="tab" data-tab="faves"></button>
73
+ </div>
74
+ <div class="dp-panel" role="tabpanel" data-panel="mine">
75
+ <div class="dp-viz"><canvas aria-hidden="true"></canvas></div>
76
+ <div class="dp-now"><p class="dp-title"></p><p class="dp-time"></p></div>
77
+ <input class="dp-seek" type="range" min="0" max="0" step="0.1" value="0">
78
+ <div class="dp-buttons">
79
+ <button type="button" data-act="prev">${icon(ICONS.prev)}</button>
80
+ <button type="button" data-act="toggle" class="dp-main"></button>
81
+ <button type="button" data-act="next">${icon(ICONS.next)}</button>
82
+ </div>
83
+ <ol class="dp-list"></ol>
84
+ <p class="dp-note dp-empty"></p>
85
+ </div>
86
+ <div class="dp-panel" role="tabpanel" data-panel="faves"></div>`;
87
+ container.appendChild(root);
88
+
89
+ const $ = (sel) => root.querySelector(sel);
90
+ const ui = {
91
+ tabs: $(".dp-tabs"),
92
+ tabMine: $('[data-tab="mine"]'),
93
+ tabFaves: $('[data-tab="faves"]'),
94
+ mine: $('[data-panel="mine"]'),
95
+ faves: $('[data-panel="faves"]'),
96
+ viz: $(".dp-viz"),
97
+ title: $(".dp-title"),
98
+ time: $(".dp-time"),
99
+ seek: $(".dp-seek"),
100
+ prev: $('[data-act="prev"]'),
101
+ toggle: $('[data-act="toggle"]'),
102
+ next: $('[data-act="next"]'),
103
+ list: $(".dp-list"),
104
+ empty: $(".dp-empty"),
105
+ now: $(".dp-now"),
106
+ buttons: $(".dp-buttons"),
107
+ };
108
+
109
+ const audio = new Audio();
110
+ audio.preload = "metadata";
111
+ const viz = createVisualizer(ui.viz.querySelector("canvas"), { pixelSize: opts.pixelSize, height: opts.bars });
112
+
113
+ const words = () =>
114
+ labelsFor(
115
+ // inside a shadow root, look from the element that holds it
116
+ opts.lang || (container.host ?? container).closest?.("[lang]")?.getAttribute("lang") || document.documentElement.lang,
117
+ opts.labels,
118
+ );
119
+
120
+ // ---- drawing the controls from the current state
121
+
122
+ function renderTime() {
123
+ const t = audio.currentTime, d = audio.duration;
124
+ const count = tracks.length > 1 ? ` · ${index + 1}/${tracks.length}` : "";
125
+ ui.time.textContent = `${clock(t)} / ${clock(d)}${count}`;
126
+ ui.seek.max = Number.isFinite(d) ? String(d) : "0";
127
+ ui.seek.value = String(t);
128
+ ui.seek.setAttribute("aria-valuetext", `${clock(t)} / ${clock(d)}`);
129
+ }
130
+
131
+ function renderList() {
132
+ ui.list.textContent = "";
133
+ if (tracks.length < 2) return;
134
+ tracks.forEach((t, i) => {
135
+ const li = document.createElement("li");
136
+ const b = document.createElement("button");
137
+ b.type = "button";
138
+ b.textContent = t.title || `Track ${i + 1}`;
139
+ b.addEventListener("click", () => select(i, true));
140
+ li.appendChild(b);
141
+ ui.list.appendChild(li);
142
+ });
143
+ }
144
+
145
+ function renderFaves() {
146
+ const w = words();
147
+ const embed = embedFor(opts.playlist);
148
+ // an open embed stays as it is (rebuilding it would stop what it's playing)
149
+ const open = ui.faves.querySelector("iframe");
150
+ if (embedOpen && embed && open?.src === embed.src) {
151
+ open.title = w.favesTitle;
152
+ return;
153
+ }
154
+ ui.faves.textContent = "";
155
+ if (!embed) {
156
+ const p = document.createElement("p");
157
+ p.className = "dp-note";
158
+ p.textContent = w.noPlaylist;
159
+ ui.faves.appendChild(p);
160
+ return;
161
+ }
162
+ if (embedOpen) {
163
+ const f = document.createElement("iframe");
164
+ f.className = "dp-embed";
165
+ f.src = embed.src;
166
+ f.height = String(embed.height);
167
+ f.title = w.favesTitle;
168
+ f.loading = "lazy";
169
+ f.allow = "autoplay *; encrypted-media *; fullscreen *; clipboard-write";
170
+ ui.faves.appendChild(f);
171
+ return;
172
+ }
173
+ if (w.favesText) {
174
+ const p = document.createElement("p");
175
+ p.className = "dp-note";
176
+ p.textContent = w.favesText;
177
+ ui.faves.appendChild(p);
178
+ }
179
+ const b = document.createElement("button");
180
+ b.type = "button";
181
+ b.className = "dp-load";
182
+ b.textContent = fill(w.load, embed.service);
183
+ // the service's player only loads when asked, so it costs nothing until then
184
+ b.addEventListener("click", () => {
185
+ embedOpen = true;
186
+ renderFaves();
187
+ });
188
+ const note = document.createElement("p");
189
+ note.className = "dp-small";
190
+ note.textContent = fill(w.loadNote, embed.service);
191
+ ui.faves.append(b, note);
192
+ }
193
+
194
+ function render() {
195
+ const w = words();
196
+ const both = tracks.length > 0 && !!embedFor(opts.playlist);
197
+ root.setAttribute("aria-label", w.region);
198
+ ui.tabs.hidden = !both;
199
+ ui.tabMine.textContent = w.mine;
200
+ ui.tabFaves.textContent = w.faves;
201
+ ui.tabMine.setAttribute("aria-selected", String(tab === "mine"));
202
+ ui.tabFaves.setAttribute("aria-selected", String(tab === "faves"));
203
+ // with only a playlist, it's the whole player; with only tracks, the tab is hidden
204
+ const showMine = both ? tab === "mine" : tracks.length > 0 || !opts.playlist;
205
+ ui.mine.hidden = !showMine;
206
+ ui.faves.hidden = showMine;
207
+
208
+ const has = tracks.length > 0;
209
+ for (const el of [ui.viz, ui.now, ui.seek, ui.buttons]) el.hidden = !has;
210
+ ui.viz.hidden = !has || !opts.visualizer;
211
+ ui.empty.hidden = has;
212
+ ui.empty.textContent = w.empty;
213
+ ui.prev.hidden = ui.next.hidden = ui.list.hidden = tracks.length < 2;
214
+ ui.prev.setAttribute("aria-label", w.prev);
215
+ ui.next.setAttribute("aria-label", w.next);
216
+ ui.toggle.setAttribute("aria-label", audio.paused ? w.play : w.pause);
217
+ ui.toggle.innerHTML = icon(audio.paused ? ICONS.play : ICONS.pause);
218
+ ui.seek.setAttribute("aria-label", w.seek);
219
+
220
+ const t = tracks[index];
221
+ ui.title.textContent = t ? t.title || "" : "";
222
+ [...ui.list.querySelectorAll("button")].forEach((b, i) => {
223
+ if (i === index) b.setAttribute("aria-current", "true");
224
+ else b.removeAttribute("aria-current");
225
+ });
226
+ renderTime();
227
+ }
228
+
229
+ // ---- playing
230
+
231
+ function setMeta() {
232
+ const t = tracks[index];
233
+ if (!t || typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
234
+ try {
235
+ navigator.mediaSession.metadata = new MediaMetadata({ title: t.title || "", artist: t.artist || opts.artist || "" });
236
+ } catch {
237
+ /* not supported */
238
+ }
239
+ }
240
+
241
+ function select(i, play) {
242
+ if (!tracks.length) return;
243
+ index = ((i % tracks.length) + tracks.length) % tracks.length;
244
+ const keep = play ?? !audio.paused;
245
+ audio.src = tracks[index].src;
246
+ setMeta();
247
+ render();
248
+ if (keep) start();
249
+ }
250
+
251
+ function start() {
252
+ if (!tracks.length) return;
253
+ if (!audio.src) select(index, false);
254
+ // the analyser can only be made after a click (the browser's rule for sound)
255
+ if (opts.visualizer && !audioCtx && typeof AudioContext !== "undefined" && allLocal(tracks)) {
256
+ try {
257
+ audioCtx = new AudioContext();
258
+ const src = audioCtx.createMediaElementSource(audio);
259
+ const an = audioCtx.createAnalyser();
260
+ an.fftSize = 256;
261
+ an.smoothingTimeConstant = 0.75;
262
+ src.connect(an);
263
+ an.connect(audioCtx.destination);
264
+ viz.connect(an);
265
+ } catch {
266
+ /* no visualizer; the music still plays */
267
+ }
268
+ }
269
+ audioCtx?.resume?.();
270
+ audio.play().catch(() => {});
271
+ }
272
+
273
+ const toggle = () => (audio.paused ? start() : audio.pause());
274
+
275
+ // ---- events
276
+
277
+ const on = (el, type, fn) => el.addEventListener(type, fn);
278
+ on(ui.tabMine, "click", () => ((tab = "mine"), render()));
279
+ on(ui.tabFaves, "click", () => ((tab = "faves"), render()));
280
+ on(ui.toggle, "click", toggle);
281
+ on(ui.prev, "click", () => select(index - 1));
282
+ on(ui.next, "click", () => select(index + 1));
283
+ on(ui.seek, "input", () => {
284
+ audio.currentTime = Number(ui.seek.value);
285
+ renderTime();
286
+ });
287
+ on(audio, "timeupdate", renderTime);
288
+ on(audio, "loadedmetadata", renderTime);
289
+ on(audio, "durationchange", renderTime);
290
+ on(audio, "play", () => {
291
+ viz.setRunning(true);
292
+ render();
293
+ });
294
+ on(audio, "pause", () => {
295
+ viz.setRunning(false);
296
+ render();
297
+ });
298
+ on(audio, "ended", () => {
299
+ if (tracks.length > 1 && index < tracks.length - 1) select(index + 1, true);
300
+ else if (tracks.length > 1) select(0, false); // back to the start, stopped
301
+ });
302
+
303
+ const session = typeof navigator !== "undefined" && "mediaSession" in navigator ? navigator.mediaSession : null;
304
+ const handlers = {
305
+ play: () => start(),
306
+ pause: () => audio.pause(),
307
+ previoustrack: () => select(index - 1),
308
+ nexttrack: () => select(index + 1),
309
+ };
310
+ for (const [k, fn] of Object.entries(handlers)) {
311
+ try {
312
+ session?.setActionHandler(k, fn);
313
+ } catch {
314
+ /* action not supported */
315
+ }
316
+ }
317
+
318
+ // page language changes (a language switch) update the words
319
+ const langWatch =
320
+ typeof MutationObserver !== "undefined"
321
+ ? new MutationObserver(() => {
322
+ if (!opts.lang) {
323
+ render();
324
+ renderFaves();
325
+ }
326
+ })
327
+ : null;
328
+ langWatch?.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });
329
+
330
+ renderList();
331
+ if (tracks.length) {
332
+ audio.src = tracks[0].src;
333
+ setMeta();
334
+ }
335
+ renderFaves();
336
+ render();
337
+
338
+ return {
339
+ /** Change any option; the rest keep their values. A playing track keeps playing if it's still listed. */
340
+ update(next = {}) {
341
+ if (destroyed) return;
342
+ const prevPlaylist = opts.playlist;
343
+ opts = { ...opts, ...next };
344
+ let nextTracks = (opts.tracks || []).filter((t) => t && t.src);
345
+ // once the visualizer is connected, tracks from other sites would play silent
346
+ if (audioCtx && !allLocal(nextTracks)) nextTracks = nextTracks.filter((t) => allLocal([t]));
347
+ if (!sameTracks(tracks, nextTracks)) {
348
+ const current = tracks[index]?.src;
349
+ tracks = nextTracks;
350
+ const found = tracks.findIndex((t) => t.src === current);
351
+ renderList();
352
+ if (found >= 0) index = found;
353
+ else {
354
+ index = 0;
355
+ audio.pause();
356
+ audio.removeAttribute("src");
357
+ if (tracks.length) audio.src = tracks[0].src;
358
+ setMeta();
359
+ }
360
+ }
361
+ if (opts.playlist !== prevPlaylist) embedOpen = false;
362
+ if (next.tab) tab = next.tab;
363
+ viz.resize({ pixelSize: opts.pixelSize, height: opts.bars });
364
+ renderFaves();
365
+ render();
366
+ },
367
+ play: start,
368
+ pause: () => audio.pause(),
369
+ /** Jump to a track by its position (0 = first). */
370
+ select: (i) => select(i, true),
371
+ get state() {
372
+ return {
373
+ playing: !audio.paused,
374
+ index,
375
+ track: tracks[index] ?? null,
376
+ time: audio.currentTime,
377
+ duration: Number.isFinite(audio.duration) ? audio.duration : 0,
378
+ };
379
+ },
380
+ /** The <audio> element, for anything not covered here. */
381
+ audio,
382
+ destroy() {
383
+ destroyed = true;
384
+ audio.pause();
385
+ audio.removeAttribute("src");
386
+ viz.destroy();
387
+ langWatch?.disconnect();
388
+ audioCtx?.close?.();
389
+ for (const k of Object.keys(handlers)) {
390
+ try {
391
+ session?.setActionHandler(k, null);
392
+ } catch {
393
+ /* ignore */
394
+ }
395
+ }
396
+ root.remove();
397
+ },
398
+ };
399
+ }
package/src/react.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { CSSProperties, ReactElement } from "react";
2
+ import type { PlayerOptions } from "./index.js";
3
+
4
+ export interface DitherPlayerProps extends PlayerOptions {
5
+ className?: string;
6
+ style?: CSSProperties;
7
+ }
8
+
9
+ export declare function DitherPlayer(props: DitherPlayerProps): ReactElement;
package/src/react.js ADDED
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ // React component. Works with React 18 and 19, including Next.js (client only).
4
+ //
5
+ // import { DitherPlayer } from "dither-player/react";
6
+ // <DitherPlayer tracks={[{ title: "Night bus", src: "/music/night-bus.mp3" }]} playlist="https://…" />
7
+ //
8
+ // It keeps playing across re-renders; put it in a layout to keep playing between pages.
9
+
10
+ import { createElement, useEffect, useRef } from "react";
11
+ import { createPlayer } from "./player.js";
12
+
13
+ export function DitherPlayer({ className, style, tracks, playlist, lang, labels, artist, visualizer, pixelSize, bars, tab }) {
14
+ const box = useRef(null);
15
+ const player = useRef(null);
16
+ const opts = { tracks, playlist, lang, labels, artist, visualizer, pixelSize, bars, tab };
17
+ for (const k of Object.keys(opts)) if (opts[k] === undefined) delete opts[k];
18
+ const key = JSON.stringify(opts);
19
+
20
+ useEffect(() => {
21
+ player.current = createPlayer(box.current, JSON.parse(key));
22
+ return () => {
23
+ player.current?.destroy();
24
+ player.current = null;
25
+ };
26
+ // created once; later changes go through update()
27
+ // eslint-disable-next-line react-hooks/exhaustive-deps
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ player.current?.update(JSON.parse(key));
32
+ }, [key]);
33
+
34
+ return createElement("div", { ref: box, className, style });
35
+ }
package/src/styles.js ADDED
@@ -0,0 +1,69 @@
1
+ // Default look. Everything follows the text color it sits in; restyle it with these
2
+ // CSS variables on the player or any parent (all optional):
3
+ // --dp-ink lines, icons, bars and filled buttons (default: the text color)
4
+ // --dp-muted secondary text (default: text color at 62%)
5
+ // --dp-line borders (default: text color at 22%)
6
+ // --dp-accent hover and focus (default: the ink)
7
+ // --dp-on-ink text and icons on filled buttons (default: Canvas, the page background)
8
+ // --dp-mono font for times and tabs (default: the system monospace)
9
+ //
10
+ // The defaults live in var() fallbacks, never as declarations, so values set on a
11
+ // parent always win. Filled buttons get their fill from box-shadow in the ink color,
12
+ // and their text/icon color separately, so the two can never collapse into one.
13
+
14
+ const INK = "var(--dp-ink, currentColor)";
15
+ const MUTED = "var(--dp-muted, color-mix(in srgb, currentColor 62%, transparent))";
16
+ const LINE = "var(--dp-line, color-mix(in srgb, currentColor 22%, transparent))";
17
+ const ACCENT = "var(--dp-accent, var(--dp-ink, currentColor))";
18
+ const ON_INK = "var(--dp-on-ink, Canvas)";
19
+ const MONO = "var(--dp-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)";
20
+
21
+ export const CSS = `
22
+ .dp{display:grid;grid-template-columns:minmax(0,1fr);gap:.6rem;min-width:0}
23
+ .dp [hidden]{display:none!important}
24
+ .dp button{font:inherit;color:inherit;cursor:pointer}
25
+ .dp button:focus-visible,.dp input:focus-visible{outline:2px solid ${ACCENT};outline-offset:2px}
26
+ .dp-tabs{display:flex}
27
+ .dp-tabs button{flex:1;font-family:${MONO};font-size:.95em;line-height:1;padding:.35em .5em;color:${MUTED};background:transparent;border:1px solid ${LINE}}
28
+ .dp-tabs button+button{border-left:none}
29
+ .dp-tabs button[aria-selected=true]{color:${INK};border-color:${INK};box-shadow:inset 0 0 0 3em ${INK};-webkit-text-fill-color:${ON_INK}}
30
+ .dp-panel{display:grid;grid-template-columns:minmax(0,1fr);gap:.5rem;min-width:0}
31
+ .dp-viz{min-width:0;overflow:hidden;color:${INK};border-bottom:1px solid ${LINE};padding-bottom:2px}
32
+ .dp-viz canvas{display:block;image-rendering:pixelated}
33
+ .dp-now{display:grid;gap:.1rem;min-width:0}
34
+ .dp-title{margin:0;font-weight:700;line-height:1.25;overflow-wrap:anywhere}
35
+ .dp-time{margin:0;font-family:${MONO};font-size:.95em;line-height:1.1;color:${MUTED};font-variant-numeric:tabular-nums;white-space:pre}
36
+ .dp-seek{-webkit-appearance:none;appearance:none;width:100%;height:14px;margin:0;color:${INK};background:transparent;cursor:pointer}
37
+ .dp-seek::-webkit-slider-runnable-track{height:2px;background:${LINE}}
38
+ .dp-seek::-moz-range-track{height:2px;background:${LINE}}
39
+ .dp-seek::-moz-range-progress{height:2px;background:currentColor}
40
+ .dp-seek::-webkit-slider-thumb{-webkit-appearance:none;width:10px;height:10px;margin-top:-4px;background:currentColor;border:0;border-radius:0}
41
+ .dp-seek::-moz-range-thumb{width:10px;height:10px;background:currentColor;border:0;border-radius:0}
42
+ .dp-buttons{display:flex;gap:.4rem}
43
+ .dp-buttons button{display:grid;place-items:center;width:2.25rem;height:2.25rem;padding:0;color:${INK};background:transparent;border:1px solid ${LINE}}
44
+ .dp-buttons button.dp-main{border-color:${INK};box-shadow:inset 0 0 0 3em ${INK}}
45
+ .dp-buttons button.dp-main svg{color:${ON_INK}}
46
+ .dp-buttons button:hover{border-color:${ACCENT}}
47
+ .dp-buttons svg{width:13px;height:13px}
48
+ .dp-list{margin:0;padding:.35rem 0 0;list-style:none;display:grid;border-top:1px dashed ${LINE}}
49
+ .dp-list button{width:100%;text-align:left;font-size:.875em;color:${MUTED};background:none;border:0;padding:.15rem 0}
50
+ .dp-list button[aria-current=true]{color:inherit}
51
+ .dp-list button[aria-current=true]::before{content:"\\25B8\\00A0";color:${ACCENT}}
52
+ .dp-note{margin:0;font-size:.875em;color:${MUTED}}
53
+ .dp-small{margin:0;font-size:.8em;color:${MUTED}}
54
+ .dp-load{justify-self:start;font-size:.95em;background:transparent;border:1px solid ${LINE};padding:.35em .6em}
55
+ .dp-load:hover{border-color:${ACCENT}}
56
+ .dp-embed{display:block;width:100%;border:0}
57
+ `;
58
+
59
+ /** Put the styles in the document (or the shadow root) the player lives in, once. */
60
+ export function injectStyles(el) {
61
+ if (typeof document === "undefined") return;
62
+ const root = el.getRootNode?.() ?? document;
63
+ const target = root instanceof Document ? root.head : root;
64
+ if (!target || target.querySelector?.("style[data-dither-player]")) return;
65
+ const style = document.createElement("style");
66
+ style.setAttribute("data-dither-player", "");
67
+ style.textContent = CSS;
68
+ target.prepend(style);
69
+ }
@@ -0,0 +1,105 @@
1
+ // A 1-bit spectrum: one column per frequency band, dense at the bottom and thinning
2
+ // out toward the top, ordered-dithered with the same 4×4 Bayer grid as dither-moon.
3
+
4
+ const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);
5
+
6
+ function inkOf(el) {
7
+ const m = getComputedStyle(el).color.match(/[\d.]+/g);
8
+ return m ? m.slice(0, 3).map(Number) : [230, 226, 214];
9
+ }
10
+
11
+ /**
12
+ * @param {HTMLCanvasElement} canvas
13
+ * @param {{ pixelSize: number, height: number }} size screen pixels per bar pixel, rows
14
+ */
15
+ export function createVisualizer(canvas, size) {
16
+ const ctx = canvas.getContext("2d");
17
+ const still = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
18
+ let analyser = null;
19
+ let bins = new Uint8Array(0);
20
+ let levels = [];
21
+ let running = false;
22
+ let frame = 0;
23
+ let visible = true;
24
+
25
+ function fit() {
26
+ const box = canvas.parentElement;
27
+ const W = Math.max(8, Math.floor((box?.clientWidth || 120) / size.pixelSize));
28
+ const H = Math.max(3, size.height);
29
+ if (canvas.width !== W || canvas.height !== H) {
30
+ canvas.width = W;
31
+ canvas.height = H;
32
+ canvas.style.width = `${W * size.pixelSize}px`;
33
+ canvas.style.height = `${H * size.pixelSize}px`;
34
+ }
35
+ if (levels.length !== W) levels = new Array(W).fill(0);
36
+ }
37
+
38
+ function draw() {
39
+ fit();
40
+ const W = canvas.width, H = canvas.height;
41
+ const live = running && analyser && !still;
42
+ if (live) analyser.getByteFrequencyData(bins);
43
+ const ink = inkOf(canvas);
44
+ const img = ctx.createImageData(W, H);
45
+ let moving = false;
46
+ for (let x = 0; x < W; x++) {
47
+ // columns spread over the bands on a log scale, closer to how we hear
48
+ const b = Math.min(bins.length - 1, Math.floor(Math.pow(bins.length || 1, x / W)));
49
+ const target = live && b >= 0 ? bins[b] / 255 : 0;
50
+ levels[x] += (target - levels[x]) * (target > levels[x] ? 0.6 : 0.15);
51
+ if (levels[x] > 0.01) moving = true;
52
+ const v = Math.max(1 / H, 0.9 * levels[x]) * H;
53
+ for (let y = 0; y < H; y++) {
54
+ const fill = Math.min(1, Math.max(0, v - y)) * (1 - (0.6 * y) / H);
55
+ if (fill > BAYER[(y & 3) * 4 + (x & 3)]) {
56
+ const i = ((H - 1 - y) * W + x) * 4;
57
+ img.data[i] = ink[0];
58
+ img.data[i + 1] = ink[1];
59
+ img.data[i + 2] = ink[2];
60
+ img.data[i + 3] = 255;
61
+ }
62
+ }
63
+ }
64
+ ctx.putImageData(img, 0, 0);
65
+ return moving;
66
+ }
67
+
68
+ function loop() {
69
+ frame = 0;
70
+ const moving = visible ? draw() : true;
71
+ if (running || moving) frame = requestAnimationFrame(loop);
72
+ }
73
+
74
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => draw()) : null;
75
+ if (canvas.parentElement) ro?.observe(canvas.parentElement);
76
+ const io =
77
+ typeof IntersectionObserver !== "undefined"
78
+ ? new IntersectionObserver(([e]) => {
79
+ visible = e.isIntersecting;
80
+ })
81
+ : null;
82
+ io?.observe(canvas);
83
+ draw();
84
+
85
+ return {
86
+ connect(node) {
87
+ analyser = node;
88
+ bins = new Uint8Array(node.frequencyBinCount);
89
+ },
90
+ setRunning(on) {
91
+ running = on;
92
+ if (!frame) frame = requestAnimationFrame(loop);
93
+ },
94
+ resize(next) {
95
+ Object.assign(size, next);
96
+ draw();
97
+ },
98
+ redraw: draw,
99
+ destroy() {
100
+ cancelAnimationFrame(frame);
101
+ ro?.disconnect();
102
+ io?.disconnect();
103
+ },
104
+ };
105
+ }