nixamp 0.1.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.
@@ -0,0 +1 @@
1
+ (function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){if(!Number.isFinite(e)||e<0)return`--:--`;let t=Math.floor(e),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function t(e){return e.artist?`${e.artist} — ${e.title}`:e.title}function n(e){let t=e.split(`/`).pop()??e,n=t.lastIndexOf(`.`);return n>0?t.slice(0,n):t}var r=new Set([`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`,`avi`]);function i(e,t=``){if(t.startsWith(`video/`))return!0;if(t.startsWith(`audio/`))return!1;let n=e.lastIndexOf(`.`);return n>0&&r.has(e.slice(n+1).toLowerCase())}var a=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function o(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&a.has(e.slice(n+1).toLowerCase())}function s(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function c(e){return e.filter(e=>o(e.name,e.type)).sort((e,t)=>s(l(e),l(t))).map(e=>({title:n(e.name),artist:``,album:u(l(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function l(e){return e.webkitRelativePath||e.name}function u(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function d(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var f=2048,p=class{elements;handlers;context=null;analyser=null;wired=new WeakSet;active;frequencies=new Uint8Array;constructor(e,t){this.elements=e,this.handlers=t,this.active=e.audio;for(let t of[e.audio,e.video])t.crossOrigin=`anonymous`,t.addEventListener(`timeupdate`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`loadedmetadata`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`ended`,()=>{t===this.active&&this.handlers.onEnded()}),t.addEventListener(`play`,()=>{t===this.active&&this.handlers.onState(!0)}),t.addEventListener(`pause`,()=>{t===this.active&&this.handlers.onState(!1)}),t.addEventListener(`error`,()=>{t===this.active&&this.handlers.onError(m(t))})}get playing(){return!this.active.paused&&!this.active.ended}get position(){return this.active.currentTime}get duration(){return Number.isFinite(this.active.duration)?this.active.duration:0}get showingVideo(){return this.active===this.elements.video}ensureGraph(e){let t=globalThis.AudioContext??globalThis.webkitAudioContext;if(t){if(this.context??=new t,this.analyser||(this.analyser=this.context.createAnalyser(),this.analyser.fftSize=f,this.analyser.smoothingTimeConstant=.6,this.analyser.connect(this.context.destination),this.frequencies=new Uint8Array(this.analyser.frequencyBinCount)),!this.wired.has(e))try{this.context.createMediaElementSource(e).connect(this.analyser),this.wired.add(e)}catch{this.wired.add(e)}this.context.resume()}}read(){return this.analyser&&this.analyser.getByteFrequencyData(this.frequencies),this.frequencies}levels(){if(!this.analyser)return[0,0];let e=this.read(),t=0;for(let n of e)t+=n;let n=e.length===0?0:t/e.length/255;return[Math.min(1,n*2.2),Math.min(1,n*2.2)]}async load(e,t){let n=e.video?this.elements.video:this.elements.audio;n!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=n),this.active.src=e.url,this.active.load(),t&&await this.play()}async play(){this.ensureGraph(this.active);try{await this.active.play()}catch(e){this.handlers.onError(e instanceof Error?e.message:`playback was refused`)}}pause(){this.active.pause()}stop(){this.active.pause(),this.active.currentTime=0}seek(e){Number.isFinite(e)&&(this.active.currentTime=Math.max(0,e))}set volume(e){this.elements.audio.volume=e,this.elements.video.volume=e}get volume(){return this.active.volume}};function m(e){switch(e.error?.code){case MediaError.MEDIA_ERR_ABORTED:return`playback was aborted`;case MediaError.MEDIA_ERR_NETWORK:return`the network dropped mid-track`;case MediaError.MEDIA_ERR_DECODE:return`this browser could not decode that`;case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:return`this browser cannot play that format`;default:return`playback failed`}}function h(){return{revision:0,tracks:[],index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function g(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function _(e,t){return`${e===``?``:g(e)}${t.startsWith(`/`)?t:`/${t}`}`}function v(e,t){return _(e,`/api/media/${t}`)}function y(e){if(typeof e!=`object`||!e)return null;let t=e;if(!Array.isArray(t.tracks))return null;let n=h(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[];return{revision:r(t.revision,0),tracks:t.tracks.map(e=>{let t=typeof e==`object`&&e?e:{};return{title:typeof t.title==`string`?t.title:`Untitled`,artist:typeof t.artist==`string`?t.artist:``,album:typeof t.album==`string`?t.album:``,duration:r(t.duration,0)}}),index:r(t.index,0),playing:t.playing===!0,position:r(t.position,0),bars:Array.isArray(t.bars)?t.bars.map(e=>r(e,0)):[],levels:[r(i[0],0),r(i[1],0)],silent:t.silent===!0,note:typeof t.note==`string`?t.note:``,root:typeof t.root==`string`?t.root:n.root}}var b=class{handlers;source=null;base=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}get connected(){return this.source!==null}connect(e){let t=g(e);this.close(),this.base=t,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let n=new EventSource(_(t,`/api/events`));this.source=n,n.onopen=()=>this.handlers.onStatus(`live`),n.onmessage=e=>{let t=y(x(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},n.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(_(this.base,`/api/command`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(e)});if(!t.ok){this.handlers.onStatus(`error`,`command refused (${t.status})`);return}let n=y(await t.json());n&&this.handlers.onSnapshot(n)}media(e){return v(this.base,e)}close(){this.source?.close(),this.source=null}};function x(e){try{return JSON.parse(e)}catch{return null}}async function S(e,t){try{let n=await fetch(_(e,`/api/state`),{signal:t});return n.ok?y(await n.json()):null}catch{return null}}async function C(e,t){try{let n=await fetch(_(e,`/api/health`),{signal:t});if(!n.ok)return null;let r=await n.json();return r.name===`nixamp`?r.version??`unknown`:null}catch{return null}}var w=.14,T=.02;function E(e,t){let n=[];for(let r=0;r<=e;r++){let i=r/e,a=Math.round(1*(t/1)**i),o=n[n.length-1];n.push(o===void 0?a:Math.max(a,o+1))}return n}function D(e,t){let n=[];for(let r=0;r+1<t.length;r++){let i=Math.min(t[r],e.length),a=Math.min(Math.max(t[r+1],i+1),e.length),o=0,s=0;for(let t=i;t<a;t++)o+=e[t],s++;n.push(s===0?0:o/s/255)}return n}function O(e,t,n=w){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function k(e,t,n=T){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function A(e,t,n,r,i){let{width:a,height:o}=t;if(a<=0||o<=0||n.length===0)return;e.clearRect(0,0,a,o);let s=a/n.length,c=Math.max(1,s*.72),l=Math.max(2,o*.012);e.fillStyle=i.bar,n.forEach((t,n)=>{let r=Math.max(1,t*(o-l*2));e.fillRect(n*s+(s-c)/2,o-r,c,r)}),e.fillStyle=i.peak,r.forEach((t,n)=>{let r=o-Math.max(1,t*(o-l*2))-l*2;e.fillRect(n*s+(s-c)/2,Math.max(0,r),c,l)})}var j=`nixamp.remote`,M=`nixamp.volume`;function N(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function P(){let n={status:N(`status`),source:N(`source`),install:N(`install`),video:N(`video`),audio:N(`audio`),title:N(`title-line`),album:N(`album-line`),elapsed:N(`elapsed`),total:N(`total`),seek:N(`seek`),canvas:N(`spectrum`),glyphs:N(`glyphs`),levels:N(`levels`),playlist:N(`playlist`),playlistTitle:N(`playlist-panel`),note:N(`note`),files:N(`files`),folder:N(`folder`),remoteUrl:N(`remote-url`),remoteForm:N(`remote-form`),remoteState:N(`remote-state`),disconnect:N(`disconnect`),listenHere:N(`listen-here`),volume:N(`volume`),prev:N(`prev`),playPause:N(`play-pause`),stop:N(`stop`),next:N(`next`)},r=`local`,i=[],a=0,o=h(),s=`idle`,l=``,u=`Pick files, or connect to a nixamp running somewhere else.`,f=!1,m=Array(24).fill(0),_=Array(24).fill(0),v=[],y=()=>r===`remote`&&!n.listenHere.checked,x=new p({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),K()},onEnded:()=>U(1),onState:()=>K(),onError:e=>{u=e,K()}}),w=new b({onSnapshot:e=>{o=e,y()&&(m=e.bars.length>0?e.bars:m,_=k(_,m)),K()},onStatus:(e,t)=>{s=e,l=t??``,K()}}),T=()=>r===`remote`?o.tracks.length:i.length,P=()=>r===`remote`?o.index:a,F=()=>{if(r===`remote`){let e=o.tracks[o.index];return e?t(e):`Nothing loaded.`}let e=i[a];return e?t(e):`Nothing loaded.`},I=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,L=()=>y()?o.tracks[o.index]?.duration??0:x.duration,R=()=>y()?o.position:x.position,z=()=>y()?o.playing:x.playing;async function B(e){if(r===`remote`){if(y()){await w.send({type:`play`,index:e});return}await w.send({type:`select`,index:e}),await V(e);return}let t=i[e];t&&(a=e,await x.load(t,!0),X(t.video),Z(),K())}async function V(e){let t=o.tracks[e];t&&(await x.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:w.media(e),video:!1,objectUrl:!1},!0),Z())}async function H(){if(y()){await w.send({type:`toggle`});return}T()!==0&&(x.playing?x.pause():x.position>0?await x.play():await B(P()),K())}async function U(e){let t=T();if(t!==0){if(y()){await w.send({type:e>0?`next`:`prev`});return}await B((P()+e+t)%t)}}async function W(){if(y()){await w.send({type:`stop`});return}x.stop(),m=Array(24).fill(0),_=[...m],K()}let G=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function K(){let t=T(),a=z();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=F(),n.album.textContent=I();let c=R(),d=L();n.elapsed.textContent=e(c),n.total.textContent=d>0?e(d):`--:--`,f||(n.seek.value=String(d>0?Math.round(c/d*1e3):0),n.seek.disabled=d<=0||y()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${w.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${l?` — ${l}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let p=r===`remote`&&o.note!==``?o.note:u;n.note.textContent=p,n.note.hidden=p===``,J(),n.glyphs.textContent=m.map(G).join(``);let[h,g]=y()?o.levels:x.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(g*6)).padEnd(6,`·`)}`}let q=``;function J(){let a=r===`remote`?o.tracks.map(e=>[t(e),e.duration]):i.map(e=>[t(e),e.duration]),s=`${r}:${a.map(([e,t])=>`${e}@${t}`).join(`|`)}`;s!==q&&(q=s,n.playlist.replaceChildren(...a.map(([t,n],r)=>{let i=document.createElement(`li`);i.className=`row`,i.dataset.index=String(r);let a=document.createElement(`span`);a.className=`n`,a.textContent=String(r+1).padStart(2,` `);let o=document.createElement(`span`);o.className=`name`,o.textContent=t;let s=document.createElement(`span`);return s.className=`time`,s.textContent=n>0?e(n):`--:--`,i.append(a,o,s),i})));let c=P(),l=z();Array.from(n.playlist.children).forEach((e,t)=>{let n=e;n.classList.toggle(`selected`,t===c),n.classList.toggle(`playing`,t===c&&l)}),n.playlist.children[c]?.scrollIntoView({block:`nearest`})}function Y(){let t=n.canvas,r=Math.min(2,globalThis.devicePixelRatio||1),i=Math.round(t.clientWidth*r),a=Math.round(t.clientHeight*r);i>0&&a>0&&(t.width!==i||t.height!==a)&&(t.width=i,t.height=a);let s=t.getContext(`2d`);if(y())_=k(_,m);else{let e=x.read();e.length>0&&(v.length!==25&&(v=E(24,e.length)),m=O(m,D(e,v)),_=k(_,m))}if(s){let e=getComputedStyle(document.documentElement);A(s,{width:t.width,height:t.height},m,_,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(z()){n.glyphs.textContent=m.map(G).join(``);let[t,r]=y()?o.levels:x.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(R());let i=L();!f&&i>0&&(n.seek.value=String(Math.round(R()/i*1e3)))}requestAnimationFrame(Y)}function X(e){n.video.hidden=!e}function Z(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:F(),album:I(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void H()),navigator.mediaSession.setActionHandler(`pause`,()=>void H()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void U(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void U(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&B(n)}),n.prev.addEventListener(`click`,()=>void U(-1)),n.next.addEventListener(`click`,()=>void U(1)),n.stop.addEventListener(`click`,()=>void W()),n.playPause.addEventListener(`click`,()=>void H()),n.seek.addEventListener(`input`,()=>{f=!0}),n.seek.addEventListener(`change`,()=>{let e=L();e>0&&x.seek(Number(n.seek.value)/1e3*e),f=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;x.volume=e;try{localStorage.setItem(M,String(e))}catch{}});let Q=e=>{e.addEventListener(`change`,()=>{let t=c(Array.from(e.files??[]));if(t.length===0){u=`Nothing playable in that selection.`,K();return}d(i),i=t,a=0,r=`local`,w.close(),u=``,B(0)})};Q(n.files),Q(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=g(n.remoteUrl.value);if(t===``){u=`That is not an address.`,K();return}(async()=>{if(s=`connecting`,K(),await C(t)===null){s=`error`,l=`no nixamp answered there`,r=`local`,K();return}r=`remote`,u=``;try{localStorage.setItem(j,t)}catch{}w.connect(t),K()})()}),n.disconnect.addEventListener(`click`,()=>{w.close(),r=`local`,s=`idle`,l=``,K()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await w.send({type:`stop`}),await V(o.index)):x.stop(),K()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),H();return;case`s`:W();return;case`n`:case`ArrowRight`:U(1);return;case`p`:case`ArrowLeft`:U(-1);return;case`ArrowDown`:e.preventDefault(),B(Math.min(T()-1,P()+1));return;case`ArrowUp`:e.preventDefault(),B(Math.max(0,P()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(M);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),x.volume=Number(e));let t=localStorage.getItem(j);t&&(n.remoteUrl.value=t)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await C(e)===null)return;let t=await S(e);t&&t.tracks.length!==0&&(n.remoteUrl.value=e,r=`remote`,u=``,w.connect(e),K())})(),K(),requestAnimationFrame(Y)}P(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});
Binary file
Binary file
@@ -0,0 +1,110 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>nixamp</title>
7
+ <meta name="description" content="nixamp — it really whips the terminal's ass. A Winamp-shaped player for your files and for the nixamp running on your other machine." />
8
+ <link rel="manifest" href="/manifest.webmanifest" />
9
+ <meta name="theme-color" content="#080c09" />
10
+ <link rel="icon" href="/icons/icon-192.png" type="image/png" />
11
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
12
+ <meta name="apple-mobile-web-app-capable" content="yes" />
13
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
14
+ <meta name="apple-mobile-web-app-title" content="nixamp" />
15
+ <meta name="mobile-web-app-capable" content="yes" />
16
+ <meta property="og:title" content="nixamp" />
17
+ <meta property="og:description" content="It really whips the terminal's ass." />
18
+ <meta property="og:type" content="website" />
19
+ <script type="module" crossorigin src="/assets/index-Dhja5wxB.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BGKWWaIx.css">
21
+ </head>
22
+ <body>
23
+ <main id="app">
24
+ <header class="bar">
25
+ <span class="brand">⣿ NIXAMP</span>
26
+ <span id="status" class="status" data-playing="false">■ STOPPED</span>
27
+ <span id="source" class="chip">no source</span>
28
+ <button id="install" type="button" class="ghost" hidden>Install</button>
29
+ </header>
30
+
31
+ <section class="panel" data-title="Now Playing">
32
+ <video id="video" playsinline hidden></video>
33
+ <audio id="audio"></audio>
34
+ <div id="title-line" class="track-title">Nothing loaded.</div>
35
+ <div id="album-line" class="track-sub">—</div>
36
+ <div class="scrub">
37
+ <span id="elapsed" class="time">00:00</span>
38
+ <input id="seek" type="range" min="0" max="1000" value="0" step="1" aria-label="Seek" disabled />
39
+ <span id="total" class="time muted">--:--</span>
40
+ </div>
41
+ </section>
42
+
43
+ <div class="split">
44
+ <section class="panel" data-title="Spectrum Analyser">
45
+ <canvas id="spectrum" aria-hidden="true"></canvas>
46
+ <div class="meters">
47
+ <span id="glyphs" class="glyphs"></span>
48
+ <span id="levels" class="levelmeter">L······ R······</span>
49
+ </div>
50
+ </section>
51
+
52
+ <section class="panel" id="playlist-panel" data-title="Playlist (0)">
53
+ <ol id="playlist" class="playlist"></ol>
54
+ </section>
55
+ </div>
56
+
57
+ <section class="transport">
58
+ <button id="prev" type="button" aria-label="Previous">|◀◀</button>
59
+ <button id="play-pause" type="button" class="primary" aria-label="Play">▶</button>
60
+ <button id="stop" type="button" aria-label="Stop">■</button>
61
+ <button id="next" type="button" aria-label="Next">▶▶|</button>
62
+ <label class="volume">
63
+ <span aria-hidden="true" class="vol">VOL</span>
64
+ <input id="volume" type="range" min="0" max="100" value="100" aria-label="Volume" />
65
+ </label>
66
+ </section>
67
+
68
+ <div class="split">
69
+ <section class="panel" data-title="Files">
70
+ <p class="hint">Nothing is uploaded. The browser decodes your files where they are.</p>
71
+ <div class="picker">
72
+ <label class="button">
73
+ Open files
74
+ <input id="files" type="file" multiple accept="audio/*,video/*" />
75
+ </label>
76
+ <label class="button">
77
+ Open folder
78
+ <input id="folder" type="file" multiple webkitdirectory directory />
79
+ </label>
80
+ </div>
81
+ </section>
82
+
83
+ <section class="panel" data-title="Remote">
84
+ <p class="hint">Run <code>nixamp serve ~/Music --host 0.0.0.0</code> and drive it from here.</p>
85
+ <form id="remote-form" class="picker">
86
+ <input id="remote-url" type="text" inputmode="url" placeholder="192.168.1.7:4321" aria-label="nixamp server address" />
87
+ <button type="submit" class="button">Connect</button>
88
+ <button id="disconnect" type="button" class="ghost" hidden>Disconnect</button>
89
+ </form>
90
+ <label class="check">
91
+ <input id="listen-here" type="checkbox" />
92
+ Listen on this device
93
+ </label>
94
+ <p class="hint">Status: <span id="remote-state" data-status="idle">not connected</span></p>
95
+ </section>
96
+ </div>
97
+
98
+ <p id="note" class="note" hidden></p>
99
+
100
+ <footer class="statusbar">
101
+ <span><b>Space</b> Play</span>
102
+ <span><b>↑↓</b> Select</span>
103
+ <span><b>n/p</b> Next/Prev</span>
104
+ <span><b>s</b> Stop</span>
105
+ <span class="spacer"></span>
106
+ <a href="https://github.com/profullstack/nixamp">github.com/profullstack/nixamp</a>
107
+ </footer>
108
+ </main>
109
+ </body>
110
+ </html>
@@ -0,0 +1,299 @@
1
+ #!/bin/sh
2
+ # nixamp installer.
3
+ #
4
+ # curl -fsSL https://nixamp.com/install.sh | sh
5
+ #
6
+ # Installs the CLI always, and the desktop app when this machine has a desktop
7
+ # to run it on. Everything lands under your home directory: no root, no package
8
+ # manager, no system files touched. Updating is `nixamp update` and removing is
9
+ # `nixamp uninstall`, which runs a script this installer leaves behind.
10
+ #
11
+ # sh -s -- --cli-only never install the desktop app
12
+ # sh -s -- --desktop install it even with no desktop session detected
13
+ # sh -s -- --version X install a specific release
14
+ # sh -s -- --prefix DIR install root (default: ~/.local)
15
+ set -eu
16
+
17
+ REPO="profullstack/nixamp"
18
+ SITE="${NIXAMP_SITE:-https://nixamp.com}"
19
+ PREFIX="${NIXAMP_PREFIX:-$HOME/.local}"
20
+ VERSION="${NIXAMP_VERSION:-}"
21
+ WANT_DESKTOP=auto
22
+
23
+ while [ $# -gt 0 ]; do
24
+ case "$1" in
25
+ --cli-only) WANT_DESKTOP=no ;;
26
+ --desktop) WANT_DESKTOP=yes ;;
27
+ --version) VERSION="${2:?--version needs a value}"; shift ;;
28
+ --prefix) PREFIX="${2:?--prefix needs a value}"; shift ;;
29
+ -h|--help) sed -n '2,15p' "$0" 2>/dev/null || echo "See $SITE"; exit 0 ;;
30
+ *) echo "unknown option: $1" >&2; exit 64 ;;
31
+ esac
32
+ shift
33
+ done
34
+
35
+ say() { printf '%s\n' "$*"; }
36
+ fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
37
+
38
+ # --- what are we on -----------------------------------------------------------
39
+
40
+ case "$(uname -s)" in
41
+ Linux) OS=linux ;;
42
+ Darwin) OS=darwin ;;
43
+ *) fail "unsupported operating system: $(uname -s). nixamp supports Linux and macOS." ;;
44
+ esac
45
+
46
+ case "$(uname -m)" in
47
+ x86_64|amd64) ARCH=x64 ;;
48
+ arm64|aarch64) ARCH=arm64 ;;
49
+ *) fail "unsupported architecture: $(uname -m)." ;;
50
+ esac
51
+
52
+ if command -v curl >/dev/null 2>&1; then
53
+ fetch() { curl -fsSL "$1"; }
54
+ download(){ curl -fsSL --progress-bar -o "$2" "$1"; }
55
+ elif command -v wget >/dev/null 2>&1; then
56
+ fetch() { wget -qO- "$1"; }
57
+ download(){ wget -q --show-progress -O "$2" "$1"; }
58
+ else
59
+ fail "curl or wget is required."
60
+ fi
61
+
62
+ command -v tar >/dev/null 2>&1 || fail "tar is required."
63
+
64
+ # A desktop app needs a desktop. Over SSH into a server there is nothing to
65
+ # show it on, and pulling 100MB of Electron onto a box that will never run it
66
+ # is not a kindness.
67
+ has_desktop_session() {
68
+ [ "$OS" = darwin ] && return 0
69
+ [ -n "${DISPLAY:-}" ] && return 0
70
+ [ -n "${WAYLAND_DISPLAY:-}" ] && return 0
71
+ [ -n "${XDG_CURRENT_DESKTOP:-}" ] && return 0
72
+ return 1
73
+ }
74
+
75
+ if [ "$WANT_DESKTOP" = auto ]; then
76
+ if has_desktop_session; then WANT_DESKTOP=yes; else WANT_DESKTOP=no; fi
77
+ fi
78
+
79
+ # --- which release ------------------------------------------------------------
80
+
81
+ if [ -z "$VERSION" ]; then
82
+ VERSION=$(fetch "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \
83
+ | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1)
84
+ [ -n "$VERSION" ] || fail "could not determine the latest version. Pass --version, or see $SITE"
85
+ fi
86
+
87
+ # Overridable so the installer can be exercised against a local release
88
+ # rather than only in production, which is the one place you cannot rehearse.
89
+ BASE="${NIXAMP_RELEASE_BASE:-https://github.com/$REPO/releases/download/v$VERSION}"
90
+ SHARE="$PREFIX/share/nixamp"
91
+ BIN="$PREFIX/bin"
92
+
93
+ say "nixamp $VERSION"
94
+ say " platform: $OS-$ARCH"
95
+ say " desktop: $WANT_DESKTOP"
96
+ say " prefix: $PREFIX"
97
+ say ""
98
+
99
+ WORK=$(mktemp -d)
100
+ trap 'rm -rf "$WORK"' EXIT INT TERM
101
+
102
+ mkdir -p "$SHARE" "$BIN"
103
+ PATHS="$BIN/nixamp
104
+ $SHARE"
105
+ METHOD=cli-tarball
106
+
107
+ # --- the desktop app ----------------------------------------------------------
108
+
109
+ if [ "$WANT_DESKTOP" = yes ]; then
110
+ if [ "$OS" = linux ]; then
111
+ ASSET="nixamp-$VERSION-linux-$ARCH.tar.gz"
112
+ say "Downloading the desktop app..."
113
+ if download "$BASE/$ASSET" "$WORK/app.tar.gz"; then
114
+ rm -rf "$SHARE/app"
115
+ mkdir -p "$SHARE/app"
116
+ # --strip-components=1: the archive holds a single top-level directory.
117
+ tar -xzf "$WORK/app.tar.gz" -C "$SHARE/app" --strip-components=1
118
+ METHOD=linux-app
119
+
120
+ # Electron needs a sandbox it is allowed to use. Where the SUID helper
121
+ # did not survive the tarball, and unprivileged user namespaces are
122
+ # restricted, starting without the sandbox beats not starting at all.
123
+ cat > "$SHARE/app/launch.sh" <<'LAUNCH'
124
+ #!/bin/sh
125
+ set -eu
126
+ here="$(cd "$(dirname "$0")" && pwd)"
127
+ app="$here/nixamp"
128
+ if [ -u "$here/chrome-sandbox" ]; then
129
+ exec "$app" "$@"
130
+ fi
131
+ if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || echo 0)" = "1" ] \
132
+ || ! unshare --user true >/dev/null 2>&1; then
133
+ echo "nixamp: no usable Chromium sandbox here; starting without it." >&2
134
+ exec "$app" --no-sandbox "$@"
135
+ fi
136
+ exec "$app" "$@"
137
+ LAUNCH
138
+ chmod 0755 "$SHARE/app/launch.sh"
139
+
140
+ # A launcher, an icon and a menu entry: what a .deb would give you, done
141
+ # by hand because doing it by hand needs no root.
142
+ mkdir -p "$PREFIX/share/applications" "$PREFIX/share/icons/hicolor/512x512/apps"
143
+ [ -f "$SHARE/app/resources/icon.png" ] &&
144
+ cp "$SHARE/app/resources/icon.png" "$PREFIX/share/icons/hicolor/512x512/apps/nixamp.png"
145
+
146
+ cat > "$PREFIX/share/applications/nixamp.desktop" <<DESKTOP
147
+ [Desktop Entry]
148
+ Type=Application
149
+ Name=nixamp
150
+ GenericName=Media Player
151
+ Comment=It really whips the terminal's ass.
152
+ Exec=$SHARE/app/launch.sh %U
153
+ Icon=nixamp
154
+ Terminal=false
155
+ StartupWMClass=nixamp
156
+ Categories=AudioVideo;Audio;Player;
157
+ Keywords=music;audio;player;winamp;terminal;
158
+ DESKTOP
159
+
160
+ command -v update-desktop-database >/dev/null 2>&1 &&
161
+ update-desktop-database "$PREFIX/share/applications" >/dev/null 2>&1 || true
162
+
163
+ PATHS="$PATHS
164
+ $PREFIX/share/applications/nixamp.desktop
165
+ $PREFIX/share/icons/hicolor/512x512/apps/nixamp.png"
166
+ else
167
+ say " the desktop app is not published for $OS-$ARCH at $VERSION; installing the CLI only."
168
+ WANT_DESKTOP=no
169
+ fi
170
+
171
+ else
172
+ ASSET="nixamp-$VERSION-mac-$ARCH.zip"
173
+ say "Downloading the desktop app..."
174
+ if command -v unzip >/dev/null 2>&1 && download "$BASE/$ASSET" "$WORK/app.zip"; then
175
+ APPS="$HOME/Applications"
176
+ mkdir -p "$APPS"
177
+ rm -rf "$APPS/nixamp.app"
178
+ unzip -q "$WORK/app.zip" -d "$APPS"
179
+ METHOD=macos-app
180
+ PATHS="$PATHS
181
+ $APPS/nixamp.app"
182
+ else
183
+ say " the desktop app could not be installed; installing the CLI only."
184
+ WANT_DESKTOP=no
185
+ fi
186
+ fi
187
+ fi
188
+
189
+ # --- the CLI ------------------------------------------------------------------
190
+ #
191
+ # A desktop install already contains it, and that copy runs on the Node inside
192
+ # Electron, so no system Node is needed. Without the desktop, the CLI comes as
193
+ # its own bundle and does need one.
194
+
195
+ if [ "$METHOD" = linux-app ]; then
196
+ CLI_DIR="$SHARE/app/resources/cli"
197
+ RUNTIME="$SHARE/app/nixamp"
198
+ elif [ "$METHOD" = macos-app ]; then
199
+ CLI_DIR="$HOME/Applications/nixamp.app/Contents/Resources/cli"
200
+ RUNTIME="$HOME/Applications/nixamp.app/Contents/MacOS/nixamp"
201
+ else
202
+ # Pure JavaScript, so one bundle runs everywhere a Node does.
203
+ ASSET="nixamp-cli-$VERSION.tar.gz"
204
+ say "Downloading the CLI..."
205
+ download "$BASE/$ASSET" "$WORK/cli.tar.gz" || fail "could not download $BASE/$ASSET"
206
+
207
+ rm -rf "$SHARE/cli"
208
+ mkdir -p "$SHARE/cli"
209
+ tar -xzf "$WORK/cli.tar.gz" -C "$SHARE/cli" --strip-components=1
210
+ CLI_DIR="$SHARE/cli"
211
+ RUNTIME=""
212
+
213
+ command -v node >/dev/null 2>&1 ||
214
+ say " note: no desktop app was installed, so the CLI needs Node 24 or newer. It was not found."
215
+ fi
216
+
217
+ # The shim. Written here rather than shipped, because only the installer knows
218
+ # which of the two runtimes this machine ended up with.
219
+ if [ -n "$RUNTIME" ]; then
220
+ cat > "$BIN/nixamp" <<SHIM
221
+ #!/bin/sh
222
+ # nixamp. Runs on the Node inside the desktop app, so no system Node is
223
+ # required. Written by the installer; \`nixamp uninstall\` removes it.
224
+ NIXAMP_HOME="$SHARE" ELECTRON_RUN_AS_NODE=1 exec "$RUNTIME" "$CLI_DIR/bin/nixamp.mjs" "\$@"
225
+ SHIM
226
+ else
227
+ cat > "$BIN/nixamp" <<SHIM
228
+ #!/bin/sh
229
+ # nixamp. Written by the installer; \`nixamp uninstall\` removes it.
230
+ command -v node >/dev/null 2>&1 || {
231
+ echo "nixamp: node 24 or newer is required for a CLI-only install." >&2
232
+ exit 69
233
+ }
234
+ NIXAMP_HOME="$SHARE" exec node "$CLI_DIR/bin/nixamp.mjs" "\$@"
235
+ SHIM
236
+ fi
237
+ chmod 0755 "$BIN/nixamp"
238
+
239
+ # --- what was installed, and how to remove it ---------------------------------
240
+
241
+ INSTALLED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
242
+ DESKTOP_FLAG=false
243
+ [ "$WANT_DESKTOP" = yes ] && DESKTOP_FLAG=true
244
+
245
+ {
246
+ printf '{\n'
247
+ printf ' "version": "%s",\n' "$VERSION"
248
+ printf ' "method": "%s",\n' "$METHOD"
249
+ printf ' "installer": "%s/install.sh",\n' "$SITE"
250
+ printf ' "installedAt": "%s",\n' "$INSTALLED_AT"
251
+ printf ' "prefix": "%s",\n' "$PREFIX"
252
+ printf ' "desktop": %s,\n' "$DESKTOP_FLAG"
253
+ printf ' "paths": [\n'
254
+ printf '%s\n' "$PATHS" | sed 's/.*/ "&",/' | sed '$ s/,$//'
255
+ printf ' ]\n}\n'
256
+ } > "$SHARE/manifest.json"
257
+
258
+ {
259
+ echo '#!/bin/sh'
260
+ echo '# Removes nixamp. Written by the installer, which knew exactly what it created.'
261
+ echo '# Your music is NOT touched.'
262
+ echo 'set -eu'
263
+ printf '%s\n' "$PATHS" | sed 's|.*|rm -rf "&"|'
264
+ echo 'echo "nixamp removed."'
265
+ } > "$SHARE/uninstall.sh"
266
+ chmod 0755 "$SHARE/uninstall.sh"
267
+
268
+ # --- report -------------------------------------------------------------------
269
+
270
+ say ""
271
+ say "Installed nixamp $VERSION"
272
+ if [ "$WANT_DESKTOP" = yes ]; then say " desktop app and CLI"; else say " CLI only (no desktop session detected)"; fi
273
+
274
+ # ffmpeg decodes every track. Saying so now beats a confusing failure on first
275
+ # use, when the playlist loads and nothing comes out of the speakers.
276
+ if ! command -v ffmpeg >/dev/null 2>&1; then
277
+ say ""
278
+ say " ffmpeg was not found, and nixamp decodes with ffmpeg."
279
+ say " Debian/Ubuntu: sudo apt install ffmpeg"
280
+ say " macOS: brew install ffmpeg"
281
+ fi
282
+
283
+ case ":$PATH:" in
284
+ *":$BIN:"*)
285
+ say ""
286
+ say "Try: nixamp ~/Music"
287
+ ;;
288
+ *)
289
+ say ""
290
+ say "$BIN is not on your PATH. Add it:"
291
+ say " echo 'export PATH=\"$BIN:\$PATH\"' >> ~/.profile && . ~/.profile"
292
+ say ""
293
+ say "Or run it directly: $BIN/nixamp ~/Music"
294
+ ;;
295
+ esac
296
+
297
+ say ""
298
+ say "Update with \`nixamp update\`, remove with \`nixamp uninstall\`."
299
+ say "Docs: $SITE"
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "nixamp — it really whips the terminal's ass",
3
+ "short_name": "nixamp",
4
+ "description": "A Winamp-shaped player for your own files, and a remote for the nixamp running on your other machine.",
5
+ "id": "/",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "display_override": ["standalone", "minimal-ui"],
10
+ "orientation": "any",
11
+ "theme_color": "#080c09",
12
+ "background_color": "#080c09",
13
+ "categories": ["music", "entertainment", "utilities"],
14
+ "lang": "en",
15
+ "dir": "ltr",
16
+ "icons": [
17
+ {
18
+ "src": "/icons/icon-192.png",
19
+ "sizes": "192x192",
20
+ "type": "image/png",
21
+ "purpose": "any"
22
+ },
23
+ {
24
+ "src": "/icons/icon-512.png",
25
+ "sizes": "512x512",
26
+ "type": "image/png",
27
+ "purpose": "any"
28
+ },
29
+ {
30
+ "src": "/icons/icon-192-maskable.png",
31
+ "sizes": "192x192",
32
+ "type": "image/png",
33
+ "purpose": "maskable"
34
+ },
35
+ {
36
+ "src": "/icons/icon-512-maskable.png",
37
+ "sizes": "512x512",
38
+ "type": "image/png",
39
+ "purpose": "maskable"
40
+ }
41
+ ],
42
+ "shortcuts": [
43
+ {
44
+ "name": "Open files",
45
+ "short_name": "Files",
46
+ "url": "/?source=files",
47
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
48
+ }
49
+ ]
50
+ }
package/web/dist/sw.js ADDED
@@ -0,0 +1,91 @@
1
+ /* nixamp service worker — generated, do not edit */
2
+ const CACHE = "nixamp-1788892198880";
3
+ const PRECACHE = [
4
+ "/",
5
+ "/apple-touch-icon.png",
6
+ "/assets/index-BGKWWaIx.css",
7
+ "/assets/index-Dhja5wxB.js",
8
+ "/icons/icon-192-maskable.png",
9
+ "/icons/icon-192.png",
10
+ "/icons/icon-512-maskable.png",
11
+ "/icons/icon-512.png",
12
+ "/index.html",
13
+ "/manifest.webmanifest"
14
+ ];
15
+ const NEVER_CACHE = ["/api/"];
16
+
17
+ self.addEventListener("install", (event) => {
18
+ event.waitUntil((async () => {
19
+ const cache = await caches.open(CACHE);
20
+ // One miss must not fail the whole install, or a single 404 leaves the app
21
+ // with no offline shell at all.
22
+ await Promise.all(PRECACHE.map((url) => cache.add(new Request(url, { cache: "reload" })).catch(() => {})));
23
+ await self.skipWaiting();
24
+ })());
25
+ });
26
+
27
+ self.addEventListener("activate", (event) => {
28
+ event.waitUntil((async () => {
29
+ for (const key of await caches.keys()) if (key !== CACHE) await caches.delete(key);
30
+ await self.clients.claim();
31
+ })());
32
+ });
33
+
34
+ self.addEventListener("message", (event) => {
35
+ if (event.data === "skip-waiting") self.skipWaiting();
36
+ });
37
+
38
+ self.addEventListener("fetch", (event) => {
39
+ const request = event.request;
40
+ if (request.method !== "GET") return;
41
+ const url = new URL(request.url);
42
+ if (url.origin !== self.location.origin) return;
43
+ // The control API and the media it streams are live state, never a cache.
44
+ if (NEVER_CACHE.some((prefix) => url.pathname.startsWith(prefix))) return;
45
+
46
+ if (request.mode === "navigate") {
47
+ // Network first, so a deploy lands; the shell answers when there is no network.
48
+ event.respondWith((async () => {
49
+ try {
50
+ const fresh = await fetch(request);
51
+ const cache = await caches.open(CACHE);
52
+ cache.put("/index.html", fresh.clone());
53
+ return fresh;
54
+ } catch {
55
+ return (await caches.match("/index.html")) ?? (await caches.match("/")) ?? Response.error();
56
+ }
57
+ })());
58
+ return;
59
+ }
60
+
61
+ // Everything else is a hashed asset or an icon: cache first, refreshed behind.
62
+ event.respondWith((async () => {
63
+ const cached = await caches.match(request);
64
+ if (cached) {
65
+ event.waitUntil(refresh(request));
66
+ return cached;
67
+ }
68
+ try {
69
+ const fresh = await fetch(request);
70
+ if (fresh.ok && fresh.type === "basic") {
71
+ const cache = await caches.open(CACHE);
72
+ cache.put(request, fresh.clone());
73
+ }
74
+ return fresh;
75
+ } catch {
76
+ return Response.error();
77
+ }
78
+ })());
79
+ });
80
+
81
+ async function refresh(request) {
82
+ try {
83
+ const fresh = await fetch(request);
84
+ if (fresh.ok && fresh.type === "basic") {
85
+ const cache = await caches.open(CACHE);
86
+ await cache.put(request, fresh);
87
+ }
88
+ } catch {
89
+ /* offline: the cached copy stands */
90
+ }
91
+ }