Commit Graph

140 Commits

Author SHA1 Message Date
84b4203daf A feed's page follows its row after a check (#316)
The header was drawn once, on opening the feed. feed_row put the new row in S.feeds and redrew the
feed list but not the open page, so a check that cleared an error left the error showing, with the
old "checked" time, until the feed was opened again. Seen on Eli Bendersky's website: a connect
timeout at 15:13, a clean forced check at 15:28, and the timeout still on the page.

Only the stat line and the error banner are redrawn: the whole page would close the open item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:37:19 +00:00
0696583547 Reduced motion, touch hover, overscroll and artwork edges (#310 #311 #312 #313 #314 #315)
Fixes from the UI-polish review of the main window (2026-10-19).

Reduced motion took every animation away, and a turning icon was the only sign a check was
running. The refresh buttons now carry aria-busy and a "Checking for new items" label while
checking, and under reduced motion spinners pulse and toasts and sheets fade.

Every :hover rule is behind @media (hover:hover), since Safari keeps :hover on the last thing
tapped; the two phone resets that undid it for .btn and .ep had nothing left to do and are gone.
An item row's actions still show on hover without the query, so a tablet with no pointer can
reach them by tapping the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:16:41 +00:00
6ec2c35291 Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:12:34 +00:00
4c1b78408f An x in a search box clears it (#291)
Once something is typed: at the box's right end, inside it. One click
handler for every box, which clears it and fires its input event, so each
box's own handler brings its list back whole. The browser's own x, drawn
by some browsers and not others, gives way to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:55:25 +00:00
b4c0048f68 The HTML and Markdown choice drops the Markdown source view (#285)
Rendered Markdown is the one wanted beside the publisher's HTML. A browser
that had chosen the source view shows the HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:45:18 +00:00
d5ffe5d042 Switch an item between its HTML and its Markdown (#285)
A feed that sends <source:markdown> beside its HTML (Scripting News) has its
Markdown kept in entries.markdown, added to existing databases by an ALTER.
A stored item gets it when a scan finds it told differently, the Markdown
counting as a change, the way corrections arrive (#141). The API sends it as
written and made HTML, cleaned by ammonia like the description.

The item's page has a choice between its meta line and its buttons: HTML,
Markdown or Markdown source, remembered in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:39:16 +00:00
5cf7a28c44 An OPML's page lists its shows' items together, not the shows (#283)
GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.

The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:41:38 +00:00
7f61250852 Keep is a button on the show's page, a plus, not a switch in its settings (#280)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:02:55 +00:00
2c4ba7896e Keep a show from an OPML when the OPML drops it (#280)
A show inside a subscribed OPML went when the OPML stopped listing it
(orphaned only if it had downloads), and went for you when you left the
OPML. Keep, in its settings, is a per-person flag on the subscription:
when the OPML drops a kept show, or nobody subscribes to the OPML any more,
it becomes a catalogue entry outside the OPML, taking the OPML's settings
while it is there, and stays on the lists of those who kept it. Whoever had
it only through the OPML loses it as before. Leaving the OPML yourself
leaves what you kept on your list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:37:01 +00:00
1aef6ba421 Browser suite: the server's TypeSafe key is the shell's, and Signals' tests run only when Signals change (#271)
/src/.envrc's TYPESAFE_KEY is a development key, so the test daemon uses it
and the server paying for Signals is tested end to end. Signals' tests spend
it, so each runs only when the Signals code or the test itself has changed
since it last passed (a hash kept under target/signals-tests);
IPX_SIGNALS_TESTS=1 runs them anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:27:41 +00:00
9a41641eaa Signals: an admin chooses whether the server's TypeSafe key pays for everyone (#271)
With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev
about everyone's Signals on the server's key; otherwise each person turns
Signals on in Settings with a key of their own, which is now checked with
TypeSafe before it is saved. The Signals folder shows only when they are on.

The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture
feeds were sent to the real TypeSafe to be categorised; it is blanked now,
and an empty key counts as none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:18:03 +00:00
f1a156c974 A Signal with no key links to typesafe.ai and to the key's field in Settings (#266)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:32:57 +00:00
0c2c3a379e No + on the Signals folder's row, so it is like any folder's (#265)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:22:29 +00:00
b18c92d1f5 A Signal's own icon, from all of Font Awesome Free, searchable (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:36:08 +00:00
ce485a82ac Signals judge one subject, with a sentence of your own, not any one word (#259)
Jev was asked "Is this item about any of these: ai, development, programming?", so any one broad
word was enough and the Signal's name, which says what is meant, was never sent. AI Development
held 54 items: Sega pulling a game remaster (0.90), a Surface Laptop review (0.95), a C++ tutorial
(0.99). It is now asked about one subject, called by the Signal's name, described by its words
and, when those are not enough, its owner's sentence. Scored against TypeSafe over those 54 with
"AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.":
5 stay, Linux kernel AI code review highest at 0.94; the remaster 0.06, the C++ tutorial 0.02.

signals.description is new, and its first start deletes every verdict, which answered the old
question, so everything in the window is asked again. Any change to a Signal's name, words or
sentence drops its verdicts, since each is part of the question. The queue query names the
Signal's sentence apart from the item's description, which share a row; the test caught the
item's winning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:12:29 +00:00
1ae91170fd Signal counts kept current on the page: after a read, and when Jev picks something out (#258)
The page fetched the Signals' unread counts only with the feed list, so reading an item, here or
in its own feed, left its Signal's badge as it was, and so did Jev adding one. The Apple Signal said
2 unread over a page with none. The page now fetches them again after a read or unread lands, and
on a new signal_hits event, which the judge sends once a pass for each person it found items for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:54:42 +00:00
20f53183ac Signals: Jev's request in the shape TypeSafe takes, and its reason kept when it refuses (#257)
Every call was refused with 422: a noul question's `criteria` is an object of what true and false
mean, and it was sent a sentence. The error kept only the status, from error_for_status, so the log
and the trace said 422 and not why; TypeSafe's body said "Input should be a valid dictionary"
at questions.3.noul.criteria. The body is now in the error and on the jev span, with the status.

A request refused as malformed (400, 422) ends the pass, since every other item's would be refused
the same way: the 50 warnings a pass were the same failure 50 times. The state is text now, as
TypeSafe's docs show it, rather than an object that sent null for a feed with no title, and the
questions are keyed signal_<id>. Checked against TypeSafe with the corrected request: 0.96 for an
item about the keywords, 0.02 for one that was not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:48:06 +00:00
8d93b58deb Signals folder under All Subscriptions, with its + on the folder's row (#256)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:39:04 +00:00
0fdd609791 Signals in a folder of their own in the feed list, as an OPML's feeds are (#255)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:14:45 +00:00
70990c70c7 Signals: feeds made from your subscriptions, picked by Jev (#254)
A Signal is a name and keywords. A task beside DeadAir's asks TypeSafe's Jev, on the owner's own
key, about each item of their subscribed feeds from the last 30 days and every new one: one call
an item, one noul question for each of their Signals still to hear about it, 0.5 or more a fit.
Every verdict is kept, fitting or not, in signal_verdicts, so nothing is asked twice, and new
keywords drop a Signal's verdicts to be judged afresh. A key TypeSafe refuses (401/402/403) is
passed over until its owner saves another, so a bad key is not sent the whole queue.

A Signal's items come through entries_where with an EXISTS on its hits, so filters, search, sort
and read state are an ordinary list's, and reading an item in a Signal reads it in its feed.

The page shows Signals under the places, with New signal, and Settings takes the TypeSafe key,
which is never sent back. The browser suite stubs Jev in the fixture server (IPX_TYPESAFE_URL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:54:50 +00:00
1949f3e493 Compression across the board: directory answers, uploads, metrics, traces, the page (#253)
Measured 2026-10-16, uncompressed until now: the directory's answers (the feed list ipx reads,
603 KB of JSON, ~134 ms of a 159 ms Directory request), ipx's snapshot upload (~100 KB each half
hour), both /metrics (4.7 and 0.9 KB every 15 s) and every trace export to Tempo. Already
compressed: ipx-web's answers (zstd, #239), the home page and the directory's public pages
through Cloudflare (brotli, zstd).

- The directory compresses with brotli, zstd or gzip as asked, and unpacks a gzipped request.
- ipx sends its snapshot gzipped; the directory is deployed first.
- Both /metrics answer gzipped, as Prometheus asks.
- opentelemetry-otlp's gzip-http: OTEL_EXPORTER_OTLP_COMPRESSION=gzip compresses the export, set
  for all three in production's compose file; Caddy's exporter reads the same variable.
- web/build.mjs writes .br (quality 11) and .gz (level 9) beside each text file, and Caddy serves
  them in place (file_server precompressed): app.js 30 KB where zstd on the fly gave 37, app.css
  11 where 14. A .br or .gz asked for by name is refused, or /admin.js.br would be the admin
  page's script without the check that keeps it to admins; in a handle of its own, since a bare
  respond runs after the handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:02:39 +00:00
cdf2c98f9f Currently Listening and the Directory open at once, or with a spinner (#248)
Both fetched on every visit and drew only when the answer came: Currently Listening took 1.3 s.
fetchPage keeps their answers by address, as the item lists are kept, sharing a request already
on its way; both draw from what is kept at once and again only if the refresh changed them, and
show the spinner with nothing kept. They are fetched ahead with the lists when the page loads,
and when the pointer or a finger reaches their row. Removing an episode from Currently Listening
forgets its kept copy, which would otherwise show it again until the refresh. The preload loop
takes a failed fetch as done instead of leaving an unhandled rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:24:25 +00:00
6640ca9612 Lists open at once from what the page keeps and fetches ahead, or with a spinner (#247)
A click on a feed, All Subscriptions or a filter fetched the list and only then drew it, so
nothing answered the click until the server did. The page now keeps the first page of each list
(the newest 40, by view, filter, sort and search), draws a kept one at once and refreshes it
behind, drawing again only if it changed. It fetches ahead: All Subscriptions and the twenty
feeds with the most unread once the page is idle, two at a time, and a feed or filter when the
pointer or a finger reaches it. A feed_row or a read forgets that feed's lists and All
Subscriptions'; the forgotten are fetched again behind. A list with nothing kept shows a
spinner if its answer takes more than 120 ms.

list_shown (#246) is reported when the list first shows, so a kept one reports its few ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:19 +00:00
a9991545f9 Tests: two people on one file, each with their own DeadAir version, place and playlist
The DeadAir tests named for each person's clock used one account. Now three play one file at
once (DeadAir at two pauses, and the original) and each keeps their own place; and two fetch
the same /media/{id}/deadair.m3u8 at once and each gets their own playlist, no-store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:04:47 +00:00
a5e64e520d Refresh buttons turn from the press to the end of their scan; quick checks keep the count (#232, #233)
The buttons followed the feeds being checked, so they stopped between one feed and the next;
7ecbe82 held them for 600 ms over each gap. Now pressing one starts it, and scan_done stops
it only when it is the end of the scan asked for: scan_done carries `only` and `forced`, so a
scheduled scan finishing first does not stop a button whose scan is queued behind it. An
error event, a job that failed without a scan_done, stops them all.

A row's spinner stood in for its unread count from the moment its feed was checked; most
checks take milliseconds, so each count blinked as the scan went by. It now waits 300 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:00:50 +00:00
954399ed5d Administer the directory from iPX with an rwa token (#206)
A Directory feed's page offers Check now and Remove to an admin whose token is rwa (the page
learns it from can_admin on the feed's page, asked of the directory only for admins). Admin,
Directory lists the servers sharing, from a new GET /v1/instances, and the blocked feeds by
title: a feed is often blocked because its address carries a key, so the address stays in the
directory. Unblocking or dropping a snapshot sends this server's snapshot again at once; since
directory's 403 reaches the page as 403, not 502.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:40:15 +00:00
38c1abba1d Each service its own version: ipx, ipx-directory, the page (#207)
The workspace stops holding one version. ipx keeps 0.12.1-dev and its vX.Y.Z tags; the directory
starts at 0.1.0-dev, tagged directory-vX.Y.Z, and the page at 0.1.0-dev in package.json, tagged
web-vX.Y.Z, which web/build.mjs gives the script for the logo's tooltip beside ipx's. crates/feed
is a library, not released. CLAUDE.md's release steps are for one service at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:35:41 +00:00
f9870938bf ipx-web: the page in its own container, behind Caddy (#203)
web/build.mjs builds a static site, web/dist, with the icons and typeface
beside the pages; Dockerfile.web serves it with Caddy, which passes /api
and /media to ipx on the same origin, so cookies, live updates and
Cloudflare Access work as before. ipx serves no page any more: build.rs
and every include_str! of the web files are gone, and its image is built
without node.

What ipx did to the page as it served it is kept another way. Caddy asks
ipx before serving a page (forward_auth): /api/page-check sends a browser
not signed in to /login and a non-admin away from /admin, and refuses them
/admin.js; /api/login-check sends someone Access vouches for past the
form; a page asked for with ?token= goes to /api/signin, which sets the
cookie. The theme and the admin link, once written into the page per
person, are set by web/boot.js in <head> from the theme cookie and the
last visit, before the first frame, and /api/me brings the version and
the account's theme from before.

Caddy strips the Cf-Access-* headers from every peer but TUNNEL_PEER, the
address cloudflared's requests come from, which is the boundary
trusted_proxies drew; ipx then trusts only Caddy's address.

The browser suite runs Caddy in front of its daemon, so it tests what
production would run. Not deployed: production's cutover is on #203.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:03:22 +00:00
c47aa57909 The Directory comes from the directory service (#205)
ipx asks ipx-directory on its people's behalf, with the token an admin set
in the new Admin, Directory tab, so the page never talks to it and never
sees the token or a feed's address. /api/directory, /api/popular and a
feed's Directory page answer from it; subscribing takes the address from
the directory and goes through Add feed's steps. With no directory set
up, the Directory says so. The local directory code is gone; its privacy
checks are the snapshot builder's now, and their tests moved with them.

With sharing on, ipx sends its snapshot after every scan, whenever the
config changes (set_cfg) and every half hour: each public feed and how
many here subscribe, never an OPML or Patreon creator itself or anything
private. A feed can be marked private in its settings, for a paid feed
whose address does not give it away; anyone subscribed may mark it, only
an admin unmark it. The admin's category for a feed that names none goes
with it, which the directory uses only while the feed names none, so the
category an admin gives a blog still reaches the Directory.

The directory lists only feeds it has read as a feed: the first snapshot
after a start went before the first scan, when an OPML was not yet known
to be one, and the directory listed the OPML. It now fetches what a
snapshot brings in at once instead of within the minute.

The browser suite runs its own ipx-directory on SQLite; the Directory's
tests match feeds by title, since the directory names them by address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:31:32 +00:00
fca704f7ff Add a feed no longer asks for a folder (#201)
A feed's settings stopped showing its folder in 0.10.3; the dialog that adds one
still asked for it. The API keeps its folder for scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:52:22 +00:00
973eeb67c7 Metrics on a thread and port of their own, from memory alone (#194)
/metrics shared the web server's threads, and answered after a token
lookup and three counting queries, so a pool run dry, one of the things it
is there to show, would have stalled it, and a slow scrape held a web
thread. Now [web] metrics_bind starts a listener on its own OS thread with
a current-thread runtime, reading only what is in memory: pool counts,
channel capacity, the daemon runtime's metrics through its handle, /proc.
No sign-in, as exporters have none; publish the port to the LAN only. The
feed, pending and downloaded counts stay with the healthcheck's log lines,
which the dashboard already reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:04:51 +00:00
9fc1e7e248 Prometheus metrics for the daemon's state between requests (#194)
Logs and traces show requests, scans and downloads; nothing showed what
the daemon holds meanwhile. /metrics, behind sign-in, gives database
connections in use and idle against the pool's maximum, commands waiting
for the worker, live-update listeners, free torrent slots, episodes
playing through DeadAir, the library's counts, the tokio runtime's
workers, tasks and queue, and the process's memory and open files, in
Prometheus's text format, written by hand rather than with a client
library. Not logged or traced: Prometheus asks every 15 s. The iPX
dashboard gains a Daemon row of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:56:54 +00:00
cd9aef36a1 A play's trace holds the playlist, audio and saved place it fetched (#192)
hls.js sends traceparent on its own requests (xhrSetup); a beacon, which
cannot set a header, and Safari's player, fetching the playlist itself,
carry it in the query, which the server reads where there is no header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:41:28 +00:00
6c931d0485 The page reports its errors and playback timings, and its requests carry a trace (#190)
Nothing the page did in the browser reached the logs: a DeadAir playback
failure on the phone was a toast and nothing more. POST /api/client takes
page errors, unhandled rejections, hls.js and media errors, the time from
play to sound and the length of a stall, and logs them as ev lines under
ipx::client, inside the request's span. Only those kinds are taken, the
text cut to 500 characters, and the page sends each at most once a minute.

The page's API requests send traceparent, a new trace id for each tap or
key, and the server's request span takes it as its parent, so one action's
requests are one trace in Tempo, the report included. The stub DOM the
smoke and native-bridge tests run in gains crypto and performance, which
every browser the page runs in has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:15:20 +00:00
9648a79f3f Keep media out of shared caches (#189)
Played from ipodderx.sdf1.net, DeadAir's audio reached ipx as one request
with no Range header: Cloudflare caches by extension, deadair.mp3 has one,
and /media said nothing about caching, so Cloudflare fetched the whole file
and served ranges from its own copy, to anyone Access let in, without the
subscriber check (#129). /media/{id} has no extension and was never cached.
Cache-Control: private on every media response, the save included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:23:27 +00:00
ba9759f44f Gesture tests send a drag's touches at once, not one by one (#184)
Each touch event was awaited before the next went, so on a busy machine a
drag's ten messages took longer than the 800 ms a swipe has, and the page
took it for a slow drag. Reproduced by spacing the messages 110 ms apart:
the swipe back stayed on Second Episode, as the failing run did. The page
is right to want a swipe quick; a finger does it in well under that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:58:26 +00:00
e56bc9e8dd Save an episode without its dead air when DeadAir is on (#186)
The save button fetched the original while the player played it shortened.
/media/{id}/deadair sends the original's ID3 tag and then the pieces of the
person's plan one after another, read as it goes: MP3 and ADTS frames each
stand alone, so that is a file any player takes, and nothing is stored, as
with the playlist. The tag stops at the first kept frame, since what can
follow it, the Xing frame, gives the original's length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:38:30 +00:00
4ccf2e5511 DeadAir's pause on a slider, shown only while DeadAir is on (#183)
The four segmented buttons gave no sense that 0.2 to 0.5 s is one scale,
and stayed on screen with DeadAir off, where they did nothing. A range
input with a dot at each stop and Brisk/Relaxed at its ends says both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:14:50 +00:00
089202f72b DeadAir plays an HLS playlist of the original, with a pause of your own (#181, #182)
DeadAir made a shortened copy of each episode with ffmpeg: as much disk
again as the original, one pause length for everyone, and a minutes-long
wait after the download. Seeking past silences instead was tried on an
iPhone and left about 1.3 s of nothing at every cut (#178).

Now it finds a file's silences once (silencedetect, plus ffprobe for
each frame's byte position) and stores a plan for each pause a person
can choose, 0.2 to 0.5 s: the cuts, and the frames kept as byte ranges
of the original. /media/{id}/deadair.m3u8 hands this person's plan out
as an HLS playlist of ranges of /media/{id}/deadair.mp3, the original.
Tested on iOS Safari first (#180): no gap at the cuts, plays locked,
seeks exactly, and needs no ID3 timestamp in front of each piece.

Other browsers get hls.js (1.6.19, the light build), loaded only when
needed. Not where canPlayType says it can play HLS: Chromium answers
"maybe" and then fails on MP3 ranges with "stream parsing failed", so
only Apple's browsers play the playlist themselves.

The pause is per person and per show, on deadair_prefs.pause_ms, 0.3 s
unless chosen; 0.2 s, what the copies left, made speech sound rushed.
AAC in MP4 has no ranges a playlist can use, so only MP3 and ADTS AAC
are shortened (the library is 803 MP3s and no .m4a). The copies, their
table and #179's expiry go: the first start drops the table and deletes
the files it names, only those named name.deadair.*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:46:01 +00:00
d6c0e3e7a6 Show that DeadAir is still working on an episode (#176)
A new download in a show with DeadAir on plays at full length until its
copy is made, and nothing said so. Each file now carries
deadair_pending while DeadAir is on for its show and the copy is still
to come (not for one heard through, which it passes over), and the
episode's line and details say "Cutting dead air…". The maker sends a
deadair_done event when it finishes a file, and the page takes the list
again, so the line becomes how much shorter the episode is without a
reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:05:18 +00:00
827cc7489f DeadAir in the page: a switch per show, one beside the speed, and the time saved (#172)
A DeadAir switch in each show's settings, and the same switch beside
the speed for the show that is playing, drawn as a waveform with its gap
closed up, outlined when off and solid when on, with aria-pressed. A
file the server gives a copy for plays from /media/{id}/deadair. An
episode's line says how much shorter it is, and Settings says how much
DeadAir has saved you.

Turning it on or off mid-episode saves the place with a request it waits
for, not a beacon, then asks for the entry again from the new
GET /api/entries/{feed}/{guid}, whose position is in the clock of the
version it now plays, and carries on there. A copy not made yet plays
from the next time. Turning a show on wakes the copy maker, so the copy
is there in seconds rather than at its next pass.

The browser test plays a real MP3 with long silences, quiet.mp3, from a
show it adds and removes itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:18:41 +00:00
cb92d5e355 Say nothing in an empty list or an empty reader
Asked for: "Nothing here yet — try Scan now." and "Pick an item to read it." are gone. A search
that finds nothing still says so, as the words typed are the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:50:22 +00:00
ecbc253894 Redesign the feed settings dialog (#167)
One column of label, box and grey paragraph, eight times at the same weight, three screens long on a
phone with Save at the bottom of the third. Approved from the mockups on #167.

- Download automatically comes first, as a switch; how many, the keywords and explicit, which only
  ever applied to automatic downloads, hang beneath it and fold away when it is off.
- How many is Default, Newest or All, said as a sentence with the number in it, over a strip of the
  feed's own newest items, each as tall as it is long and lit where it downloads; dragging along
  it sets Newest. All is 0, which now means all (#166).
- Keywords and hidden words are chips: Enter or a comma adds one, its x or Backspace removes it.
- The feed's own settings, the same for everyone and an admin's to change, sit apart in a box.
- The body scrolls and Cancel and Save stay at the foot.
- Theme colours only; iOS switches, but checkboxes in Classic. openModal takes a class for a
  dialog with a frame of its own, set whole each time so it never outlives it.

A browser test covers the strip, chips, the fold and a save that holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:25:57 +00:00
4e417389fe Catch the feed list up when the page comes back into view (#168)
All Subscriptions said 1 unread when the server had none. The badge is the page's own sum of its
feed list, loaded once and patched only by events the server sends when a scan or a download
changes a feed. Reading an item, marking everything read or unsubscribing sends none, so another
device's page kept its old counts, and an iPhone stops the event stream while the app is in the
background, missing everything meanwhile; the reconnect did not catch up.

The page now takes the feed list and the open list again when it comes back into view and when the
stream reconnects after a drop. A browser test reads an item behind the page's back and checks the
badge follows once the page is shown again; it fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:13:16 +00:00
c7eef3e516 Make Glass's top bar solid on a phone, so the strip above it matches (#147)
In a home-screen app iOS paints the strip behind the status bar and the Dynamic Island itself, in
one flat colour, from theme-color. Glass's bar is see-through, so theme-color fell back to Glass's
page background, #0b0d14 (#eef1f7 light), above a bar that showed #191e34 on average, bluer at the
left and purple at the right: a near-black band, or a white one in light mode.

No flat strip can match a see-through bar, so on a phone the bar is solid, in what it showed on
average (--glass-bar, measured in Chromium off the bar's top edge), and theme-color carries the
same colour. Everywhere else Glass is as it was. The browser test checks the bar is solid and
theme-color equals it in both modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:10:14 +00:00
a553931c76 Run the top bar's colour up behind the Dynamic Island on iOS 26 (#147)
46f9370 set theme-color, but Safari 26 ignores it. iOS 26 paints the strip behind the status bar
and the island in the background colour of a sticky or fixed element at the page's top edge, and
failing that the root's. The top bar was neither, and the root had no colour of its own (only
body did), so iOS used black or white: a band of another colour above the bar.

The bar is sticky now, though nothing scrolls under it, so iOS reads its colour; the root has the
page's background for screens with nothing at the top edge; Classic's metal bar gets a colour
under its gradient, the top of the metal, since iOS reads no gradient. theme-color stays, set to
the bar's colour, for iPhones before iOS 26 and for Android. Glass's bar is see-through, so iOS
frosts what is under it there, as the bar itself does.

Taken from developers' reports of iOS 26, not Apple's documentation, and not yet seen on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:50:28 +00:00
46f9370018 Paint the bar around the Dynamic Island in the theme's background (#147)
The page named no theme-color, so the status bar iOS draws around the Dynamic Island was its own
black or white, a band across the top of every theme. The page and the admin page now carry one,
set to the page's background whenever the theme or mode changes; Android's toolbar takes it too.
Not seen on a phone yet: the browser test checks the colour matches, not what iOS does with it.

A browser test at 360px covers this and #146's row of buttons. The stub DOM gains
getComputedStyle, which every browser has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:51:19 +00:00
a553d050ca Have ipx add, rm and import tell a running daemon to read the catalogue again (#120)
`docker exec iPX ipx rm make` printed "removed make" and took it out of the catalogue table, but
the web UI went on listing it. add, rm and import run in a process of their own and write the
whole catalogue there; the daemon kept its own copy in memory, and its next change of its own --
an admin's edit, a feed categorised (#117), a moved feed followed (#115) -- wrote the removed
feed back and dropped any the command line had added.

A new socket command, reload, has the daemon read the catalogue and settings again from the
database. The socket answers it at once, as it answers status, with the counts after it: queued
behind a scan, the web UI could write the old copy back in the meantime. add, rm and import send
it when a daemon is live, and say so if it does not answer. User commands need nothing: the
daemon reads accounts from the database on every request.

A browser test runs the real `ipx add --list` and `ipx rm` against the test daemon, with an
admin's edit after each, and fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:22 +00:00
9c2f3eaf1b Search Currently Listening from the search box (#127)
Currently Listening's search box said "Search items…" and did nothing: loadEntries returns early
for a place with its own list. Typing now redraws the list with the search sent along, as
/api/entries already takes one, title and text, and says so when nothing in progress matches.
The keys list says / searches it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:21 +00:00
697e907c86 Keep the feed list's icons when it redraws, rather than load each again (#134)
Checking every feed made all the feeds' icons in the feed list flash while it ran. A check sends
each feed's new row as it reads the feed, and renderFeeds, run once a frame while they come in,
emptied the list and built every row anew, every <img> with it: each icon went blank and was
loaded and drawn again, many times a second through a scan of 150 feeds.

The rows are still rebuilt, but the images already drawn go into the new ones in place of the
fresh copies, matched by their markup, so only an icon that has changed is made anew. A browser
test checks the rows are new and the images in them the same elements, and fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:37:35 +00:00