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.
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/bin/nixamp.mjs +7 -0
- package/dist/audio.d.ts +60 -0
- package/dist/audio.js +206 -0
- package/dist/fft.d.ts +52 -0
- package/dist/fft.js +154 -0
- package/dist/main.d.ts +47 -0
- package/dist/main.js +307 -0
- package/dist/manage.d.ts +25 -0
- package/dist/manage.js +117 -0
- package/dist/meta.d.ts +2 -0
- package/dist/meta.js +12 -0
- package/dist/playlist.d.ts +11 -0
- package/dist/playlist.js +66 -0
- package/dist/protocol.d.ts +56 -0
- package/dist/protocol.js +53 -0
- package/dist/serve.d.ts +1 -0
- package/dist/serve.js +10 -0
- package/dist/server.d.ts +105 -0
- package/dist/server.js +617 -0
- package/package.json +65 -0
- package/src/audio.ts +233 -0
- package/src/fft.ts +165 -0
- package/src/main.ts +311 -0
- package/src/manage.ts +134 -0
- package/src/meta.ts +12 -0
- package/src/playlist.ts +66 -0
- package/src/protocol.ts +91 -0
- package/src/serve.ts +11 -0
- package/src/server.ts +649 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-BGKWWaIx.css +1 -0
- package/web/dist/assets/index-Dhja5wxB.js +1 -0
- package/web/dist/icons/icon-192-maskable.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/index.html +110 -0
- package/web/dist/install.sh +299 -0
- package/web/dist/manifest.webmanifest +50 -0
- package/web/dist/sw.js +91 -0
|
@@ -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
|
|
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
|
+
}
|