Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
60 changes: 41 additions & 19 deletions BookPlayer.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions BookPlayer/AppIntents/BPAppShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ struct BPAppShortcuts: AppShortcutsProvider {
shortTitle: "intent_lastbook_play_title",
systemImageName: "play.fill"
),
AppShortcut(
intent: PlayBookIntent(),
phrases: [
"Play \(\.$book) in \(.applicationName)",
"Play the book \(\.$book) in \(.applicationName)",
"Listen to \(\.$book) in \(.applicationName)",
"Start \(\.$book) in \(.applicationName)"
],
shortTitle: "intent_play_book_title",
systemImageName: "play.circle.fill"
),
AppShortcut(
intent: PausePlaybackIntent(),
phrases: [
Expand Down
79 changes: 79 additions & 0 deletions BookPlayer/AppIntents/BookEntity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// BookEntity.swift
// BookPlayer
//
// Created by Gianni Carlo on 6/6/26.
// Copyright © 2026 BookPlayer LLC. All rights reserved.
//

import AppIntents
import BookPlayerKit
import Foundation

/// Represents a book in the library that can be selected as a parameter in
/// the Shortcuts app and resolved by Siri when playing a specific book.
@available(macOS 14.0, watchOS 10.0, *)
struct BookEntity: AppEntity, Identifiable {
/// The book's `relativePath`, used everywhere as its unique identifier.
let id: String
let title: String
let details: String

static var typeDisplayRepresentation: TypeDisplayRepresentation = "book_title"

var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: details.isEmpty ? nil : "\(details)"
)
}

static var defaultQuery = BookEntityQuery()

init(id: String, title: String, details: String) {
self.id = id
self.title = title
self.details = details
}

init(item: SimpleLibraryItem) {
self.id = item.relativePath
self.title = item.title
self.details = item.details
}
}

/// Backs `BookEntity` selection: resolves identifiers, provides suggestions for
/// the picker, and matches spoken/typed titles for Siri.
@available(macOS 14.0, watchOS 10.0, *)
struct BookEntityQuery: EntityStringQuery {
/// Resolve specific books by their `relativePath` identifiers.
func entities(for identifiers: [String]) async throws -> [BookEntity] {
let coreServices = try await AppServices.shared.awaitCoreServices()
let libraryService = coreServices.libraryService

return identifiers.compactMap { identifier in
libraryService.getSimpleItem(with: identifier).map(BookEntity.init(item:))
}
}

/// Match books by a typed/spoken query (title or author), powering Siri resolution.
func entities(matching string: String) async throws -> [BookEntity] {
let coreServices = try await AppServices.shared.awaitCoreServices()
let items = coreServices.libraryService.searchAllBooks(
query: string,
limit: 50,
offset: nil
) ?? []

return items.map(BookEntity.init(item:))
}

/// Suggestions shown in the Shortcuts picker: most recently played books first.
func suggestedEntities() async throws -> [BookEntity] {
let coreServices = try await AppServices.shared.awaitCoreServices()
let items = coreServices.libraryService.getLastPlayedItems(limit: 20) ?? []

return items.map(BookEntity.init(item:))
}
}
41 changes: 41 additions & 0 deletions BookPlayer/AppIntents/PlayBookIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// PlayBookIntent.swift
// BookPlayer
//
// Created by Gianni Carlo on 6/6/26.
// Copyright © 2026 BookPlayer LLC. All rights reserved.
//

import AppIntents
import BookPlayerKit
import Foundation

/// Plays a specific book chosen by the user, selectable in the Shortcuts app
/// and resolvable by Siri (e.g. "Play ⟨book⟩ in BookPlayer").
@available(macOS 14.0, watchOS 10.0, *)
struct PlayBookIntent: AudioPlaybackIntent {
static var title: LocalizedStringResource = "intent_play_book_title"

@Parameter(
title: "book_title",
requestValueDialog: IntentDialog("intent_play_book_request_title")
)
var book: BookEntity

init() {}

init(book: BookEntity) {
self.book = book
}

func perform() async throws -> some IntentResult {
let coreServices = try await AppServices.shared.awaitCoreServices()

try await AppServices.shared.loadAndKeepAlive(
relativePath: book.id,
playerLoaderService: coreServices.playerLoaderService
)

return .result()
}
}
86 changes: 58 additions & 28 deletions BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,40 @@ struct AudiobookShelfRootView: View {
"error_title".localized,
isPresented: .init(get: { loadError != nil }, set: { if !$0 { loadError = nil } }),
actions: {
Button("ok_button".localized) {
loadError = nil
showConnectionForm = true
// Library/identity loads can fail for many reasons (transient network, token
// expired, server moved, custom-header proxy issue). The previous "OK" button
// unconditionally pushed the user into the add-server form, which led people
// to re-add their server and end up with a duplicate.
//
// For the specific "session expired" case (401/403 mid-session) we already know
// the URL and customHeaders are fine — just the token is stale — so we show a
// narrower set of actions that funnel into the existing connection's sign-in
// form (which preserves customHeaders + selectedLibraryId).
if (loadError as? IntegrationError)?.isSessionExpired == true {
// Session expired: Retry would just hit the same 401, so omit it.
Button("integration_connection_details_title".localized) {
loadError = nil
connectionViewModel.signInFlow = nil
showConnectionForm = true
}
Button("cancel_button".localized, role: .cancel) {
loadError = nil
dismiss()
}
} else {
Button("integration_retry_button".localized) {
loadError = nil
Task { await loadLibraries() }
}
Button("integration_connection_details_title".localized) {
loadError = nil
connectionViewModel.signInFlow = nil
showConnectionForm = true
}
Button("cancel_button".localized, role: .cancel) {
loadError = nil
dismiss()
}
}
},
message: { Text(loadError?.localizedDescription ?? "") }
Expand All @@ -139,17 +170,13 @@ struct AudiobookShelfRootView: View {
NavigationStack {
IntegrationConnectionView(viewModel: connectionViewModel, integrationName: "AudiobookShelf")
.toolbar {
ToolbarItemGroup(placement: .cancellationAction) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.foregroundStyle(theme.linkColor)
}
}
if connectionService.connection != nil {
ToolbarItemGroup(placement: .confirmationAction) {
Button("integration_connect_button") {
showConnectionForm = false
Task { await loadLibraries() }
// Outer X only when we're NOT in Add Server mode — IntegrationConnectionView's
// own Cancel button handles that case (and just dismisses the form sheet).
if !connectionViewModel.isAddingServer {
ToolbarItemGroup(placement: .cancellationAction) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.foregroundStyle(theme.linkColor)
}
}
}
Expand All @@ -158,28 +185,24 @@ struct AudiobookShelfRootView: View {
}
.tint(theme.linkColor)
.environmentObject(theme)
.interactiveDismissDisabled()
}
.sheet(isPresented: $showLibraryPicker) {
libraryPickerSheet
.interactiveDismissDisabled(resolvedLibrary == nil)
}
.environmentObject(theme)
.onChange(of: availableLibraries) { _, libraries in
if let libraries, libraries.count > 1, resolvedLibrary == nil {
showLibraryPicker = true
}
}
.onChange(of: connectionViewModel.connectionState) { _, newValue in
if newValue == .connected {
showConnectionForm = false
if resolvedLibrary == nil {
Task { await loadLibraries() }
}
}
.onChange(of: connectionViewModel.signInCompletedAt) { _, newValue in
guard newValue != nil else { return }
showConnectionForm = false
resolvedLibrary = nil
Task { await loadLibraries() }
}
.task {
if connectionService.connection == nil {
if connectionService.connections.isEmpty {
showConnectionForm = true
} else if resolvedLibrary == nil {
await loadLibraries()
Expand Down Expand Up @@ -226,9 +249,16 @@ struct AudiobookShelfRootView: View {
.navigationTitle("library_title".localized)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if resolvedLibrary != nil {
ToolbarItem(placement: .cancellationAction) {
Button("done_title".localized) { showLibraryPicker = false }
ToolbarItem(placement: .cancellationAction) {
Button("cancel_button".localized) {
// No library chosen yet — there's nothing to browse, so back out
// to the server picker rather than leave the user on a disabled view.
if resolvedLibrary == nil {
showLibraryPicker = false
dismiss()
} else {
showLibraryPicker = false
}
}
}
}
Expand Down Expand Up @@ -338,7 +368,7 @@ private struct AudiobookShelfTabRoot: View {
}
}
.toolbar {
ToolbarItemGroup(placement: .cancellationAction) {
ToolbarItem(placement: .cancellationAction) {
Menu {
Button {
showConnectionDetails = true
Expand Down
Loading
Loading