Skip to content

Release 6.0.0 - #1658

Open
GianniCarlo wants to merge 103 commits into
mainfrom
develop
Open

GianniCarlo wants to merge 103 commits into
mainfrom
develop

Conversation

@GianniCarlo

@GianniCarlo GianniCarlo commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Improvements

– We're ready for the iPhone Duo!
– New library layouts: browse your books as covers or as a grid of cards
– Transfer books from your computer's browser over your Wi-Fi network
– You can now stream from your Jellyfin and Audiobookshelf servers
– Faster, more reliable cloud uploads that keep going in the background, with progress on the Lock Screen
– New layout improvements for Settings (on iPad) and for the Player screen
– The app is about 35 MB smaller

Bugfixes

– Hardcover: works with Hardcover's new API keys, pasted with or without "Bearer"
– Fixed artworks not loading in the Recents widget
– Fixed the player not opening after launching the app with an import waiting
– Fixed a folder's search staying filtered after closing search
– Mini player labels are easier to read

Special thanks to:

– @klikh (sponsor)
– @s00d and @Hirobreak for their code contributions to this update

If you experience any issues, please reach us at support@bookplayer.app

GianniCarlo and others added 30 commits October 8, 2026 11:35
…release check

Adds the BookPlayerUITests target and a separate ReleaseCheck scheme (tests in
Release), so the shared BookPlayer scheme and CI's Unit Tests plan never pick
them up. Five tests: fresh launch, import, playback, the upgrade checks after
installing over the previous release, and a label-only seeding test that
drives the previous release (which has no identifiers).

Four accessibility identifiers, no visible change: library rows
(library.row.<relativePath>, since a row's label includes its progress), the
mini player, the player's play/pause button and its elapsed time.

The target was added with the xcodeproj gem, which re-sorts about 28
out-of-order project lines by ID; their content is unchanged.
…orted iOS version

scripts/release-check/release_check.py builds the Release configuration for the
simulator and runs four scenarios on each supported iOS major (deployment
target up to the SDK, minus SKIPPED_MAJORS: iOS 18 has no simulator runtime for
Xcode 27): fresh launch, import, playback, and upgrade from the previous
release, which is cached and built from its tag once when missing.

Any new crash report from the app fails the check. A failure without one gets
a single retry on a reset simulator and is reported as passed on retry. Each
major gets two simulators so neither is reset right after playing audio, every
simulator and test command has a time limit, and Swift packages are cached
between runs.

Run by the prepare-release-ios skill before tagging; never by CI. Against the
5.22.1 build it fails on iOS 26.5 and passes on iOS 27, which is how that
launch crash shipped.
CLAUDE.md lists the BookPlayerUITests target and explains the release check.
Both CLAUDE.md and the reviewer guide now flag an `if` or several statements
inside a ForEach that builds rotor or toolbar content (iOS 27-only
conformances, the 5.22.1 launch crash). The reviewer also warns when an
identifier or label the release-check tests rely on changes.
When the previous release crashes on an iOS version while seeding the upgrade
scenario, that version upgrades from the release before it instead (whose users
never had data in the crashing release), and the upgrade is reported as not
tested if that one crashes too; a crash of the build being checked still fails.
Also record the cached build's commit and clean state before building, not at
the end of the run.
Release check: run the Release build's UI tests on every supported iOS version before tagging
Library items can now carry links to the Jellyfin and AudiobookShelf items
they came from (Hardcover links move onto the same model), so a book imported
from the user's own server streams from it, its link syncs to other devices,
and listening progress goes back to the server. This is #1523 replayed onto
develop, with the connection services moved into Shared for both Kits.

Data model and tiers
- CoreData v12 adds ExternalResource (provider name and id, hostId, sync
  status), cascading from LibraryItem. The v11->v12 mapping runs with
  inference off; tests pin its checksums, as a stale mapping breaks launch.
- SwiftData V3 replaces SyncTasksStorage with one container keyed by queue;
  v2->v3 replays the old queue in FIFO order and backfills Hardcover links.
  An incompatible tasks store is moved aside (an older build over a newer
  schema must not lose queued deletes) instead of crash-looping launch;
  other failures, including a bug in the custom stage, still trap.
- A new lite tier syncs the library without S3. Streaming gates on pro OR
  lite, pro wins when both are held, and streaming tiers import server books
  as linked references while other tiers download them.

Concurrent sync engine
- ConcurrenceService runs one worker per queue over a capped OperationQueue:
  "sync" stays serial for BookPlayer-server jobs, S3 PUTs get an uploadFile
  lane, and each provider a lane for progress pushes, so a slow media server
  never stalls library sync.
- Tasks pop only on success and failures retry every 5s forever (Android
  parity), so permanent failures are consumed: a missing file, a 4xx on a
  presigned PUT, an unknown host, an expired session, a provider 4xx, and
  non-finite persisted progress that would crash-loop every pop.
- synced:true and the PRO external_set pipe's uploaded:true are confirmed
  only after a real 2xx PUT, streamed from disk; confirmations retry and are
  then consumed rather than re-uploading a multi-GB file.
- The tier policy is set before workers wake and rechecked per upload.
  Progress pushes run on every tier (they hit the user's own server); a lapse
  cancels only the server and upload lanes; logout wipes every queue and
  cancels in-flight work, down to the Task inside a sync operation, so
  nothing runs under the next account's token.
- Operation state is lock-guarded, and pushes coalesce per providerId.

Syncing links and resolving hosts
- hostId now survives sync-down; decoding is per element, so one bad element
  no longer nils the list and feeds deletions; reconciliation drops only
  links that already synced; lastSyncedAt travels as epoch seconds (a Date
  crashed JSONSerialization); lookups are provider-scoped, as ids are unique
  only per provider; linked books key relativePath on the provider item id.
- IntegrationHostResolver mirrors Android: server GUID (case-insensitive),
  then canonical URL key, then nil; never the first connection, which
  streamed the wrong file or pushed progress to the wrong server. An
  unresolvable host discards its pushes and fails downloads with a
  missing-connection error; old hostIds get no backfill by design.

Playback and progress
- Linked chapters always stream from the external URL with the server's
  headers, bound books included, never rerouted to S3 on refresh; every
  currentItem swap rebinds the chapter subscription.
- Pushes are debounced and dropped if cancelled; ABS takes a 0-1 fraction
  (0-100 marked books finished). A resume prompt offers the server's position
  when it is ahead, whether or not the player is open. The buffering overlay
  shows only while a chapter streams.

Security
- PlayableChapter never encodes the external URL or headers: they hold the
  live Authorization token, and encoded items cross the WatchConnectivity
  context, which is persisted on both devices.
- Custom proxy headers ride along, but Authorization is deduped
  case-insensitively and wins; presigned PUT URLs log only their path.

Tests cover the task engine, host resolution, the resource service, the
token never encoding, and the v11->v12 migration. Positional specifiers in
sync_invitation_description are fixed in 23 locales (Lokalise needs it too).
Virtual imports guessed "m4a" for any extension they didn't know, staged the
selection in a shared mailbox on ImportManager, and made a twin Book on
re-import. They now hydrate real file metadata, pass the selection as a value,
and treat the (providerName, providerId) pair as the item's identity.

- Real extensions: the selection is hydrated first (Jellyfin getItems with
  media sources, chunked at 100 ids; ABS batch/get, as list endpoints omit
  audioFiles) and builders take a non-optional extension. Audio-less items are
  skipped, with an alert when nothing is importable. ABS audio files decode
  from their nested `metadata` and lose the leading dot of `ext`.
- One VirtualImportPipeline (hydrate, build, skip, present) serves every
  import site. Imports are honestly async, the Task lives at the view, and an
  isImporting guard stops double taps. Five near-copy Jellyfin VMs become
  three, with a role parameter as on ABS.
- Integrations own staging: an ExternalImportBatch value goes to the screen's
  own confirmation sheet as @published state, so removing a row can't silently
  no-op, and cancel keeps the selection. Confirm calls an onImportConfirmed
  closure that root views point at ExternalImportBus, a stateless object
  injected with .environmentObject so a missed injection crashes instead of an
  @entry default splitting producers from the consumer. ImportManager is
  non-optional and handles file imports only.
- insertItems reuses the existing row on re-import: relativePath has no
  store-level uniqueness, so twins made path lookups arbitrary. Sync-down
  reconciles resources by the pair as well, still keeping notSynced links.
- Details screens own their VM via @StateObject, so the root re-render that
  Stream itself causes no longer mints a fresh VM mid-import. The Subscribe
  and Stream CTAs compute live from accountService on .accountUpdate, so a
  purchase on the pushed screen no longer leaves a stale Subscribe button.
- ConcurrenceService refreshes its own access policy on .accountUpdate, like
  SyncService, replacing the iOS and watch push-in wirings.
- Hardcover: syncService is no longer optional (nil silently skipped sync-up);
  a status-only stub synced from another device is repaired from fetched
  metadata instead of rendering blank; embedded artwork is extracted before
  falling back to the Hardcover cover, which had raced first render and
  permanently shadowed the file's own art.
- Sync: teardownTask is set synchronously under a lock, closing the fast
  logout/login window; consumed or dropped uploads clean their temp hard link;
  a restart generation stops a cellular double toggle racing two PUTs.
- Also: an absent processedFile no longer drops a synced resource; the stream
  pick is deterministic; loadPlayer throws fileMissing for stale references;
  ABS resume uses the real lastUpdate; the media-servers alert dismisses the
  player first.
Item details, the library row and the playback-failure alert each answered
"is this resource a media server?" their own way, and the details view model
could only be tested by hoisting its rules into statics. There is now one
answer, and the details screen loads in a way tests can drive.

- SimpleExternalResource.isMediaServer is the single definition: a switch
  with no default, so a new provider is a compile error in one place. The
  details section, the streaming pick and the library row all read it, so
  Hardcover links (progress sync, not a source) no longer repeat in External
  Resources, show as a row badge, or win the streaming pick and leave a
  streamable item with no URL. An unknown providerName is no longer listed in
  details; that is the price of the compile-time check.
- Hosts resolve in ItemDetailsViewModel, off the main thread, through
  IntegrationHostResolver, instead of the section view building a
  KeychainService. The host field starts on the raw hostId and upgrades in
  place, and hosts resolve before the Hardcover selection so they never queue
  behind its network fetch. The section has its own file; the caller decides
  whether it shows.
- The view model loads from the view's .task, not a Task started in init.
  SwiftUI cancels it on dismissal, so a Hardcover stub repair can't land after
  the sheet is gone, and init now takes protocol types so tests can mock and
  await it. No .task(id:): item is fixed, and didLoad guards re-appears while
  resetting on cancellation.
- The alert offered Media Servers on remoteURL == nil, but the API presigns a
  URL for every synced item, so that only meant "this account doesn't sync":
  free users got it for any missing file, and synced users never got it for a
  server missing on this device. PlayableChapter.needsMediaServer() now
  requires the streaming entitlement and a media-server resource that failed
  to stream or has an unresolved host (hasUnresolvedExternalHost, a
  per-device result kept out of CodingKeys). Both alert sites share it.
- The media-servers listener moves to MainView, which owns the sheet and the
  player cover it sequences, rather than relying on LibraryRootView staying
  mounted as the default tab.
- The library row subtitle is one Text, so the provider icon scales with
  Dynamic Type, default spacing returns and the line truncates once. The
  Stream button uses the accent-filled treatment of the Download button.
- The import confirmation shell moves to its own flow-agnostic file, with
  confirm disabled at zero rows and the row mapping tested on
  ExternalImportViewModel; the duplicated Jellyfin folder-import flow folds
  into a protocol extension. Two rebase-resurrected files that never compiled
  and an unused ItemListView property are deleted.
The inbound half of media-server progress sync lived on ItemListViewModel,
behind PlaybackSyncProgressDelegate: a single delegate slot that CarPlay also
claimed and answered with an empty stub, so a car-only session never pulled.
ExternalProgressService now owns the pull, as the push has always been a
service, and treats AudiobookShelf exactly like Jellyfin.

- It subscribes to .bookPlayed, posted for every playback start, so the phone
  and CarPlay pull alike. It asks every linked server concurrently, ignores the
  ones that fail, and offers a position only when it is more than 15s ahead of
  local (newer date or farther position), and the newest wins: the
  strictly-newer rule SyncService applies to our own cloud. The rule lives on
  the ExternalPlaybackProgress values it compares.
- Only one refresh runs at a time; each playback start replaces it, under one
  lock. Its answer is dropped once the item is no longer the one requested,
  so a slow reply for a book the user left can't seek the current one to
  another book's timestamp.
- The list ingest is provider-neutral, so AudiobookShelf items now refresh
  from their server too. A providerId under another provider name never moves
  a row: ids only mean something to the server that issued them.
- The result is published, not written into UI state. ResumeOfferArbiter, its
  only subscriber, gives each offer to exactly one surface: CarPlay's
  two-action alert when the app isn't foregrounded, the SwiftUI alert
  otherwise, so unlocking the phone never re-asks. It is created eagerly
  beside PlayerState, because a cold launch into the car registers CarPlay
  before CoreServices exist.

Stream resolution moves into an injected ExternalStreamResolver, testable
without a real keychain: an unmatched host resolves to nothing rather than to
another server (the Android contract), and the integration's Authorization
beats a user's lowercase authorization header. Downloads now use the same
resolver and streamingResource. Their old pick, any row not .notSynced, could
land on Hardcover (the API's markExternalSourceUploaded marks every provider
row 'downloaded') and throw a missing-connection error on a device whose
Jellyfin connection was fine.

ExternalImportBus becomes ExternalImportEvents, owned by MainView, where its
producers and consumer all live.
The engine that runs every sync lane (BookPlayer server, S3 uploads,
media-server progress pushes) now owns their counts and their one screen, and
carries a name that says what it is.

- SyncQueueService publishes one per-lane QueueCounts snapshot. The Profile
  row sums it, the queue screen reads each lane, and background refresh waits
  only on laneDrained(.sync): provider pushes retry forever against an
  unreachable home server and S3 uploads already run on a background
  URLSession, so neither should hold the refresh window open until it expires.
  canSyncListContents still gates list refresh on the sync lane alone. The two
  count services and SyncService.observeTasksCount are gone.
- One Queued Tasks screen lists every lane as a collapsible section, sync
  first and the rest alphabetical, each header carrying its live count, with
  no per-queue drill-down. The library's blocked-refresh sheet presents the
  same screen. Rows keep the old sync view's labels and icons.
- The concurrent-sync family is renamed to SyncQueue (ConcurrenceService to
  SyncQueueService, ConcurrentSyncTask to QueuedSyncTask, the @model types,
  the folder). SchemaV3 is unshipped, so its renamed models stay inside V3
  rather than adding a schema version.

The rename exposed a launch crash. The guard that sets aside a task store no
schema can open matched Cocoa codes 134504/134100, but SwiftData wraps them
opaquely (SwiftDataError.loadIssueModelContainer), so it never fired: a store
from an unknown schema hit the fatalError and crash-looped the app. The
decision now comes from the store's own metadata, before the container is
built, checking each MigrationPlan schema with
isConfiguration(withName:compatibleWithStoreMetadata:), the check SwiftData
itself makes. An unknown store is moved aside (one .incompatible generation
kept), a current or migratable one is left alone, and any other load failure
still crashes, since a migration bug must not wipe a healthy store.
Media-server books now import with the length and chapters their server
reports, stream for anyone who has ever paid, and report playback failures on
whichever surface the user is on.

- The progress pull is cross-device sync, so it now needs lite or pro,
  checked on every call, and losing the entitlement cancels a pull in flight.
  The push still runs on every tier: it only writes to the user's own server.
  Every list refresh (appear, pull-to-refresh, sync activation, CarPlay) pulls
  the whole level right after its cloud sync, whatever that sync's outcome,
  from the level's media-server rows fetched off the main thread.
- Stream and Download are separate actions in both browsers. Stream needs the
  entitlement and otherwise opens the subscribe sheet; Download always
  downloads, so both paid tiers can do either. Only a whole-level Download
  confirms, counting the resolved requests. Fixed alongside: the details
  Download button streamed, author/narrator Download did nothing, selections
  skipped the downloadable filter, and a failed Jellyfin build read as success.
- A virtual import needs a real extension and a positive server-reported
  length: a 0-duration row is silently unplayable, and the playback-time probe
  meant to rescue it could not (it also swapped a bound book for one of its
  files), so it is gone. Jellyfin falls back to the media-source runtime.
- Chapters come from the provider at import and on the pull, which covers a
  second device that never hydrates. They are only ever added, never replaced.
  Jellyfin reports starts only, so each chapter runs to the next and the last
  to the runtime; ABS's last chapter stretches to the item's end, since a tail
  gap pinned playback to chapter 1 and the book never finished. The pull asks
  for chapters only for books still missing them.
- Streaming reaches only the user's own server, so hasStreamingEnabled() stops
  aliasing sync: an active tier, a tip, or a subscription that expired without
  a refund grants it. Playing an external item now checks it.
- Playback failures went through WindowHelper.activeWindow, which finds no
  window in CarPlay or on a locked phone, so they were dropped. The resume
  offer's arbiter, now PromptSurfaceArbiter, routes them by its rule: the car
  only when the app isn't foreground-active, PlayerState otherwise. A pending
  failure holds back the resume offer, and a downloaded book is never blamed
  on a missing server.

Streaming adds no buffering overlay, as develop never had one for S3, and
leaves the buffer depth to AVFoundation: a fixed low ceiling stalls on a link
hiccup that the adaptive default rides through.
The media-server screens get one look and honest copy, and the code that only
makes sense for a server takes a type that cannot be Hardcover.

- The paywall stops selling what is free: connecting a server and downloading
  from it cost nothing, so that row goes, and the sync row names both hops,
  since only the pull back through our servers is paid. The cloud disclaimer
  is replaced: the audio files stay on the user's server, and the subscription
  unlocks streaming and covers syncing progress through ours. "Files" on
  purpose, because library rows do reach our servers.
- The sign-up screen, renamed MediaServersLoginView after the LoginView it
  mirrors, gets back the loading state, error alert and loading overlay its
  copy had lost: a failed Apple sign-in did nothing at all. On both login
  screens the buttons are the safe-area inset rather than an overlay on a
  short hardcoded spacer, so the disclaimer scrolls clear of them.
- One primary button: PrimaryButtonStyle takes the accent treatment instead of
  imitating Sign in with Apple, replaces three hand-rolled copies, and every
  button is a Capsule. Stream is a waveform, not a download arrow beside the
  real one, and the library's import menu stops using the audiobook glyph.
- The Jellyfin and AudiobookShelf marks are symbol sets scaled to cap height,
  so they follow Dynamic Type. A media-server file not on the device shows its
  provider's mark in the artwork corner instead of a cloud, saying whose
  machine it is on, and VoiceOver now names the provider.
- The import sheet uses a real NavigationStack and a themed List instead of a
  hand-drawn bar whose buttons vanished on dark themes.
- hasStreamingEnabled() requires a signed-in user, since donationMade outlives
  logout and kept streaming open for a signed-out tipper. It reads
  getAccountId(), because logout blanks the account row but leaves it behind.
- MediaServerProvider is ProviderName's server half. The stream resolver, host
  resolver and progress push no longer sweep unknown providers into default:,
  so adding a server is a compile error in each.

Tier and product names stay untranslated brand literals, as "BookPlayer Pro"
is everywhere, and SwiftUI literals drop a redundant .localized, since
LocalizedStringKey already localizes them. NSAllowsLocalNetworking stays out
of Info.plist: next to NSAllowsArbitraryLoads it would narrow plain HTTP to
the LAN and break a server reached over a tunnel. The unreferenced 2023 Plus
assets are deleted.
Background uploads will rely on BGContinuedProcessingTask, which is iOS 26+,
and call iOS 27 SDK APIs behind #available. All iOS targets move from 18.0 to
26.0; the watch targets stay on watchOS 10.

- Availability checks that are now always true are gone, keeping the iOS 26
  branch: the search tab and searchable modifier, the Liquid Glass toolbar
  and glass helpers, the mini player accessory, the iOS 26 app icons, the
  iOS 17/18 widget and intent paths (a single widget bundle now), the iOS
  13/16 annotations, and the contentMarginsDisabledIfAvailable wrapper (the
  widgets call contentMarginsDisabled() directly). The iOS 26.1 checks stay.
- Background tasks are submitted through submitTaskRequest on iOS 27, where
  BGTaskScheduler.submit(_:) is deprecated.
- CI builds on the xcode-27 runner (preview) with Xcode_27.0.app: Xcode 26.x
  can't compile those iOS 27 calls, and macos-26 ships no Xcode 27.
- CLAUDE.md: the search tab is iPhone-only, not iOS 26-only.
A failed sync task retried every 5 seconds forever, whatever the server said
and with no record of why, and some requests could never succeed. Those
requests are fixed, the server lanes hold while sync is off, and a task the
server refuses is parked where the user can see, retry and report it.

- GET requests carry real uuids. `[String: Any]` parameters holding an
  optional were interpolated as `Optional("…")` or `nil`, so no GET on
  /v1/library ever sent a valid uuid and the API fell back to the path. As
  a valid uuid changes the row returned, a bound book's chapter refresh now
  resolves by path only (the parent's uuid returns the parent, which has no
  file URL), and .remoteContentsURL keeps the container's uuid (api#49).
- matchUuid requests are split at the server's 1,000-record limit, and
  coalescing won't exceed it, so a large library no longer wedges the lane.
- Upload temp links no longer leak when the upload never runs (file already
  stored, provider-backed item, access denied). One helper removes them and
  only ever deletes a regular file strictly inside tmp: never a folder's
  mirrored tree, whose children's queued uploads still read it.
- Queued upload rows show the task's relativePath, not a generic label.
- The server lanes (sync, uploadFile) hold while sync is off. Every launch
  woke every persisted task, and an account whose RevenueCat cache already
  read inactive never ran cancelAllJobs: the lapsed-subscriber retry storm.
  Workers check serverLanesEnabled, set only by SyncService with isActive,
  before each pop. Held tasks are never cleared, since a paying subscriber
  can read inactive before RevenueCat's first fetch. Provider pushes ignore
  the gate.

A coded 4xx (the server's `error` key, with its HTTP status) now parks the task;
anything uncoded keeps retrying. Leaf tasks (update, uploadArtwork, uploadFile)
park alone. A structural sync-lane task stops the lane behind it, since later
tasks build on its change. not_subscribed and tier_required park at account
scope and trigger a fresh RevenueCat read: inactive runs the lapse path,
anything else keeps the tasks held and reports. The watch, with no screen to
show them, drops task-level failures. The pause lives on the task reference in
the unshipped SchemaV3; the queue never coalesces into a parked or running task,
background refresh treats a lane with nothing runnable as drained, and parked
tasks get one automatic retry at launch.

- Queued Tasks shows a parked row's server message with Retry and Report,
  and a blocked lane's header turns red. Profile reads "Sync paused: N need
  attention", and a pull-to-refresh blocked by a parked task says so.
- Report mails support a text file (or shares it, without Mail) with the
  paused task, every queued task and its state, and the local library tree
  with uuids, so support can compare the device against the server.
- One Sentry warning per parked task, grouped by job type and error code,
  carries only job type, code, status, lane, scope and item uuid: never the
  server message, a path, or breadcrumbs, whose API query strings carry
  relativePath. Nothing is marked reported while crash reports are off.
A book's file went up as one presigned PUT: capped at 5 GiB, restarted from
byte zero after any stall, with a URL frozen into the queue that died when
the signing credentials rotated, and a client-sent synced:true that could lie
when S3 rejected the bytes. Uploads now drive bookplayer-api's /upload/*
routes, and keep going at full speed in the background.

- S3 is the source of truth: every run rebuilds from the uploaded part list
  and the background session's tasks for the stored uploadId (in the
  unshipped SchemaV3), so a relaunch resumes without re-sending parts. 64 MiB
  parts, 8 in flight (fewer on low disk), fresh URLs on every top-up. A 403
  resends a part; a lost or rejected upload restarts up to 3 times, then
  parks with the server's code, the dead upload forgotten so a Retry starts
  fresh. The server sets synced at complete. A book whose temp link iOS
  purged is found again by uuid in Processed. GET queries send `+` as %2B
  (Express read "A+B.mp3" as "A B.mp3").
- On a tier without S3 (LITE) a book is never confirmed synced: a PUT with
  no URL there means no file is stored, and confirming left the book looking
  backed up, so its file never went up after an upgrade.
- A downloaded media-server book goes through the upload lane instead of a
  whole-file PUT inside the serial sync lane, where a book over 5 GiB failed
  every retry and blocked all sync. Only a media-server link marks an upload
  provider-backed; a Hardcover link did too, so such a book never uploaded.
- An upload answered with item_not_found re-registers the book through the
  sync lane from its current state, or drops it if it's gone locally. A
  second one in a session parks it so this can't cycle; a Retry clears that
  and registers it once more.
- A book over 10 GiB parks as file_too_large with the app's own message and
  the only Dismiss: the book stays on this device, and Sentry isn't told.
  Profile drops "Sync paused: N need attention" for a red Queued sync tasks
  title with a warning icon, keeping its sync caption.
- iOS's background session wakes, uploads and downloads, are answered once
  the work settles (under a background task, 25 s cap): answering at once
  froze the next parts until the following wake.
- Outside a continued task iOS batches background parts (1 GB took 507 s
  instead of 297 s), so one BGContinuedProcessingTask with a Live Activity
  runs the whole queue. It starts from the foreground, with S3 access, an
  allowed network and a book that can move, when books are queued (an
  import, the first sync after signing in or subscribing), from "Continue in
  background" on Queued Tasks and Profile, or from a Retry.
- The upload task is stored before the sync .upload that made it is popped:
  a kill in between lost the upload.

Missing items are found by uuid. A book imported while lapsed never reached
the server (the return's root pull deleted it), and the first sync's /keys
path compare read a path gone stale here as missing, so its re-upload moved
the item back. All local uuids now go in one POST /v1/library/status:
unknown items are matched by path, then registered like an import; on PRO,
books without a file go straight to the upload lane; /keys is no longer
called. A lapse makes the return a first sync, which never clears queued
work and whose root pull never deletes. The pass runs again on becoming PRO
and weekly, phone only. Accepted: a pre-uuid item another device deleted
under a different uuid comes back.

The Queued Tasks info button goes: its sign-out advice no longer holds.
CLAUDE.md records CI on Xcode 27, the iOS 26 minimum and current packages.
AudiobookShelf has no per-instance id: the login response's serverSettings.id
is the constant "server-settings" on every server. Sign-in now stores no
server id, so an ABS book's hostId is always the canonical address of the
server it came from.

- The address key matches Android's canonicalServerKey, whose keys phones
  already store as hostIds: the path is percent-decoded, an internationalized
  host reads in punycode, and hosts parse as Android's java.net.URI parses
  them. It allows underscores, which the desktop JVM rejects, so
  http://my_nas:80 keys as http://my_nas. An address that URI gives no host
  keeps its whole string, lowercased. Every row of the parity table was
  checked against AOSP's URI.
- Accepted: a host typed in Unicode still keys differently (Android keeps it
  as typed, Foundation stores punycode). It's rare for a server, and pinned
  in the parity test.
- When no saved server matches a streamed book, the playback alert names
  the server and its address, if the hostId is one; an id-shaped hostId (a
  Jellyfin id, a legacy "server-settings") keeps the usual message. A tier
  that can't stream gets no server named, since adding it wouldn't play the
  book; the car and the Media Servers button stay quiet then too.
AudiobookShelf's item download is a zip for any book stored in a folder, so
streaming and downloading those books failed. Each file is now looked up
when a book plays or downloads (GET api/items/{id}?expanded=1) and served
from api/items/{id}/file/{ino}, relative to the saved server address, with
the token in the header. The ino changes whenever a file is replaced, so
it's never stored. Matches the Android app (#138).

- An item made of several files imports as a volume: a bound folder named
  after the title holding the link, with one book per file named by its
  flattened path. Its books play, download, register for sync and upload
  through the volume's link. A volume made on another device pulls its book
  list before downloading. A streamed volume can be renamed (it has no
  folder on disk), and renaming any folder onto a taken path is refused.
- Streamed books are named <title>.<ext>, without the provider prefix and
  cleaned as on Android; a name another book has (ignoring case) gets part
  of the new book's uuid before the last dot.
- Hardcover searches a volume by its first book's author, read on the main
  context at its current path.
- A lookup gives up after 5 s on playback, falling back to the cloud copy,
  and after 30 s for a download. An item's answer, failures included, is
  reused for its other files, so an unreachable server is waited on once; a
  failure is reused for two minutes, so a server back from an outage serves
  the next files.
- Only a 401 means the session expired: playback says so (for a play the
  user started, when no cloud copy plays the book), and so do downloads and
  CarPlay. A 403 is this user not being allowed that item. When nothing
  serves a book, a play the user started says its server didn't, not that a
  local file is missing; the last book preloaded at launch stays quiet.
  Tapping the book again rebuilds it from the library, so signing in again
  or adding the missing server takes effect.

Playing a volume's files:
- A load superseded by a newer load or a pause leaves the flags and the
  player alone and shows no alert: URLSession's cancellation is treated as
  a cancellation, not a failure.
- A chapter that fails to load drops its player item, so the next tap loads
  it again instead of playing the previous file and saving its time against
  the new chapter. One that fails while playing, or as the previous file
  ends, pauses first and explains, as a tapped play does, so the app doesn't
  go on saying it plays, in silence.
- A chapter change whose load was cancelled (a pause, an interruption)
  doesn't keep the previous file's item, so the next play loads the new
  chapter. A play that finds the next chapter still loading waits for it.

Downloading them:
- A file that 404s is looked up once more. The retry runs on the session's
  delegate queue, a background wake waits for it, and a cancel stops it,
  ends the book's progress and reports nothing; otherwise its failure says
  why. It checks it still belongs to the download in flight, not just to
  its path. A file being retried isn't counted as finished, so the volume
  stays tracked if its other files finish first. Failed lookups get their
  own messages.
A stream import from the media-server browser is now created in the folder
being browsed, as that folder is now, and its placement prompt shows when it
should and says where the import landed.

- Like Android's basePath, a stream import made inside a folder is created
  there instead of at the library root and then moved: one registration per
  item instead of a registration and a move, and a volume's name is checked
  where it ends up. A single book's file name stays unique across the
  library (an offloaded book is restored by it). File imports still land at
  the root and move, since they're copied into the Processed root.
- The target folder was the node stored when it was opened, which a sync
  pull can rename (a stream then went to the root), replace at that path
  (the file import's sync move named the old uuid, got Item not found and
  parked the sync lane) or delete (a file import moved under a path that no
  longer existed). It's now the folder found by its uuid, else the folder at
  its old path with that folder's own uuid, else the root, and the prompt
  describes where the import actually landed.
- Stream from a book's details closes the browser too, as Stream from the
  list does. The prompt waits for the browser to be gone, so closing it is
  what lets the prompt show, and what shows the import happened.
- A stream import shows the file import's toolbar spinner and title while
  its items are inserted and matched on Hardcover, until its prompt is
  ready.
- A prompt iOS dropped counts as on screen until cleared, and only closing
  the import screen cleared it. Stream imports never use that screen, so
  after one dropped prompt every later stream prompt was discarded. Closing
  anything that covers the library now clears it too: a cover can't open
  over a prompt that's really on screen.
- An import that created nothing shows no prompt, instead of "Import 0 files
  into" with options that had nothing to act on.
…ther copy

A stream import skipped a book already in the library, so streaming it again
into another folder did nothing. It now makes another copy, as importing a file
again does: its own row, link and name (`-<uuid prefix>` on a clash, as for any
streamed book), so a user can keep one book in several folders. The copies share
the server's progress: each pushes to the same server item, and the progress
pull already updates every row linked to it. The API keeps one link per library
item, so it needs no change.

With several rows per server book, a link is found through its item. The lookup
by (providerName, providerId) is gone, and a finished download marks the link of
the book that downloaded rather than the first one with that server id.
Android still skips a re-import; it follows in its own PR.
Moving an item out of a folder ranks the rest of it again, and those rank
updates were published with only a path. develop's task storage refuses a task
without a uuid, so they were dropped and that order never synced; the sync-queue
rewrite relaxed that guard, and the update task's force-cast crashed the app
(any move out of a non-empty folder, with sync on).

The rank updates now carry each item's uuid, so that order syncs (only the
ranks that change, as a reorder sends them: each is a request), and an update
task without a uuid is refused instead of trapping.
A move rebuilt an item's path at the root from its originalFileName and never
checked that path: a streamed copy (named apart with `-<uuid prefix>`) moved
onto the original's path, giving two rows one path, and only a file in the way
ever stopped a move. Now, as on Android:

- Each item moves under its current name, at the root too (its file moves under
  that name anyway). One whose name another item or a file already has at the
  destination stays where it is; the rest move. moveItems returns what moved and
  what it left (an item gone from the library is in neither, so no sync move is
  queued for it: the server would answer Item not found). Callers sync only what
  moved and report a clash with the error FileManager throws for a move onto a
  taken name, worded and localized by iOS.
- The folder the items left gets its details rebuilt by the move itself, so a
  clash reported afterwards doesn't leave it stale.
- "Delete folder only" is refused when a child's name is taken in the folder's
  parent: nothing moves, and the folder (which would take that child with it)
  isn't deleted.
- A move into a folder that no longer exists throws, instead of filing the items
  at the root under its path.
A progress push now carries the book's lastPlayDate, as the Android app's does
(bookplayer-android #130 and #132). The queued task already stored the date,
but the push never read it.

- Jellyfin gets LastPlayedDate. It stores a date only when a client sends one.
- AudiobookShelf gets lastUpdate, in epoch milliseconds. Without it, ABS
  records when the push landed. It honors ours when updating an existing
  entry; a book's first write still takes the server's clock.

The date is persisted with the task, like the position, so a stored value no
real date has (NaN, infinite, past the year 5000) is left out instead of
trapping the conversion on every pop. A task without a date sends none, and
the server keeps or stamps its own.
GianniCarlo and others added 30 commits October 10, 2026 20:22
The title and author used the theme's fixed colors, which get no vibrancy
on glass: the author's gray measured about 3:1 against the accessory in
dark mode on iOS 27, as it did on the old overlay. The system primary and
secondary styles adapt to the glass (4.2:1 there, 6.1:1 on iOS 26.5, and
better in light mode too), and they already were what Reduce Transparency
used, so that branch goes.

The labels also stack with no spacing again, as on the old overlay: the
default spacing put the author about 8 pt from the capsule's bottom edge.
…essory

Use the native tab accessory for the mini player on every iOS version
Four spots placed or sized things from UIScreen.main, which is the whole
display. The app's window can be smaller than that: in Split View, and on
iPhone Duo, where it also resizes when the phone opens or closes. They now
read the window or the view instead:

- The UIKit loading spinner centers on its window and stays centered when
  the window resizes. Blocking (window interaction off) is unchanged.
- Button-free mode's trailing-edge exclusion uses the gesture area's width.
- The confetti emitter spans its own view and follows it when it resizes.
- The player's swipe-to-dismiss threshold uses the view's displayScale,
  the same value as before on a standard iPhone at the default zoom.

The other UIScreen.main reads (theme brightness and light/dark, the share
extension) don't affect layout and stay as they are.
Closing search clears the text but left the All / Books / Folders scope as
it was. The list counts a scope other than All as filtering, so it kept
showing every folder (or book) in the folder's subtree, with the scope bar
hidden and nothing on screen saying the list was filtered. Reopening search
didn't help either: the scope bar only shows once there's text. The only way
out was to type something, pick All, and close again.

The scope now goes back to All whenever search closes, which also matches a
newly opened folder. The in-field clear button and hiding the keyboard keep
search open, so they keep the scope.
iPhone had only the Search tab and iPad only the per-folder search, picked by
device idiom. On iPhone Duo the idiom is the phone's on both displays, so the
open phone got the iPhone set even at iPad size. Both are now on every
device: the Search tab finds books across the library, and each folder has
its own search with the All / Books / Folders scopes.

Selecting the Search tab now activates its search, so its field shows up
focused right away; on iPhone the tab opened with no field visible. Its
prompt reads "Search Books" (it finds books only), from existing strings, so
it doesn't read the same as the library root's "Search Library".
… iPad

The orientation lock saved the device's orientation when it was turned on.
A phone lying flat reports face up, which counted as portrait, so turning
the lock on in bed with the app in landscape flipped it to portrait. It now
saves the interface's orientation, in the same stored format, so existing
locks keep working.

iPad never honored the lock: iOS doesn't ask an app that supports
multitasking for its supported orientations. iOS 26's interface orientation
lock preference didn't help either; the iPad simulator never applied it, in
full screen or windowed. So the toggle now shows on iPhone only, where it
works, including iPhone Duo while closed (its inner display ignores locks).

The rule both root controllers repeated moves to OrientationLock.
Offer both kinds of search on every device
OrientationLock takes the UserDefaults to use (.standard by default) so the
tests can pass their own suite. They pin the stored format: values earlier
versions saved (including face up and face down, which lock to portrait), the
interface orientation saved now, and clearing it.
In landscape the player squeezed its portrait layout into a short screen:
the artwork shrank to under a third of its size, a book without artwork had
its title and author cut, and swipe-to-close needed most of the screen.

On a short screen (vertical size class compact, every iPhone in landscape)
the artwork now sits on the left, as large as fits above the button row,
with the chapter row, progress and playback controls on the right. The
button row stays across the bottom, its buttons kept together in the middle.
An AnyLayout switches between the two, so rotating moves the views instead
of rebuilding them. Swipe-to-close needs half the distance side by side.
* feat(player): show chapters and bookmarks beside the player on iPhone Duo

On iOS 27.1 the player is the primary side of an ArrangementView whose
secondary side is a panel with the chapters and bookmarks, switched with a
segmented control. ArrangementView shows it when there's room: the open
iPhone Duo in landscape, split on the fold when half-folded. Closed, or open
in portrait, the player is alone as before.

In the panel the lists have no close button and picking a row doesn't
dismiss. The chapters checkmark follows playback and the bookmarks reload
after any save that touches one, so both stay current while on screen. The
player's list button hides while the panel shows.

CI moves to Xcode 27.1, which has the iOS 27.1 SDK.

* fix(player): address review feedback on the Duo panel

- More no longer offers the chapters or bookmarks while the panel shows
  them. PlayerView reads whether the panel shows once and passes it to the
  button row and the More menu.
- The chapters list scrolls to the new chapter only while the playing
  chapter's row is on screen, so it doesn't jump away from chapters someone
  is browsing.
- BookmarksViewModelTests cover the reload: a save touching a bookmark
  reloads the list, a save touching other entities doesn't.

* fix(player): don't scroll the chapters list when the chapter changes

Following playback pulled the list away from chapters someone was
browsing. Tracking which rows are on screen to avoid that wasn't reliable
(the list drops some "row left the screen" events during a fling), and
the visibility modifier also kept the rows' background from reaching the
list. The list scrolls to the playing chapter when it opens, as before;
the checkmark still moves with playback.

* refactor(player): keep the list button and move the lists' chrome to their presenters

- Drop the panel detection (PlayerListPanelReader, the list button and More entry
  hidden while the panel shows). SwiftUI can't tell whether ArrangementView shows its
  secondary side, and splitArrangementAxis reads horizontal on a closed Duo in landscape
  too, which hid the list button with no panel on screen. Both always show now.
- ChaptersView and BookmarksView no longer know where they're shown: no isBesidePlayer,
  no NavigationStack, close button or dismiss of their own. PlayerView's PlayerListSheet
  wraps them for the sheets (navigation stack, close button, onSelect closes the sheet
  after a pick); the panel keeps its own navigation stack with the Chapters | Bookmarks
  switch, and a pick there leaves it on screen.

* refactor(player): reload bookmarks from a LibraryService publisher

BookmarksViewModel followed every Core Data save in the app and filtered the saved object
IDs for a Bookmark. Every bookmark write already goes through LibraryService (createBookmark,
addNote, deleteBookmark, and sync's addBookmark(from:)), so the service now says so itself:
bookmarksUpdatePublisher sends the book's path after each write, like its metadata and
progress publishers. Sync sends only when it adds a bookmark or changes its note, so an
unchanged listing doesn't reload anything. The view model reloads when the path is its book's.

Tests: the view model tests drive the mock's publisher instead of posting fake saves; new
LibraryService tests check which writes send the path.

* refactor(player): follow the current chapter with switchToLatest

ChaptersViewModel subscribed to the item's $currentChapter inside the current-item sink,
cancelling and rebinding a second subscription on every book change. Mapping each item to
its $currentChapter and switching to the latest does the same in one pipeline, like
PlayerManager.currentProgressPublisher(): the previous book's updates stop on their own.

* fix(player): whole-row taps in the Duo panel, and Reload as an icon

- PlayerListPanel: the player's .buttonStyle(.plain) reached the panel's lists, so only a
  row's text took taps and rows had no highlight. The lists get .buttonStyle(.automatic)
  back, as in the sheets.
- ChaptersView: Reload is an icon (arrow.triangle.2.circlepath), like Share. On the open
  Duo the navigation bar moves trailing icon items into the status bar strip and keeps text
  ones in the bar, so the two lists put their action in different places.
- Reload and Share move from .confirmationAction to .primaryAction: neither confirms
  anything, and an icon in the confirmation slot is drawn as a filled confirm button.

* perf(player): one bookmarks reload per burst of writes

Bookmark writes come in bursts: a sync pull adds a book's bookmarks one by one, and an
automatic bookmark is a delete, a create and a note. Each write reloaded the list (four
fetches on main). The open book's writes are now debounced by 100 ms into one reload;
other books' writes are filtered out first, so they can't push it back.
…ose (#1642)

* feat(player): show the lists below the player on iPhone Duo in portrait

- PlayerListPanel: the split may also put the lists below the player, but only when the
  whole screen is regular width (iPhone Duo open in portrait). Without that, the closed
  Duo split too and the player got a 334 x 283 pt strip.
- PlayerView: the player's layout moves into PlayerContentView, inside the split, so it
  reads its own part's size classes (ArrangementView gives each side its own). It goes
  side by side when it's wider than tall and its height is compact: above the lists it's
  as short as iPhone in landscape. The shape check is needed because with the lists
  hidden on the closed Duo in portrait, the split still reports a compact height. The
  drag to close moves with it; the offset and background stay in PlayerView, so the
  lists still move with the player.

* feat(player): lists below the player only across a fold (L pose)

- PlayerListPanel: the split goes top and bottom only when a fold runs across the screen
  (iPhone Duo half open in portrait, read from the active division reserved region). Flat
  in portrait the player keeps the whole screen; the regular-width gate is no longer
  needed, and iPad (no fold) never splits that way. The player is told it sits above the
  lists (isPlayerAboveLists) in the same update as the split.
- PlayerView: side by side when the screen's height is compact or the player is above the
  lists. This replaces the measured-shape rule, which switched the layout one pass after
  the split changed and made the player jump when the lists came in or went.
… line (#1645)

* fix(player): top-align the artwork side by side, keep the times on one line

- PlayerView: side by side, the artwork sits at the top of its column, level with the
  title row. Where its width limits it (iPhone Duo closed in landscape) it no longer drops
  below the title. Each side also keeps at least 40 pt from the edge (the gap between the
  artwork and the controls): the closed Duo has its camera on one side only, which left
  the content 22 pt from the other edge. The safe-area insets are read in the same layout
  pass (GeometryReader), so a rotation never shows uneven margins.
- ListeningProgressView: the times under the slider never wrap. Each side reserves the
  width of the longer time (the other is laid out hidden), so the label between them stays
  centered and is the one that gives way, truncating from the start ("...pter 3 of 6").
  With hours (a chapter or the whole book over an hour) the time wrapped on the closed
  Duo.

* fix(player): let the player's times wrap when even they don't fit

The times under the slider keep the room they need first, so at regular text sizes they
stay on one line as before; only when even they don't fit (the largest text sizes) were
they cut off by the one-line limit. Without it they wrap wherever the player has the
height for it.
…1646)

With "Open player on app launch" on, a cold launch with files waiting to import presented
the import screen (UIKit) and requested the player's cover (SwiftUI) from the same screen.
The cover couldn't present, but isShowingPlayer stayed true, so every later request,
the mini player included, set true to true and did nothing until the app was relaunched.

The import now wins:
- LibraryRootView: the launch checks for pending files first and skips opening the
  player when there are any.
- MainView.showPlayer(): requests to open the player are ignored while the import screen
  is up, so the flag can't be set with no cover to show.
On iPad and the open iPhone Duo the settings list sits beside the screen it opens; in the
book pose the two sides meet at the fold, so screens like the Tip Jar no longer straddle
it. iPhone and the closed Duo collapse it into one stack, as before.

- SettingsView: a NavigationSplitView. The list is a List(selection:) in the same grouped
  style (in a split view's first column a list defaults to the sidebar style); its
  NavigationLinks set the selection, and the detail shows that screen, or a gear with
  "Settings" before anything is picked.
- Narrower than 700 pt (the Duo open in portrait, 669 pt) the split view is told the width
  is compact, so it collapses into one stack, list first; otherwise the system hides the
  list behind its sidebar button and leaves an empty screen.
- It reaches the top of the screen (ignoring the top container safe area), so the columns'
  content scrolls behind the status bar as in the other tabs, with a theme background
  behind it instead of the split view's own (black in dark mode).
- The selected row is highlighted as a grouped list does it, about 7% of the theme's text
  color over the row (settingsRowBackground(for:), fed by the selectedSettingsScreen
  environment value): ThemedSection's row background replaces the system's selection.
Themes and App Icon showed the BookPlayer Pro card, the same one at the top of the
Settings list, which beside the list on a wide screen meant two of them on screen. A tip
is what unlocks the extra themes and icons, so those screens now offer it directly:

- While the account is free, their toolbar's Restore becomes "Unlock", which opens the Tip
  Jar in a sheet (unlocksWithTipJar(), with a close button). The Tip Jar keeps its own
  Restore, and its thank-you closes the sheet; the screens follow the account's access
  level, so they unlock as it updates. Accounts with access see neither.
- The Pro card is gone from both; showPro with it. The card stays at the top of the list.
- New string unlock_button in every locale.

Hardcover settings, nearby:
- "You can find your Hardcover API access at…" (was "Your can find…").
- The Progress Tracking footer renders its Markdown bold: Text only reads Markdown in a
  literal, so "**Currently Reading**" showed its asterisks.
* fix(onboarding): keep the story progress bars off the top edge

Where nothing sits above them, like iPhone Duo open in landscape (its
status bar moves to the side), they touched the top of the screen.

* feat(library): show the library as covers or a grid of cards

The Library options get a Covers / Grid / List picker, synced as
library_layout:style. Covers shows the artwork alone; Grid shows cards with
the duration, title and author. Tiles reorder from a long press on iOS 27,
accept files dropped from other apps, and show folders as a stack.

Two iOS 27.0 SwiftUI bugs shape the grid: a reorderable item whose view
comes down to a conditional crashes on lift (the tile is wrapped in a
ZStack), and a drop destination on any ancestor of the reorder container
misplaces drops. So other apps' files are now accepted per screen, right
after the reorder container, instead of by a tab-wide onDrop.

Tiles decode artwork at 420 pt. ArtworkService now drops that copy too
whenever an item's artwork is replaced or removed.

* fix(library): save the reorder within the drop again

The grid's drop moved items in memory and deferred the save and the reload
to the next turn, while chasing a drop that landed off its slot. That was
the drop destination's placement, not the save: reloadItems() fetches
synchronously, so the list's original save-then-reload already updates the
tiles within the drop, without a window where another reload could bring
back the old order.
Recently Played showed a placeholder for any cover over WidgetKit's
718,080-pixel limit, and Last Played decoded the whole cover before
shrinking it, which runs a widget extension out of memory on a large one.
Both now build an ImageIO thumbnail straight from the cached file at the
size they draw it, so the full cover is never decoded and every image is
far under the limit.
…ld (#1651)

* feat(library): fit the grid to wide screens, the Mac and the Duo's fold

On iPhone Duo half open in landscape (the book pose) the fold ran through
a column of tiles. The grid now reads the fold's position from
reservedRegions(kind: .division) and, while it runs top to bottom across
the grid, switches to fixed columns that leave it in the gap between two
of them (FoldedTileColumns): one tile width on both sides so rows still
line up, the leftover space at the outer edges.

Covers fit too many small tiles on wide screens: they now use a 140 pt
minimum in a regular-width window (100 pt otherwise). On a Mac, which
draws the app at 77%, every tile minimum is 1.3x and follows the Text
Size setting, as the fonts do.

Cards size each row to its tallest card instead of reserving two title
lines and an author line on every card: LazyVGrid stretches a tile to
its row, so a row lines up while short titles no longer leave a gap. The
author now gets up to two lines, and the text sits 6 pt below the cover
with 10 pt under it.

* fix(library): stop a folder tile re-laying out forever in right-to-left

In Arabic, Covers or Grid with a folder hung the app at launch on its
launch screen. The tile sizes its stack and progress mark from its own
measured width, and in a right-to-left layout that width came back a
rounding error apart on every pass (160.6, then 160.59999999999997), so
each update triggered another. The tile now only takes a width that
changed by half a point or more.
* Transfer books from a computer's browser on the same network

The library's add menu gets "Transfer from Computer". While its sheet is up, the
app runs an upload-only HTTP server (SwiftBrowserDrop 1.0, on Network.framework's
NetworkListener) and shows the address to open, port 80 first, then 8080. Files
dropped on the page wait in a tmp staging folder, and only complete ones are listed.

The sheet is the ImportConfirmationView shell, which gains an empty-state slot for
the address and Copy, then lists the files like an import, removable. Done moves them
into an owned holding folder and sends an ImportOperation on operationPublisher, the
same step as the import screen's Done, so it goes straight to the placement prompt.
ImportOperation's new ownedSourceRoot deletes those sources, suggests no folder named
after the holding folder, and removes it when finished.

Cancel, or the app being killed, wipes everything (leftovers are removed at launch).
The background suspends the server and the foreground resumes it on the same port,
keeping what arrived. The sheet keeps the screen awake while it's up.

Co-authored-by: Pavel Kuzmin <Virus191288@gmail.com>

* Address the local review of the Transfer sheet

- The list's header counts the files inside folders (the shell's new fileCount), as the
  UIKit import screen does, instead of the rows: a 20-file folder no longer reads "1 File".
- The shell's empty state scrolls once the largest text sizes make it taller than the
  screen, where it clipped the Copy button, and stays centered when it fits. It also gets
  the theme's navigation bar colour scheme, which only the list had.
- Copy announces itself to VoiceOver, and every tap gets its own haptic and two seconds.
- An interruption while the app is inactive (Control Center over the sheet) resumes at
  once: coming back from inactive doesn't send willEnterForeground.
- TransferViewModel gets tests against a real server on 127.0.0.1 (rows, Done's single
  import, removal before Done, Cancel), and ImportOperation tests for a folder item and a
  source outside the owned folder.

* Tighten the Transfer tests and keep Copy's spoken label fixed

- Done's test now checks that a later Cancel leaves the handed-over files alone and the
  session closed, and Cancel's test that staging was the test's own before it's wiped
  (a missing root no longer counts as wiped).
- The removal test and the rows doc no longer claim more than they show.
- VoiceOver keeps hearing "Copy address" on the button: the announcement says it worked,
  and a label changing under focus can be read again over it.

* Fall back to any free port and drop the Transfer failure state

The server now tries port 80, then 8080, then any free port (the address shows the port),
so listening practically never fails. When it does, the sheet shows nothing new: the
browser already says it can't reach the phone, and coming back to the app tries again.
So the empty state's failure message and Retry button go, and so does
transfer_server_start_failed_message in every language. The list always shows its usual
caption.

* Rename the library's integration sheet to LibrarySheet

Transfer from Computer isn't an integration: in the app that word means a third-party
service you connect an account to (Media Servers, Hardcover). What the two sheets share is
being presented over the library from MainView, so IntegrationSheet becomes LibrarySheet
and activeIntegrationSheet becomes activeLibrarySheet. No behavior change.

* Keep ImportOperation's source deletion as it was

The owned holding folder is removed when the import finishes, so deleting each of its
files right after copying bought nothing: holding and Processed share the app
container's volume, where copies are clones. removesSource goes, and both call sites
ask DataManager.isAppManagedSource again, exactly as on develop.

---------

Co-authored-by: Gianni Carlo <gcarlo89@hotmail.com>
* feat(siri): suggest books in Now Playing while nothing plays

iOS never offered BookPlayer's books on the Lock Screen, in Control
Center or in the Home app while nothing played. Those suggestions come
only from SiriKit media, and the app donated a bare INPlayMediaIntent
(no book, no response) once per launch, never opted in through the
intent definition, and declared no media category.

MediaSuggestionsController (owned by AppServices) now donates an
INPlayMediaIntent naming the book as its container each time a book
starts, sets the 5 most recent unfinished books as upcoming media, and
makes an INMediaUserContext current at launch (the book count, and
subscribed for PRO/LITE only). PlayMedia gets the mediaContainer-only
supported combination, which is the opt-in, and the app declares the
intent and the Audiobooks media category. A book's donations are
deleted when it's finished or deleted, and when an id played from one
is gone or now plays as another item.

The app handles INPlayMediaIntent itself (application(_:handlerFor:))
instead of the Intents extension: a suggestion, Siri or a Shortcut
plays the book it names through awaitCoreServices and
loadAndKeepAlive, cold launch included. It used to answer success and
play the last book, or nothing on a launch in the background. A spoken
title or author plays the most recently played match, a part of a bound
book counting as the bound book.

* perf(siri): shrink suggestion covers once per launch with ImageIO

The covers sent with donations and the upcoming list were shrunk with
UIImage(data:) and byPreparingThumbnail, from the original cover in
the artwork cache (often thousands of pixels), and every refresh of the
list shrank all five again although their covers rarely change.

They're now decoded straight to 256 px with ImageIO's thumbnail API, as
the widgets do, so the full cover is never decoded; @Concurrent keeps
the decode off the main actor. The controller keeps each book's shrunk
cover for the launch: dropped when any cover changes (a cover read
before the change isn't stored), trimmed to the listed books after each
refresh, and not kept while a book has none, since the library can
cache one without an artwork update. A bound book whose folder has no
cover cached yet still donates the playing part's, but that one isn't
kept, so the folder's own replaces it.

* fix(siri): play the book a media intent names when it opens the app

When iOS opens the app with an INPlayMediaIntent instead of handing it
to PlayMediaIntentHandler in the background, it arrives as a user
activity. While the app was running, CommandParser turned any media
intent into "play the last book", which is wrong now that donations
and suggestions name a book; on a cold launch the activity was
dropped, since scene(_:willConnectTo:) only handled the playback
activity.

ActionParserService now sends a media intent's activity through the
handler, which plays the book it names (or the last one when it names
none), and the scene passes a launch's media intent activity to it.

* fix(siri): don't donate a book left while its cover was read

A new donation task replaced the previous one without cancelling it.
If book A's cover was still being read when book B started and B's
finished first, A was donated after B, and iOS would treat A as the
more recent play. Starting a book now cancels the previous book's
pending donation, which is skipped once its cover read returns.
The Intents extension only handled the custom Sleep Timer intent, which
the App Intents already cover (a duration, the end of the chapter,
cancel), and the Time Listened widget was the last thing configured
through a custom SiriKit intent. Keeping them meant an app extension
of its own, its provisioning profile, and intent classes generated
into both frameworks.

The extension goes, along with the custom intents in the intent
definition (only PlayMedia stays, compiled into the app alone), the
sleep timer's intent donations, CommandParser's SleepTimerIntent
parsing and the playback activity's Siri donation. That activity still
plays the last book when a shortcut saved from it opens the app; a
media intent's activity is played by ActionParserService. Shortcuts
built with the old Sleep Timer action don't migrate (decided).

The Time Listened widget moves to AppIntentConfiguration with
TimeListenedConfigurationIntent, which adopts
CustomIntentMigratedAppIntent for PlayAndSleepActionIntent and keeps
its parameter names, types and timer option names, so widgets already
placed keep their settings. Without it, a widget set up with SiriKit
froze and couldn't be edited until it was added again.
The four fruit-based-26 alternate icons were the same 2048x2048 16-bit PNG,
8.75 MB each. They're loose bundle files, so App Thinning can't drop them
and Xcode's copypng copies them unchanged: about 35 MB of the App Store
size since 5.13.0. Resize them to the sizes their siblings use, 120/180 px
on iPhone and 152/167 px on iPad.

Also drop the 463 KB Adobe XMP chunk from each retro-icon PNG. The pixels
are unchanged, and copypng already strips it from the build, so this only
shrinks the repo.
Hardcover's API page now hands out bare keys (hc_pat_…), but the API still
requires `Authorization: Bearer <key>`, and the app sent the saved text as
the whole header. A key pasted as shown was rejected ("Invalid Authorization
format"). The key is now saved bare (a pasted "Bearer " is dropped) and every
request adds the scheme; tokens saved with it before read without it, so
existing links keep working. The placeholder shows the new key format.
#1656)

* fix(onboarding): skip the second onboarding when its prices don't load

The second onboarding priced its paywall with
`Purchases.shared.products([defaultOption]).first!`. RevenueCat's
`products(_:)` can't throw: any failure (a StoreKit error or timeout, an
id the storefront doesn't have) comes back as an empty list, so the force
unwrap crashed. The onboarding starts from a Task at launch, so it crashed
even after the app had gone to the background. The server keeps offering
the onboarding until it hears `second_onboarding_start`, which is only
sent once the stories are on screen, so on a device where StoreKit failed
the app crashed on every launch (one 5.22.2 user: 10 crashes in a day).

The stories are now built only when every price the paywall needs loaded,
its default and all its options. Otherwise the onboarding is skipped and
offered again on a later launch. That also covers the slider's
`options.first!` / `.last!`, which trapped when the options came back
empty. Each paywall's products are looked up once instead of twice, so a
hung StoreKit request costs one wait, and the lookup is injectable for
tests.

Fixes BOOKPLAYER-11HV

* fix(onboarding): label the custom-amount slider without force unwraps

The slider's end labels read `action.options.first!` / `.last!`. Most
onboardings the server sends have no options (the support_paywall and
tips ones: only the PRO default and the tip jar), and an action with no
options but with slider_options would crash on "Choose custom amount".
No onboarding sends that today; the labels now fall back to the slider's
own min and max.

Also tests the no-options paywall, the most common shape: only its
default is looked up, and without that price the onboarding is skipped.
The upgrade scenario retargets a copy of the candidate's .xctestrun at the
previous release's app but kept the candidate's DYLD_FRAMEWORK_PATH and
DYLD_LIBRARY_PATH, so the old app loaded the candidate's BookPlayerKit.
Once #1653 removed TimeParser.getTimerOption(from:), 5.22.2 stopped at
launch with a missing symbol, the check fell back to building 5.22.1, and
6.0.0 couldn't get an upgrade result. The copy now drops every DYLD_* entry
of the app's environment; the test runner's own variables stay.

This branch has not been deployed

No deployments
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.

3 participants