Skip to content

The Binder: a CD binder home screen widget that flips through your books - #110

Merged
StaticHumStudio merged 19 commits into
masterfrom
feat/220w1-binder
Nov 1, 2026
Merged

StaticHumStudio merged 19 commits into
masterfrom
feat/220w1-binder

Conversation

@StaticHumStudio

Copy link
Copy Markdown
Owner

What changes for users

  • The Binder, a 2x1 home screen widget that works like an old CD binder. Two square sleeves sit open on either side of a gilt hinge. The first spread is the Nine Lives eye and your first book, and each swipe down turns to the next two covers (swipe up goes back). It uses the app's Noir look whatever theme the app is set to.
  • Set up your way. Adding it opens a small setup screen: tap a cover to Play it (preselected) or Open its page, and the order (defaults to most recently listened, plus the Library's sorts). Long press, Widget settings changes them later. Each Binder keeps its own choices.
  • Play from the home screen follows the app's rules. A Play tap goes through the same Play the app uses, so a book that is loading, archived, or out of reach opens its page instead of misbehaving. It works with the app closed, including the book you were already on, a book with nothing loaded, and a slow server, and the playback notification always comes up. An invisible handoff screen keeps Android from refusing that, never blocks the home screen beyond the Binder's own swipe while it waits, and gives up after 30 seconds.
  • Paid, with an honest locked state. Free users can add it. It shows the eye, a lock and "Unlock to fill the Binder", never a title or cover, and a tap opens the unlock screen. The unlock screen's benefit list gains one line for widgets. Unlocking fills it in under a second, and a trial that ends relocks it at the next hourly check.
  • Stays current without draining the battery. It redraws on play, pause, book or chapter change, sync, mode switch, sign out, unlock change and a cover pick. Zero redraws while paused. One hourly backstop.

Part of #8 (the large Nine Lives widget lands next and closes it).

Tests

  • JVM: 1,963 pass (master 08ecdc3 has 1,889). CI parity green (testDebugUnitTest, assembleDebug, compileDebugAndroidTestKotlin, lintDebug, lintRelease, minifyReleaseWithR8, and R8 keeps all five widget classes).

Evidence

/home/static/nine-lives-evidence/2026-10-31-220w/SUMMARY.md, on a Pixel 6 emulator (API 36) with a mock Audiobookshelf:

  • Square covers with a fixed hinge and gilt page edges: w1/06-swipe-strip-v3.png. Locked: w1/03-locked-crop-v2.png. Forced right to left: w1/17-rtl.png.
  • Unlock fills it in 0.76 s. Cold Play with the process killed, with nothing loaded, and with a 14 s slow server: the book plays and the notification is foreground.
  • Zero redraws over 10 idle minutes. A cover picked with Find a cover shows on the Binder right away (w1/20-cover-hook.png).

Review

  • Claude Opus review 1: paywall, data and battery clean. Fixed: covers stretched and cropped instead of square, a stale Open tap after a mode switch, the handoff blocking the home screen on a cold tap, and the backstop at 30 minutes (now 60).
  • Round 2: fixed the hinge landing over a cover in right to left languages, and proved and fixed Android refusing the playback notification on a slow cold start. Also fixed during the build: a cold Play with no book loaded opened the page, and the fanned back pages drew a staircase of hinges.
  • Round 3: one low (the same refusal for the book already loaded), fixed with a test.

Design deltas (accepted)

  • Swipes up and down: the only widget list Android lets you fling is vertical, since sideways belongs to the launcher. Contained to one layout if tap zones are wanted instead.
  • A 2x1 shows a spread of two squares, like an open binder.
  • Android draws the stack's back pages fanned behind the front. They read as page edges.

Needs the S26U

One UI fling feel, Widget settings from a long press, short wide cells, battery over a day, and a real trial expiry.

Draft copy pending Jeff

Binder. Flip through your books like an old CD binder. Unlock to fill the Binder. Unlock to bring the altar home. Signed out. Open Nine Lives to sign in. The folder went quiet. Open Nine Lives to find it. Set up your Binder. When you tap a cover. Play it. Open its page. Order. Done. Your picks wait here until you unlock. Play. Pause. Open Nine Lives. Locked. Tap to see the unlock. Benefit line: Home screen widgets, the Binder and Nine Lives.

🤖 Generated with Claude Code

https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf

StaticHumStudio and others added 17 commits November 1, 2026 00:19
… app's own rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
…dget can carry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
…t up when you add it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
…d relock after a trial

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
… while the book loads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
…ere you left it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
…ded corners

Each sleeve is now the largest square its half of the widget can hold, and the
two squares sit against the hinge. Covers are fitted whole instead of cropped
and their corners are rounded to match the sleeves. The locked, empty and
picker faces use the same square sleeves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
…cks the lock and library like Play

A locked tap goes to Unlock without waiting for anything. A cold start that
decides slowly closes the invisible handoff screen after 2 seconds and lets
the play carry on in the background. "Open its page" now ignores a stale tap
from another mode or account, and sends a locked tap to Unlock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
A process the tap just started still reads as offline until its first server
check. With no book to restore, the widget decided before that check and sent
every streamed book to its page. Now a streamed book that reads blocked gets
one server check first, then plays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
…ages

The hinge no longer turns with each page, so the fanned pages behind the
front one stop drawing a staircase of hinges. Each page is the two sleeves on
an inset page with a gilt edge, so what peeks out behind is a page edge, not
a sliver of cover art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
Every Binder layout is pinned left to right. StackView never mirrors its fan
of pages, so a mirrored hinge grid put the hinge over the right cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
Android 12 and later refuse to start the playback service in the foreground
once the app has no screen up, and a slow server let that happen after the
widget's handoff screen had closed (playing, but with no notification and
open to being killed). The handoff screen now stays until the service has
started, or the load ends, or 30 seconds pass. It is fully transparent and
lets every touch through, so the home screen stays usable meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
Choosing a new cover (a search result or a photo) or going back to the
original now redraws home screen widgets right away, instead of waiting for
the next sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
…ion too

When the tapped book is the one the app had loaded, a cold start reloads it
in the background after Play. The widget's handoff screen now waits for that
reload and for the playback service to start, the same as for a new book.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-01T04:59:14.223323Z ad40d00 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e64b26b79e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +78 to +80
withTimeoutOrNull(deadline) {
coroutineScope {
missing.map { book -> async { permits.withPermit { fetchToDisk(book, px) } } }.awaitAll()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make cover downloads cancellable at the warm-up deadline

When uncached covers come from a slow or unreachable server, the eight-second deadline does not stop the downloads: fetchToDisk() performs blocking OkHttp execute() and stream reads without connecting coroutine cancellation to the calls. The shared client allows 30-second connection and 60-second read timeouts, and structured concurrency waits for these blocking children even after withTimeoutOrNull expires. Since BinderFactory.onDataSetChanged() synchronously waits for warm() before publishing its spreads, this can leave the widget loading for a minute or longer instead of falling back to titles after eight seconds. Cancel the underlying calls on timeout or enforce a download-level deadline.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9844089. Widget cover downloads now run as cancellable OkHttp calls (enqueue, with call.cancel() on coroutine cancellation) on a derived client with a 6 s callTimeout, under the 8 s warm deadline, so warm() returns on time and falls back to titles. The shared client is untouched. A cancelled or failed download leaves no file. Tests use a socket that never answers.

Cover downloads are cancellable and capped by a 6 second call timeout, so a stalled server can no longer keep the Binder widget loading past the 8 second fallback to titles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9844089ccd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +107 to +111
SleeveFill.Empty -> {
views.setInt(sleeve, "setBackgroundResource", R.drawable.widget_sleeve_empty)
views.setViewVisibility(cover, View.GONE)
views.setViewVisibility(title, View.GONE)
views.setContentDescription(sleeve, null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the click action when rendering an empty sleeve

When the library has an even number of books, the final spread has an empty right sleeve. The collection uses a single view type, so StackView can recycle a spread previously displaying a book into that final spread. This branch hides the artwork and title but leaves the previous sleeve's fill-in intent attached, allowing a tap on the visibly empty sleeve to play or open the previously displayed book. Explicitly clear the sleeve's click action when applying SleeveFill.Empty so recycled views cannot retain another book's action.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ad40d00. Every sleeve now sets its tap on every bind. An empty sleeve sends WidgetLaunch.Nothing, which the trampoline finishes on without launching anything, so a recycled view can't keep another book's action.

Every sleeve now sets its tap on every bind, and an empty one sends an explicit do nothing action, so a recycled row no longer plays or opens the previous book.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf
@StaticHumStudio
StaticHumStudio merged commit df9f86c into master Nov 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant