Repository navigation
Release 6.0.0 - #1658
Open
GianniCarlo wants to merge 103 commits into
Open
Release 6.0.0#1658GianniCarlo wants to merge 103 commits into
GianniCarlo wants to merge 103 commits into
Conversation
…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.
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.
Size from the window, not the screen
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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