Skip to content

Commit

Permalink
Merge branch 'carplay'
Browse files Browse the repository at this point in the history
  • Loading branch information
rasmuslos committed Feb 23, 2024
2 parents 5d35f1a + d13560d commit b270920
Show file tree
Hide file tree
Showing 9 changed files with 448 additions and 6 deletions.
24 changes: 20 additions & 4 deletions ShelfPlayer.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
3A792B972B6F9F53002A48E6 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 3A792B962B6F9F53002A48E6 /* InfoPlist.xcstrings */; };
3A79A9692AE53555006B61FC /* DownloadIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A79A9682AE53555006B61FC /* DownloadIndicator.swift */; };
3A82864D2AEC104400384BC9 /* SleepTimerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A82864C2AEC104400384BC9 /* SleepTimerButton.swift */; };
3A83B8732B8893E000A05957 /* CarPlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A83B8722B8893E000A05957 /* CarPlayDelegate.swift */; };
3A853FAF2AD03FA600FACAF6 /* AuthorView+Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A853FAE2AD03FA600FACAF6 /* AuthorView+Header.swift */; };
3A8810EE2AD2C071009696D1 /* PodcastLoadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8810ED2AD2C071009696D1 /* PodcastLoadView.swift */; };
3A8810F02AD2C0E4009696D1 /* PodcastUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8810EF2AD2C0E4009696D1 /* PodcastUnavailableView.swift */; };
Expand Down Expand Up @@ -109,6 +110,8 @@
3ACD19B72ACB5A49007D01FE /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACD19B62ACB5A49007D01FE /* ErrorView.swift */; };
3ACD19B92ACB5B32007D01FE /* SessionsImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACD19B82ACB5B32007D01FE /* SessionsImportView.swift */; };
3AD41D792B6B8F82003316CE /* AudiobookGenreFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD41D782B6B8F82003316CE /* AudiobookGenreFilter.swift */; };
3AD6BE852B89060F00EEA347 /* CarPlay+NowPlaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD6BE842B89060F00EEA347 /* CarPlay+NowPlaying.swift */; };
3AD6BE872B89066300EEA347 /* CarPlay+Playback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD6BE862B89066300EEA347 /* CarPlay+Playback.swift */; };
3ADB9B202B13B9760015F884 /* AudiobookContextMenuModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADB9B1F2B13B9760015F884 /* AudiobookContextMenuModifier.swift */; };
3ADD29622B6D88EF0060248E /* DownloadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADD29612B6D88EF0060248E /* DownloadButton.swift */; };
3AE31DCE2B4B3C310092DF36 /* AuthorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE31DCD2B4B3C310092DF36 /* AuthorsView.swift */; };
Expand Down Expand Up @@ -174,6 +177,7 @@
3A79A9682AE53555006B61FC /* DownloadIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadIndicator.swift; sourceTree = "<group>"; };
3A82864B2AEC0DD800384BC9 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
3A82864C2AEC104400384BC9 /* SleepTimerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepTimerButton.swift; sourceTree = "<group>"; };
3A83B8722B8893E000A05957 /* CarPlayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayDelegate.swift; sourceTree = "<group>"; };
3A853FAE2AD03FA600FACAF6 /* AuthorView+Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthorView+Header.swift"; sourceTree = "<group>"; };
3A8810ED2AD2C071009696D1 /* PodcastLoadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLoadView.swift; sourceTree = "<group>"; };
3A8810EF2AD2C0E4009696D1 /* PodcastUnavailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastUnavailableView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -226,6 +230,8 @@
3ACD19B62ACB5A49007D01FE /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
3ACD19B82ACB5B32007D01FE /* SessionsImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsImportView.swift; sourceTree = "<group>"; };
3AD41D782B6B8F82003316CE /* AudiobookGenreFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookGenreFilter.swift; sourceTree = "<group>"; };
3AD6BE842B89060F00EEA347 /* CarPlay+NowPlaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarPlay+NowPlaying.swift"; sourceTree = "<group>"; };
3AD6BE862B89066300EEA347 /* CarPlay+Playback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarPlay+Playback.swift"; sourceTree = "<group>"; };
3ADB9B1F2B13B9760015F884 /* AudiobookContextMenuModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookContextMenuModifier.swift; sourceTree = "<group>"; };
3ADD29612B6D88EF0060248E /* DownloadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadButton.swift; sourceTree = "<group>"; };
3AE31DCD2B4B3C310092DF36 /* AuthorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -286,6 +292,7 @@
3A0AE6B62AB624CC00ACCD68 /* Account */,
3A7118EB2AD5ACA4005F7E08 /* NowPlaying */,
3AADC0352B6D0D3D00C4088D /* Navigation */,
3A83B8712B88933500A05957 /* CarPlay */,
3A2BC1942AD8121400054727 /* Settings.bundle */,
3A27A66D2AB7631900F80C92 /* Info.plist */,
3A792B962B6F9F53002A48E6 /* InfoPlist.xcstrings */,
Expand Down Expand Up @@ -446,6 +453,16 @@
path = Series;
sourceTree = "<group>";
};
3A83B8712B88933500A05957 /* CarPlay */ = {
isa = PBXGroup;
children = (
3A83B8722B8893E000A05957 /* CarPlayDelegate.swift */,
3AD6BE862B89066300EEA347 /* CarPlay+Playback.swift */,
3AD6BE842B89060F00EEA347 /* CarPlay+NowPlaying.swift */,
);
path = CarPlay;
sourceTree = "<group>";
};
3A8810EC2AD2C061009696D1 /* Episode */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -758,6 +775,7 @@
3AF240E82ADA853A0065C0A4 /* SearchView.swift in Sources */,
3ACD19B92ACB5B32007D01FE /* SessionsImportView.swift in Sources */,
3AB4529D2ACF4D0D005D5DBA /* SeriesView.swift in Sources */,
3AD6BE872B89066300EEA347 /* CarPlay+Playback.swift in Sources */,
3ACB3B962AD1D186005EA609 /* PodcastEntryView+Library.swift in Sources */,
3A77B6FD2ACDE2FA00F22C7F /* AuthorUnavailableView.swift in Sources */,
3A0AE6BA2AB624E700ACCD68 /* LoginView.swift in Sources */,
Expand All @@ -768,6 +786,7 @@
3ACD19B52ACB58EB007D01FE /* LoadingView.swift in Sources */,
3ACD19AE2ACB57CD007D01FE /* EntryView.swift in Sources */,
3A50AABA2ACC523000BF5438 /* AudiobookEntryView+Series.swift in Sources */,
3A83B8732B8893E000A05957 /* CarPlayDelegate.swift in Sources */,
3A8810F22AD2C2C3009696D1 /* PodcastView.swift in Sources */,
3A1011872AD69DDA00AAE220 /* PlaybackSpeedButton.swift in Sources */,
3A79A9692AE53555006B61FC /* DownloadIndicator.swift in Sources */,
Expand All @@ -780,6 +799,7 @@
3A1011822AD6975C00AAE220 /* MPVolumeView+Volume.swift in Sources */,
3A337E3A2B0E13CE009C932A /* EpisodeContextMenuModifier.swift in Sources */,
3A1011842AD697B600AAE220 /* NowPlayingSheet+BottomButtons.swift in Sources */,
3AD6BE852B89060F00EEA347 /* CarPlay+NowPlaying.swift in Sources */,
3AA0187C2B4B4D120023DA7B /* AppDelegate.swift in Sources */,
3A10117D2AD6971900AAE220 /* VolumeSlider.swift in Sources */,
3AA089352B6E413800773414 /* AudiobookList.swift in Sources */,
Expand Down Expand Up @@ -990,8 +1010,6 @@
INFOPLIST_KEY_CFBundleDisplayName = ShelfPlayer;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
INFOPLIST_KEY_NSSiriUsageDescription = "Play your audiobooks or podcasts using Siri";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
Expand Down Expand Up @@ -1026,8 +1044,6 @@
INFOPLIST_KEY_CFBundleDisplayName = ShelfPlayer;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
INFOPLIST_KEY_NSSiriUsageDescription = "Play your audiobooks or podcasts using Siri";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
filePath = "iOS/Utility/BackgroundTaskHandler.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "140"
endingLineNumber = "140"
startingLineNumber = "156"
endingLineNumber = "156"
landmarkName = "submitTask(failed:)"
landmarkType = "7">
</BreakpointContent>
Expand Down
2 changes: 2 additions & 0 deletions iOS/Audiobooks.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.carplay-audio</key>
<true/>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.security.app-sandbox</key>
Expand Down
76 changes: 76 additions & 0 deletions iOS/CarPlay/CarPlay+NowPlaying.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// CarPlay+NowPlaying.swift
// iOS
//
// Created by Rasmus Krämer on 23.02.24.
//

import CarPlay
import Defaults
import SPPlayback

internal extension CarPlayDelegate {
func updateNowPlayingTemplate() -> NowPlayingObserver {
CPNowPlayingTemplate.shared.updateNowPlayingButtons([
CPNowPlayingPlaybackRateButton() { _ in
var rate = AudioPlayer.shared.playbackRate + Defaults[.playbackSpeedAdjustment]

if rate > 2 {
rate = 0.25
}

AudioPlayer.shared.playbackRate = rate
}
])

CPNowPlayingTemplate.shared.upNextTitle = String(localized: "carPlay.chapters")
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = true

let observer = NowPlayingObserver(interfaceController: interfaceController)
CPNowPlayingTemplate.shared.add(observer)

return observer
}

class NowPlayingObserver: NSObject, CPNowPlayingTemplateObserver {
private var interfaceController: CPInterfaceController?

init(interfaceController: CPInterfaceController?) {
self.interfaceController = interfaceController
}

func nowPlayingTemplateUpNextButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) {
Task {
if AudioPlayer.shared.chapters.count > 1 {
try await interfaceController?.pushTemplate(CPListTemplate(title: .init(localized: "carPlay.chapters.title"), sections: [
.init(items: AudioPlayer.shared.chapters.map { chapter in
let item = CPListItem(
text: chapter.title,
detailText: (chapter.end - chapter.start).hoursMinutesSecondsString(includeSeconds: true, includeLabels: false))

item.handler = { _, completion in
AudioPlayer.shared.seek(to: chapter.start)
Task {
try await self.interfaceController?.popTemplate(animated: true)
completion()
}
}

item.isPlaying = AudioPlayer.shared.chapter == chapter

return item
})
]), animated: true)
} else {
try await interfaceController?.presentTemplate(CPAlertTemplate(titleVariants: [.init(localized: "carPlay.chapters.empty.short"), .init(localized: "carPlay.chapters.empty")], actions: [
.init(title: .init(localized: "carPlay.chapters.dismiss"), style: .cancel) { _ in
Task {
try? await self.interfaceController?.dismissTemplate(animated: true)
}
}
]), animated: true)
}
}
}
}
}
40 changes: 40 additions & 0 deletions iOS/CarPlay/CarPlay+Playback.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// CarPlay+Playback.swift
// iOS
//
// Created by Rasmus Krämer on 23.02.24.
//

import CarPlay
import SPBase
import SPOffline
import SPPlayback

internal extension CarPlayDelegate {
static func startPlayback(item: CPSelectableListItem, completion: () -> Void) {
(item.userInfo! as! PlayableItem).startPlayback()
NotificationCenter.default.post(name: Self.updateContentNotifications, object: nil)

completion()
}

static func updateSections(_ sections: [CPListSection]) -> [CPListSection] {
sections.map {
CPListSection(items: $0.items.map {
let item = $0 as! CPListItem
let playableItem = $0.userInfo as! PlayableItem

if AudioPlayer.shared.item == playableItem {
item.isPlaying = true
item.playbackProgress = OfflineManager.shared.requireProgressEntity(item: playableItem).progress
} else {
item.isPlaying = false
}

return item
}, header: $0.header!, headerSubtitle: $0.headerSubtitle, headerImage: $0.headerImage, headerButton: $0.headerButton, sectionIndexTitle: $0.sectionIndexTitle)
}
}

static let updateContentNotifications = NSNotification.Name("io.rfk.shelfplayer.carplay.update")
}
129 changes: 129 additions & 0 deletions iOS/CarPlay/CarPlayDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// CarPlayDelegate.swift
// iOS
//
// Created by Rasmus Krämer on 23.02.24.
//

import CarPlay
import Defaults
import SPBase
import SPPlayback
import SPOffline
import SPOfflineExtended

class CarPlayDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
// we need to keep a strong reference to this object
internal var nowPlayingObserver: NowPlayingObserver?
internal var interfaceController: CPInterfaceController?

func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) {
self.interfaceController = interfaceController

Task {
// Check if the user is logged in

if !AudiobookshelfClient.shared.isAuthorized {
try await interfaceController.presentTemplate(CPAlertTemplate(titleVariants: [String(localized: "carPlay.unauthorized.short"), String(localized: "carPlay.unauthorized")], actions: []), animated: true)

return
}

nowPlayingObserver = updateNowPlayingTemplate()

// Try to fetch libraries

// if false, let libraries = try? await AudiobookshelfClient.shared.getLibraries() {
try await interfaceController.setRootTemplate(try buildOfflineListTemplate(), animated: true)
}
}

func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController) {
self.interfaceController = nil
nowPlayingObserver = nil
}
}

extension CarPlayDelegate {
private func buildOfflineListTemplate() async throws -> CPListTemplate {
let template = CPListTemplate(title: String(localized: "carPlay.offline.title"), sections: try getOfflineSections(), assistantCellConfiguration: .init(position: .top, visibility: .always, assistantAction: .playMedia))

Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in
Task { @MainActor in
template.updateSections(Self.updateSections(template.sections))
}
}
NotificationCenter.default.addObserver(forName: Self.updateContentNotifications, object: nil, queue: nil) { _ in
Task { @MainActor in
template.updateSections(Self.updateSections(template.sections))
}
}

return template
}

private func getOfflineSections() throws -> [CPListSection] {
let podcasts = try OfflineManager.shared.getPodcasts()
let audiobooks = try OfflineManager.shared.getAudiobooks()

var sections: [CPListSection] = [
.init(items: audiobooks.map { @MainActor in
var image: UIImage?
var detailText = ""

if let imageUrl = $0.image?.url, let data = try? Data(contentsOf: imageUrl) {
image = UIImage(data: data)
}

if let author = $0.author {
detailText += author
}
if let narrator = $0.narrator {
if !detailText.isEmpty {
detailText += ""
}

detailText += narrator
}

let item = CPListItem(text: $0.name, detailText: detailText.isEmpty ? nil : detailText, image: image, accessoryImage: nil, accessoryType: .none)

item.isExplicitContent = $0.explicit

item.playingIndicatorLocation = .trailing
item.isPlaying = AudioPlayer.shared.item == $0

item.userInfo = $0
item.handler = Self.startPlayback

item.playbackProgress = OfflineManager.shared.requireProgressEntity(item: $0).progress

return item
}, header: String(localized: "carPlay.offline.sections.audiobooks"), headerSubtitle: nil, headerImage: UIImage(systemName: "bookmark.fill"), headerButton: nil, sectionIndexTitle: nil),
]

sections.append(contentsOf: podcasts.map {
CPListSection(items: $0.value.map { @MainActor in
let item = CPListItem(text: $0.name, detailText: $0.descriptionText)

item.userInfo = $0
item.handler = Self.startPlayback

item.playingIndicatorLocation = .trailing
item.isPlaying = AudioPlayer.shared.item == $0

item.playbackProgress = OfflineManager.shared.requireProgressEntity(item: $0).progress

return item
}, header: $0.key.name, headerSubtitle: $0.key.author, headerImage: {
if let imageUrl = $0.image?.url, let data = try? Data(contentsOf: imageUrl) {
return UIImage(data: data)
}

return nil
}($0.key), headerButton: nil, sectionIndexTitle: nil)
})

return sections
}
}
Loading

0 comments on commit b270920

Please sign in to comment.