iOS: the app's settings in Settings, not behind a shake; iPhone upright (#445)

The server address and the player token were set from alerts that
opened on shaking the phone, which nobody finds. Settings now has a This
device section in the app: the server, the player token and its
address, and a Make button that creates an API token and hands it
straight to the app's keychain. The app sends what it has at ready
(appSettings) and takes setServer, setToken and clearToken; a new
server rebuilds the web view as the alert did. The first-launch prompt
and the Cannot reach alert stay native, since there is no page then.

The iPhone is portrait only; the iPad keeps all four orientations, which
Split View and Slide Over need (#434 checked the iPad's layout).

Build 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-13 08:39:54 -05:00
parent 69f1a38e9f
commit 171c77475a
10 changed files with 142 additions and 61 deletions

View File

@@ -482,6 +482,7 @@ async function prefsModal(){
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
<span class="hint">Checked with TypeSafe before it is saved, kept on this server and never shown again.</span></div></div>`}
${deviceSettingsHTML()}
<div class="field boxed"><label for="tokname">API tokens</label>
<div id="tokens"></div>
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
@@ -498,6 +499,7 @@ async function prefsModal(){
$<HTMLSelectElement>('#sfont').onchange=(e: Event)=>setFont((e.target as HTMLSelectElement).value,true);
$<HTMLSelectElement>('#srfont').onchange=(e: Event)=>setReadingFont((e.target as HTMLSelectElement).value,true);
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
wireDeviceSettings();
drawTokens();
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }

View File

@@ -57,6 +57,64 @@ function haptic(kind: 'tap'|'done'|'warn'){
// Here, after ipxHost exists: in the app the button waits for the host to say it has a picker.
paintRoute();
/// The app's own settings (#445): which server it opens, and the player token it sends instead of
/// the page's cookie (#419). They were in an alert that opened on shaking the phone, which nobody
/// found. The app sends them at ready and after each change; null in a browser, or in an app that
/// keeps them elsewhere, and then Settings shows nothing for them.
type AppSettings = {server: string, api: string, tokenSet: boolean, saved?: boolean};
let appSettings: AppSettings|null=null;
function hostAppSettings(m: AppSettings){
appSettings=m;
if(m.saved) toast('Saved on this device.');
// Settings open: drawn again, so "Not set" turns to "Set" under the person's eyes.
const box=$('#sdevice');
if(box){ box.outerHTML=deviceSettingsHTML(); wireDeviceSettings(); }
}
function deviceSettingsHTML(){
const a=appSettings;
if(!a) return '';
return `<div class="field boxed" id="sdevice"><label>This device</label>
<span class="hint">The iPX app's own settings, kept on this phone.</span>
<label for="sserver">Server</label>
<div class="inline"><input type="text" inputmode="url" id="sserver" value="${esc(a.server)}" autocapitalize="off" autocomplete="off" spellcheck="false">
<button class="btn" id="sserverset">Open</button></div>
<span class="hint">The address the app opens. Plain http only works on your own network, such as 192.168.1.130:8099.</span>
<label for="sptoken">Player token</label>
<div class="inline"><input type="password" id="sptoken" autocomplete="off" spellcheck="false"
placeholder="${a.tokenSet?'Set':'Not set'}">
<button class="btn" id="sptokenmake" title="Make a token for this device and save it" aria-label="Make a player token">${ICON.plus} Make</button>
<button class="btn ico" id="sptokenclear" title="Clear the player token" aria-label="Clear the player token"${a.tokenSet?'':' hidden'}>${ICON.trash}</button></div>
<label for="sapi">Player address</label>
<div class="inline"><input type="text" inputmode="url" id="sapi" value="${esc(a.api)}" autocapitalize="off" autocomplete="off" spellcheck="false">
<button class="btn" id="sptokensave">Save</button></div>
<span class="hint">With a token, playback and CarPlay keep working when this page's sign-in
ends. Make one here, or paste one from API tokens below. It is kept in the phone's keychain.</span></div>`;
}
function wireDeviceSettings(){
if(!appSettings||!ipxHost) return;
const host=ipxHost;
$<HTMLButtonElement>('#sserverset').onclick=()=>{
const url=$<HTMLInputElement>('#sserver').value.trim();
// The app checks it, and on a new server loads it in place of this page.
if(url&&url!==appSettings?.server) host({t:'setServer', url});
};
const api_=()=>$<HTMLInputElement>('#sapi').value.trim();
$<HTMLButtonElement>('#sptokensave').onclick=()=>{
const token=$<HTMLInputElement>('#sptoken').value.trim();
host({t:'setToken', api:api_(), ...(token?{token}:{})});
};
$<HTMLButtonElement>('#sptokenmake').onclick=async()=>{
try{
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name:'iPX app on a phone'})});
host({t:'setToken', api:api_(), token:t.token});
drawTokens();
}catch(err){ toast(why(err),true); }
};
$<HTMLButtonElement>('#sptokenclear').onclick=()=>host({t:'clearToken'});
}
function installNativePlayback(){
const post = ipxHost!;
const M = HTMLMediaElement.prototype;
@@ -207,6 +265,8 @@ function installNativePlayback(){
if(m.t==='host'){ hostCan.clear(); for(const c of m.can||[]) hostCan.add(c); paintRoute(); return; }
if(m.t==='sleep'||m.t==='slept'){ hostSleep(m); return; }
if(m.t==='push'){ hostPush(m); return; }
if(m.t==='appSettings'){ hostAppSettings(m); return; }
if(m.t==='appSettingsError'){ toast(m.message||'The app did not take that.', true); return; }
if(m.t==='nowPlaying'){ adopt(m); return; }
if(!N.on) return;
switch(m.t){