Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 101 additions & 34 deletions BookPlayer.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions BookPlayer/Base.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"settings_boostvolume_description" = "Doubles the volume.\nUse with caution and care for your hearing.";
"settings_globalspeed_title" = "Global Speed Control";
"settings_globalspeed_description" = "Set speed across all books.";
"settings_openplayer_launch_title" = "Open Player on Launch";
"settings_carplay_showplayer_title" = "Show Player on CarPlay Connect";
"settings_startupplayer_description" = "Open the player for the last played book when launching the app or connecting to CarPlay. On CarPlay, this interrupts audio playing in other apps.";
"settings_autolock_title" = "Disable Autolock";
"settings_autolock_description" = "Prevent the device from locking when on the Player screen.";
"settings_siri_lastplayed_title" = "Last played book";
Expand Down Expand Up @@ -102,6 +105,12 @@
"player_book_remaining_title" = "%@ left";
"chapters_title" = "Chapters";
"chapters_item_description" = "Start: %@ - Duration: %@";
"reload_button" = "Reload";
"reload_chapters_title" = "Reload Chapters";
"reparse_chapters_found_title" = "Chapters Reloaded";
"reparse_chapters_found_description" = "Found %d chapters.";
"reparse_chapters_none_description" = "No additional chapters were found in this file.";
"reparse_chapters_download_description" = "Download this book before reloading its chapters.";
"restore_title" = "Restore";
"themes_caps_title" = "THEMES";
"plus_app_icons_title" = "App Icons";
Expand Down Expand Up @@ -238,6 +247,7 @@
"gesture_swipe_vertically_title" = "Swipe vertically to create bookmark";
"details_title" = "Details";
"download_title" = "Download";
"download_incomplete_error" = "The download was incomplete. Please try again.";
"cancel_download_title" = "Cancel download";
"remove_downloaded_file_title" = "Remove from device";
"download_from_url_title" = "Download from URL";
Expand Down
58 changes: 58 additions & 0 deletions BookPlayer/Generated/AutoMockable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,26 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol {
loadChaptersIfNeededRelativePathAssetReceivedInvocations.append((relativePath: relativePath, asset: asset))
await loadChaptersIfNeededRelativePathAssetClosure?(relativePath, asset)
}
//MARK: - reloadChapters

var reloadChaptersRelativePathCallsCount = 0
var reloadChaptersRelativePathCalled: Bool {
return reloadChaptersRelativePathCallsCount > 0
}
var reloadChaptersRelativePathReceivedRelativePath: String?
var reloadChaptersRelativePathReceivedInvocations: [String] = []
var reloadChaptersRelativePathReturnValue: Int?
var reloadChaptersRelativePathClosure: ((String) async -> Int?)?
func reloadChapters(relativePath: String) async -> Int? {
reloadChaptersRelativePathCallsCount += 1
reloadChaptersRelativePathReceivedRelativePath = relativePath
reloadChaptersRelativePathReceivedInvocations.append(relativePath)
if let reloadChaptersRelativePathClosure = reloadChaptersRelativePathClosure {
return await reloadChaptersRelativePathClosure(relativePath)
} else {
return reloadChaptersRelativePathReturnValue
}
}
//MARK: - createFolder

var createFolderWithInsideThrowableError: Error?
Expand Down Expand Up @@ -1398,6 +1418,18 @@ class PlayerManagerProtocolMock: PlayerManagerProtocol {
jumpToChapterReceivedInvocations.append(chapter)
jumpToChapterClosure?(chapter)
}
//MARK: - reloadCurrentItem

var reloadCurrentItemCallsCount = 0
var reloadCurrentItemCalled: Bool {
return reloadCurrentItemCallsCount > 0
}
var reloadCurrentItemClosure: (() -> Void)?
@MainActor
func reloadCurrentItem() {
reloadCurrentItemCallsCount += 1
reloadCurrentItemClosure?()
}
//MARK: - markAsCompleted

var markAsCompletedCallsCount = 0
Expand Down Expand Up @@ -1687,6 +1719,32 @@ class SyncServiceProtocolMock: SyncServiceProtocol {
set(value) { underlyingDownloadErrorPublisher = value }
}
var underlyingDownloadErrorPublisher: PassthroughSubject<(String, Error), Never>!
//MARK: - updateSyncEnabled

var updateSyncEnabledCallsCount = 0
var updateSyncEnabledCalled: Bool {
return updateSyncEnabledCallsCount > 0
}
var updateSyncEnabledReceivedEnabled: Bool?
var updateSyncEnabledReceivedInvocations: [Bool] = []
var updateSyncEnabledClosure: ((Bool) -> Void)?
func updateSyncEnabled(_ enabled: Bool) {
updateSyncEnabledCallsCount += 1
updateSyncEnabledReceivedEnabled = enabled
updateSyncEnabledReceivedInvocations.append(enabled)
updateSyncEnabledClosure?(enabled)
}
//MARK: - logout

var logoutCallsCount = 0
var logoutCalled: Bool {
return logoutCallsCount > 0
}
var logoutClosure: (() async -> Void)?
func logout() async {
logoutCallsCount += 1
await logoutClosure?()
}
//MARK: - queuedJobsCount

var queuedJobsCountCallsCount = 0
Expand Down
2 changes: 1 addition & 1 deletion BookPlayer/Library/ItemList/ItemListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ struct ItemListView: View {
preferencesService.register(folderUuid: uuid)
}
}
.onReceive(preferencesService.preferencesChanged) { key in
.onReceive(preferencesService.preferencesChanged.receive(on: DispatchQueue.main)) { key in
// Server-driven sort change for the location currently on screen:
// PreferencesSyncService has already rewritten orderRank in CoreData
// via dispatchResort → sortContents. The cached `[SimpleLibraryItem]`
Expand Down
8 changes: 8 additions & 0 deletions BookPlayer/Library/ItemList/LibraryRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ struct LibraryRootView: View {

func handleLibraryLoaded() async {
await loadLastBookIfNeeded()
/// Open the player on launch when enabled and a book is loaded. Checked here, after the load above,
/// so it covers both a plain cold launch (where `loadLastBookIfNeeded` just loaded the last book)
/// and the case where the book was already loaded by another scene (e.g. CarPlay) — which makes the
/// `currentItem == nil` guard inside `loadLastBookIfNeeded` return early.
if UserDefaults.standard.bool(forKey: Constants.UserDefaults.openPlayerOnAppLaunch),
playerManager.currentItem != nil {
playerState.showPlayer = true
}
importManager.notifyPendingFiles()
showSecondOnboarding()

Expand Down
3 changes: 3 additions & 0 deletions BookPlayer/Library/ItemList/Views/BookView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,12 @@ struct BookView: View {
let audioMetadataService = AudioMetadataService()
let libraryService = LibraryService()
libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService)
let accountService = AccountService()
accountService.setup(dataManager: dataManager)
syncService.setup(
isActive: true,
libraryService: libraryService,
accountService: accountService,
dataManager: dataManager
)

Expand Down
12 changes: 12 additions & 0 deletions BookPlayer/Library/ItemList/Views/ItemArtworkView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ struct ItemArtworkView: View {
) { _ in
downloadState = .notDownloaded
}
/// A download/verification failure (e.g. a discarded truncated file) must reset
/// the cell — otherwise, now that completion is gated on verification, it would
/// stay stuck on the progress spinner until the view recomputes from disk. The
/// error payload only carries `relativePath`, so this matches the item directly
/// (a bound-book child error doesn't reset the parent cell — same limitation as
/// the Watch).
.onReceive(
syncService.downloadErrorPublisher
.filter { $0.0 == item.relativePath }
) { _ in
downloadState = .notDownloaded
}
}

@ViewBuilder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ struct PercentageProgressView: View {
}

var body: some View {
Group {
// `progress` can arrive non-finite (e.g. an item whose duration is still
// 0 makes `percentCompleted` 0/0 = NaN). `Int(NaN)` traps at runtime, so
// collapse any non-finite value to 0 and clamp into the expected 0...1.
let progress = self.progress.isFinite ? min(max(self.progress, 0), 1) : 0
return Group {
if progress == 0 {
EmptyView()
} else if progress == 1 {
Expand Down
25 changes: 8 additions & 17 deletions BookPlayer/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ struct MainView: View {
@Environment(\.libraryService) private var libraryService
@Environment(\.playerState) private var playerState
@Environment(\.syncService) private var syncService
@Environment(\.accountService) private var accountService
@Environment(\.jellyfinService) private var jellyfinService
@Environment(\.audiobookshelfService) private var audiobookshelfService
@Environment(\.playbackService) private var playbackService
Expand Down Expand Up @@ -124,22 +123,14 @@ struct MainView: View {
playerState.showPlayer = false
}
}
.onReceive(
NotificationCenter.default.publisher(for: .accountUpdate, object: nil)
) { _ in
guard accountService.hasAccount() else { return }

if accountService.hasSyncEnabled() {
if !syncService.isActive {
syncService.isActive = true
Task {
try? await listSyncRefreshService.syncList(at: nil)
listState.reloadAll()
}
}
} else if syncService.isActive {
syncService.isActive = false
syncService.cancelAllJobs()
// SyncService owns the active/inactive decision (it observes account/subscription
// changes itself). The view only reacts to sync becoming active to refresh the
// library list — it no longer writes `isActive`.
.onChange(of: syncService.isActive) { _, isActive in
guard isActive else { return }
Task {
try? await listSyncRefreshService.syncList(at: nil)
listState.reloadAll()
}
}
}
Expand Down
75 changes: 75 additions & 0 deletions BookPlayer/Player/PlayerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject {
private var timeControlSubscription: AnyCancellable?
private var playableChapterSubscription: AnyCancellable?
private var isPlayingSubscription: AnyCancellable?
/// Tracks the brief muted play used to claim Now Playing on CarPlay connect, so we can pause once it starts
private var nowPlayingClaimSubscription: AnyCancellable?
private var periodicTimeObserver: Any?
private var disposeBag = Set<AnyCancellable>()
/// Flag determining if it should resume playback after finishing up loading an item
Expand Down Expand Up @@ -714,6 +716,21 @@ extension PlayerManager {
jumpTo(chapter.start + 0.1, recordBookmark: false)
}

@MainActor
func reloadCurrentItem() {
// Rebuild the in-memory item from storage so externally-changed data (e.g. re-parsed
// chapters) takes effect. Playback position is preserved — it's persisted in Core Data and
// re-seeded by time in `PlayableItem.init` — and the chapter-change subscription must be
// re-bound to the new instance, otherwise the end-of-chapter sleep timer silently breaks.
guard let relativePath = currentItem?.relativePath,
let libraryItem = libraryService.getSimpleItem(with: relativePath),
let updatedItem = try? playbackService.getPlayableItem(from: libraryItem) else {
return
}
currentItem = updatedItem
bindPlayableChapterSubscription(to: updatedItem, dropInitialReplay: true)
}

func initializeChapterTime(_ time: Double) {
guard let currentItem = self.currentItem else { return }

Expand Down Expand Up @@ -870,6 +887,64 @@ extension PlayerManager {
play(autoPlayed: false)
}

/// Take over the system Now Playing slot for CarPlay. iOS only designates the Now Playing app from
/// one that *actually plays* (a third-party app can't fake `playbackState` — that's an Apple-private
/// entitlement), so we briefly play **muted**, then pause the instant playback starts — landing on
/// our book paused, now owning Now Playing, with no audible blip. Skips if nothing is loaded or we're
/// already playing (we'd own it). A timeout fallback guarantees we unmute even if playback never starts.
///
/// Note: this *will* interrupt audio another app is actively playing. There's no reliable way to detect
/// that beforehand here — `isOtherAudioPlaying` / `secondaryAudioShouldBeSilencedHint` only update once
/// our own session is active (the very act we're trying to avoid), and read stale (false) on a CarPlay
/// background launch. So we accept the takeover as a known side effect.
///
/// Accepted edge: if the user taps play during the brief muted window, our `.first()` captures that
/// `.playing` and pauses it — they'd tap again. Distinguishing their play from ours on the shared
/// player isn't reliable, and the window is short, so we accept it. Returns whether attempted.
@MainActor
@discardableResult
func claimNowPlayingThenPause() -> Bool {
/// `nowPlayingClaimSubscription == nil` gates re-entrancy: a claim stays "in flight" (subscription
/// non-nil) until its unmute completes below, so a second blip can't start mid-claim and get unmuted
/// by this one's timer.
guard currentItem != nil, !isPlaying, nowPlayingClaimSubscription == nil else { return false }

audioPlayer.isMuted = true
nowPlayingClaimSubscription = timeControlPassthroughPublisher
.filter { $0 == .playing }
.first()
// DispatchQueue (not RunLoop) so the timeout still fires while the run loop is in tracking mode
// (e.g. CarPlay scrolling).
.timeout(.seconds(3), scheduler: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] _ in
guard let self else { return }
/// Pause regardless of how we completed: on success this is the intended pause; on **timeout**
/// it cancels the still-pending `play()` (which re-checks `Task.isCancelled` after the async
/// `prepareForPlayback`), so a slow/streaming load can't end up playing aloud and un-paused.
self.pause()
/// Unmute only after the pause settles — `timeControlStatus` reaches `.paused` asynchronously,
/// so unmuting on the same tick can leak a few ms of audio (same reason `bindPauseObserver`
/// delays). Clear the subscription here (not earlier), so the re-entrancy guard keeps blocking
/// a new claim until we're fully done and this timer can't unmute someone else's blip.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
guard let self else { return }
self.audioPlayer.isMuted = false
self.nowPlayingClaimSubscription = nil
}
},
receiveValue: { [weak self] _ in
/// We actually became the Now Playing app — re-publish the cover, whose async load can finish
/// before we own the slot (so its push is ignored) and is never re-pushed otherwise.
guard let self, let chapter = self.currentItem?.currentChapter else { return }
self.setNowPlayingArtwork(chapter: chapter)
}
)

play()
return true
}

/// Persist a marker so the next successful activation can report whether — and how —
/// the audio session recovered. Beta builds only.
private func markAudioSessionFailure(_ error: NSError) {
Expand Down
3 changes: 3 additions & 0 deletions BookPlayer/Player/PlayerManagerProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public protocol PlayerManagerProtocol: AnyObject {
func directSkip(_ interval: TimeInterval)
func jumpTo(_ time: Double, recordBookmark: Bool)
func jumpToChapter(_ chapter: PlayableChapter)
/// Rebuild `currentItem` from storage (and re-bind the chapter subscription) so externally
/// changed data — e.g. re-parsed chapters — takes effect. Preserves playback position.
@MainActor func reloadCurrentItem()
func markAsCompleted(_ flag: Bool)
func setSpeed(_ newValue: Float)
func setBoostVolume(_ newValue: Bool)
Expand Down
42 changes: 40 additions & 2 deletions BookPlayer/Player/Views/Chapters/ChaptersView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,45 @@ struct ChaptersView: View {
.navigationTitle("chapters_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("done_title") {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Label("voiceover_close_button", systemImage: "xmark")
}
.foregroundStyle(theme.linkColor)
}
if model.canReloadChapters {
ToolbarItem(placement: .confirmationAction) {
reloadButton
}
}
}
.bpAlert($model.currentAlert)
}
}
}

@ViewBuilder
private var reloadButton: some View {
Button {
Task { await model.reloadChapters() }
} label: {
// Keep the title laid out (just hidden) while loading so the spinner overlay doesn't
// change the toolbar item's width.
Text("reload_button")
.foregroundStyle(theme.linkColor)
.opacity(model.isReloadingChapters ? 0 : 1)
.overlay {
if model.isReloadingChapters {
ProgressView()
}
}
}
.disabled(model.isReloadingChapters)
.accessibilityLabel("reload_chapters_title")
}

@ViewBuilder
private func rowView(_ chapter: PlayableChapter, index: Int) -> some View {
let title =
Expand Down Expand Up @@ -83,16 +111,26 @@ struct ChaptersView: View {
}

extension ChaptersView {
@MainActor
class Model: ObservableObject {
@Published var chapters: [PlayableChapter]
@Published var currentChapter: PlayableChapter?
@Published var isReloadingChapters = false
@Published var currentAlert: BPAlertContent?

init(chapters: [PlayableChapter], currentChapter: PlayableChapter?) {
self.chapters = chapters
self.currentChapter = currentChapter
}

func handleChapterSelected(_ chapter: PlayableChapter) {}

/// Whether the "re-parse chapters" action applies to the current item.
var canReloadChapters: Bool { false }

/// Re-parse chapters from the file, replacing the list when more are found, and surface
/// the outcome via `currentAlert`.
func reloadChapters() async {}
}
}

Expand Down
Loading
Loading