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

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- In the iOS app, Settings has a This device section for the app's server and player token, with a button that makes a token and hands it to the app (#445).
- In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433).
- In the iOS app, the player bar shows what CarPlay plays, so it can be paused on the phone after leaving the car, including when the app was opened by the car (#420, #442).
- In the iOS app, the icon's unread count follows the page's: reading and new items change it at once, not the next time the app is opened (#421).

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){