Skip to content
Merged
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
4 changes: 4 additions & 0 deletions BookPlayer.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@
631908AA2E369BDB009249C1 /* ProgressSeekingSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908A92E369BDA009249C1 /* ProgressSeekingSectionView.swift */; };
631908AC2E369E31009249C1 /* GlobalSpeedSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908AB2E369E31009249C1 /* GlobalSpeedSectionView.swift */; };
631908AE2E369EDC009249C1 /* BoostVolumeSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908AD2E369EDC009249C1 /* BoostVolumeSectionView.swift */; };
631908F02E369EDC009249C1 /* StartupPlayerSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908F12E369EDC009249C1 /* StartupPlayerSectionView.swift */; };
631908B02E369FFA009249C1 /* AutoSleepTimerSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908AF2E369FFA009249C1 /* AutoSleepTimerSectionView.swift */; };
631908B22E36A1AB009249C1 /* SmartRewindSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908B12E36A1AB009249C1 /* SmartRewindSectionView.swift */; };
631908B42E36A26C009249C1 /* SkipIntervalsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631908B32E36A26C009249C1 /* SkipIntervalsSectionView.swift */; };
Expand Down Expand Up @@ -1329,6 +1330,7 @@
631908A92E369BDA009249C1 /* ProgressSeekingSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressSeekingSectionView.swift; sourceTree = "<group>"; };
631908AB2E369E31009249C1 /* GlobalSpeedSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSpeedSectionView.swift; sourceTree = "<group>"; };
631908AD2E369EDC009249C1 /* BoostVolumeSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoostVolumeSectionView.swift; sourceTree = "<group>"; };
631908F12E369EDC009249C1 /* StartupPlayerSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupPlayerSectionView.swift; sourceTree = "<group>"; };
631908AF2E369FFA009249C1 /* AutoSleepTimerSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoSleepTimerSectionView.swift; sourceTree = "<group>"; };
631908B12E36A1AB009249C1 /* SmartRewindSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartRewindSectionView.swift; sourceTree = "<group>"; };
631908B32E36A26C009249C1 /* SkipIntervalsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkipIntervalsSectionView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2683,6 +2685,7 @@
631908B12E36A1AB009249C1 /* SmartRewindSectionView.swift */,
631908AF2E369FFA009249C1 /* AutoSleepTimerSectionView.swift */,
631908AD2E369EDC009249C1 /* BoostVolumeSectionView.swift */,
631908F12E369EDC009249C1 /* StartupPlayerSectionView.swift */,
631908AB2E369E31009249C1 /* GlobalSpeedSectionView.swift */,
631908A92E369BDA009249C1 /* ProgressSeekingSectionView.swift */,
631908A72E3697DC009249C1 /* ListOptionsSectionView.swift */,
Expand Down Expand Up @@ -4599,6 +4602,7 @@
4151A6DD26E4A13A00E49DBE /* MainCoordinator.swift in Sources */,
634BA54C2C0C21AF0015314D /* SecondOnboardingCoordinator.swift in Sources */,
631908AE2E369EDC009249C1 /* BoostVolumeSectionView.swift in Sources */,
631908F02E369EDC009249C1 /* StartupPlayerSectionView.swift in Sources */,
63C6C2E62B5029BC00FFE0D8 /* SettingsAutolockView.swift in Sources */,
4124122826D19A8700B099DB /* StorageViewModel.swift in Sources */,
4158387926EB8D8800F4A12B /* LoadingViewController.swift in Sources */,
Expand Down
3 changes: 3 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
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
60 changes: 60 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 @@ -885,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
89 changes: 85 additions & 4 deletions BookPlayer/Services/CarPlayManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class CarPlayManager: NSObject {
private var disposeBag = Set<AnyCancellable>()
/// Reference for updating boost volume title
let boostVolumeItem = CPListItem(text: "", detailText: nil)
/// One-shot flag: when CarPlay connects with no loaded book yet, surface the
/// player on the next `.bookReady` (e.g. a resume-on-connect shortcut). Honors
/// the `carPlayShowPlayerOnConnect` setting without auto-loading a book ourselves.
private var shouldShowPlayerOnConnect = false

override init() {
super.init()
Expand All @@ -36,15 +40,83 @@ class CarPlayManager: NSObject {
func connect(_ interfaceController: CPInterfaceController) {
self.interfaceController = interfaceController
self.interfaceController?.delegate = self
/// Reset connect-scoped state so a re-connect without a paired disconnect doesn't act on a stale flag
self.shouldShowPlayerOnConnect = false
self.setupNowPlayingTemplate()
self.setRootTemplate()
/// On a cold launch, `initializeDataIfNeeded()` runs and rebuilds the root template in its
/// completion — which tears down anything we push now. So when init is pending, defer presenting
/// the player to that rebuild (see `initializeDataIfNeeded`); otherwise present once the root here
/// is committed (pushing synchronously, before CarPlay sets the root, races and gets dropped).
let willInitializeData = WindowHelper.activeWindow == nil
self.setRootTemplate { [weak self] _, _ in
if !willInitializeData {
self?.showPlayerOnConnectIfNeeded()
}
}
self.initializeDataIfNeeded()
}

/// On connect, jump to Now Playing if a book is already loaded; otherwise arm a one-shot
/// so the next `.bookReady` (e.g. a resume-on-connect shortcut) surfaces it.
@MainActor
private func showPlayerOnConnectIfNeeded() {
guard UserDefaults.standard.bool(forKey: Constants.UserDefaults.carPlayShowPlayerOnConnect) else {
return
}

if let playerManager = AppServices.shared.coreServices?.playerManager,
playerManager.currentItem != nil {
/// Take over the system Now Playing slot (brief muted play) so the pushed screen shows our book
/// instead of a blank placeholder.
playerManager.claimNowPlayingThenPause()
pushNowPlayingTemplate()
} else {
/// Nothing loaded yet; arm so the next `.bookReady` presents the player, and on a cold (killed)
/// launch — where no main-app window restored the last book — load it so that `.bookReady` fires.
shouldShowPlayerOnConnect = true
loadLastPlayedBookIfAvailable()
}
}

/// On a cold (killed) CarPlay launch there's no main-app window to run the last-book restore, so
/// `currentItem` is nil. Load the last played book (paused) so `.bookReady` fires and the armed
/// `shouldShowPlayerOnConnect` flag presents the player. Stays on the tabs if there's no last book.
@MainActor
private func loadLastPlayedBookIfAvailable() {
Task { @MainActor in
guard
let coreServices = AppServices.shared.coreServices,
coreServices.playerManager.currentItem == nil,
let lastItem = coreServices.libraryService.getLibraryLastItem()
else { return }

do {
try await coreServices.playerLoaderService.loadPlayer(
lastItem.relativePath,
autoplay: false,
recordAsLastBook: false
)
} catch {
/// The preload failed, so `.bookReady` won't fire — disarm so we don't stay armed waiting to
/// present. Stay silent (no alert): this is an automatic preload, and the user gets the proper
/// error if/when they tap the book themselves (same as the main app's cold-launch restore).
shouldShowPlayerOnConnect = false
}
}
}

func disconnect() {
self.interfaceController = nil
self.recentTemplate = nil
self.libraryTemplate = nil
self.shouldShowPlayerOnConnect = false
}

/// Push the shared Now Playing template, avoiding a duplicate push if it's already on top
@MainActor
private func pushNowPlayingTemplate() {
guard interfaceController?.topTemplate != CPNowPlayingTemplate.shared else { return }
interfaceController?.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil)
}

@MainActor
Expand All @@ -56,7 +128,10 @@ class CarPlayManager: NSObject {
let dataInitializerCoordinator = DataInitializerCoordinator(alertPresenter: self)

dataInitializerCoordinator.onFinish = { [weak self] in
self?.setRootTemplate()
self?.setRootTemplate { [weak self] _, _ in
/// Present the player now that the post-init root template is in place (deferred from connect)
self?.showPlayerOnConnectIfNeeded()
}
if let coreServices = AppServices.shared.coreServices {
coreServices.watchService.startSession()
let listRefreshService = ListSyncRefreshService(
Expand Down Expand Up @@ -125,6 +200,12 @@ class CarPlayManager: NSObject {
self.reloadRecentItems()

self.setupNowPlayingTemplate()

if self.shouldShowPlayerOnConnect {
self.shouldShowPlayerOnConnect = false
AppServices.shared.coreServices?.playerManager.claimNowPlayingThenPause()
self.pushNowPlayingTemplate()
}
})
.store(in: &disposeBag)

Expand Down Expand Up @@ -237,7 +318,7 @@ class CarPlayManager: NSObject {
}

/// Setup root Tab bar template with the Recent and Library tabs
func setRootTemplate() {
func setRootTemplate(completion: ((Bool, Error?) -> Void)? = nil) {
let recentTemplate = CPListTemplate(title: "recent_title".localized, sections: [])
self.recentTemplate = recentTemplate
recentTemplate.tabTitle = "recent_title".localized
Expand All @@ -248,7 +329,7 @@ class CarPlayManager: NSObject {
libraryTemplate.tabImage = UIImage(systemName: "books.vertical")
let tabTemplate = CPTabBarTemplate(templates: [recentTemplate, libraryTemplate])
tabTemplate.delegate = self
self.interfaceController?.setRootTemplate(tabTemplate, animated: false, completion: nil)
self.interfaceController?.setRootTemplate(tabTemplate, animated: false, completion: completion)
}

/// Reload content for the root library template
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ struct SettingsPlayerControlsView: View {
ProgressSeekingSectionView()
ListOptionsSectionView()
ProgressLabelsSectionView()
StartupPlayerSectionView()
}
.environmentObject(theme)
.scrollContentBackground(.hidden)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// StartupPlayerSectionView.swift
// BookPlayer
//
// Created by Gianni Carlo on 27/6/26.
// Copyright © 2026 BookPlayer LLC. All rights reserved.
//

import BookPlayerKit
import SwiftUI

struct StartupPlayerSectionView: View {
@AppStorage(Constants.UserDefaults.openPlayerOnAppLaunch) var openPlayerOnAppLaunch: Bool = false
@AppStorage(Constants.UserDefaults.carPlayShowPlayerOnConnect) var carPlayShowPlayerOnConnect: Bool = false

@EnvironmentObject var theme: ThemeViewModel

var body: some View {
ThemedSection {
Toggle(isOn: $openPlayerOnAppLaunch) {
Text("settings_openplayer_launch_title")
.bpFont(.body)
}
Toggle(isOn: $carPlayShowPlayerOnConnect) {
Text("settings_carplay_showplayer_title")
.bpFont(.body)
}
} footer: {
Text("settings_startupplayer_description")
.bpFont(.caption)
.foregroundStyle(theme.secondaryColor)
}
}
}

#Preview {
Form {
StartupPlayerSectionView()
}
.environmentObject(ThemeViewModel())
}
3 changes: 3 additions & 0 deletions BookPlayer/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"settings_boostvolume_description" = "ضاعف مستوى الصوت.\nاستخدم بحذر وعناية.";
"settings_globalspeed_title" = "التحكم بالسرعة الافتراضية لجميع الكتب";
"settings_globalspeed_description" = "تعيين السرعة على جميع الكتب.";
"settings_openplayer_launch_title" = "فتح المشغل عند بدء التشغيل";
"settings_carplay_showplayer_title" = "عرض المشغل عند الاتصال بـ CarPlay";
"settings_startupplayer_description" = "فتح المشغل لآخر كتاب تم تشغيله عند فتح التطبيق أو الاتصال بـ CarPlay. في CarPlay، يؤدي هذا إلى إيقاف الصوت قيد التشغيل في التطبيقات الأخرى.";
"settings_autolock_title" = "تعطيل القفل التلقائي";
"settings_autolock_description" = "منع قفل الجهاز عند فتح شاشة المشغل";
"settings_siri_lastplayed_title" = "آخر كتاب تم تشغيله";
Expand Down
3 changes: 3 additions & 0 deletions BookPlayer/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"settings_boostvolume_description" = "Dobla el volum.\nFeu servir amb precaució i cura de la vostra audició.";
"settings_globalspeed_title" = "Control global de velocitat";
"settings_globalspeed_description" = "Estableix velocitat en tots els llibres.";
"settings_openplayer_launch_title" = "Obre el reproductor en iniciar";
"settings_carplay_showplayer_title" = "Mostra el reproductor en connectar CarPlay";
"settings_startupplayer_description" = "Obre el reproductor amb l'últim llibre escoltat en iniciar l'aplicació o connectar-te a CarPlay. A CarPlay, això interromp l'àudio que s'estigui reproduint en altres aplicacions.";
"settings_autolock_title" = "Desactiva el bloqueig automàtic";
"settings_autolock_description" = "Eviteu que el dispositiu es bloquegi quan estigui a la pantalla del reproductor.";
"settings_siri_lastplayed_title" = "Últim llibre reproduït";
Expand Down
3 changes: 3 additions & 0 deletions BookPlayer/cs.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"settings_boostvolume_description" = "Zdvojnásobí hlasitost přehrávání.\nPoužívejte opatrně a chraňete svůj sluch.";
"settings_globalspeed_title" = "Nastavení rychlosti přehrávání";
"settings_globalspeed_description" = "Nastavit rychlost přehrávání pro všechny audioknihy.";
"settings_openplayer_launch_title" = "Otevřít přehrávač při spuštění";
"settings_carplay_showplayer_title" = "Zobrazit přehrávač při připojení CarPlay";
"settings_startupplayer_description" = "Otevřít přehrávač s naposledy přehrávanou knihou při spuštění aplikace nebo připojení k CarPlay. V CarPlay to přeruší zvuk přehrávaný v jiných aplikacích.";
"settings_autolock_title" = "Zakázat automatické uzamčení";
"settings_autolock_description" = "Zabránit uzamčení zařízení při zobrazení Přehrávače.";
"settings_siri_lastplayed_title" = "Naposledy přehrávaná audiokniha";
Expand Down
Loading
Loading