├── ShelfPlayerKit ├── Foundation │ ├── OfflineMode.swift │ ├── Utility │ │ ├── Percentage.swift │ │ ├── AuthorizationStrategy.swift │ │ ├── DownloadStatus.swift │ │ ├── ConfigureableUpNextStrategy.swift │ │ ├── Item │ │ │ ├── HomeRow.swift │ │ │ ├── Placeholder.swift │ │ │ ├── AudiobookSection.swift │ │ │ ├── Bookmark.swift │ │ │ └── Chapter.swift │ │ ├── SleepTimerConfiguration.swift │ │ ├── ProgressEntity.swift │ │ └── TintColor.swift │ └── Items │ │ ├── Person.swift │ │ └── Series.swift ├── Embassy │ ├── Intents │ │ ├── Skip │ │ │ ├── SkipOptionsProvider.swift │ │ │ ├── SkipBackwardsIntent.swift │ │ │ └── SkipForwardsIntent.swift │ │ ├── ListenNowIntent.swift │ │ ├── DownloadIntent.swift │ │ ├── Start │ │ │ ├── StartAudiobookIntent.swift │ │ │ ├── StartPodcastIntent.swift │ │ │ └── StartIntent.swift │ │ ├── RemoveDownloadIntent.swift │ │ ├── SleepTimer │ │ │ ├── CancelSleepTimerIntent.swift │ │ │ └── ExtendSleepTimerIntent.swift │ │ ├── Playback │ │ │ ├── PlayIntent.swift │ │ │ ├── PauseIntent.swift │ │ │ ├── CreateBookmarkIntent.swift │ │ │ ├── NowPlayingIntent.swift │ │ │ ├── StartWidgetConfiguration.swift │ │ │ └── SetPlaybackRateIntent.swift │ │ ├── SearchIntent.swift │ │ ├── SetFinishedIntent.swift │ │ ├── OpenIntent.swift │ │ └── CheckForDownloadsIntent.swift │ ├── Embassy.swift │ ├── IntentError.swift │ └── SleepTimerLiveActivityAttributes.swift ├── Fixtures │ ├── Library+Fixture.swift │ ├── ItemID+Fixture.swift │ ├── Author+Fixture.swift │ ├── Series+Fixture.swift │ ├── Session+Fixture.swift │ ├── Podcast+Fixture.swift │ ├── Episode+Fixture.swift │ └── Collection+Fixture.swift ├── Network │ ├── Client │ │ ├── APIClientError.swift │ │ ├── HTTPHeader.swift │ │ ├── APICredentialProvider.swift │ │ ├── HTTPMethod.swift │ │ ├── ABSClient.swift │ │ └── AuthorizedAPIClientCredentialProvider.swift │ ├── Payloads │ │ └── ProgressPayload.swift │ ├── API+Library.swift │ ├── API+Bookmark.swift │ ├── API+Episode.swift │ ├── API+Narrators.swift │ ├── Convert │ │ ├── PlayableItemUtility+Convert.swift │ │ ├── Collection+Convert.swift │ │ ├── AudiobookSection+Convert.swift │ │ └── Podcast+Convert.swift │ └── API+Author.swift ├── Extensions │ ├── String+Random.swift │ ├── View+UniversalContentShape.swift │ ├── Data+sha256.swift │ ├── ItemID+Navigate.swift │ ├── SwiftUI+Keys.swift │ ├── SecondaryShadow.swift │ ├── Episode+Sort.swift │ ├── View+Modify.swift │ ├── Double+FormatRate.swift │ ├── String+Distance.swift │ └── ItemID+UI.swift ├── Persistence │ ├── Resolve │ │ ├── ItemID+Resolve.swift │ │ ├── ItemID+API.swift │ │ ├── PlayableItem+Filter.swift │ │ ├── ItemID+URL.swift │ │ └── SortOrder+URL.swift │ ├── Current │ │ ├── PersistedDiscoveredServer.swift │ │ ├── SchemaV2.swift │ │ ├── PersistedKeyValueEntity.swift │ │ ├── PersistedSearchIndexEntry.swift │ │ ├── PersistedChapter.swift │ │ ├── PersistedBookmark.swift │ │ ├── PersistedPlaybackSession.swift │ │ └── Items │ │ │ └── PersistedPodcast.swift │ └── Convert │ │ ├── Episode+ConvertOffline.swift │ │ ├── Podcast+ConvertOffline.swift │ │ ├── Progress+ConvertOffline.swift │ │ └── Audiobook+ConvertOffline.swift ├── ShelfPlayerKit.swift └── Human Interface │ ├── CircularProgressIndicator.swift │ └── Image │ ├── ContrastModifier.swift │ └── ImagePlaceholder.swift ├── Screenshots ├── iOS Author.png ├── iOS Episode.png ├── iOS Player.png ├── iOS Playlist.png ├── iOS Podcast.png ├── iOS Series.png ├── iOS Audiobook.png ├── iOS Listen Now.png ├── iPadOS Author.png ├── iPadOS Episode.png ├── iPadOS Player.png ├── iPadOS Podcast.png ├── iPadOS Series.png ├── iOS Podcast Home.png ├── iPadOS Audiobook.png └── iPadOS Podcast Home.png ├── Multiplatform ├── Assets.xcassets │ ├── Contents.json │ ├── Logo.imageset │ │ ├── ShelfPlayer.png │ │ └── Contents.json │ ├── WidgetBackground.colorset │ │ └── Contents.json │ ├── xsign.symbolset │ │ └── Contents.json │ ├── shelfPlayer.fill.symbolset │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Assets.xcassets │ ├── Contents.json │ ├── house.imageset │ │ ├── 911118absdl.jpg │ │ └── Contents.json │ ├── shapes.imageset │ │ ├── Ohne Titel.png │ │ └── Contents.json │ ├── trees.imageset │ │ ├── 913210absdl.jpg │ │ └── Contents.json │ ├── circles.imageset │ │ ├── 100275absdl.jpg │ │ └── Contents.json │ ├── leaves.imageset │ │ ├── 911433absdl.jpg │ │ └── Contents.json │ └── painting.imageset │ │ ├── 101804absdl.jpg │ │ └── Contents.json ├── Extensions │ ├── String+Equatable.swift │ ├── Array+Repeat.swift │ ├── String+Random.swift │ ├── SwiftUI │ │ ├── CompactToolbarModifier.swift │ │ ├── MainActor+withAnimation.swift │ │ ├── Alignment+Text.swift │ │ ├── View+ReverseMask.swift │ │ ├── View+Preview.swift │ │ └── Color+IsLight.swift │ ├── UIKit │ │ ├── UIScreen+Radius.swift │ │ ├── UINavigationController+Gesture.swift │ │ └── UIWindow+Shake.swift │ ├── Chapter+Format.swift │ ├── URL+AllocatedSize.swift │ └── Keys+Entries.swift ├── CarPlay │ ├── Utility │ │ ├── CarPlayItemController.swift │ │ └── CarPlayPodcastItemController.swift │ ├── CarPlayController.swift │ └── Panels │ │ └── CarPlayPodcastListController.swift ├── Item │ ├── CommonActions │ │ ├── ItemShareButton.swift │ │ ├── QueuePlayButton.swift │ │ ├── ItemConfigureButton.swift │ │ ├── ItemCollectionMembershipEditButton.swift │ │ ├── QueueButton.swift │ │ ├── ProgressResetButton.swift │ │ ├── ItemIDLoadLink.swift │ │ └── PlayableItemSwipeActionsModifier.swift │ ├── PDFViewer.swift │ ├── DownloadStatusTracker.swift │ ├── PlayButton │ │ └── MediumPlayButtonStyle.swift │ ├── Picker │ │ └── ItemDisplayTypePicker.swift │ ├── BookmarksList.swift │ └── ChaptersList.swift ├── zh-Hans.lproj │ └── AppIntentVocabulary.plist ├── Utility │ ├── SerifModifier.swift │ ├── UnavailableWrapper.swift │ ├── Triangle.swift │ ├── EmptyCollectionView.swift │ ├── LoadingView.swift │ ├── RowTitle.swift │ ├── OutdatedServerRow.swift │ ├── ErrorView.swift │ ├── HeroBackground.swift │ ├── HeroBackButton.swift │ ├── CircleProgressIndicator.swift │ └── TimeRow.swift ├── Sheets │ ├── StatisticsSheet.swift │ ├── CustomTabValueSheet.swift │ └── DescriptionSheet.swift ├── sv.lproj │ └── AppIntentVocabulary.plist ├── de.lproj │ └── AppIntentVocabulary.plist ├── en.lproj │ └── AppIntentVocabulary.plist ├── nl.lproj │ └── AppIntentVocabulary.plist ├── Embassy │ ├── fr.lproj │ │ └── Intents.strings │ ├── nl.lproj │ │ └── Intents.strings │ ├── ru.lproj │ │ └── Intents.strings │ ├── de.lproj │ │ └── Intents.strings │ ├── en.lproj │ │ └── Intents.strings │ ├── sv.lproj │ │ └── Intents.strings │ ├── zh-Hans.lproj │ │ └── Intents.strings │ ├── uk.lproj │ │ └── Intents.strings │ └── ContextProvider.swift ├── PrivacyInfo.xcprivacy ├── ru.lproj │ └── AppIntentVocabulary.plist ├── uk.lproj │ └── AppIntentVocabulary.plist ├── FREE_DEVELOPER_ACCOUNT.entitlements ├── fr.lproj │ └── AppIntentVocabulary.plist ├── Entitlements.entitlements ├── Navigation │ └── ItemView.swift ├── Control │ └── MultiplatformApp.swift ├── Preferences │ ├── ColorSchemePreference.swift │ ├── PodcastSortOrderPreference.swift │ └── LibraryEnumerator.swift ├── Collections │ └── AuthorList.swift ├── Progress │ └── SyncGate.swift ├── Episode │ └── EpisodeViewModel.swift └── Person │ └── PersonView+Header.swift ├── ShelfPlayback ├── ShelfPlayback.swift ├── AudioPlayerError.swift └── Utility │ ├── DisplayContext+Origin.swift │ ├── MPVolumeView+Volume.swift │ └── AudioPlayerItem.swift ├── Configuration ├── Widgets.xcconfig ├── Multiplatform.xcconfig ├── Release.xcconfig ├── Base.xcconfig └── Debug.xcconfig.template ├── ShelfPlayer.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ ├── WorkspaceSettings.xcsettings │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ └── rasmus.xcuserdatad │ ├── IDEFindNavigatorScopes.plist │ └── WorkspaceSettings.xcsettings ├── WidgetsExtension ├── Info.plist ├── FREE_DEVELOPER_ACCOUNT.entitlements ├── Utility │ ├── WidgetAppIcon.swift │ └── WidgetBackground.swift ├── Entitlements.entitlements └── WidgetsBundle.swift ├── ShelfPlayer.icon ├── icon.json └── Assets │ └── ShelfPlayer.svg └── Support.md /ShelfPlayerKit/Foundation/OfflineMode.swift: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Screenshots/iOS Author.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Author.png -------------------------------------------------------------------------------- /Screenshots/iOS Episode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Episode.png -------------------------------------------------------------------------------- /Screenshots/iOS Player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Player.png -------------------------------------------------------------------------------- /Screenshots/iOS Playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Playlist.png -------------------------------------------------------------------------------- /Screenshots/iOS Podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Podcast.png -------------------------------------------------------------------------------- /Screenshots/iOS Series.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Series.png -------------------------------------------------------------------------------- /Screenshots/iOS Audiobook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Audiobook.png -------------------------------------------------------------------------------- /Screenshots/iOS Listen Now.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Listen Now.png -------------------------------------------------------------------------------- /Screenshots/iPadOS Author.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iPadOS Author.png -------------------------------------------------------------------------------- /Screenshots/iPadOS Episode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iPadOS Episode.png -------------------------------------------------------------------------------- /Screenshots/iPadOS Player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iPadOS Player.png -------------------------------------------------------------------------------- /Screenshots/iPadOS Podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iPadOS Podcast.png -------------------------------------------------------------------------------- /Screenshots/iPadOS Series.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iPadOS Series.png -------------------------------------------------------------------------------- /Screenshots/iOS Podcast Home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iOS Podcast Home.png -------------------------------------------------------------------------------- /Screenshots/iPadOS Audiobook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iPadOS Audiobook.png -------------------------------------------------------------------------------- /Screenshots/iPadOS Podcast Home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Screenshots/iPadOS Podcast Home.png -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Multiplatform/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/Logo.imageset/ShelfPlayer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Multiplatform/Assets.xcassets/Logo.imageset/ShelfPlayer.png -------------------------------------------------------------------------------- /Multiplatform/Preview Assets.xcassets/house.imageset/911118absdl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Multiplatform/Preview Assets.xcassets/house.imageset/911118absdl.jpg -------------------------------------------------------------------------------- /Multiplatform/Preview Assets.xcassets/shapes.imageset/Ohne Titel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Multiplatform/Preview Assets.xcassets/shapes.imageset/Ohne Titel.png -------------------------------------------------------------------------------- /Multiplatform/Preview Assets.xcassets/trees.imageset/913210absdl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Multiplatform/Preview Assets.xcassets/trees.imageset/913210absdl.jpg -------------------------------------------------------------------------------- /ShelfPlayback/ShelfPlayback.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShelfPlayback.swift 3 | // ShelfPlayback 4 | // 5 | // Created by Rasmus Krämer on 01.06.25. 6 | // 7 | 8 | @_exported import ShelfPlayerKit 9 | -------------------------------------------------------------------------------- /Multiplatform/Preview Assets.xcassets/circles.imageset/100275absdl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Multiplatform/Preview Assets.xcassets/circles.imageset/100275absdl.jpg -------------------------------------------------------------------------------- /Multiplatform/Preview Assets.xcassets/leaves.imageset/911433absdl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Multiplatform/Preview Assets.xcassets/leaves.imageset/911433absdl.jpg -------------------------------------------------------------------------------- /Multiplatform/Preview Assets.xcassets/painting.imageset/101804absdl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/ShelfPlayer/HEAD/Multiplatform/Preview Assets.xcassets/painting.imageset/101804absdl.jpg -------------------------------------------------------------------------------- /Configuration/Widgets.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Widgets.xcconfig 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 29.05.25. 6 | // 7 | 8 | CODE_SIGN_ENTITLEMENTS = $(WIDGETS_CODE_SIGN_ENTITLEMENTS) 9 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/Percentage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Percentage.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 29.08.24. 6 | // 7 | 8 | public typealias Percentage = Double 9 | -------------------------------------------------------------------------------- /Configuration/Multiplatform.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Multiplatform.xcconfig 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 29.05.25. 6 | // 7 | 8 | CODE_SIGN_ENTITLEMENTS = $(MULTIPLATFORM_CODE_SIGN_ENTITLEMENTS) 9 | -------------------------------------------------------------------------------- /ShelfPlayer.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/String+Equatable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Equatable.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 02.02.24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String: Equatable { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/xsign.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "xsign.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /ShelfPlayer.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/shelfPlayer.fill.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "shelfPlayer.fill.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /ShelfPlayer.xcodeproj/project.xcworkspace/xcuserdata/rasmus.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Multiplatform/CarPlay/Utility/CarPlayItemController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CarPlayItemRow.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 25.04.25. 6 | // 7 | 8 | import Foundation 9 | import CarPlay 10 | 11 | @MainActor 12 | protocol CarPlayItemController: Sendable { 13 | var row: CPListItem { get } 14 | } 15 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/Array+Repeat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Repeat.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 05.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array { 11 | init(repeating: Element, count: Int) { 12 | self.init((0.. 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/AuthorizationStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorizationStrategy.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 15.08.25. 6 | // 7 | 8 | public enum AuthorizationStrategy: Int, Identifiable, Sendable { 9 | case usernamePassword 10 | case openID 11 | 12 | public var id: Int { 13 | rawValue 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /WidgetsExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.widgetkit-extension 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Skip/SkipOptionsProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SkipOptionsProvider.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 19.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | struct SkipOptionsProvider: DynamicOptionsProvider { 12 | func results() async throws -> [TimeInterval] { 13 | [15, 30, 45, 60, 90, 120] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Fixtures/Library+Fixture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Library+Fixture.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 06.10.25. 6 | // 7 | 8 | import Foundation 9 | 10 | #if DEBUG 11 | public extension Library { 12 | static var fixture: Library { 13 | .init(id: "fixture", connectionID: "fixture", name: "Fixture", type: .audiobooks, index: -1) 14 | } 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Client/APIClientError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIClientError.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 28.07.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum APIClientError: Error { 11 | case parseError 12 | case serializeError 13 | 14 | case invalidItemType 15 | case invalidResponseCode 16 | 17 | case notFound 18 | case unauthorized 19 | } 20 | -------------------------------------------------------------------------------- /Configuration/Release.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Release.xcconfig 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 23.01.24. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | #include "Base.xcconfig" 12 | 13 | DEVELOPMENT_TEAM = N8AA4S3S96 14 | BUNDLE_ID_PREFIX = io.rfk 15 | 16 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = ENABLE_CENTRALIZED 17 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Fixtures/ItemID+Fixture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemID+Fixture.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 30.12.24. 6 | // 7 | 8 | import Foundation 9 | 10 | #if DEBUG 11 | public extension ItemIdentifier { 12 | static var fixture: ItemIdentifier { 13 | .init(primaryID: "fixture", groupingID: nil, libraryID: "fixture", connectionID: "fixture", type: .audiobook) 14 | } 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/String+Random.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Random.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 25.02.24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | static func random(length: Int) -> String { 12 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 13 | return String((0..(_ shape: S) -> some View where S: Shape { 13 | self 14 | .contentShape([.accessibility, .dragPreview, .hoverEffect, .interaction], shape) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.800", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/SwiftUI/CompactToolbarModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompactToolbarModifier.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 19.09.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | @ViewBuilder 12 | func largeTitleDisplayMode() -> some View { 13 | if #available(iOS 26, *) { 14 | self 15 | .toolbarTitleDisplayMode(.inlineLarge) 16 | } else { 17 | self 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Multiplatform/Item/CommonActions/ItemShareButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemShareButton.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 14.06.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ItemShareButton: View { 12 | let item: Item 13 | 14 | var body: some View { 15 | ShareLink(item: item, subject: Text(verbatim: item.name), message: Text(verbatim: item.transferableDescription), preview: SharePreview(item.name, image: item)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/Data+sha256.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 09.07.24. 6 | // 7 | 8 | import Foundation 9 | import CommonCrypto 10 | 11 | extension Data { 12 | var sha256: Data { 13 | var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) 14 | 15 | withUnsafeBytes { 16 | _ = CC_SHA256($0.baseAddress, CC_LONG(count), &hash) 17 | } 18 | 19 | return Data(hash) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Configuration/Base.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Base.xcconfig 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 23.01.24. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | MARKETING_VERSION = 3.1.4 12 | 13 | ENTITLEMENT_BASE = Entitlements 14 | 15 | WIDGETS_CODE_SIGN_ENTITLEMENTS = WidgetsExtension/$(ENTITLEMENT_BASE).entitlements 16 | MULTIPLATFORM_CODE_SIGN_ENTITLEMENTS = Multiplatform/$(ENTITLEMENT_BASE).entitlements 17 | -------------------------------------------------------------------------------- /Multiplatform/zh-Hans.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INPlayMediaIntent 10 | IntentExamples 11 | 12 | 播放有声读物 1984 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /WidgetsExtension/FREE_DEVELOPER_ACCOUNT.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.${BUNDLE_ID_PREFIX}.shelfplayer 8 | 9 | keychain-access-groups 10 | 11 | $(AppIdentifierPrefix)io.rfk.ShelfPlayer 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/ConfigureableUpNextStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpNextStrategy.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 27.05.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum ConfigureableUpNextStrategy: String, Sendable, Hashable, CaseIterable, Equatable, Identifiable, Codable, Defaults.Serializable, Defaults.PreferRawRepresentable { 11 | case `default` 12 | case listenNow 13 | case disabled 14 | 15 | public var id: String { 16 | rawValue 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/Item/HomeRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeRow.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 09.07.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct HomeRow: Identifiable, Sendable { 12 | public let id: String 13 | public let label: String 14 | public let entities: [T] 15 | 16 | public init(id: String, label: String, entities: [T]) { 17 | self.id = id 18 | self.label = label 19 | self.entities = entities 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/SwiftUI/MainActor+withAnimation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainActor+withAnimation.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 25.08.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension MainActor { 12 | static func withAnimation(_ animation: Animation? = nil, _ body: @MainActor () -> T) async { 13 | let _ = await MainActor.run { 14 | SwiftUI.withAnimation(animation) { 15 | body() 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Multiplatform/Utility/SerifModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SerifModifier.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 01.05.24. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct SerifModifier: ViewModifier { 12 | @Default(.enableSerifFont) private var enableSerifFont 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .modify(if: enableSerifFont) { 17 | $0 18 | .fontDesign(.serif) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Client/APICredentialProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APICredentialProvider.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 28.07.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol APICredentialProvider: Sendable { 11 | var configuration: (URL, [HTTPHeader]) { get async throws } 12 | var accessToken: String? { get async throws } 13 | 14 | var shouldPostAuthorizationFailure: Bool { get async } 15 | 16 | func refreshAccessToken(current: String?) async throws -> String? 17 | } 18 | -------------------------------------------------------------------------------- /ShelfPlayback/Utility/DisplayContext+Origin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DisplayContext+Origin.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 13.07.25. 6 | // 7 | 8 | import Foundation 9 | import ShelfPlayerKit 10 | 11 | public extension DisplayContext { 12 | var origin: AudioPlayerItem.PlaybackOrigin { 13 | switch self { 14 | case .series(let series): .series(series.id) 15 | case .collection(let collection): .collection(collection.id) 16 | default: .unknown 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Resolve/ItemID+Resolve.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemID+Resolve.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 26.02.25. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public extension ItemIdentifier { 12 | var resolved: Item { 13 | get async throws { 14 | try await resolvedComplex.0 15 | } 16 | } 17 | var resolvedComplex: (Item, [Episode]) { 18 | get async throws { 19 | try await ResolveCache.shared.resolve(self) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/SwiftUI/Alignment+Text.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Alignment+Text.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 30.08.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | internal extension HorizontalAlignment { 12 | var textAlignment: TextAlignment { 13 | switch self { 14 | case .leading: 15 | .leading 16 | case .trailing: 17 | .trailing 18 | default: 19 | .center 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/ItemID+Navigate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemID+Navigate.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 04.03.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension ItemIdentifier { 11 | @MainActor 12 | func navigateIsolated() { 13 | RFNotification[.navigate].send(payload: self) 14 | } 15 | func navigate() { 16 | Task { 17 | await navigateIsolated() 18 | } 19 | } 20 | func navigate() async { 21 | await navigateIsolated() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Multiplatform/Sheets/StatisticsSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatisticsSheet.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 29.05.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | #if DEBUG 12 | struct StatisticsSheet: View { 13 | @Environment(ProgressViewModel.self) private var progressViewModel 14 | 15 | var body: some View { 16 | List { 17 | 18 | } 19 | } 20 | } 21 | #endif 22 | 23 | #if DEBUG 24 | #Preview { 25 | StatisticsSheet() 26 | .previewEnvironment() 27 | } 28 | #endif 29 | -------------------------------------------------------------------------------- /ShelfPlayerKit/ShelfPlayerKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShelfPlayerKit.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 22.06.24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | import AppIntents 11 | 12 | @_exported import Defaults 13 | @_exported import DefaultsMacros 14 | 15 | @_exported import RFVisuals 16 | @_exported import RFNotifications 17 | 18 | public struct ShelfPlayerKit { 19 | public static let logger = Logger(subsystem: "io.rfk.shelfPlayerKit", category: "ShelfPlayerKit") 20 | } 21 | 22 | public struct ShelfPlayerKitPackage: AppIntentsPackage {} 23 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/SwiftUI/View+ReverseMask.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | 4 | // Taken from: https://www.fivestars.blog/articles/reverse-masks-how-to/ 5 | 6 | extension View { 7 | @inlinable 8 | func reverseMask(alignment: Alignment = .center, @ViewBuilder _ mask: () -> Mask) -> some View { 9 | self.mask { 10 | Rectangle() 11 | .overlay(alignment: alignment) { 12 | mask() 13 | .blendMode(.destinationOut) 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Multiplatform/Utility/UnavailableWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnavailableWrapper.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 22.06.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UnavailableWrapper: View { 11 | @ViewBuilder let content: () -> Content 12 | 13 | var body: some View { 14 | ScrollView { 15 | ZStack { 16 | Spacer() 17 | .containerRelativeFrame([.horizontal, .vertical]) 18 | 19 | content() 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/UIKit/UIScreen+Radius.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIScreen+Radius.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 06.05.24. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | // tomfoolery. 12 | // Taken from https://github.com/kylebshr/ScreenCorners/tree/main 13 | 14 | extension UIScreen { 15 | private var cornerRadiusKey: String { 16 | ["Radius", "Corner", "display", "_"].reversed().joined() 17 | } 18 | 19 | var displayCornerRadius: CGFloat { 20 | value(forKey: cornerRadiusKey) as? CGFloat ?? 0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Multiplatform/Item/CommonActions/QueuePlayButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueuePlayButton.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 29.01.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct QueuePlayButton: View { 12 | @Environment(Satellite.self) private var satellite 13 | 14 | let itemID: ItemIdentifier 15 | 16 | var body: some View { 17 | Button("item.play", systemImage: "play.fill") { 18 | satellite.start(itemID) 19 | } 20 | .disabled(satellite.isLoading(observing: itemID)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ShelfPlayer.xcodeproj/project.xcworkspace/xcuserdata/rasmus.xcuserdatad/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildLocationStyle 6 | UseAppPreferences 7 | CustomBuildLocationType 8 | RelativeToDerivedData 9 | DerivedDataLocationStyle 10 | Default 11 | ShowSharedSchemesAutomaticallyEnabled 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Triangle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Triangle.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 04.10.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Triangle: Shape { 11 | func path(in rect: CGRect) -> Path { 12 | var path = Path() 13 | 14 | path.move(to: CGPoint(x: rect.minX, y: rect.minY)) 15 | path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) 16 | path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) 17 | 18 | return path 19 | } 20 | } 21 | 22 | #Preview { 23 | Triangle() 24 | .frame(width: 200, height: 200) 25 | } 26 | -------------------------------------------------------------------------------- /Multiplatform/sv.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INPlayMediaIntent 10 | IntentExamples 11 | 12 | Spela ljuboken 1984 med ShelfPlayer 13 | Spela podden "Text och musik med Eric Schüldt" med ShelfPlayer 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/UIKit/UINavigationController+Gesture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UINavigationController+Gesture.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 09.10.23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UINavigationController: @retroactive UIGestureRecognizerDelegate { 12 | override open func viewDidLoad() { 13 | super.viewDidLoad() 14 | interactivePopGestureRecognizer?.delegate = self 15 | } 16 | 17 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 18 | return viewControllers.count > 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Multiplatform/Utility/EmptyCollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyCollectionView.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 23.02.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct EmptyCollectionView: View { 11 | var body: some View { 12 | UnavailableWrapper { 13 | Inner() 14 | } 15 | } 16 | 17 | struct Inner: View { 18 | var body: some View { 19 | ContentUnavailableView("item.empty", systemImage: "questionmark.folder", description: Text("item.empty.description")) 20 | } 21 | } 22 | } 23 | 24 | #Preview { 25 | EmptyCollectionView() 26 | } 27 | -------------------------------------------------------------------------------- /ShelfPlayback/Utility/MPVolumeView+Volume.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MPVolumeView+Volume.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 25.08.24. 6 | // 7 | 8 | import Foundation 9 | import MediaPlayer 10 | 11 | #if os(iOS) 12 | extension MPVolumeView { 13 | @MainActor 14 | static func setVolume(_ volume: Float) { 15 | let volumeView = MPVolumeView() 16 | let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider 17 | 18 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { 19 | slider?.value = volume 20 | } 21 | } 22 | } 23 | #endif 24 | -------------------------------------------------------------------------------- /Multiplatform/de.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INPlayMediaIntent 10 | IntentExamples 11 | 12 | Spiele das Hörbuch 1984 mit ShelfPlayer 13 | Spiele die Episode "Industrielle Gesellschaft und ihre Zukunft" mit ShelfPlayer 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Client/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPMethod.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 28.07.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum HTTPMethod { 11 | case get 12 | case post 13 | case patch 14 | case delete 15 | 16 | var value: String { 17 | switch self { 18 | case .get: 19 | "GET" 20 | case .post: 21 | "POST" 22 | case .patch: 23 | "PATCH" 24 | case .delete: 25 | "DELETE" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Multiplatform/en.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INPlayMediaIntent 10 | IntentExamples 11 | 12 | Play the audiobook 1984 using ShelfPlayer 13 | Play the podcast episode "Indusitral society and it's future" using ShelfPlayer 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Multiplatform/nl.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INPlayMediaIntent 10 | IntentExamples 11 | 12 | Play the audiobook 1984 using ShelfPlayer 13 | Play the podcast episode "Indusitral society and it's future" using ShelfPlayer 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/fr.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Media Search"; 2 | 3 | "ErAaNp" = "Repeat Mode"; 4 | 5 | "G3J6fP" = "Play Media"; 6 | 7 | "KcCcQu" = "Play ${mediaItems}"; 8 | 9 | "LdrEPN" = "A request to play media."; 10 | 11 | "No0K9w" = "resume"; 12 | 13 | "Pk1Fkx" = "don't resume"; 14 | 15 | "QqdHiR" = "shuffled"; 16 | 17 | "RvX3Zd" = "Playback Speed"; 18 | 19 | "WktFNa" = "Shuffled"; 20 | 21 | "YYfsKp" = "Play ${mediaContainer}"; 22 | 23 | "cV2HLF" = "Queue Location"; 24 | 25 | "cfJexL" = "Items"; 26 | 27 | "f3d3kn" = "Play media"; 28 | 29 | "mCAocc" = "not shuffled"; 30 | 31 | "rvNMpm" = "Resume"; 32 | 33 | "xdnqvn" = "Container"; 34 | 35 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/nl.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Media Search"; 2 | 3 | "ErAaNp" = "Repeat Mode"; 4 | 5 | "G3J6fP" = "Play Media"; 6 | 7 | "KcCcQu" = "Play ${mediaItems}"; 8 | 9 | "LdrEPN" = "A request to play media."; 10 | 11 | "No0K9w" = "resume"; 12 | 13 | "Pk1Fkx" = "don't resume"; 14 | 15 | "QqdHiR" = "shuffled"; 16 | 17 | "RvX3Zd" = "Playback Speed"; 18 | 19 | "WktFNa" = "Shuffled"; 20 | 21 | "YYfsKp" = "Play ${mediaContainer}"; 22 | 23 | "cV2HLF" = "Queue Location"; 24 | 25 | "cfJexL" = "Items"; 26 | 27 | "f3d3kn" = "Play media"; 28 | 29 | "mCAocc" = "not shuffled"; 30 | 31 | "rvNMpm" = "Resume"; 32 | 33 | "xdnqvn" = "Container"; 34 | 35 | -------------------------------------------------------------------------------- /Multiplatform/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyAccessedAPITypes 10 | 11 | 12 | NSPrivacyAccessedAPIType 13 | NSPrivacyAccessedAPICategoryUserDefaults 14 | NSPrivacyAccessedAPITypeReasons 15 | 16 | 1C8F.1 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Multiplatform/ru.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INPlayMediaIntent 10 | IntentExamples 11 | 12 | Воспроизвести аудиокнигу 1984 в ShelfPlayer 13 | Воспроизвести эпизод подкаста "Промышленное общество' и его будущее" в ShelfPlayer 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Multiplatform/uk.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INPlayMediaIntent 10 | IntentExamples 11 | 12 | Відтворити аудіокнигу «1984» за допомогою ShelfPlayer 13 | Відтворити епізод подкасту «Indusitral society and it's future» за допомогою ShelfPlayer 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/Chapter+Format.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chapter+Format.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 29.04.25. 6 | // 7 | 8 | import Foundation 9 | import ShelfPlayback 10 | 11 | extension Chapter { 12 | var timeOffsetFormatted: String { 13 | "\(startOffset.formatted(.duration(unitsStyle: .positional, allowedUnits: [.hour, .minute, .second], maximumUnitCount: 3))) - \(endOffset.formatted(.duration(unitsStyle: .positional, allowedUnits: [.hour, .minute, .second], maximumUnitCount: 3))) • \((endOffset - startOffset).formatted(.duration(unitsStyle: .positional, allowedUnits: [.hour, .minute, .second], maximumUnitCount: 3)))" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Multiplatform/Item/CommonActions/ItemConfigureButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemConfigureButton.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 06.08.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ItemConfigureButton: View { 12 | @Environment(Satellite.self) private var satellite 13 | 14 | let itemID: ItemIdentifier 15 | 16 | var body: some View { 17 | Button("item.configure", systemImage: "gearshape") { 18 | satellite.present(.configureGrouping(itemID)) 19 | } 20 | } 21 | } 22 | 23 | #if DEBUG 24 | #Preview { 25 | ItemConfigureButton(itemID: .fixture) 26 | .previewEnvironment() 27 | } 28 | #endif 29 | -------------------------------------------------------------------------------- /Multiplatform/Item/CommonActions/ItemCollectionMembershipEditButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemCollectionMembershipEditButton.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 22.07.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ItemCollectionMembershipEditButton: View { 12 | @Environment(Satellite.self) private var satellite 13 | 14 | let itemID: ItemIdentifier 15 | 16 | var body: some View { 17 | Button("item.collection.editMembership.open", systemImage: ItemIdentifier.ItemType.playlist.icon) { 18 | satellite.present(.editCollectionMembership(itemID)) 19 | } 20 | .disabled(satellite.isOffline) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /WidgetsExtension/Utility/WidgetAppIcon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetAppIcon.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 01.06.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayerKit 10 | 11 | struct WidgetAppIcon: View { 12 | @Environment(\.colorScheme) private var colorScheme 13 | @Default(.tintColor) private var tintColor 14 | 15 | var body: some View { 16 | Button(intent: CreateBookmarkIntent()) { 17 | Label(String("ShelfPlayer"), image: "shelfPlayer.fill") 18 | .labelStyle(.iconOnly) 19 | .foregroundStyle(colorScheme == .dark ? tintColor.color : .black) 20 | } 21 | .buttonStyle(.plain) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /WidgetsExtension/Utility/WidgetBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetBackground.swift 3 | // WidgetsExtension 4 | // 5 | // Created by Rasmus Krämer on 02.06.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayerKit 10 | 11 | struct WidgetBackground: View { 12 | @Environment(\.colorScheme) private var colorScheme 13 | @Default(.tintColor) private var tintColor 14 | 15 | var body: some View { 16 | if colorScheme == .light { 17 | Rectangle() 18 | .fill(tintColor.color.gradient) 19 | } else { 20 | Rectangle() 21 | .fill(.background.secondary) 22 | } 23 | } 24 | } 25 | 26 | #Preview { 27 | WidgetBackground() 28 | } 29 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/SwiftUI+Keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment+Keys.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 26.08.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public extension EnvironmentValues { 12 | @Entry var library: Library? = nil 13 | @Entry var connectionID: ItemIdentifier.ConnectionID? = nil 14 | 15 | @Entry var displayContext: DisplayContext = .unknown 16 | 17 | @Entry var playbackBottomOffset: CGFloat = 0 18 | @Entry var playbackBottomSafeArea: CGFloat = 0 19 | } 20 | 21 | public enum DisplayContext { 22 | case unknown 23 | case person(Person) 24 | case series(Series) 25 | case collection(ItemCollection) 26 | } 27 | -------------------------------------------------------------------------------- /Multiplatform/FREE_DEVELOPER_ACCOUNT.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.${BUNDLE_ID_PREFIX}.shelfplayer 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | com.apple.security.network.client 14 | 15 | keychain-access-groups 16 | 17 | $(AppIdentifierPrefix)io.rfk.ShelfPlayer 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Payloads/ProgressPayload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudiobookshelfClient+MediaProgress.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 17.09.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ProgressPayload: Sendable, Codable { 11 | public let id: String 12 | public let libraryItemId: String 13 | public let episodeId: String? 14 | 15 | public let duration: Double? 16 | public let progress: Double? 17 | public let currentTime: Double? 18 | 19 | public let isFinished: Bool 20 | public let hideFromContinueListening: Bool? 21 | 22 | public let lastUpdate: Int64? 23 | public let startedAt: Int64? 24 | public let finishedAt: Int64? 25 | } 26 | -------------------------------------------------------------------------------- /WidgetsExtension/Entitlements.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.icloud-container-identifiers 6 | 7 | com.apple.developer.ubiquity-kvstore-identifier 8 | $(TeamIdentifierPrefix)$(CFBundleIdentifier) 9 | com.apple.security.application-groups 10 | 11 | group.${BUNDLE_ID_PREFIX}.shelfplayer 12 | 13 | keychain-access-groups 14 | 15 | $(AppIdentifierPrefix)io.rfk.ShelfPlayer 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/ru.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Поиск медиа"; 2 | 3 | "ErAaNp" = "Режим повторения"; 4 | 5 | "G3J6fP" = "Воспроизвести медиа"; 6 | 7 | "KcCcQu" = "Воспроизвести ${mediaItems}"; 8 | 9 | "LdrEPN" = "Запрос на воспроизведение медиа."; 10 | 11 | "No0K9w" = "продолжить"; 12 | 13 | "Pk1Fkx" = "не продолжать"; 14 | 15 | "QqdHiR" = "перемещено"; 16 | 17 | "RvX3Zd" = "Скорость воспроизведения"; 18 | 19 | "WktFNa" = "Перемешать"; 20 | 21 | "YYfsKp" = "Воспроизвести ${mediaContainer}"; 22 | 23 | "cV2HLF" = "Расположение в очереди"; 24 | 25 | "cfJexL" = "Объекты"; 26 | 27 | "f3d3kn" = "Воспроизвести медиа"; 28 | 29 | "mCAocc" = "не перемещено"; 30 | 31 | "rvNMpm" = "Продолжить"; 32 | 33 | "xdnqvn" = "Container"; 34 | 35 | -------------------------------------------------------------------------------- /Multiplatform/fr.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INPlayMediaIntent 10 | IntentExamples 11 | 12 | Écouter le livre audio 1984 avec ShelfPlayer 13 | Écouter l'épisode du balado « La société industrielle et son avenir » avec ShelfPlayer 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/Item/Placeholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Placeholder.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 31.08.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Episode { 11 | static let placeholder: Episode = .init( 12 | id: .init(primaryID: "placeholder", groupingID: "placeholder", libraryID: "placeholder", connectionID: "placeholder", type: .episode), 13 | name: "Placeholder", 14 | authors: [], 15 | description: nil, 16 | addedAt: .now, 17 | released: nil, 18 | size: 0, 19 | duration: 0, 20 | podcastName: "Placeholder", 21 | type: .regular, 22 | index: .init(season: nil, episode: "placeholder")) 23 | } 24 | -------------------------------------------------------------------------------- /Multiplatform/Utility/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingView.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 02.10.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoadingView: View { 11 | var showOfflineControls = false 12 | 13 | var body: some View { 14 | UnavailableWrapper { 15 | Inner() 16 | } 17 | } 18 | 19 | struct Inner: View { 20 | let symbols = ["pc", "server.rack", "cpu", "memorychip", "hourglass", "zzz"] 21 | 22 | var body: some View { 23 | ContentUnavailableView("loading", systemImage: symbols.randomElement()!) 24 | .symbolEffect(.pulse) 25 | } 26 | } 27 | } 28 | 29 | #Preview { 30 | LoadingView() 31 | } 32 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Resolve/ItemID+API.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 26.11.24. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | extension ItemIdentifier { 12 | var pathComponent: String { 13 | if let groupingID { 14 | "\(groupingID)/\(primaryID)" 15 | } else { 16 | primaryID 17 | } 18 | } 19 | 20 | var apiItemID: String { 21 | if let groupingID { 22 | groupingID 23 | } else { 24 | primaryID 25 | } 26 | } 27 | var apiEpisodeID: String? { 28 | if groupingID != nil { 29 | primaryID 30 | } else { 31 | nil 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/ListenNowIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlayingIntent 2.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 14.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct ListenNowIntent: AppIntent { 12 | public static let title: LocalizedStringResource = "intent.listenNow" 13 | public static let description = IntentDescription("intent.listenNow.description") 14 | 15 | public init() {} 16 | 17 | public static var parameterSummary: some ParameterSummary { 18 | Summary("intent.listenNow") 19 | } 20 | 21 | public func perform() async throws -> some ReturnsValue<[ItemEntity]> { 22 | return await .result(value: listenNowItemEntities()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ShelfPlayer.icon/icon.json: -------------------------------------------------------------------------------- 1 | { 2 | "fill" : { 3 | "automatic-gradient" : "display-p3:0.97527,0.83540,0.27629,1.00000" 4 | }, 5 | "groups" : [ 6 | { 7 | "blur-material" : 0.5, 8 | "hidden" : false, 9 | "layers" : [ 10 | { 11 | "image-name" : "ShelfPlayer.svg", 12 | "name" : "ShelfPlayer" 13 | } 14 | ], 15 | "lighting" : "individual", 16 | "shadow" : { 17 | "kind" : "neutral", 18 | "opacity" : 0.5 19 | }, 20 | "specular" : true, 21 | "translucency" : { 22 | "enabled" : true, 23 | "value" : 0.5 24 | } 25 | } 26 | ], 27 | "supported-platforms" : { 28 | "circles" : [ 29 | "watchOS" 30 | ], 31 | "squares" : "shared" 32 | } 33 | } -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Embassy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Embassy.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 02.06.25. 6 | // 7 | 8 | import Foundation 9 | import WidgetKit 10 | 11 | public struct Embassy { 12 | public static func unsetWidgetIsPlaying() { 13 | guard let current = Defaults[.playbackInfoWidgetValue] else { 14 | return 15 | } 16 | 17 | Defaults[.playbackInfoWidgetValue] = .init(currentItemID: current.currentItemID, isDownloaded: current.isDownloaded, isPlaying: nil, listenNowItems: current.listenNowItems) 18 | 19 | WidgetCenter.shared.reloadTimelines(ofKind: "io.rfk.shelfPlayer.start") 20 | WidgetCenter.shared.reloadTimelines(ofKind: "io.rfk.shelfPlayer.listenNow") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/SwiftUI/View+Preview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Modify.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 17.10.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import ShelfPlayback 11 | 12 | extension View { 13 | #if DEBUG 14 | @ViewBuilder 15 | func previewEnvironment() -> some View { 16 | @Namespace var namespace 17 | 18 | self 19 | .environment(Satellite.shared.debugPlayback()) 20 | .environment(PlaybackViewModel.shared) 21 | .environment(ConnectionStore.shared) 22 | .environment(ProgressViewModel.shared) 23 | .environment(ListenedTodayTracker.shared) 24 | .environment(\.namespace, namespace) 25 | } 26 | #endif 27 | } 28 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Current/PersistedDiscoveredServer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistedDiscoveredConnection.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 23.12.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | extension SchemaV2 { 12 | @Model 13 | final class PersistedDiscoveredConnection { 14 | #Unique([\.connectionID]) 15 | 16 | private(set) var connectionID: String 17 | 18 | private(set) var host: URL 19 | private(set) var user: String 20 | 21 | init(connectionID: String, host: URL, user: String) { 22 | self.connectionID = connectionID 23 | self.host = host 24 | self.user = user 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Multiplatform/Item/CommonActions/QueueButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueueButton.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 30.08.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import ShelfPlayback 11 | 12 | struct QueueButton: View { 13 | static let systemImage = "text.line.last.and.arrowtriangle.forward" 14 | 15 | @Environment(Satellite.self) private var satellite 16 | 17 | let itemID: ItemIdentifier 18 | 19 | var short: Bool = false 20 | var hideLast: Bool = false 21 | 22 | var body: some View { 23 | Button(short ? "playback.queue.add.short" : "playback.queue.add", systemImage: Self.systemImage) { 24 | satellite.queue(itemID) 25 | } 26 | .disabled(satellite.isLoading(observing: itemID)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/SecondaryShadow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Shadow.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 11.10.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct SecondaryShadow: ViewModifier { 12 | @Environment(\.colorScheme) private var colorScheme 13 | 14 | let radius: CGFloat 15 | let opacity: Double 16 | 17 | public func body(content: Content) -> some View { 18 | content 19 | .shadow(color: (colorScheme == .dark ? Color.gray : .black).opacity(opacity), radius: radius) 20 | } 21 | } 22 | 23 | public extension View { 24 | @ViewBuilder 25 | func secondaryShadow(radius: CGFloat = 12, opacity: Double = 0.3) -> some View { 26 | modifier(SecondaryShadow(radius: radius, opacity: opacity)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Convert/Episode+ConvertOffline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Episode+Convert.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 08.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | extension Episode { 12 | convenience init(downloaded episode: PersistedEpisode) { 13 | self.init(id: episode.id, 14 | name: episode.name, 15 | authors: episode.authors, 16 | description: episode.overview, 17 | addedAt: episode.addedAt, 18 | released: episode.released, 19 | size: episode.size, 20 | duration: episode.duration, 21 | podcastName: episode.podcast.name, 22 | type: episode.type, 23 | index: episode.index) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Resolve/PlayableItem+Filter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayableItem+Filter.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 01.02.25. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public extension PlayableItem { 12 | func isIncluded(in filter: ItemFilter) async -> Bool { 13 | let included: Bool 14 | let entity = await PersistenceManager.shared.progress[id] 15 | 16 | switch filter { 17 | case .all: 18 | included = true 19 | case .active: 20 | included = entity.progress > 0 && entity.progress < 1 21 | case .finished: 22 | included = entity.isFinished 23 | case .notFinished: 24 | included = !entity.isFinished 25 | } 26 | 27 | return included 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Multiplatform/Entitlements.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.carplay-audio 6 | 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | com.apple.developer.siri 10 | 11 | com.apple.developer.ubiquity-kvstore-identifier 12 | $(TeamIdentifierPrefix)$(CFBundleIdentifier) 13 | com.apple.security.application-groups 14 | 15 | group.${BUNDLE_ID_PREFIX}.shelfplayer 16 | 17 | keychain-access-groups 18 | 19 | $(AppIdentifierPrefix)io.rfk.ShelfPlayer 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/DownloadIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 01.07.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct DownloadIntent: AppIntent { 12 | public static let title: LocalizedStringResource = "intent.download" 13 | public static let description = IntentDescription("intent.download.description") 14 | 15 | @Parameter(title: "intent.entity.item", description: "intent.entity.item.description") 16 | public var item: ItemEntity 17 | 18 | public init() {} 19 | 20 | @MainActor 21 | public func perform() async throws -> some ReturnsValue { 22 | try await PersistenceManager.shared.download.download(item.id) 23 | return .result(value: item) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Multiplatform/Utility/RowTitle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RowTitle.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 08.10.23. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct RowTitle: View { 12 | @Default(.enableSerifFont) private var enableSerifFont 13 | 14 | let title: String 15 | var fontDesign: Font.Design? = nil 16 | 17 | var body: some View { 18 | Text(title) 19 | .font(.headline) 20 | .fontDesign(fontDesign == .serif && !enableSerifFont ? nil : fontDesign) 21 | .accessibilityAddTraits(.isHeader) 22 | .accessibilityRemoveTraits(.isStaticText) 23 | } 24 | } 25 | 26 | #if DEBUG 27 | #Preview { 28 | RowTitle(title: "Title") 29 | } 30 | 31 | #Preview { 32 | RowTitle(title: "Title", fontDesign: .serif) 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Start/StartAudiobookIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayAudiobookIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 03.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | @AppIntent(schema: .books.playAudiobook) 12 | public struct StartAudiobookIntent: AudioPlaybackIntent { 13 | @AppDependency private var audioPlayer: IntentAudioPlayer 14 | 15 | public init() {} 16 | public init(audiobook: Audiobook) async { 17 | self.target = await .init(audiobook: audiobook) 18 | } 19 | 20 | @Parameter(optionsProvider: AudiobookEntityOptionsProvider()) 21 | public var target: AudiobookEntity 22 | 23 | public func perform() async throws -> some IntentResult { 24 | try await audioPlayer.start(target.id, false) 25 | return .result() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/IntentError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntentError.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 31.05.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum IntentError: Error, CustomLocalizedStringResourceConvertible { 11 | case notFound 12 | 13 | case noPlaybackItem 14 | case invalidItemType 15 | 16 | case wrongExecutionContext 17 | 18 | public var localizedStringResource: LocalizedStringResource { 19 | switch self { 20 | case .notFound: 21 | "intent.error.notFound" 22 | 23 | case .noPlaybackItem: 24 | "intent.error.noPlaybackItem" 25 | case .invalidItemType: 26 | "intent.error.invalidItemType" 27 | default: 28 | "intent.error" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/RemoveDownloadIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadIntent 2.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 01.07.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct RemoveDownloadIntent: AppIntent { 12 | public static let title: LocalizedStringResource = "intent.removeDownload" 13 | public static let description = IntentDescription("intent.removeDownload.description") 14 | 15 | @Parameter(title: "intent.entity.item", description: "intent.entity.item.description") 16 | public var item: ItemEntity 17 | 18 | public init() {} 19 | 20 | @MainActor 21 | public func perform() async throws -> some ReturnsValue { 22 | try await PersistenceManager.shared.download.remove(item.id) 23 | return .result(value: item) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Convert/Podcast+ConvertOffline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Podcast+Convert.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 07.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | extension Podcast { 12 | convenience init(downloaded podcast: PersistedPodcast) { 13 | self.init(id: podcast.id, 14 | name: podcast.name, 15 | authors: podcast.authors, 16 | description: podcast.overview, 17 | genres: podcast.genres, 18 | addedAt: podcast.addedAt, 19 | released: podcast.released, 20 | explicit: podcast.explicit, 21 | episodeCount: podcast.totalEpisodeCount, 22 | incompleteEpisodeCount: nil, 23 | publishingType: podcast.publishingType) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Multiplatform/Utility/OutdatedServerRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OutdatedServerRow.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 23.08.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayerKit 10 | 11 | struct OutdatedServerRow: View { 12 | let version: String? 13 | 14 | var isUsingOutdatedServer: Bool { 15 | ShelfPlayerKit.isUsingOutdatedServer(version) 16 | } 17 | 18 | var body: some View { 19 | if isUsingOutdatedServer { 20 | Text("connection.outdatedServer") 21 | .foregroundStyle(.orange) 22 | } 23 | } 24 | } 25 | 26 | #Preview { 27 | List { 28 | ForEach(["1.1.1", "2.25.4", "2.26.0", "2.28.0", "3.0.0"], id: \.hashValue) { version in 29 | Section(version) { 30 | OutdatedServerRow(version: version) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/SleepTimerLiveActivityAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SleepTimerLiveActivityAttributes.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 29.06.25. 6 | // 7 | 8 | import Foundation 9 | import ActivityKit 10 | 11 | public struct SleepTimerLiveActivityAttributes: ActivityAttributes { 12 | public struct ContentState: Codable, Hashable { 13 | public let deadline: Date? 14 | public let chapters: Int? 15 | 16 | public let isPlaying: Bool 17 | 18 | public init(deadline: Date?, chapters: Int?, isPlaying: Bool) { 19 | self.deadline = deadline 20 | self.chapters = chapters 21 | self.isPlaying = isPlaying 22 | } 23 | } 24 | 25 | public let started: Date 26 | 27 | public init(started: Date) { 28 | self.started = started 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Convert/Progress+ConvertOffline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Progress+Convert.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 23.12.24. 6 | // 7 | 8 | 9 | 10 | extension ProgressEntity { 11 | init(persistedEntity: PersistedProgress) { 12 | self.init(id: persistedEntity.id, 13 | connectionID: persistedEntity.connectionID, 14 | primaryID: persistedEntity.primaryID, 15 | groupingID: persistedEntity.groupingID, 16 | progress: persistedEntity.progress, 17 | duration: persistedEntity.duration, 18 | currentTime: persistedEntity.currentTime, 19 | startedAt: persistedEntity.startedAt, 20 | lastUpdate: persistedEntity.lastUpdate, 21 | finishedAt: persistedEntity.finishedAt) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Current/SchemaV2.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SchemaV2.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 27.11.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | enum SchemaV2: VersionedSchema { 12 | static var versionIdentifier: Schema.Version { 13 | .init(2, 0, 0) 14 | } 15 | static var models: [any PersistentModel.Type] {[ 16 | PersistedAudiobook.self, 17 | PersistedEpisode.self, 18 | PersistedPodcast.self, 19 | 20 | PersistedAsset.self, 21 | PersistedBookmark.self, 22 | PersistedChapter.self, 23 | 24 | PersistedProgress.self, 25 | PersistedPlaybackSession.self, 26 | 27 | PersistedKeyValueEntity.self, 28 | PersistedSearchIndexEntry.self, 29 | 30 | PersistedDiscoveredConnection.self, 31 | ]} 32 | } 33 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/UIKit/UIWindow+Shake.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIWindow+Shake.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 02.10.24. 6 | // 7 | 8 | import Foundation 9 | import ShelfPlayback 10 | 11 | #if os(iOS) 12 | import UIKit 13 | 14 | @MainActor 15 | var motionStarted: Date? 16 | 17 | extension UIWindow { 18 | open override func motionBegan(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { 19 | guard motion == .motionShake else { 20 | return 21 | } 22 | 23 | motionStarted = .now 24 | } 25 | open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { 26 | guard let motionStarted, motion == .motionShake else { 27 | return 28 | } 29 | 30 | RFNotification[.shake].send(payload: motionStarted.distance(to: .now)) 31 | } 32 | } 33 | #endif 34 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/API+Library.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudiobookshelfClient+Libraries.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 03.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension APIClient { 11 | func libraries() async throws -> [Library] { 12 | let response: LibrariesResponse = try await response(path: "api/libraries", method: .get) 13 | return response.libraries.map { Library(id: $0.id, connectionID: connectionID, name: $0.name, type: $0.mediaType, index: $0.displayOrder) } 14 | } 15 | 16 | func genres(from libraryID: ItemIdentifier.LibraryID) async throws -> [String] { 17 | let response: LibraryResponse = try await response(path: "api/libraries/\(libraryID)", method: .get, query: [ 18 | .init(name: "include", value: "filterdata") 19 | ]) 20 | return response.filterdata.genres 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/Episode+Sort.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayableItem+Sort.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 01.02.25. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public extension Episode { 12 | func compare(other episode: Episode, sortOrder: EpisodeSortOrder, ascending: Bool) -> Bool { 13 | switch sortOrder { 14 | case .name: 15 | return name.localizedStandardCompare(episode.name) == .orderedAscending 16 | case .index: 17 | return index < episode.index 18 | case .released: 19 | guard let lhsReleaseDate = releaseDate else { return false } 20 | guard let rhsReleaseDate = episode.releaseDate else { return true } 21 | 22 | return lhsReleaseDate < rhsReleaseDate 23 | case .duration: 24 | return duration < episode.duration 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/SleepTimer/CancelSleepTimerIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SetSleepTimerIntent 3.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 28.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct CancelSleepTimerIntent: AudioPlaybackIntent { 12 | public static let title: LocalizedStringResource = "intent.cancelSleepTimer" 13 | public static let description = IntentDescription("intent.cancelSleepTimer.description") 14 | 15 | @AppDependency private var audioPlayer: IntentAudioPlayer 16 | 17 | public init() {} 18 | 19 | public func perform() async throws -> some IntentResult { 20 | guard await audioPlayer.isPlaying != nil else { 21 | throw IntentError.noPlaybackItem 22 | } 23 | 24 | await audioPlayer.setSleepTimer(nil) 25 | 26 | return .result() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/SleepTimer/ExtendSleepTimerIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SetSleepTimerIntent 2.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 28.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct ExtendSleepTimerIntent: AudioPlaybackIntent { 12 | public static let title: LocalizedStringResource = "intent.extendSleepTimer" 13 | public static let description = IntentDescription("intent.extendSleepTimer.description") 14 | 15 | @AppDependency private var audioPlayer: IntentAudioPlayer 16 | 17 | public init() {} 18 | 19 | public func perform() async throws -> some IntentResult { 20 | guard await audioPlayer.isPlaying != nil else { 21 | throw IntentError.noPlaybackItem 22 | } 23 | 24 | await audioPlayer.extendSleepTimer() 25 | 26 | return .result() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/de.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Media Search"; 2 | 3 | "ApUiZd" = "${mediaItems}"; 4 | 5 | "ErAaNp" = "Repeat Mode"; 6 | 7 | "G3J6fP" = "Play Media"; 8 | 9 | "KcCcQu" = "Play ${mediaItems}"; 10 | 11 | "LdrEPN" = "A request to play media."; 12 | 13 | "No0K9w" = "resume"; 14 | 15 | "Pk1Fkx" = "don't resume"; 16 | 17 | "QqdHiR" = "shuffled"; 18 | 19 | "RvX3Zd" = "Playback Speed"; 20 | 21 | "WEjMGd" = "Play ${mediaContainer}"; 22 | 23 | "WktFNa" = "Shuffled"; 24 | 25 | "YYfsKp" = "Play ${mediaContainer}"; 26 | 27 | "cV2HLF" = "Queue Location"; 28 | 29 | "cfJexL" = "Items"; 30 | 31 | "f3d3kn" = "Play"; 32 | 33 | "mCAocc" = "not shuffled"; 34 | 35 | "mIvv3D" = "Resume ${mediaItems}"; 36 | 37 | "o6CuZu" = "Resume ${mediaContainer}"; 38 | 39 | "rvNMpm" = "Resume"; 40 | 41 | "sLPQxZ" = "Resume ${mediaContainer}"; 42 | 43 | "vUAqfM" = "${mediaItems}"; 44 | 45 | "xdnqvn" = "Container"; 46 | 47 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/en.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Media Search"; 2 | 3 | "ApUiZd" = "${mediaItems}"; 4 | 5 | "ErAaNp" = "Repeat Mode"; 6 | 7 | "G3J6fP" = "Play Media"; 8 | 9 | "KcCcQu" = "Play ${mediaItems}"; 10 | 11 | "LdrEPN" = "A request to play media."; 12 | 13 | "No0K9w" = "resume"; 14 | 15 | "Pk1Fkx" = "don't resume"; 16 | 17 | "QqdHiR" = "shuffled"; 18 | 19 | "RvX3Zd" = "Playback Speed"; 20 | 21 | "WEjMGd" = "Play ${mediaContainer}"; 22 | 23 | "WktFNa" = "Shuffled"; 24 | 25 | "YYfsKp" = "Play ${mediaContainer}"; 26 | 27 | "cV2HLF" = "Queue Location"; 28 | 29 | "cfJexL" = "Items"; 30 | 31 | "f3d3kn" = "Play"; 32 | 33 | "mCAocc" = "not shuffled"; 34 | 35 | "mIvv3D" = "Resume ${mediaItems}"; 36 | 37 | "o6CuZu" = "Resume ${mediaContainer}"; 38 | 39 | "rvNMpm" = "Resume"; 40 | 41 | "sLPQxZ" = "Resume ${mediaContainer}"; 42 | 43 | "vUAqfM" = "${mediaItems}"; 44 | 45 | "xdnqvn" = "Container"; 46 | 47 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/sv.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Media Search"; 2 | 3 | "ApUiZd" = "${mediaItems}"; 4 | 5 | "ErAaNp" = "Repeat Mode"; 6 | 7 | "G3J6fP" = "Play Media"; 8 | 9 | "KcCcQu" = "Play ${mediaItems}"; 10 | 11 | "LdrEPN" = "A request to play media."; 12 | 13 | "No0K9w" = "resume"; 14 | 15 | "Pk1Fkx" = "don't resume"; 16 | 17 | "QqdHiR" = "shuffled"; 18 | 19 | "RvX3Zd" = "Playback Speed"; 20 | 21 | "WEjMGd" = "Play ${mediaContainer}"; 22 | 23 | "WktFNa" = "Shuffled"; 24 | 25 | "YYfsKp" = "Play ${mediaContainer}"; 26 | 27 | "cV2HLF" = "Queue Location"; 28 | 29 | "cfJexL" = "Items"; 30 | 31 | "f3d3kn" = "Play"; 32 | 33 | "mCAocc" = "not shuffled"; 34 | 35 | "mIvv3D" = "Resume ${mediaItems}"; 36 | 37 | "o6CuZu" = "Resume ${mediaContainer}"; 38 | 39 | "rvNMpm" = "Resume"; 40 | 41 | "sLPQxZ" = "Resume ${mediaContainer}"; 42 | 43 | "vUAqfM" = "${mediaItems}"; 44 | 45 | "xdnqvn" = "Container"; 46 | 47 | -------------------------------------------------------------------------------- /Multiplatform/Utility/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 02.10.23. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ErrorView: View { 12 | var itemID: ItemIdentifier? 13 | 14 | var body: some View { 15 | UnavailableWrapper { 16 | ErrorViewInner(label: itemID?.type.errorLabel, systemImage: itemID?.type.icon) 17 | } 18 | } 19 | } 20 | 21 | struct ErrorViewInner: View { 22 | var label: LocalizedStringKey? = nil 23 | var systemImage: String? = nil 24 | 25 | var body: some View { 26 | ContentUnavailableView(label ?? "error.unavailable", systemImage: systemImage ?? "xmark", description: Text("error.unavailable.text")) 27 | } 28 | } 29 | 30 | #if DEBUG 31 | #Preview { 32 | ErrorView() 33 | } 34 | #Preview { 35 | ErrorView(itemID: .fixture) 36 | } 37 | #endif 38 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Playback/PlayIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 31.05.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | import WidgetKit 11 | 12 | public struct PlayIntent: AudioPlaybackIntent { 13 | public static let title: LocalizedStringResource = "intent.play" 14 | public static let description = IntentDescription("intent.play.description") 15 | 16 | @AppDependency private var audioPlayer: IntentAudioPlayer 17 | 18 | public init() {} 19 | 20 | public func perform() async throws -> some IntentResult { 21 | guard await audioPlayer.isPlaying != nil else { 22 | Embassy.unsetWidgetIsPlaying() 23 | throw IntentError.noPlaybackItem 24 | } 25 | 26 | await audioPlayer.setPlaying(true) 27 | 28 | return .result() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/zh-Hans.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Media Search"; 2 | 3 | "ApUiZd" = "${mediaItems}"; 4 | 5 | "ErAaNp" = "Repeat Mode"; 6 | 7 | "G3J6fP" = "Play Media"; 8 | 9 | "KcCcQu" = "Play ${mediaItems}"; 10 | 11 | "LdrEPN" = "A request to play media."; 12 | 13 | "No0K9w" = "resume"; 14 | 15 | "Pk1Fkx" = "don't resume"; 16 | 17 | "QqdHiR" = "shuffled"; 18 | 19 | "RvX3Zd" = "Playback Speed"; 20 | 21 | "WEjMGd" = "Play ${mediaContainer}"; 22 | 23 | "WktFNa" = "Shuffled"; 24 | 25 | "YYfsKp" = "Play ${mediaContainer}"; 26 | 27 | "cV2HLF" = "Queue Location"; 28 | 29 | "cfJexL" = "Items"; 30 | 31 | "f3d3kn" = "Play"; 32 | 33 | "mCAocc" = "not shuffled"; 34 | 35 | "mIvv3D" = "Resume ${mediaItems}"; 36 | 37 | "o6CuZu" = "Resume ${mediaContainer}"; 38 | 39 | "rvNMpm" = "Resume"; 40 | 41 | "sLPQxZ" = "Resume ${mediaContainer}"; 42 | 43 | "vUAqfM" = "${mediaItems}"; 44 | 45 | "xdnqvn" = "Container"; 46 | 47 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Playback/PauseIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PauseIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 31.05.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | import WidgetKit 11 | 12 | public struct PauseIntent: AudioPlaybackIntent { 13 | public static let title: LocalizedStringResource = "intent.pause" 14 | public static let description = IntentDescription("intent.pause.description") 15 | 16 | @AppDependency private var audioPlayer: IntentAudioPlayer 17 | 18 | public init() {} 19 | 20 | public func perform() async throws -> some IntentResult { 21 | guard await audioPlayer.isPlaying != nil else { 22 | Embassy.unsetWidgetIsPlaying() 23 | throw IntentError.noPlaybackItem 24 | } 25 | 26 | await audioPlayer.setPlaying(false) 27 | 28 | return .result() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Multiplatform/Item/CommonActions/ProgressResetButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressResetButton.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 29.01.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ProgressResetButton: View { 12 | @Environment(Satellite.self) private var satellite 13 | 14 | let itemID: ItemIdentifier 15 | 16 | @State private var tracker: ProgressTracker 17 | 18 | init(itemID: ItemIdentifier) { 19 | self.itemID = itemID 20 | _tracker = .init(initialValue: .init(itemID: itemID)) 21 | } 22 | 23 | var body: some View { 24 | if let progress = tracker.progress, progress > 0 { 25 | Button("item.progress.reset", systemImage: "square.slash", role: .destructive) { 26 | satellite.deleteProgress(itemID) 27 | } 28 | .disabled(satellite.isLoading(observing: itemID)) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/Item/AudiobookSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudiobookSection.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 02.11.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum AudiobookSection: Sendable { 11 | case audiobook(audiobook: Audiobook) 12 | case series(seriesID: ItemIdentifier, seriesName: String, audiobookIDs: [ItemIdentifier]) 13 | 14 | public var audiobook: Audiobook? { 15 | switch self { 16 | case .audiobook(let audiobook): 17 | audiobook 18 | case .series: 19 | nil 20 | } 21 | } 22 | } 23 | 24 | extension AudiobookSection: Hashable {} 25 | extension AudiobookSection: Identifiable { 26 | public var id: ItemIdentifier { 27 | switch self { 28 | case .audiobook(let audiobook): 29 | audiobook.id 30 | case .series(let seriesID, _, _): 31 | seriesID 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/Item/Bookmark.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bookmark.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 26.11.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Bookmark { 11 | public let itemID: ItemIdentifier 12 | 13 | public let time: UInt64 14 | public let note: String 15 | 16 | public let created: Date 17 | 18 | public init(itemID: ItemIdentifier, time: UInt64, note: String, created: Date) { 19 | self.itemID = itemID 20 | self.time = time 21 | self.note = note 22 | self.created = created 23 | } 24 | } 25 | 26 | extension Bookmark: Sendable {} 27 | extension Bookmark: Hashable {} 28 | extension Bookmark: Identifiable { 29 | public var id: String { 30 | "\(itemID)_\(time)" 31 | } 32 | } 33 | extension Bookmark: Comparable { 34 | public static func <(lhs: Self, rhs: Self) -> Bool { 35 | lhs.time < rhs.time 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /WidgetsExtension/WidgetsBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetsBundle.swift 3 | // Widgets 4 | // 5 | // Created by Rasmus Krämer on 29.05.25. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | import AppIntents 11 | import ShelfPlayerKit 12 | 13 | @main 14 | struct WidgetsBundle: WidgetBundle { 15 | init() { 16 | let semaphore = DispatchSemaphore(value: 0) 17 | 18 | Task { 19 | try? await PersistenceManager.shared.authorization.waitForConnections() 20 | semaphore.signal() 21 | } 22 | } 23 | 24 | var body: some Widget { 25 | StartWidget() 26 | 27 | ListenedTodayWidget() 28 | ListenNowWidget() 29 | 30 | SleepTimerLiveActivity() 31 | } 32 | } 33 | 34 | struct ShelfPlayerWidgetPackage: AppIntentsPackage { 35 | nonisolated(unsafe) static let includedPackages: [any AppIntentsPackage.Type] = [ 36 | ShelfPlayerKitPackage.self, 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /Multiplatform/CarPlay/CarPlayController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CarPlayControlelr.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 19.10.24. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import CarPlay 10 | import ShelfPlayback 11 | 12 | @MainActor 13 | class CarPlayController { 14 | private let interfaceController: CPInterfaceController 15 | 16 | private let tabBar: CarPlayTabBar 17 | private let nowPlayingController: CarPlayNowPlayingController 18 | 19 | init(interfaceController: CPInterfaceController) async throws { 20 | self.interfaceController = interfaceController 21 | 22 | tabBar = .init(interfaceController: interfaceController) 23 | nowPlayingController = .init(interfaceController: interfaceController) 24 | 25 | try await interfaceController.setRootTemplate(tabBar.template, animated: false) 26 | } 27 | func destroy() { 28 | nowPlayingController.remove() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ShelfPlayback/Utility/AudioPlayerItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueueItem.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 21.02.25. 6 | // 7 | 8 | import Foundation 9 | import ShelfPlayerKit 10 | 11 | public struct AudioPlayerItem: Sendable { 12 | public let itemID: ItemIdentifier 13 | 14 | let origin: PlaybackOrigin 15 | let startWithoutListeningSession: Bool 16 | 17 | public init(itemID: ItemIdentifier, origin: PlaybackOrigin, startWithoutListeningSession: Bool) { 18 | self.itemID = itemID 19 | 20 | self.origin = origin 21 | self.startWithoutListeningSession = startWithoutListeningSession 22 | } 23 | 24 | public enum PlaybackOrigin: Sendable { 25 | case series(ItemIdentifier) 26 | case podcast(ItemIdentifier) 27 | 28 | case collection(ItemIdentifier) 29 | 30 | case upNextQueue 31 | case carPlay 32 | 33 | case unknown 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Playback/CreateBookmarkIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayIntent 2.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 19.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct CreateBookmarkIntent: AudioPlaybackIntent { 12 | public static let title: LocalizedStringResource = "intent.createBookmark" 13 | public static let description = IntentDescription("intent.createBookmark.description") 14 | 15 | @AppDependency private var audioPlayer: IntentAudioPlayer 16 | 17 | @Parameter(title: "intent.createBookmark.note") 18 | public var note: String? 19 | 20 | public init() {} 21 | 22 | public func perform() async throws -> some IntentResult { 23 | guard await audioPlayer.isPlaying != nil else { 24 | throw IntentError.noPlaybackItem 25 | } 26 | 27 | try await audioPlayer.createBookmark(note) 28 | 29 | return .result() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/Item/Chapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chapter.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 26.11.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Chapter { 11 | public let id: Int 12 | public let startOffset: TimeInterval 13 | public let endOffset: TimeInterval 14 | public let title: String 15 | 16 | public init(id: Int, startOffset: TimeInterval, endOffset: TimeInterval, title: String) { 17 | self.id = id 18 | self.startOffset = startOffset 19 | self.endOffset = endOffset 20 | self.title = title 21 | } 22 | 23 | public var duration: TimeInterval { 24 | endOffset - startOffset 25 | } 26 | } 27 | 28 | extension Chapter: Sendable {} 29 | extension Chapter: Hashable {} 30 | extension Chapter: Comparable { 31 | public static func <(lhs: Self, rhs: Self) -> Bool { 32 | lhs.startOffset < rhs.startOffset 33 | } 34 | } 35 | extension Chapter: Identifiable {} 36 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Convert/Audiobook+ConvertOffline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Audiobook+Convert.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 03.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | extension Audiobook { 12 | convenience init(downloaded audiobook: PersistedAudiobook) { 13 | self.init(id: audiobook.id, 14 | name: audiobook.name, 15 | authors: audiobook.authors, 16 | description: audiobook.overview, 17 | genres: audiobook.genres, 18 | addedAt: audiobook.addedAt, 19 | released: audiobook.released, 20 | size: audiobook.size, 21 | duration: audiobook.duration, 22 | subtitle: audiobook.subtitle, 23 | narrators: audiobook.narrators, 24 | series: audiobook.series, 25 | explicit: audiobook.explicit, 26 | abridged: audiobook.abridged) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/URL+AllocatedSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Size.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 09.11.24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URL { 11 | func directoryTotalAllocatedSize(recursive: Bool = true) throws -> Int? { 12 | guard try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true, try checkResourceIsReachable() else { 13 | return nil 14 | } 15 | 16 | let contents: [URL] 17 | 18 | if recursive, let objects = FileManager.default.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL] { 19 | contents = objects 20 | } else { 21 | contents = try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil) 22 | } 23 | 24 | return contents.lazy.reduce(0) { 25 | $0 + ((try? $1.resourceValues(forKeys: [.totalFileAllocatedSizeKey]))?.totalFileAllocatedSize ?? 0) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Playback/NowPlayingIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlayingIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 02.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct NowPlayingIntent: AudioPlaybackIntent { 12 | public static let title: LocalizedStringResource = "intent.nowPlaying" 13 | public static let description = IntentDescription("intent.nowPlaying.description") 14 | 15 | @AppDependency private var audioPlayer: IntentAudioPlayer 16 | 17 | public init() {} 18 | 19 | public static var parameterSummary: some ParameterSummary { 20 | Summary("intent.nowPlaying") 21 | } 22 | 23 | public func perform() async throws -> some ReturnsValue { 24 | guard let currentItemID = await audioPlayer.currentItemID else { 25 | throw IntentError.noPlaybackItem 26 | } 27 | 28 | return try await .result(value: ItemEntity(item: currentItemID.resolved)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Configuration/Debug.xcconfig.template: -------------------------------------------------------------------------------- 1 | // 2 | // Debug.xcconfig 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 23.01.24. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | #include "Base.xcconfig" 12 | 13 | DEVELOPMENT_TEAM = ABC123456 14 | BUNDLE_ID_PREFIX = change.me 15 | 16 | // The "ENABLE_CENTRALIZED" flag is used to enable features that require a shared app group "group.io.rfk.shelfplayer", as well as, a paid developer account. Change ShelfPlayerKit+Utility#15 (the app group, which is defined for you as `group.${BUNDLE_ID_PREFIX}.shelfplayer` in the relevant entitlements) if you enabled this flag. 17 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG 18 | // SWIFT_ACTIVE_COMPILATION_CONDITIONS = ENABLE_CENTRALIZED DEBUG 19 | 20 | // ShelfPlayer uses entitlements only available to paid or even authorized developers. 21 | // This line uses an alternate set of entitlements, which should be available to everyone. 22 | ENTITLEMENT_BASE = FREE_DEVELOPER_ACCOUNT -------------------------------------------------------------------------------- /ShelfPlayerKit/Human Interface/CircularProgressIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularProgressIndicator.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 16.02.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct CircularProgressIndicator: View { 11 | let completed: Percentage 12 | 13 | let background: Color 14 | let tint: Color 15 | 16 | public init(completed: Percentage, background: Color, tint: Color) { 17 | self.completed = completed 18 | self.background = background 19 | self.tint = tint 20 | } 21 | 22 | public var body: some View { 23 | ZStack { 24 | Circle() 25 | .trim(from: CGFloat(completed), to: 360 - CGFloat(completed)) 26 | .stroke(background, lineWidth: 3) 27 | 28 | Circle() 29 | .trim(from: 0, to: CGFloat(completed)) 30 | .stroke(tint, style: .init(lineWidth: 3, lineCap: .round)) 31 | } 32 | .rotationEffect(.degrees(-90)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Multiplatform/Navigation/ItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemView.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 22.07.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ItemView: View { 12 | let item: Item 13 | 14 | var zoomID: UUID? 15 | var episodes: [Episode] = [] 16 | 17 | var body: some View { 18 | if let audiobook = item as? Audiobook { 19 | AudiobookView(audiobook) 20 | } else if let series = item as? Series { 21 | SeriesView(series) 22 | } else if let person = item as? Person { 23 | PersonView(person) 24 | } else if let podcast = item as? Podcast { 25 | PodcastView(podcast, episodes: episodes, zoom: zoomID != nil) 26 | } else if let episode = item as? Episode { 27 | EpisodeView(episode, zoomID: zoomID) 28 | } else if let collection = item as? ItemCollection { 29 | CollectionView(collection) 30 | } else { 31 | ErrorView() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Skip/SkipBackwardsIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SkipBackwardsIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 19.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct SkipBackwardsIntent: AudioPlaybackIntent { 12 | public static let title: LocalizedStringResource = "intent.skip.backwards" 13 | public static let description = IntentDescription("intent.skip.description") 14 | 15 | @AppDependency private var audioPlayer: IntentAudioPlayer 16 | 17 | @Parameter(title: "intent.skip.interval", controlStyle: .field, inclusiveRange: (0, 108_000)) 18 | public var interval: TimeInterval? 19 | 20 | public init() {} 21 | 22 | public func perform() async throws -> some IntentResult { 23 | guard await audioPlayer.isPlaying != nil else { 24 | throw IntentError.noPlaybackItem 25 | } 26 | 27 | try await audioPlayer.skip(interval, forwards: false) 28 | 29 | return .result() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Current/PersistedKeyValueEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistedKeyValueEntity.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 28.11.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | extension SchemaV2 { 12 | @Model 13 | final class PersistedKeyValueEntity { 14 | #Index([\.id], [\.key], [\.cluster]) 15 | #Unique([\.id], [\.key]) 16 | 17 | private(set) var id: UUID 18 | 19 | private(set) var key: String 20 | private(set) var cluster: String 21 | 22 | var value: Data 23 | 24 | init(key: String, cluster: String, value: Data, isCachePurgeable: Bool) { 25 | id = UUID() 26 | 27 | self.key = key 28 | self.cluster = cluster 29 | self.value = value 30 | self.isCachePurgeable = isCachePurgeable 31 | } 32 | 33 | private(set) var isCachePurgeable: Bool 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Skip/SkipForwardsIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SkipBackwardsIntent 2.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 19.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct SkipForwardsIntent: AudioPlaybackIntent { 12 | public static let title: LocalizedStringResource = "intent.skip.forwards" 13 | public static let description = IntentDescription("intent.skip.description") 14 | 15 | @AppDependency private var audioPlayer: IntentAudioPlayer 16 | 17 | // 0s --> 30m 18 | @Parameter(title: "intent.skip.interval", controlStyle: .field, inclusiveRange: (0, 108_000)) 19 | public var interval: TimeInterval? 20 | 21 | public init() {} 22 | 23 | public func perform() async throws -> some IntentResult { 24 | guard await audioPlayer.isPlaying != nil else { 25 | throw IntentError.noPlaybackItem 26 | } 27 | 28 | try await audioPlayer.skip(interval, forwards: true) 29 | 30 | return .result() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Multiplatform/Sheets/CustomTabValueSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomTabValueSheet.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 25.10.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct CustomTabValueSheet: View { 12 | @Environment(\.dismiss) private var dismiss 13 | 14 | @Default(.customTabValues) private var customTabValues 15 | 16 | var body: some View { 17 | NavigationStack { 18 | CustomTabValuesPreferences() 19 | .toolbar { 20 | Button("action.dismiss") { 21 | dismiss() 22 | } 23 | } 24 | } 25 | .onDisappear { 26 | if !customTabValues.isEmpty { 27 | RFNotification[.toggleCustomTabsActive].send() 28 | } 29 | } 30 | } 31 | } 32 | 33 | #if DEBUG 34 | #Preview { 35 | Text(verbatim: ":9") 36 | .sheet(isPresented: .constant(true)) { 37 | CustomTabValueSheet() 38 | .previewEnvironment() 39 | } 40 | } 41 | #endif 42 | -------------------------------------------------------------------------------- /Multiplatform/Item/PDFViewer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PDFViewer.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 25.01.25. 6 | // 7 | 8 | import PDFKit 9 | import SwiftUI 10 | 11 | struct PDFViewer: UIViewRepresentable { 12 | typealias UIViewType = PDFView 13 | 14 | let data: Data 15 | let singlePage: Bool 16 | 17 | init(_ data: Data, singlePage: Bool = false) { 18 | self.data = data 19 | self.singlePage = singlePage 20 | } 21 | 22 | func makeUIView(context _: UIViewRepresentableContext) -> UIViewType { 23 | let pdfView = PDFView() 24 | 25 | pdfView.document = PDFDocument(data: data) 26 | 27 | pdfView.autoScales = true 28 | pdfView.displaysAsBook = true 29 | 30 | if singlePage { 31 | pdfView.displayMode = .singlePage 32 | } 33 | 34 | return pdfView 35 | } 36 | 37 | func updateUIView(_ pdfView: UIViewType, context _: UIViewRepresentableContext) { 38 | pdfView.document = PDFDocument(data: data) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/uk.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | /* (No Comment) */ 2 | "1uT6VF" = "Пошук медіа"; 3 | 4 | /* (No Comment) */ 5 | "cfJexL" = "Елементи"; 6 | 7 | /* (No Comment) */ 8 | "cV2HLF" = "Розташування у черзі"; 9 | 10 | /* (No Comment) */ 11 | "ErAaNp" = "Режим повтору"; 12 | 13 | /* (No Comment) */ 14 | "f3d3kn" = "Відтворити медіа"; 15 | 16 | /* (No Comment) */ 17 | "G3J6fP" = "Відтворити медіа"; 18 | 19 | /* (No Comment) */ 20 | "KcCcQu" = "Відтворити ${mediaItems}"; 21 | 22 | /* (No Comment) */ 23 | "LdrEPN" = "Запит на відтворення медіа."; 24 | 25 | /* (No Comment) */ 26 | "mCAocc" = "Не перемішано"; 27 | 28 | /* (No Comment) */ 29 | "No0K9w" = "Продовжити"; 30 | 31 | /* (No Comment) */ 32 | "Pk1Fkx" = "Не продовжувати"; 33 | 34 | /* (No Comment) */ 35 | "QqdHiR" = "Перемішано"; 36 | 37 | /* (No Comment) */ 38 | "rvNMpm" = "Відновити"; 39 | 40 | /* (No Comment) */ 41 | "RvX3Zd" = "Швидкість відтворення"; 42 | 43 | /* (No Comment) */ 44 | "WktFNa" = "Перемішано"; 45 | 46 | /* (No Comment) */ 47 | "xdnqvn" = "Контейнер"; 48 | 49 | /* (No Comment) */ 50 | "YYfsKp" = "Відтворити ${mediaContainer}"; 51 | 52 | -------------------------------------------------------------------------------- /Multiplatform/Control/MultiplatformApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudiobooksApp.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 16.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AppIntents 10 | import ShelfPlayback 11 | 12 | @main 13 | struct MultiplatformApp: App { 14 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 15 | 16 | init() { 17 | #if !ENABLE_CENTRALIZED 18 | ShelfPlayerKit.enableCentralized = false 19 | #endif 20 | 21 | ShelfPlayer.launchHook() 22 | 23 | if ProcessInfo.processInfo.environment["RUN_CONVENIENCE_DOWNLOAD"] == "YES" { 24 | Task { 25 | await PersistenceManager.shared.convenienceDownload.scheduleAll() 26 | } 27 | } 28 | } 29 | 30 | var body: some Scene { 31 | WindowGroup { 32 | ContentView() 33 | } 34 | } 35 | } 36 | 37 | struct ShelfPlayerPackage: AppIntentsPackage { 38 | nonisolated(unsafe) static let includedPackages: [any AppIntentsPackage.Type] = [ 39 | ShelfPlayerKitPackage.self, 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/View+Modify.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Modify.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 10.10.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension View { 11 | @ViewBuilder 12 | func modify(@ViewBuilder _ modifier: (Self) -> T) -> some View { 13 | modifier(self) 14 | } 15 | @ViewBuilder 16 | func modify(if condition: Bool, @ViewBuilder _ modifier: (Self) -> T) -> some View { 17 | if condition { 18 | modifier(self) 19 | } else { 20 | self 21 | } 22 | } 23 | @ViewBuilder 24 | func modify(if optional: Optional, @ViewBuilder _ modifier: (Self, O) -> T) -> some View { 25 | if let optional { 26 | modifier(self, optional) 27 | } else { 28 | self 29 | } 30 | } 31 | } 32 | 33 | #Preview { 34 | @Previewable @State var test = false 35 | 36 | Button(String("toggle")) { 37 | test.toggle() 38 | } 39 | .modify(if: test) { 40 | $0 41 | .foregroundStyle(.red) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ShelfPlayer.icon/Assets/ShelfPlayer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/SearchIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 02.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct SearchIntent: AppIntent { 12 | public static let title: LocalizedStringResource = "intent.search" 13 | public static let description = IntentDescription("intent.search.description") 14 | 15 | @Parameter(title: "intent.search.query") 16 | public var search: String 17 | 18 | @Parameter(title: "intent.search.includeOnlineSearchResults", description: "intent.search.includeOnlineSearchResults.description", default: true) 19 | public var includeOnlineSearchResults: Bool 20 | 21 | public init() {} 22 | 23 | public static var parameterSummary: some ParameterSummary { 24 | Summary("intent.search \(\.$search)") 25 | } 26 | 27 | public func perform() async throws -> some ReturnsValue<[ItemEntity]> { 28 | try await .result(value: ItemEntityQuery.entities(matching: search, includeSuggestedEntities: includeOnlineSearchResults)) 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/SetFinishedIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SetFinishedIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 01.07.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct SetFinishedIntent: AppIntent { 12 | public static let title: LocalizedStringResource = "intent.setFinished" 13 | public static let description = IntentDescription("intent.setFinished.description") 14 | 15 | @Parameter(title: "intent.entity.item", description: "intent.entity.item.description") 16 | public var item: ItemEntity 17 | 18 | @Parameter(title: "intent.setFinished.finished") 19 | public var finished: Bool 20 | 21 | public init() {} 22 | 23 | @MainActor 24 | public func perform() async throws -> some ReturnsValue { 25 | if finished { 26 | try await PersistenceManager.shared.progress.markAsCompleted([item.id]) 27 | } else { 28 | try await PersistenceManager.shared.progress.markAsListening([item.id]) 29 | } 30 | 31 | return .result(value: item) 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/API+Bookmark.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bookmark.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 27.11.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension APIClient { 11 | func createBookmark(primaryID: ItemIdentifier.PrimaryID, time: UInt64, note: String) async throws -> Date { 12 | let payload: BookmarkPayload = try await response(path: "api/me/item/\(primaryID)/bookmark", method: .post, body: [ 13 | "title": note, 14 | "time": time, 15 | ]) 16 | 17 | return Date(timeIntervalSince1970: payload.createdAt / 1000) 18 | } 19 | 20 | func updateBookmark(primaryID: ItemIdentifier.PrimaryID, time: UInt64, note: String) async throws { 21 | try await response(path: "api/me/item/\(primaryID)/bookmark", method: .patch, body: [ 22 | "title": note, 23 | "time": time, 24 | ]) 25 | } 26 | 27 | func deleteBookmark(primaryID: ItemIdentifier.PrimaryID, time: UInt64) async throws { 28 | try await response(path: "api/me/item/\(primaryID)/bookmark/\(time)", method: .delete) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Items/Person.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Author.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 04.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public final class Person: Item, @unchecked Sendable { 11 | public let bookCount: Int 12 | 13 | public init(id: ItemIdentifier, name: String, description: String?, addedAt: Date, bookCount: Int) { 14 | self.bookCount = bookCount 15 | super.init(id: id, name: name, authors: [], description: description, genres: [], addedAt: addedAt, released: nil) 16 | } 17 | 18 | required init(from decoder: Decoder) throws { 19 | self.bookCount = try decoder.container(keyedBy: CodingKeys.self).decode(Int.self, forKey: .bookCount) 20 | try super.init(from: decoder) 21 | } 22 | 23 | public override func encode(to encoder: Encoder) throws { 24 | try super.encode(to: encoder) 25 | 26 | var container = encoder.container(keyedBy: CodingKeys.self) 27 | try container.encode(bookCount, forKey: .bookCount) 28 | } 29 | 30 | enum CodingKeys: String, CodingKey { 31 | case bookCount 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/OpenIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 02.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct OpenIntent: AppIntent { 12 | public static let title: LocalizedStringResource = "intent.open" 13 | public static let description = IntentDescription("intent.open.description") 14 | 15 | public static let openAppWhenRun: Bool = true 16 | 17 | @Parameter(title: "intent.entity.item", description: "intent.entity.item.description") 18 | public var item: ItemEntity 19 | 20 | public init() {} 21 | 22 | public init(item: Item) async { 23 | self.item = await .init(item: item) 24 | } 25 | public init(item: ItemEntity) { 26 | self.item = item 27 | } 28 | 29 | public static var parameterSummary: some ParameterSummary { 30 | Summary("intent.open \(\.$item)") 31 | } 32 | 33 | @MainActor 34 | public func perform() async throws -> some ReturnsValue { 35 | item.id.navigateIsolated() 36 | return .result(value: item) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Multiplatform/Preferences/ColorSchemePreference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorSchemePreference.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 22.07.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ColorSchemePreference: View { 12 | @Default(.colorScheme) private var colorScheme 13 | 14 | let buildLabel: (_ : LocalizedStringKey, _ : String) -> Label 15 | 16 | var body: some View { 17 | Picker(selection: $colorScheme) { 18 | ForEach(ConfiguredColorScheme.allCases, id: \.hashValue) { 19 | Text($0.label) 20 | .tag($0) 21 | } 22 | } label: { 23 | buildLabel("preferences.colorScheme", "lightspectrum.horizontal") 24 | } 25 | } 26 | } 27 | 28 | extension ConfiguredColorScheme { 29 | var label: LocalizedStringKey { 30 | switch self { 31 | case .system: 32 | "preferences.colorScheme.system" 33 | case .dark: 34 | "preferences.colorScheme.dark" 35 | case .light: 36 | "preferences.colorScheme.light" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Resolve/ItemID+URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemID+URL.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 12.01.25. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public extension ItemIdentifier { 12 | var url: URL { 13 | get async throws { 14 | guard let base = try? await PersistenceManager.shared.authorization.host(for: connectionID) else { 15 | throw PersistenceError.serverNotFound 16 | } 17 | 18 | switch type { 19 | case .author: 20 | return base.appending(path: "author").appending(path: primaryID) 21 | case .series: 22 | return base.appending(path: "library").appending(path: libraryID).appending(path: "series").appending(path: primaryID) 23 | default: 24 | let base = base.appending(path: "item") 25 | 26 | if let groupingID { 27 | return base.appending(path: groupingID) 28 | } else { 29 | return base.appending(path: primaryID) 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Playback/StartWidgetConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayWidgetConfiguration.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 23.10.25. 6 | // 7 | 8 | import AppIntents 9 | import WidgetKit 10 | 11 | public struct StartWidgetConfiguration: WidgetConfigurationIntent, PredictableIntent { 12 | public static let title: LocalizedStringResource = "intent.start" 13 | public static let description: IntentDescription = "intent.start.description" 14 | 15 | @Parameter(title: "intent.entity.item", description: "intent.entity.item.description") 16 | public var item: ItemEntity? 17 | 18 | public init() {} 19 | public init(item: Item) async { 20 | self.item = await ItemEntity(item: item) 21 | } 22 | 23 | public static var parameterSummary: some ParameterSummary { 24 | Summary("intent.start \(\.$item)") 25 | } 26 | 27 | public static var predictionConfiguration: some IntentPredictionConfiguration { 28 | IntentPrediction(parameters: \.$item) { 29 | $0?.displayRepresentation ?? DisplayRepresentation(title: "intent.start", subtitle: "intent.start.description") 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Multiplatform/Sheets/DescriptionSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DescriptionSheet.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 21.03.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct DescriptionSheet: View { 12 | let item: Item 13 | 14 | var body: some View { 15 | NavigationStack { 16 | ScrollView { 17 | HStack(spacing: 0) { 18 | if let description = item.description { 19 | Text(description) 20 | } else { 21 | Text("item.description.missing") 22 | .foregroundStyle(.secondary) 23 | } 24 | 25 | Spacer(minLength: 0) 26 | } 27 | .frame(maxWidth: .infinity) 28 | .padding(.horizontal, 20) 29 | } 30 | .navigationTitle(item.name) 31 | .navigationBarTitleDisplayMode(.inline) 32 | } 33 | } 34 | } 35 | 36 | #if DEBUG 37 | #Preview { 38 | DescriptionSheet(item: Audiobook.fixture) 39 | } 40 | 41 | #Preview { 42 | DescriptionSheet(item: Person.authorFixture) 43 | } 44 | #endif 45 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Items/Series.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Series.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 04.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public final class Series: Item, @unchecked Sendable { 11 | public var audiobooks: [Audiobook] 12 | 13 | public init(id: ItemIdentifier, name: String, authors: [String], description: String?, addedAt: Date, audiobooks: [Audiobook]) { 14 | self.audiobooks = audiobooks 15 | 16 | super.init(id: id, name: name, authors: authors, description: description, genres: [], addedAt: addedAt, released: nil) 17 | } 18 | 19 | required init(from decoder: Decoder) throws { 20 | self.audiobooks = try decoder.container(keyedBy: CodingKeys.self).decode([Audiobook].self, forKey: .audiobooks) 21 | try super.init(from: decoder) 22 | } 23 | 24 | public override func encode(to encoder: Encoder) throws { 25 | try super.encode(to: encoder) 26 | 27 | var container = encoder.container(keyedBy: CodingKeys.self) 28 | try container.encode(audiobooks, forKey: .audiobooks) 29 | } 30 | 31 | enum CodingKeys: String, CodingKey { 32 | case audiobooks 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/API+Episode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudiobookshelfClient+Episodes.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 11.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension APIClient { 11 | func episodes(from identifier: ItemIdentifier) async throws -> [Episode] { 12 | let item: ItemPayload = try await response(path: "api/items/\(identifier.pathComponent)", method: .get) 13 | 14 | guard let episodes = item.media?.episodes else { 15 | throw APIClientError.notFound 16 | } 17 | 18 | return episodes.compactMap { Episode(episode: $0, item: item, connectionID: connectionID) } 19 | } 20 | 21 | func recentEpisodes(from libraryID: String, limit: Int) async throws -> [Episode] { 22 | let response: EpisodesResponse = try await response(path: "api/libraries/\(libraryID)/recent-episodes", method: .get, query: [ 23 | URLQueryItem(name: "page", value: "0"), 24 | URLQueryItem(name: "limit", value: String(describing: limit)), 25 | ]) 26 | return response.episodes.enumerated().map { Episode(episode: $0.element, libraryID: libraryID, fallbackIndex: $0.offset, connectionID: connectionID) } 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/API+Narrators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudiobookshelfClient+Narrators.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 21.06.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension APIClient { 11 | func narrators(from libraryID: ItemIdentifier.LibraryID) async throws -> [Person] { 12 | let response: NarratorsResponse = try await response(path: "api/libraries/\(libraryID)/narrators", method: .get) 13 | return response.narrators.compactMap { Person(narrator: $0, libraryID: libraryID, connectionID: connectionID) } 14 | } 15 | 16 | func audiobooks(from libraryID: ItemIdentifier.LibraryID, narratorName: String, page: Int, limit: Int) async throws -> [Audiobook] { 17 | let response: ResultResponse = try await response(path: "api/libraries/\(libraryID)/items", method: .get, query: [ 18 | URLQueryItem(name: "page", value: String(describing: page)), 19 | URLQueryItem(name: "limit", value: String(describing: limit)), 20 | URLQueryItem(name: "filter", value: "narrators.\(Data(narratorName.utf8).base64EncodedString())"), 21 | ]) 22 | return response.results.compactMap { Audiobook(payload: $0, libraryID: libraryID, connectionID: connectionID) } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Multiplatform/Item/DownloadStatusTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadStatusTracker.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 24.02.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | @Observable @MainActor 12 | final class DownloadStatusTracker { 13 | let itemID: ItemIdentifier 14 | var status: DownloadStatus? 15 | 16 | init(itemID: ItemIdentifier) { 17 | self.itemID = itemID 18 | 19 | load() 20 | 21 | RFNotification[.downloadStatusChanged].subscribe { [weak self] in 22 | guard let (itemID, status) = $0 else { 23 | self?.load() 24 | return 25 | } 26 | 27 | guard self?.itemID == itemID else { 28 | return 29 | } 30 | 31 | withAnimation { 32 | self?.status = status 33 | } 34 | } 35 | } 36 | 37 | private nonisolated func load() { 38 | Task { 39 | let status = await PersistenceManager.shared.download.status(of: itemID) 40 | 41 | await MainActor.withAnimation { 42 | self.status = status 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/Double+FormatRate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Double+FormatRate.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 22.08.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct PlaybackRateFormatter: FormatStyle { 11 | var hideX: Bool = false 12 | 13 | public func hideX(_ hideX: Bool = true) -> Self { 14 | .init(hideX: true) 15 | } 16 | 17 | public func format(_ value: TimeInterval) -> String { 18 | guard value.isFinite && !value.isNaN else { 19 | return "?" 20 | } 21 | 22 | let formatter = NumberFormatter() 23 | formatter.numberStyle = .decimal 24 | formatter.decimalSeparator = "." 25 | formatter.usesGroupingSeparator = false 26 | 27 | formatter.minimumFractionDigits = 0 28 | formatter.maximumFractionDigits = 2 29 | 30 | let result = formatter.string(from: value as NSNumber) ?? "?" 31 | 32 | if hideX { 33 | return result 34 | } 35 | 36 | return "\(result)x" 37 | } 38 | } 39 | 40 | public extension FormatStyle where Self == PlaybackRateFormatter { 41 | static var playbackRate: PlaybackRateFormatter { 42 | .init() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/SwiftUI/Color+IsLight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+IsLight.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 26.08.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | internal extension Color { 12 | var isLight: Bool? { 13 | isLight(threshold: 0.6) 14 | } 15 | 16 | func isLight(threshold: Float) -> Bool? { 17 | guard let originalCGColor = self.cgColor else { 18 | return nil 19 | } 20 | 21 | // Now we need to convert it to the RGB colorspace. UIColor.white / UIColor.black are greyscale and not RGB. 22 | // If you don't do this then you will crash when accessing components index 2 below when evaluating greyscale colors. 23 | let RGBCGColor = originalCGColor.converted(to: CGColorSpaceCreateDeviceRGB(), intent: .defaultIntent, options: nil) 24 | 25 | guard let components = RGBCGColor?.components else { 26 | return nil 27 | } 28 | guard components.count >= 3 else { 29 | return nil 30 | } 31 | 32 | let brightness = Float(((components[0] * 299) + (components[1] * 587) + (components[2] * 114)) / 1000) 33 | 34 | return (brightness > threshold) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Current/PersistedSearchIndexEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistedSearchIndexEntry.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 27.11.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | 12 | extension SchemaV2 { 13 | @Model 14 | final class PersistedSearchIndexEntry { 15 | #Index([\._itemID, \.primaryName, \.secondaryName, \.authors]) 16 | #Unique([\._itemID]) 17 | 18 | private var _itemID: String 19 | 20 | private(set) var primaryName: String 21 | private(set) var secondaryName: String? 22 | 23 | private(set) var authors: [String] 24 | private(set) var authorName: String 25 | 26 | init(itemID: ItemIdentifier, primaryName: String, secondaryName: String?, authors: [String]) { 27 | _itemID = itemID.description 28 | self.primaryName = primaryName 29 | self.secondaryName = secondaryName 30 | self.authors = authors 31 | 32 | authorName = authors.joined(separator: ", ") 33 | } 34 | 35 | var itemID: ItemIdentifier { 36 | .init(string: _itemID) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Playback/SetPlaybackRateIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SetPlaybackRateIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 28.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | 12 | public struct SetPlaybackRateIntent: AudioPlaybackIntent { 13 | public static let title: LocalizedStringResource = "intent.setPlaybackRate" 14 | public static let description = IntentDescription("intent.setPlaybackRate.description") 15 | 16 | @AppDependency private var audioPlayer: IntentAudioPlayer 17 | 18 | @Parameter(title: "intent.setPlaybackRate.rate", controlStyle: .stepper, inclusiveRange: (1, 800)) 19 | public var rate: Percentage 20 | 21 | public init() {} 22 | public init(rate: Percentage) { 23 | self.rate = rate 24 | } 25 | 26 | public static var parameterSummary: some ParameterSummary { 27 | Summary("intent.setPlaybackRate \(\.$rate)") 28 | } 29 | 30 | public func perform() async throws -> some IntentResult { 31 | guard await audioPlayer.isPlaying != nil else { 32 | throw IntentError.noPlaybackItem 33 | } 34 | 35 | await audioPlayer.setPlaybackRate(rate / 100) 36 | 37 | return .result() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Current/PersistedChapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistedChapter.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 27.11.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | 12 | extension SchemaV2 { 13 | @Model 14 | final class PersistedChapter { 15 | #Index([\.id], [\._itemID]) 16 | #Unique([\.id], [\._itemID, \.startOffset]) 17 | 18 | @Attribute(.unique) 19 | private(set) var id: UUID 20 | private(set) var index: Int 21 | private(set) var _itemID: String 22 | 23 | private(set) var name: String 24 | 25 | private(set) var startOffset: TimeInterval 26 | private(set) var endOffset: TimeInterval 27 | 28 | init(index: Int, itemID: ItemIdentifier, name: String, startOffset: TimeInterval, endOffset: TimeInterval) { 29 | self.id = .init() 30 | self.index = index 31 | _itemID = itemID.description 32 | self.name = name 33 | self.startOffset = startOffset 34 | self.endOffset = endOffset 35 | } 36 | 37 | var itemID: ItemIdentifier { 38 | .init(string: _itemID) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Multiplatform/Preferences/PodcastSortOrderPreference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PodcastSortOrderPreferences.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 02.07.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct PodcastSortOrderPreference: View { 12 | @Default(.defaultEpisodeSortOrder) private var sortOrder 13 | @Default(.defaultEpisodeAscending) private var ascending 14 | 15 | let buildLabel: (_ : LocalizedStringKey, _ : String) -> Content 16 | 17 | var body: some View { 18 | Menu { 19 | ItemSortOrderPicker(sortOrder: $sortOrder, ascending: $ascending) 20 | } label: { 21 | HStack(spacing: 0) { 22 | buildLabel("preferences.defaultEpisodeSortOrder", "arrow.up.arrow.down") 23 | 24 | Spacer(minLength: 8) 25 | 26 | Image(systemName: "chevron.up.chevron.down") 27 | .imageScale(.small) 28 | .foregroundStyle(.secondary) 29 | } 30 | .contentShape(.rect) 31 | } 32 | .menuActionDismissBehavior(.disabled) 33 | } 34 | } 35 | 36 | #Preview { 37 | List { 38 | PodcastSortOrderPreference { 39 | Label($0, systemImage: $1) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Start/StartPodcastIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StartPodcastIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 19.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct StartPodcastIntent: AudioPlaybackIntent { 12 | public static let title: LocalizedStringResource = "intent.start.podcast" 13 | public static let description = IntentDescription("intent.start.description") 14 | 15 | @AppDependency private var audioPlayer: IntentAudioPlayer 16 | 17 | public init() {} 18 | public init(podcast: Podcast) async { 19 | self.podcast = await .init(podcast: podcast) 20 | } 21 | 22 | @Parameter(title: "intent.entity.item.podcast", description: "intent.entity.item.description", optionsProvider: PodcastEntityOptionsProvider()) 23 | public var podcast: PodcastEntity 24 | 25 | public static var parameterSummary: some ParameterSummary { 26 | Summary("intent.start.podcast \(\.$podcast)") 27 | } 28 | 29 | public func perform() async throws -> some ReturnsValue { 30 | let itemID = try await audioPlayer.startGrouping(podcast.id, false) 31 | let entity = try await ItemEntity(item: itemID.resolved) 32 | 33 | return .result(value: entity) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Convert/PlayableItemUtility+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayableItem+Convert.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 09.10.23. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | private let logger = Logger(subsystem: "io.rfk.ShelfPlayerKit", category: "PlayableItem+Convert") 12 | 13 | extension Chapter { 14 | init(payload: ChapterPayload) { 15 | self.init(id: payload.id, startOffset: payload.start, endOffset: payload.end, title: payload.title) 16 | } 17 | } 18 | 19 | extension PlayableItem.AudioFile { 20 | init?(track: AudiobookshelfAudioTrack) { 21 | guard let ino = track.ino else { 22 | return nil 23 | } 24 | 25 | var ext = track.metadata!.ext 26 | 27 | if ext.starts(with: ".") { 28 | ext.removeFirst() 29 | } 30 | 31 | self.init(ino: ino, 32 | fileExtension: ext, 33 | offset: track.startOffset, 34 | duration: track.duration) 35 | } 36 | } 37 | extension PlayableItem.AudioTrack { 38 | init(track: AudiobookshelfAudioTrack, base: URL) { 39 | self.init(offset: track.startOffset, 40 | duration: track.duration, 41 | resource: base.appending(path: track.contentUrl)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Multiplatform/Item/PlayButton/MediumPlayButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediumPlayButtonStyle.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 29.01.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct MediumPlayButtonStyle: PlayButtonStyle { 12 | func makeMenu(configuration: Configuration) -> some View { 13 | configuration.content 14 | .bold() 15 | .font(.footnote) 16 | .background((configuration.background.isLight ?? false) ? .white : .black) 17 | } 18 | 19 | func makeLabel(configuration: Configuration) -> some View { 20 | configuration.content 21 | .foregroundStyle((configuration.background.isLight ?? false) ? .black : .white) 22 | .padding(.vertical, 16) 23 | .padding(.horizontal, 20) 24 | } 25 | 26 | var tint: Bool { 27 | false 28 | } 29 | var cornerRadius: CGFloat { 30 | .infinity 31 | } 32 | var hideRemainingWhenUnplayed: Bool { 33 | false 34 | } 35 | } 36 | 37 | #if DEBUG 38 | #Preview { 39 | VStack { 40 | PlayButton(item: Audiobook.fixture) 41 | .playButtonSize(.medium) 42 | } 43 | .frame(maxWidth: .infinity, maxHeight: .infinity) 44 | .background(.accent) 45 | .previewEnvironment() 46 | } 47 | #endif 48 | -------------------------------------------------------------------------------- /Multiplatform/Collections/AuthorList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorList.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 03.02.24. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct PersonList: View { 12 | let people: [Person] 13 | let showImage: Bool 14 | let onAppear: ((_: Person) -> Void) 15 | 16 | var body: some View { 17 | ForEach(people) { person in 18 | NavigationLink(value: NavigationDestination.item(person)) { 19 | ItemCompactRow(item: person, context: showImage ? .author : .narrator) 20 | } 21 | .listRowInsets(.init(top: 6, leading: 20, bottom: 6, trailing: 20)) 22 | .modifier(ItemStatusModifier(item: person, hoverEffect: nil)) 23 | .onAppear { 24 | onAppear(person) 25 | } 26 | } 27 | } 28 | } 29 | 30 | #if DEBUG 31 | #Preview { 32 | NavigationStack { 33 | List { 34 | PersonList(people: .init(repeating: .authorFixture, count: 7), showImage: true) { _ in } 35 | } 36 | .listStyle(.plain) 37 | } 38 | .previewEnvironment() 39 | } 40 | 41 | #Preview { 42 | NavigationStack { 43 | List { 44 | PersonList(people: .init(repeating: .authorFixture, count: 7), showImage: false) { _ in } 45 | } 46 | .listStyle(.plain) 47 | } 48 | .previewEnvironment() 49 | } 50 | #endif 51 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/SleepTimerConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SleepTimerConfiguration.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 21.06.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum SleepTimerConfiguration: Sendable, Hashable, Codable { 11 | case interval(Date, TimeInterval) 12 | case chapters(Int, Int) 13 | 14 | public static func interval(_ timeout: TimeInterval) -> Self { 15 | .interval(.now.advanced(by: timeout), timeout) 16 | } 17 | public static func chapters(_ amount: Int) -> Self { 18 | .chapters(amount, amount) 19 | } 20 | 21 | public var extended: Self { 22 | if Defaults[.extendSleepTimerByPreviousSetting] { 23 | switch self { 24 | case .interval(let current, let extend): 25 | .interval(current.advanced(by: extend), extend) 26 | case .chapters(let current, let extend): 27 | .chapters(current + extend, extend) 28 | } 29 | } else { 30 | switch self { 31 | case .interval(let remaining, let extend): 32 | .interval(remaining + Defaults[.sleepTimerExtendInterval], extend) 33 | case .chapters(let amount, let extend): 34 | .chapters(amount + Defaults[.sleepTimerExtendChapterAmount], extend) 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Multiplatform/Item/Picker/ItemDisplayTypePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemDisplayTypePicker.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 26.01.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ItemDisplayTypePicker: View { 12 | @Binding var displayType: ItemDisplayType 13 | 14 | var body: some View { 15 | ControlGroup { 16 | ForEach(ItemDisplayType.allCases) { displayType in 17 | Button(displayType.label, systemImage: displayType.icon) { 18 | withAnimation { 19 | self.displayType = displayType 20 | } 21 | } 22 | .tag(displayType) 23 | } 24 | } 25 | } 26 | } 27 | 28 | extension ItemDisplayType { 29 | var label: LocalizedStringKey { 30 | switch self { 31 | case .grid: 32 | "item.display.grid" 33 | case .list: 34 | "item.display.list" 35 | } 36 | } 37 | 38 | var icon: String { 39 | switch self { 40 | case .grid: 41 | "square.grid.2x2" 42 | case .list: 43 | "list.bullet" 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | ItemDisplayTypePicker(displayType: .constant(.list)) 50 | } 51 | 52 | #Preview { 53 | Menu(String("Options")) { 54 | ItemDisplayTypePicker(displayType: .constant(.list)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Multiplatform/Item/BookmarksList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarksList.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 21.05.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct BookmarksList: View { 12 | @Environment(Satellite.self) private var satellite 13 | 14 | let itemID: ItemIdentifier 15 | let bookmarks: [Bookmark] 16 | 17 | @ViewBuilder 18 | private func row(for bookmark: Bookmark) -> some View { 19 | let time = TimeInterval(bookmark.time) 20 | 21 | TimeRow(title: bookmark.note, time: time, isActive: false, isFinished: false) { 22 | satellite.start(itemID, at: time) 23 | } 24 | } 25 | 26 | var body: some View { 27 | ForEach(bookmarks) { 28 | row(for: $0) 29 | } 30 | .onDelete { 31 | guard let currentItemID = satellite.nowPlayingItemID else { 32 | return 33 | } 34 | 35 | for index in $0 { 36 | satellite.deleteBookmark(at: satellite.bookmarks[index].time, from: currentItemID) 37 | } 38 | } 39 | } 40 | } 41 | 42 | #if DEBUG 43 | #Preview { 44 | List { 45 | BookmarksList(itemID: .fixture, bookmarks: [ 46 | Bookmark(itemID: .fixture, time: 300, note: "Test", created: .now), 47 | ]) 48 | } 49 | .listStyle(.plain) 50 | .previewEnvironment() 51 | } 52 | #endif 53 | -------------------------------------------------------------------------------- /Multiplatform/Extensions/Keys+Entries.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defaults+Keys.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 03.02.24. 6 | // 7 | 8 | import Foundation 9 | import ShelfPlayback 10 | 11 | extension Defaults.Keys { 12 | static let lastTabValue = Key("lastTabValue") 13 | 14 | static let carPlayTabBarLibraries = Key<[Library]?>("carPlayTabBarLibraries", default: nil) 15 | static let carPlayShowListenNow = Key("carPlayShowListenNow", default: true) 16 | static let carPlayShowOtherLibraries = Key("carPlayShowOtherLibraries", default: true) 17 | } 18 | 19 | extension RFNotification.IsolatedNotification { 20 | static var setGlobalSearch: IsolatedNotification<(String, SearchViewModel.SearchScope)> { .init("io.rfk.shelfPlayer.setGlobalSearch") } 21 | 22 | static var navigateConditionMet: IsolatedNotification { .init("io.rfk.shelfPlayer.navigate.notify") } 23 | static var _navigate: IsolatedNotification { .init("io.rfk.shelfPlayer.navigate.two") } 24 | 25 | static var changeLibrary: IsolatedNotification { .init("io.rfk.shelfPlayer.changeLibrary") } 26 | static var performBackgroundSessionSync: IsolatedNotification { .init("io.rfk.shelfPlayer.performBackgroundSessionSync") } 27 | 28 | static var presentSheet: IsolatedNotification { .init("io.rfk.shelfPlayer.presentSheet") } 29 | } 30 | -------------------------------------------------------------------------------- /Multiplatform/Utility/HeroBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeometryRectangle.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 09.10.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HeroBackground: View { 11 | let threshold: CGFloat 12 | let backgroundColor: Color? 13 | 14 | private var color: Color { 15 | if let backgroundColor { 16 | return backgroundColor 17 | } else { 18 | return .clear 19 | } 20 | } 21 | 22 | @Binding var isToolbarVisible: Bool 23 | 24 | var body: some View { 25 | GeometryReader { reader in 26 | let offset = reader.frame(in: .global).minY 27 | 28 | if offset > 0 { 29 | Rectangle() 30 | .fill(color) 31 | .animation(.smooth, value: color) 32 | .offset(y: -offset) 33 | .frame(height: offset) 34 | } 35 | 36 | Color.clear 37 | .frame(width: 0, height: 0) 38 | .onChange(of: offset) { 39 | let expected = offset < threshold 40 | 41 | if expected != isToolbarVisible { 42 | withAnimation(.spring) { 43 | isToolbarVisible = expected 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Convert/Collection+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Convert.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 13.07.25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension ItemCollection { 11 | convenience init(payload: ItemPayload, type: CollectionType, connectionID: ItemIdentifier.ConnectionID) { 12 | let items: [Item] = payload.books?.compactMap { Audiobook(payload: $0, libraryID: payload.libraryId!, connectionID: connectionID) } ?? payload.playlistItems?.compactMap { 13 | if let episode = $0.episode, let podcastName = $0.libraryItem?.media?.metadata.title { 14 | Episode(episode: episode, podcastName: podcastName, libraryID: payload.libraryId!, fallbackIndex: 0, connectionID: connectionID) 15 | } else if let libraryItem = $0.libraryItem, let audiobook = Audiobook(payload: libraryItem, libraryID: payload.libraryId!, connectionID: connectionID) { 16 | audiobook 17 | } else { 18 | nil 19 | } 20 | } ?? [] 21 | 22 | self.init(id: .init(primaryID: payload.id, groupingID: nil, libraryID: payload.libraryId!, connectionID: connectionID, type: type.itemType), 23 | name: payload.name!, 24 | description: payload.description, 25 | addedAt: Date(timeIntervalSince1970: (payload.createdAt ?? 0) / 1000), 26 | items: items) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/ProgressEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressEntity.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 17.09.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import RFNotifications 11 | 12 | public struct ProgressEntity: Sendable { 13 | public let id: String 14 | 15 | public let connectionID: String 16 | 17 | public let primaryID: String 18 | public let groupingID: String? 19 | 20 | public let progress: Percentage 21 | 22 | public let duration: TimeInterval? 23 | public let currentTime: TimeInterval 24 | 25 | public let startedAt: Date? 26 | public let lastUpdate: Date 27 | public let finishedAt: Date? 28 | 29 | public init(id: String, connectionID: String, primaryID: String, groupingID: String?, progress: Percentage, duration: TimeInterval?, currentTime: TimeInterval, startedAt: Date?, lastUpdate: Date, finishedAt: Date?) { 30 | self.id = id 31 | self.connectionID = connectionID 32 | 33 | self.primaryID = primaryID 34 | self.groupingID = groupingID 35 | 36 | self.progress = progress 37 | 38 | self.duration = duration 39 | self.currentTime = currentTime 40 | 41 | self.startedAt = startedAt 42 | self.lastUpdate = lastUpdate 43 | self.finishedAt = finishedAt 44 | } 45 | 46 | public var isFinished: Bool { 47 | progress >= 1 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Multiplatform/Item/ChaptersList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChaptersList.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 21.05.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ChaptersList: View { 12 | @Environment(Satellite.self) private var satellite 13 | 14 | let itemID: ItemIdentifier 15 | let chapters: [Chapter] 16 | 17 | @State private var progress: ProgressTracker 18 | 19 | init(itemID: ItemIdentifier, chapters: [Chapter]) { 20 | self.itemID = itemID 21 | self.chapters = chapters 22 | 23 | _progress = .init(initialValue: .init(itemID: itemID)) 24 | } 25 | 26 | private var currentTime: TimeInterval { 27 | progress.currentTime ?? 0 28 | } 29 | 30 | @ViewBuilder 31 | private func row(for chapter: Chapter) -> some View { 32 | TimeRow(title: chapter.title, time: chapter.startOffset, isActive: currentTime >= chapter.startOffset, isFinished: currentTime > chapter.endOffset) { 33 | satellite.start(itemID, at: chapter.startOffset) 34 | } 35 | } 36 | 37 | var body: some View { 38 | ForEach(chapters) { 39 | row(for: $0) 40 | } 41 | } 42 | } 43 | 44 | #if DEBUG 45 | #Preview { 46 | List { 47 | ChaptersList(itemID: .fixture, chapters: [ 48 | Chapter(id: 0, startOffset: 300, endOffset: 360, title: "Test"), 49 | ]) 50 | } 51 | .listStyle(.plain) 52 | .previewEnvironment() 53 | } 54 | #endif 55 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Current/PersistedBookmark.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistedBookmark.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 27.11.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | 12 | extension SchemaV2 { 13 | @Model 14 | final class PersistedBookmark { 15 | #Index([\.id], [\.connectionID, \.primaryID]) 16 | #Unique([\.id], [\.connectionID, \.primaryID, \.time]) 17 | 18 | @Attribute(.unique) 19 | private(set) var id = UUID() 20 | 21 | private(set) var primaryID: ItemIdentifier.PrimaryID 22 | private(set) var connectionID: ItemIdentifier.ConnectionID 23 | 24 | private(set) var time: UInt64 25 | var note: String 26 | 27 | var created: Date 28 | 29 | var status: SyncStatus 30 | 31 | init(connectionID: ItemIdentifier.ConnectionID, primaryID: ItemIdentifier.PrimaryID, time: UInt64, note: String, created: Date, status: SyncStatus) { 32 | self.connectionID = connectionID 33 | self.primaryID = primaryID 34 | self.time = time 35 | self.note = note 36 | self.created = created 37 | 38 | self.status = status 39 | } 40 | 41 | enum SyncStatus: Int, Codable { 42 | case synced 43 | case deleted 44 | case pendingUpdate 45 | case pendingCreation 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Convert/AudiobookSection+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudiobookSection+Convert.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 26.11.24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension AudiobookSection { 11 | static func parse(payload: ItemPayload, libraryID: ItemIdentifier.LibraryID, connectionID: ItemIdentifier.ConnectionID) -> Self? { 12 | if let collapsedSeries = payload.collapsedSeries { 13 | .series(seriesID: .init(primaryID: collapsedSeries.id, 14 | groupingID: nil, 15 | libraryID: libraryID, 16 | connectionID: connectionID, 17 | type: .series), 18 | seriesName: collapsedSeries.name, 19 | audiobookIDs: collapsedSeries.libraryItemIds.map { .init(primaryID: $0, 20 | groupingID: nil, 21 | libraryID: libraryID, 22 | connectionID: connectionID, 23 | type: .audiobook) }) 24 | } else if let audiobook = Audiobook(payload: payload, libraryID: libraryID, connectionID: connectionID) { 25 | .audiobook(audiobook: audiobook) 26 | } else { 27 | nil 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Fixtures/Author+Fixture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Author+Fixture.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 05.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | #if DEBUG 11 | public extension Person { 12 | static let authorFixture = Person( 13 | id: .init(primaryID: "fixture-author", groupingID: nil, libraryID: "fixture", connectionID: "fixture", type: .author), 14 | name: "George Orwell", 15 | description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed vulputate odio ut enim. Cras semper auctor neque vitae. Tortor vitae purus faucibus ornare suspendisse. Sed vulputate mi sit amet mauris. Morbi leo urna molestie at elementum eu facilisis. Condimentum vitae sapien pellentesque habitant morbi tristique senectus. Viverra ipsum nunc aliquet bibendum enim. Aliquet nec ullamcorper sit amet risus nullam eget felis eget. Feugiat nibh sed pulvinar proin. Mauris rhoncus aenean vel elit. Metus vulputate eu scelerisque felis imperdiet proin fermentum leo vel. Integer enim neque volutpat ac tincidunt vitae semper. Vitae tortor condimentum lacinia quis vel eros donec ac. Ornare aenean euismod elementum nisi quis eleifend quam adipiscing vitae. Interdum posuere lorem ipsum dolor sit amet consectetur. Mattis molestie a iaculis at erat pellentesque. Sed faucibus turpis in eu. Elit eget gravida cum sociis natoque penatibus et. Nisi quis eleifend quam adipiscing vitae proin.", 16 | addedAt: Date(), 17 | bookCount: 420) 18 | } 19 | #endif 20 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/CheckForDownloadsIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckForDownloadsIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 14.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct CheckForDownloadsIntent: ProgressReportingIntent { 12 | public static let title: LocalizedStringResource = "intent.checkForDownloads" 13 | public static let description = IntentDescription("intent.checkForDownloads.description") 14 | 15 | public init() {} 16 | 17 | public static var parameterSummary: some ParameterSummary { 18 | Summary("intent.checkForDownloads") 19 | } 20 | 21 | public func perform() async throws -> some IntentResult { 22 | progress.totalUnitCount = 100 23 | 24 | await withTaskCancellationHandler { 25 | await PersistenceManager.shared.convenienceDownload.scheduleAll() 26 | } onCancel: { 27 | PersistenceManager.shared.convenienceDownload.shouldComeToEnd = true 28 | } 29 | 30 | while !Task.isCancelled { 31 | try await Task.sleep(for: .seconds(0.4)) 32 | 33 | let currentProgress = await PersistenceManager.shared.convenienceDownload.currentProgress 34 | progress.completedUnitCount = Int64(currentProgress * 100) 35 | 36 | guard currentProgress >= 1 else { 37 | continue 38 | } 39 | 40 | break 41 | } 42 | 43 | return .result() 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Multiplatform/Progress/SyncGate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncGate.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 26.05.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct SyncGate: View { 12 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 13 | @Environment(ProgressViewModel.self) private var progressViewModel 14 | 15 | let library: Library 16 | 17 | private var isCompact: Bool { 18 | horizontalSizeClass == .compact 19 | } 20 | 21 | var body: some View { 22 | Group { 23 | if progressViewModel.importFailedConnectionIDs.contains(library.connectionID) { 24 | ContentUnavailableView("navigation.sync.failed", systemImage: "circle.badge.xmark", description: Text("navigation.sync.failed")) 25 | .symbolRenderingMode(.multicolor) 26 | .symbolEffect(.wiggle, options: .nonRepeating) 27 | .modifier(OfflineControlsModifier(startOfflineTimeout: true)) 28 | } else { 29 | ContentUnavailableView("navigation.sync", systemImage: "binoculars") 30 | .symbolEffect(.pulse) 31 | .modifier(OfflineControlsModifier(startOfflineTimeout: false)) 32 | .onAppear { 33 | progressViewModel.attemptSync(for: library.connectionID) 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | #if DEBUG 41 | #Preview { 42 | SyncGate(library: .fixture) 43 | .previewEnvironment() 44 | } 45 | #endif 46 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Convert/Podcast+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Podcast+Convert.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 07.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | internal extension Podcast { 12 | convenience init(payload: ItemPayload, connectionID: ItemIdentifier.ConnectionID) { 13 | let addedAt = payload.addedAt ?? 0 14 | let podcastType: PodcastType? 15 | 16 | if payload.type == "episodic" { 17 | podcastType = .episodic 18 | } else if payload.type == "serial" { 19 | podcastType = .serial 20 | } else { 21 | podcastType = nil 22 | } 23 | 24 | self.init( 25 | id: .init(primaryID: payload.id, groupingID: nil, libraryID: payload.libraryId!, connectionID: connectionID, type: .podcast), 26 | name: payload.media!.metadata.title!, 27 | authors: payload.media?.metadata.author?.split(separator: ", ").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? [], 28 | description: payload.media?.metadata.description, 29 | genres: payload.media?.metadata.genres ?? [], 30 | addedAt: Date(timeIntervalSince1970: addedAt / 1000), 31 | released: payload.media?.metadata.releaseDate, 32 | explicit: payload.media?.metadata.explicit ?? false, 33 | episodeCount: payload.media?.episodes?.count ?? payload.numEpisodes ?? 0, 34 | incompleteEpisodeCount: payload.numEpisodesIncomplete, 35 | publishingType: podcastType 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Multiplatform/Preferences/LibraryEnumerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEnumerator.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 25.10.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct LibraryEnumerator: View { 12 | @Environment(ConnectionStore.self) private var connectionStore 13 | 14 | @ViewBuilder let sectionLabel: (_ name: String, _ content: () -> AnyView) -> SectionLabel 15 | @ViewBuilder let label: (_ : Library) -> Label 16 | 17 | var body: some View { 18 | 19 | ForEach(connectionStore.connections) { connection in 20 | sectionLabel(connection.name) { 21 | AnyView(erasing: SectionInner(connectionID: connection.id, label: label)) 22 | } 23 | } 24 | } 25 | } 26 | 27 | private struct SectionInner: View { 28 | @Environment(ConnectionStore.self) private var connectionStore 29 | 30 | let connectionID: ItemIdentifier.ConnectionID 31 | @ViewBuilder let label: (_ : Library) -> Label 32 | 33 | var body: some View { 34 | if let libraries = connectionStore.libraries[connectionID] { 35 | ForEach(libraries) { 36 | label($0) 37 | } 38 | } else { 39 | ProgressView() 40 | } 41 | } 42 | } 43 | 44 | #if DEBUG 45 | #Preview { 46 | LibraryEnumerator { name, content in 47 | Section(name) { 48 | content() 49 | } 50 | } label: { 51 | Text($0.name) 52 | } 53 | .previewEnvironment() 54 | } 55 | #endif 56 | -------------------------------------------------------------------------------- /Multiplatform/Item/CommonActions/ItemIDLoadLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemIDLoadLink.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 04.03.25. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct ItemIDLoadLink: View { 12 | @Environment(Satellite.self) private var satellite 13 | @Environment(\.navigationContext) private var navigationContext 14 | 15 | let name: String 16 | let type: ItemIdentifier.ItemType 17 | var footer: String? = nil 18 | 19 | @ViewBuilder 20 | private var labelContent: some View { 21 | if #available(iOS 26.0, *), false { 22 | if let footer { 23 | Label(footer, systemImage: type.icon) 24 | } else { 25 | Label(type.viewLabel, systemImage: type.icon) 26 | } 27 | } else { 28 | Label(type.viewLabel, systemImage: type.icon) 29 | 30 | if let footer { 31 | Text(footer) 32 | } 33 | } 34 | } 35 | 36 | var body: some View { 37 | if let navigationContext { 38 | Button { 39 | navigationContext.path.append(.itemName(name, type)) 40 | } label: { 41 | labelContent 42 | } 43 | .disabled(satellite.isOffline) 44 | } else { 45 | #if DEBUG 46 | if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" { 47 | let _ = fatalError("Cannot load itemIDs without a library.") 48 | } 49 | #endif 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Fixtures/Series+Fixture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Series+Fixture.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 05.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | #if DEBUG 11 | public extension Series { 12 | static let fixture = Series( 13 | id: .init(primaryID: "fixture-series", groupingID: nil, libraryID: "fixture", connectionID: "fixture", type: .series), 14 | name: "The Witcher", 15 | authors: ["Andrzej Sapkowski"], 16 | description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed vulputate odio ut enim. Cras semper auctor neque vitae. Tortor vitae purus faucibus ornare suspendisse. Sed vulputate mi sit amet mauris. Morbi leo urna molestie at elementum eu facilisis. Condimentum vitae sapien pellentesque habitant morbi tristique senectus. Viverra ipsum nunc aliquet bibendum enim. Aliquet nec ullamcorper sit amet risus nullam eget felis eget. Feugiat nibh sed pulvinar proin. Mauris rhoncus aenean vel elit. Metus vulputate eu scelerisque felis imperdiet proin fermentum leo vel. Integer enim neque volutpat ac tincidunt vitae semper. Vitae tortor condimentum lacinia quis vel eros donec ac. Ornare aenean euismod elementum nisi quis eleifend quam adipiscing vitae. Interdum posuere lorem ipsum dolor sit amet consectetur. Mattis molestie a iaculis at erat pellentesque. Sed faucibus turpis in eu. Elit eget gravida cum sociis natoque penatibus et. Nisi quis eleifend quam adipiscing vitae proin.", 17 | addedAt: Date(), 18 | audiobooks: .init(repeating: .fixture, count: 7)) 19 | } 20 | #endif 21 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Current/PersistedPlaybackSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistedChapter.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 27.11.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | 12 | extension SchemaV2 { 13 | @Model 14 | final class PersistedPlaybackSession { 15 | #Index([\.id], [\._itemID]) 16 | #Unique([\.id], [\._itemID]) 17 | 18 | @Attribute(.unique) 19 | private(set) var id: UUID 20 | private(set) var _itemID: String 21 | 22 | var duration: TimeInterval 23 | var currentTime: TimeInterval 24 | 25 | private(set) var startTime: TimeInterval 26 | var timeListened: TimeInterval 27 | 28 | var started: Date 29 | var lastUpdated: Date 30 | 31 | var eligibleForEarlySync: Bool 32 | 33 | init(itemID: ItemIdentifier, duration: TimeInterval, currentTime: TimeInterval, startTime: TimeInterval, timeListened: TimeInterval) { 34 | id = .init() 35 | _itemID = itemID.description 36 | 37 | self.duration = duration 38 | self.currentTime = currentTime 39 | 40 | self.startTime = startTime 41 | self.timeListened = timeListened 42 | 43 | started = .now 44 | lastUpdated = .now 45 | 46 | eligibleForEarlySync = false 47 | } 48 | 49 | var itemID: ItemIdentifier { 50 | .init(string: _itemID) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Multiplatform/Embassy/ContextProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContextProvider.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 21.03.25. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | import Intents 11 | import ShelfPlayback 12 | 13 | public struct ContextProvider { 14 | static let logger = Logger(subsystem: "io.rfk.shelfPlayer", category: "ContextProvider") 15 | 16 | public static func updateUserContext() async { 17 | let libraries = await ShelfPlayerKit.libraries 18 | let totalCount = libraries.count * 42 19 | // let totalCount = await withTaskGroup { 20 | // for library in libraries { 21 | // $0.addTask { 22 | // switch library.type { 23 | // case .audiobooks: 24 | // try? await ABSClient[library.connectionID].audiobooks(from: library.id, filter: .all, sortOrder: .added, ascending: false, limit: 1, page: 0).1 25 | // case .podcasts: 26 | // try? await ABSClient[library.connectionID].podcasts(from: library.id, sortOrder: .addedAt, ascending: false, limit: 1, page: 0).1 27 | // } 28 | // } 29 | // } 30 | // 31 | // return await $0.reduce(0) { 32 | // $0 + ($1 ?? 0) 33 | // } 34 | // } 35 | 36 | let context = INMediaUserContext() 37 | context.numberOfLibraryItems = totalCount 38 | context.subscriptionStatus = .subscribed 39 | context.becomeCurrent() 40 | 41 | logger.info("Updated user context with \(totalCount) items") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/String+Distance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 28.03.24. 6 | // 7 | 8 | import Foundation 9 | 10 | // https://stackoverflow.com/questions/47794688/closest-match-string-array-sorting-in-swift 11 | 12 | public extension String { 13 | func levenshteinDistanceScore(to string: String, ignoreCase: Bool = true, trimWhiteSpacesAndNewLines: Bool = true) -> Double { 14 | var firstString = self 15 | var secondString = string 16 | 17 | if ignoreCase { 18 | firstString = firstString.lowercased() 19 | secondString = secondString.lowercased() 20 | } 21 | if trimWhiteSpacesAndNewLines { 22 | firstString = firstString.trimmingCharacters(in: .whitespacesAndNewlines) 23 | secondString = secondString.trimmingCharacters(in: .whitespacesAndNewlines) 24 | } 25 | 26 | let empty = [Int](repeating: 0, count: secondString.count) 27 | var last = [Int](0...secondString.count) 28 | 29 | for (i, tLett) in firstString.enumerated() { 30 | var cur = [i + 1] + empty 31 | for (j, sLett) in secondString.enumerated() { 32 | cur[j + 1] = tLett == sLett ? last[j] : Swift.min(last[j], last[j + 1], cur[j])+1 33 | } 34 | last = cur 35 | } 36 | 37 | let lowestScore = max(firstString.count, secondString.count) 38 | 39 | if let validDistance = last.last { 40 | return 1 - (Double(validDistance) / Double(lowestScore)) 41 | } 42 | 43 | return 0.0 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Fixtures/Session+Fixture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Session+Fixture.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 20.11.25. 6 | // 7 | 8 | #if DEBUG 9 | public extension SessionPayload { 10 | static let fixture = SessionPayload(id: "fixture", 11 | userId: "fixture", 12 | libraryId: "fixture", 13 | libraryItemId: "fixture", 14 | episodeId: "fixture", 15 | mediaType: "episode", 16 | mediaMetadata: nil, 17 | chapters: nil, 18 | displayTitle: nil, 19 | displayAuthor: nil, 20 | coverPath: nil, 21 | duration: nil, 22 | playMethod: 0, 23 | mediaPlayer: "ShelfPlayer", 24 | deviceInfo: nil, 25 | date: "2022-11-13", 26 | dayOfWeek: "Sunday", 27 | serverVersion: "1.2.3", 28 | timeListening: 600, 29 | startTime: 0, 30 | currentTime: 600, 31 | startedAt: 1668330137087.0, 32 | updatedAt: 1668330152157.0) 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Multiplatform/Utility/HeroBackButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackButton.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 04.10.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HeroBackButton: View { 11 | @Environment(\.navigationContext) private var navigationContext 12 | @Environment(\.presentationMode) private var presentationMode 13 | 14 | @ViewBuilder 15 | private var label: some View { 16 | Label("navigation.back", systemImage: "chevron.left") 17 | .labelStyle(.iconOnly) 18 | } 19 | 20 | var body: some View { 21 | if presentationMode.wrappedValue.isPresented { 22 | if let navigationContext { 23 | Menu { 24 | ForEach(navigationContext.path.prefix(max(0, navigationContext.path.count - 1)).enumerated().reversed(), id: \.offset) { index, destination in 25 | Button(destination.label) { 26 | navigationContext.path.remove(atOffsets: .init((index + 1).. some View { 34 | if let configuration { 35 | switch libraryType { 36 | case .audiobooks: 37 | content 38 | .secondaryShadow(radius: configuration.shadowRadius, opacity: configuration.shadowOpacity) 39 | case .podcasts: 40 | content 41 | .overlay { 42 | RoundedRectangle(cornerRadius: cornerRadius) 43 | .stroke(.gray.opacity(configuration.borderOpacity), lineWidth: configuration.borderThickness) 44 | } 45 | default: 46 | content 47 | } 48 | } else { 49 | content 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Support.md: -------------------------------------------------------------------------------- 1 | # Support for ShelfPlayer 2 | 3 | Thank you for using ShelfPlayer! If you encounter any issues or need help, there are two primary ways to get support: 4 | 5 | ## How to Request Support 6 | 7 | Before reaching out, it's important to include the following details in your request: 8 | 9 | - A clear description of the issue or question. 10 | - Steps to reproduce (for bugs). 11 | - Screenshots, if applicable. 12 | - The operating system and app version. 13 | 14 | Additionally, generating a debug log can significantly aid in diagnosing issues. To generate a debug log: 15 | 16 | 1. Go to Library within the application. 17 | 2. Select Settings. 18 | 3. Navigate to Help. 19 | 4. Click on Generate log archive. 20 | 21 | This will create a log file that you can include with your support request. 22 | 23 | ### 1. GitHub Issues 24 | 25 | For bug reports or feature requests, the preferred way to get support is by opening an issue on the GitHub repository. To do so: 26 | 27 | 1. Visit the GitHub repository: ShelfPlayer GitHub 28 | 2. Navigate to the Issues tab. 29 | 3. Click on New Issue. 30 | 4. Provide a detailed description of the problem or feature request. If reporting a bug, include steps to reproduce it, the operating system, and any relevant error messages. 31 | 32 | The issue will be reviewed and responded to as quickly as possible. 33 | 34 | ### 2. E-Mail Support 35 | 36 | If you prefer to communicate via email, support is available at: 37 | 38 | Email: (git@rfk.io) 39 | 40 | When sending an email, please provide the following information: 41 | 42 | - A description of the issue or question. 43 | - Steps to reproduce (for bugs). 44 | - Screenshots, if applicable. 45 | - The operating system and app version. 46 | 47 | Thank you for using ShelfPlayer! -------------------------------------------------------------------------------- /ShelfPlayerKit/Human Interface/Image/ImagePlaceholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Placeholder.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 10.07.25. 6 | // 7 | 8 | import SwiftUI 9 | import RFNotifications 10 | 11 | struct ImagePlaceholder: View { 12 | @Environment(\.library) private var library 13 | 14 | let itemID: ItemIdentifier? 15 | let cornerRadius: CGFloat 16 | 17 | private var itemIDIcon: String? { 18 | guard let itemID else { 19 | return nil 20 | } 21 | 22 | return itemID.type.icon 23 | } 24 | private var fallbackIcon: String { 25 | if let itemID { 26 | itemID.type.icon 27 | } else { 28 | switch library?.type { 29 | case .audiobooks: 30 | "book" 31 | case .podcasts: 32 | "play.square.stack.fill" 33 | default: 34 | "bookmark" 35 | } 36 | } 37 | } 38 | 39 | var body: some View { 40 | GeometryReader { geometryProxy in 41 | ZStack { 42 | Image(systemName: fallbackIcon) 43 | .resizable() 44 | .scaledToFit() 45 | .frame(width: geometryProxy.size.width / 3) 46 | .foregroundStyle(.gray.opacity(0.5)) 47 | } 48 | .frame(width: geometryProxy.size.width, height: geometryProxy.size.height) 49 | } 50 | .frame(maxWidth: .infinity, maxHeight: .infinity) 51 | .background(.gray.opacity(0.1)) 52 | .aspectRatio(1, contentMode: .fit) 53 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) 54 | .universalContentShape(.rect(cornerRadius: cornerRadius)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Multiplatform/Item/CommonActions/PlayableItemSwipeActionsModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwipeActionsModifier.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 13.10.23. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct PlayableItemSwipeActionsModifier: ViewModifier { 12 | @Environment(Satellite.self) private var satellite 13 | @Default(.tintColor) private var tintColor 14 | 15 | let itemID: ItemIdentifier 16 | let currentDownloadStatus: DownloadStatus? 17 | 18 | func body(content: Content) -> some View { 19 | content 20 | .swipeActions(edge: .leading) { 21 | QueueButton(itemID: itemID, hideLast: true) 22 | .labelStyle(.iconOnly) 23 | .tint(tintColor.accent) 24 | } 25 | .swipeActions(edge: .leading) { 26 | Button("item.play", systemImage: "play") { 27 | satellite.start(itemID) 28 | } 29 | .labelStyle(.iconOnly) 30 | .disabled(satellite.isLoading(observing: itemID)) 31 | .tint(tintColor.color) 32 | } 33 | .swipeActions(edge: .trailing) { 34 | DownloadButton(itemID: itemID, tint: true, initialStatus: currentDownloadStatus) 35 | .labelStyle(.iconOnly) 36 | } 37 | .swipeActions(edge: .trailing) { 38 | ProgressButton(itemID: itemID, tint: true) 39 | .labelStyle(.iconOnly) 40 | } 41 | } 42 | } 43 | 44 | #if DEBUG 45 | #Preview { 46 | List { 47 | AudiobookList(sections: .init(repeating: .audiobook(audiobook: .fixture), count: 7)) { _ in } 48 | } 49 | .previewEnvironment() 50 | } 51 | #endif 52 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Fixtures/Podcast+Fixture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Podcast+Fixture.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 08.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | #if DEBUG 11 | public extension Podcast { 12 | static let fixture = Podcast( 13 | id: .init(primaryID: "fixture-podcast", groupingID: nil, libraryID: "fixture", connectionID: "fixture", type: .podcast), 14 | name: "Tagesschau", 15 | authors: ["ARD"], 16 | description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed vulputate odio ut enim. Cras semper auctor neque vitae. Tortor vitae purus faucibus ornare suspendisse. Sed vulputate mi sit amet mauris. Morbi leo urna molestie at elementum eu facilisis. Condimentum vitae sapien pellentesque habitant morbi tristique senectus. Viverra ipsum nunc aliquet bibendum enim. Aliquet nec ullamcorper sit amet risus nullam eget felis eget. Feugiat nibh sed pulvinar proin. Mauris rhoncus aenean vel elit. Metus vulputate eu scelerisque felis imperdiet proin fermentum leo vel. Integer enim neque volutpat ac tincidunt vitae semper. Vitae tortor condimentum lacinia quis vel eros donec ac. Ornare aenean euismod elementum nisi quis eleifend quam adipiscing vitae. Interdum posuere lorem ipsum dolor sit amet consectetur. Mattis molestie a iaculis at erat pellentesque. Sed faucibus turpis in eu. Elit eget gravida cum sociis natoque penatibus et. Nisi quis eleifend quam adipiscing vitae proin.", 17 | genres: ["News"], 18 | addedAt: Date(), 19 | released: "2023-05-21T18:00:00Z", 20 | explicit: true, 21 | episodeCount: 7, 22 | incompleteEpisodeCount: 4, 23 | publishingType: .episodic 24 | ) 25 | } 26 | #endif 27 | -------------------------------------------------------------------------------- /Multiplatform/CarPlay/Utility/CarPlayPodcastItemController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CarPlayPodcastController.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 01.05.25. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import CarPlay 10 | import ShelfPlayback 11 | 12 | @MainActor 13 | final class CarPlayPodcastItemController: CarPlayItemController { 14 | private let interfaceController: CPInterfaceController 15 | 16 | let podcast: Podcast 17 | let row: CPListItem 18 | 19 | init(interfaceController: CPInterfaceController, podcast: Podcast) { 20 | self.interfaceController = interfaceController 21 | self.podcast = podcast 22 | 23 | row = CPListItem(text: podcast.name, detailText: podcast.authors.formatted(.list(type: .and, width: .short)), image: nil) 24 | 25 | loadCover() 26 | 27 | // row.handler = { [weak self] (_, completion) in 28 | row.handler = { (_, completion) in 29 | Task { 30 | try await interfaceController.pushTemplate(CarPlayPodcastController(interfaceController: interfaceController, podcast: podcast).template, animated: true) 31 | completion() 32 | } 33 | } 34 | 35 | RFNotification[.reloadImages].subscribe { [weak self] itemID in 36 | if let itemID, self?.podcast.id != itemID { 37 | return 38 | } 39 | 40 | self?.loadCover() 41 | } 42 | } 43 | 44 | private nonisolated func loadCover() { 45 | Task { 46 | let cover = await podcast.id.platformImage(size: .regular) 47 | 48 | await MainActor.run { 49 | row.setImage(cover) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Resolve/SortOrder+URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SortOrder+ApiValue.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 14.11.24. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | extension AudiobookSortOrder { 12 | var queryValue: String { 13 | switch self { 14 | case .sortName: 15 | "media.metadata.title" 16 | case .authorName: 17 | "media.metadata.authorName" 18 | case .released: 19 | "media.metadata.publishedYear" 20 | case .added: 21 | "addedAt" 22 | case .duration: 23 | "media.duration" 24 | } 25 | } 26 | } 27 | 28 | extension AuthorSortOrder { 29 | var queryValue: String { 30 | switch self { 31 | case .firstNameLastName: 32 | "name" 33 | case .lastNameFirstName: 34 | "lastFirst" 35 | case .bookCount: 36 | "numBooks" 37 | case .added: 38 | "addedAt" 39 | } 40 | } 41 | } 42 | 43 | extension SeriesSortOrder { 44 | var queryValue: String { 45 | switch self { 46 | case .sortName: 47 | "name" 48 | case .bookCount: 49 | "numBooks" 50 | case .added: 51 | "addedAt" 52 | case .duration: 53 | "totalDuration" 54 | } 55 | } 56 | } 57 | 58 | extension PodcastSortOrder { 59 | var queryValue: String { 60 | switch self { 61 | case .name: 62 | "media.metadata.title" 63 | case .author: 64 | "media.metadata.author" 65 | case .episodeCount: 66 | "media.numTracks" 67 | case .addedAt: 68 | "addedAt" 69 | case .duration: 70 | "sort.duration" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Embassy/Intents/Start/StartIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StartIntent.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 01.06.25. 6 | // 7 | 8 | import Foundation 9 | import AppIntents 10 | 11 | public struct StartIntent: AudioPlaybackIntent { 12 | public static let title: LocalizedStringResource = "intent.start" 13 | public static let description = IntentDescription("intent.start.description") 14 | 15 | @AppDependency private var audioPlayer: IntentAudioPlayer 16 | 17 | @Parameter(title: "intent.entity.item", description: "intent.entity.item.description", optionsProvider: ItemEntityOptionsProvider()) 18 | public var item: ItemEntity 19 | 20 | @Parameter(title: "intent.start.withoutPlaybackSession", description: "intent.start.withoutPlaybackSession.description", default: false) 21 | public var withoutPlaybackSession: Bool 22 | 23 | public init() {} 24 | 25 | public init(item: Item) async { 26 | self.item = await .init(item: item) 27 | } 28 | public init(item: ItemEntity) { 29 | self.item = item 30 | } 31 | 32 | public func perform() async throws -> some ReturnsValue { 33 | let itemID: ItemIdentifier 34 | 35 | switch item.id.type { 36 | case .audiobook, .episode: 37 | itemID = item.id 38 | try await audioPlayer.start(itemID, withoutPlaybackSession) 39 | case .series, .podcast: 40 | itemID = try await audioPlayer.startGrouping(item.id, withoutPlaybackSession) 41 | default: 42 | throw IntentError.invalidItemType 43 | } 44 | 45 | let entity = try await ItemEntity(item: itemID.resolved) 46 | 47 | return .result(value: entity) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Multiplatform/Utility/CircleProgressIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressIndicator.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 03.02.24. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | struct CircleProgressIndicator: View { 12 | @Default(.tintColor) private var tintColor 13 | 14 | let progress: Percentage 15 | let invertColors: Bool 16 | 17 | var body: some View { 18 | if progress < 0 { 19 | EmptyView() 20 | } else { 21 | ZStack { 22 | if progress >= 1 { 23 | Circle() 24 | .fill(Color.accentColor.quaternary) 25 | 26 | Label(1.formatted(.percent.notation(.compactName)), systemImage: "checkmark") 27 | .labelStyle(.iconOnly) 28 | .font(.caption) 29 | .foregroundStyle(invertColors ? tintColor.color : tintColor.accent) 30 | } else { 31 | Circle() 32 | .fill(Color.accentColor.quaternary) 33 | .stroke(Color.accentColor.secondary, lineWidth: 1) 34 | 35 | GeometryReader { proxy in 36 | Circle() 37 | .inset(by: proxy.size.width / 4) 38 | .trim(from: 0, to: CGFloat(progress)) 39 | .stroke(invertColors ? tintColor.color : tintColor.accent, style: StrokeStyle(lineWidth: proxy.size.width / 2)) 40 | .rotationEffect(.degrees(-90)) 41 | .animation(.spring, value: progress) 42 | } 43 | .padding(2) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Extensions/ItemID+UI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemID+UI.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 02.06.25. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | private let bundle = Bundle(for: ItemIdentifier.self) 12 | 13 | public extension ItemIdentifier.ItemType { 14 | var label: String { 15 | switch self { 16 | case .audiobook: 17 | String(localized: "item.audiobook", bundle: bundle) 18 | case .author: 19 | String(localized: "item.author", bundle: bundle) 20 | case .narrator: 21 | String(localized: "item.narrator", bundle: bundle) 22 | case .series: 23 | String(localized: "item.series", bundle: bundle) 24 | case .podcast: 25 | String(localized: "item.podcast", bundle: bundle) 26 | case .episode: 27 | String(localized: "item.episode", bundle: bundle) 28 | case .collection: 29 | String(localized: "item.collection", bundle: bundle) 30 | case .playlist: 31 | String(localized: "item.playlist", bundle: bundle) 32 | } 33 | } 34 | var icon: String { 35 | switch self { 36 | case .audiobook: 37 | "book.fill" 38 | case .author: 39 | "person.fill" 40 | case .narrator: 41 | "microphone.fill" 42 | case .series: 43 | "rectangle.grid.2x2.fill" 44 | case .podcast: 45 | "square.stack" 46 | case .episode: 47 | "play.square" 48 | case .collection: 49 | "book.pages.fill" 50 | case .playlist: 51 | "folder.fill" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Fixtures/Episode+Fixture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Episode+Fixture.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 08.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | #if DEBUG 11 | public extension Episode { 12 | static let fixture = Episode( 13 | id: .init(primaryID: "fixture-episode-\(UUID())", groupingID: "fixture", libraryID: "fixture", connectionID: "fixture", type: .episode), 14 | name: "Industrial Society and it's Future", 15 | authors: ["Ted Kaczynski"], 16 | description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed vulputate odio ut enim. Cras semper auctor neque vitae. Tortor vitae purus faucibus ornare suspendisse. Sed vulputate mi sit amet mauris. Morbi leo urna molestie at elementum eu facilisis. Condimentum vitae sapien pellentesque habitant morbi tristique senectus. Viverra ipsum nunc aliquet bibendum enim. Aliquet nec ullamcorper sit amet risus nullam eget felis eget. Feugiat nibh sed pulvinar proin. Mauris rhoncus aenean vel elit. Metus vulputate eu scelerisque felis imperdiet proin fermentum leo vel. Integer enim neque volutpat ac tincidunt vitae semper. Vitae tortor condimentum lacinia quis vel eros donec ac. Ornare aenean euismod elementum nisi quis eleifend quam adipiscing vitae. Interdum posuere lorem ipsum dolor sit amet consectetur. Mattis molestie a iaculis at erat pellentesque. Sed faucibus turpis in eu. Elit eget gravida cum sociis natoque penatibus et. Nisi quis eleifend quam adipiscing vitae proin.", 17 | addedAt: Date(), 18 | released: "1698856560000", 19 | size: 999_999_999, 20 | duration: 60 * 60, 21 | podcastName: "The Hut", 22 | type: .trailer, 23 | index: .init(season: "Season 1", episode: "Episode 1")) 24 | } 25 | #endif 26 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Client/ABSClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ABSClient.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 28.07.25. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import Security 10 | import OSLog 11 | 12 | public let ABSClient = APIClientStore.shared 13 | 14 | public final actor APIClientStore { 15 | var storage = [ItemIdentifier.ConnectionID: APIClient]() 16 | var busy = Set() 17 | 18 | fileprivate init() { 19 | RFNotification[.connectionsChanged].subscribe { [weak self] in 20 | Task { 21 | await self?.invalidate() 22 | } 23 | } 24 | } 25 | func invalidate() { 26 | storage.removeAll(keepingCapacity: true) 27 | } 28 | 29 | public subscript(_ connectionID: ItemIdentifier.ConnectionID) -> APIClient { 30 | get async throws { 31 | while busy.contains(connectionID) { 32 | try await Task.sleep(for: .seconds(0.1)) 33 | } 34 | 35 | if let client = storage[connectionID] { 36 | return client 37 | } 38 | 39 | busy.insert(connectionID) 40 | 41 | do { 42 | let provider = try await AuthorizedAPIClientCredentialProvider(connectionID: connectionID) 43 | let client = try await APIClient(connectionID: connectionID, credentialProvider: provider) 44 | 45 | storage[connectionID] = client 46 | busy.remove(connectionID) 47 | 48 | return client 49 | } catch { 50 | busy.remove(connectionID) 51 | throw error 52 | } 53 | } 54 | } 55 | 56 | public static let shared = APIClientStore() 57 | } 58 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/API+Author.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudiobookshelfClient+Authors.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 04.10.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension APIClient { 11 | func author(with identifier: ItemIdentifier) async throws -> Person { 12 | Person(author: try await response(path: "api/authors/\(identifier.pathComponent)", method: .get), connectionID: connectionID) 13 | } 14 | 15 | func authors(from libraryID: String, sortOrder: AuthorSortOrder, ascending: Bool, limit: Int, page: Int) async throws -> ([Person], Int) { 16 | let response: ResultResponse = try await response(path: "api/libraries/\(libraryID)/authors", method: .get, query: [ 17 | .init(name: "sort", value: sortOrder.queryValue), 18 | .init(name: "desc", value: ascending ? "0" : "1"), 19 | .init(name: "limit", value: String(limit)), 20 | .init(name: "page", value: String(page)), 21 | ]) 22 | 23 | return (response.results.map { Person(author: $0, connectionID: connectionID) }, response.total) 24 | } 25 | 26 | func authorID(from libraryID: String, name: String) async throws -> ItemIdentifier { 27 | let response: SearchResponse = try await response(path: "api/libraries/\(libraryID)/search", method: .get, query: [ 28 | URLQueryItem(name: "q", value: name), 29 | URLQueryItem(name: "limit", value: "1"), 30 | ]) 31 | 32 | if let id = response.authors?.first?.id { 33 | return .init(primaryID: id, 34 | groupingID: nil, 35 | libraryID: libraryID, 36 | connectionID: connectionID, 37 | type: .author) 38 | } 39 | 40 | throw APIClientError.notFound 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Network/Client/AuthorizedAPIClientCredentialProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorizedAPIClientCredentialProvider.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 17.08.25. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | final actor AuthorizedAPIClientCredentialProvider: APICredentialProvider { 12 | let logger = Logger(subsystem: "io.rfk.shelfPlayerKit", category: "AuthorizedAPIClientCredentialProvider") 13 | 14 | let connectionID: ItemIdentifier.ConnectionID 15 | 16 | var token: String? 17 | var configuration: (URL, [HTTPHeader]) 18 | 19 | var knownExpiredTokens: Set = [] 20 | 21 | var accessToken: String? { 22 | token 23 | } 24 | 25 | var shouldPostAuthorizationFailure: Bool { 26 | true 27 | } 28 | 29 | init(connectionID: ItemIdentifier.ConnectionID) async throws { 30 | self.connectionID = connectionID 31 | 32 | token = try? await PersistenceManager.shared.authorization.accessToken(for: connectionID) 33 | configuration = try await PersistenceManager.shared.authorization.configuration(for: connectionID) 34 | } 35 | 36 | func refreshAccessToken(current: String?) async throws -> String? { 37 | guard let token else { 38 | throw APIClientError.unauthorized 39 | } 40 | 41 | guard !knownExpiredTokens.contains(token) else { 42 | return nil 43 | } 44 | 45 | if token == current { 46 | knownExpiredTokens.insert(token) 47 | 48 | logger.info("Access token for \(self.connectionID) expired. Refreshing...") 49 | self.token = try await PersistenceManager.shared.authorization.refreshAccessToken(for: connectionID, current: token) 50 | } 51 | 52 | return self.token 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Multiplatform/Episode/EpisodeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EpisodeViewModel.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 30.08.24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | import SwiftUI 11 | import ShelfPlayback 12 | 13 | @Observable @MainActor 14 | final class EpisodeViewModel { 15 | let episode: Episode 16 | var library: Library! 17 | 18 | var toolbarVisible: Bool 19 | var sessionsVisible: Bool 20 | 21 | private(set) var dominantColor: Color? 22 | 23 | let sessionLoader: SessionLoader 24 | 25 | private(set) var notifyError: Bool 26 | 27 | init(episode: Episode) { 28 | self.episode = episode 29 | library = nil 30 | 31 | toolbarVisible = false 32 | sessionsVisible = false 33 | 34 | dominantColor = nil 35 | 36 | sessionLoader = .init(filter: .itemID(episode.id)) 37 | 38 | notifyError = false 39 | } 40 | } 41 | 42 | extension EpisodeViewModel { 43 | nonisolated func load(refresh: Bool) { 44 | Task { 45 | await withTaskGroup { 46 | $0.addTask { await self.extractDominantColor() } 47 | 48 | if refresh { 49 | $0.addTask { await self.sessionLoader.refresh() } 50 | 51 | $0.addTask { 52 | try? await ShelfPlayer.refreshItem(itemID: self.episode.id) 53 | self.load(refresh: false) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | private extension EpisodeViewModel { 62 | nonisolated func extractDominantColor() async { 63 | let color = await PersistenceManager.shared.item.dominantColor(of: episode.id) 64 | 65 | await MainActor.withAnimation { 66 | self.dominantColor = color 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Multiplatform/Person/PersonView+Header.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorView+Header.swift 3 | // Audiobooks 4 | // 5 | // Created by Rasmus Krämer on 06.10.23. 6 | // 7 | 8 | import SwiftUI 9 | import ShelfPlayback 10 | 11 | extension PersonView { 12 | struct Header: View { 13 | @Environment(PersonViewModel.self) private var viewModel 14 | @Environment(Satellite.self) private var satellite 15 | 16 | var body: some View { 17 | if viewModel.person.id.type == .author { 18 | VStack(spacing: 0) { 19 | ItemImage(item: viewModel.person, size: .small, cornerRadius: .infinity) 20 | .frame(width: 100, height: 100) 21 | 22 | Text(viewModel.person.name) 23 | .modifier(SerifModifier()) 24 | .font(.headline) 25 | .multilineTextAlignment(.center) 26 | .padding(.top, 8) 27 | .padding(.bottom, 12) 28 | .padding(.horizontal, 20) 29 | .toolbar { 30 | ToolbarItem(placement: .principal) { 31 | Text(verbatim: "") 32 | } 33 | } 34 | 35 | if let description = viewModel.person.description { 36 | Button { 37 | satellite.present(.description(viewModel.person)) 38 | } label: { 39 | Text(description) 40 | .lineLimit(3) 41 | } 42 | .buttonStyle(.plain) 43 | .padding(.horizontal, 20) 44 | } 45 | } 46 | .frame(maxWidth: .infinity) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Fixtures/Collection+Fixture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Fixture.swift 3 | // ShelfPlayer 4 | // 5 | // Created by Rasmus Krämer on 16.07.25. 6 | // 7 | 8 | import Foundation 9 | 10 | #if DEBUG 11 | public extension ItemCollection { 12 | static let collectionFixture = ItemCollection(id: .init(primaryID: "fixture", groupingID: nil, libraryID: "fixture", connectionID: "fixture", type: .collection), name: "Fixture", description: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", addedAt: .now, items: .init(repeating: Audiobook.fixture, count: 7)) 13 | static let playlistFixture = ItemCollection(id: .init(primaryID: "fixture", groupingID: nil, libraryID: "fixture", connectionID: "fixture", type: .collection), name: "Fixture", description: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", addedAt: .now, items: .init(repeating: Episode.fixture, count: 7)) 14 | } 15 | #endif 16 | -------------------------------------------------------------------------------- /Multiplatform/Utility/TimeRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeRow.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 20.05.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TimeRow: View { 11 | @Environment(Satellite.self) private var satellite 12 | 13 | let title: String 14 | let time: TimeInterval 15 | 16 | let isActive: Bool 17 | let isFinished: Bool 18 | 19 | let callback: () -> Void 20 | 21 | var body: some View { 22 | Button { 23 | callback() 24 | } label: { 25 | HStack(spacing: 0) { 26 | ZStack { 27 | Text(verbatim: "00:00:00") 28 | .hidden() 29 | 30 | Text(time, format: .duration(unitsStyle: .positional, allowedUnits: [.hour, .minute, .second], maximumUnitCount: 3)) 31 | } 32 | .font(.footnote) 33 | .fontDesign(.rounded) 34 | .foregroundStyle(Color.accentColor) 35 | .padding(.trailing, 12) 36 | 37 | Text(title) 38 | .bold(isActive) 39 | .foregroundStyle(isFinished ? .secondary : .primary) 40 | 41 | Spacer(minLength: 0) 42 | } 43 | .lineLimit(1) 44 | .contentShape(.rect) 45 | } 46 | .buttonStyle(.plain) 47 | .listRowBackground(Color.clear) 48 | } 49 | } 50 | 51 | #if DEBUG 52 | #Preview { 53 | List { 54 | TimeRow(title: "Test", time: 300, isActive: false, isFinished: false) {} 55 | TimeRow(title: "Test", time: 300, isActive: false, isFinished: true) {} 56 | TimeRow(title: "Test", time: 300, isActive: true, isFinished: false) {} 57 | TimeRow(title: "Test", time: 300, isActive: true, isFinished: true) {} 58 | } 59 | .listStyle(.plain) 60 | .previewEnvironment() 61 | } 62 | #endif 63 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Persistence/Current/Items/PersistedPodcast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistedPodcast.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 27.11.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | 12 | extension SchemaV2 { 13 | @Model 14 | final class PersistedPodcast { 15 | #Index([\._id]) 16 | #Unique([\._id]) 17 | 18 | private(set) var _id: String 19 | 20 | private(set) var name: String 21 | private(set) var authors: [String] 22 | 23 | private(set) var overview: String? 24 | private(set) var genres: [String] 25 | 26 | private(set) var addedAt: Date 27 | private(set) var released: String? 28 | 29 | private(set) var explicit: Bool 30 | private(set) var publishingType: Podcast.PodcastType? 31 | 32 | private(set) var totalEpisodeCount: Int 33 | 34 | @Relationship(deleteRule: .cascade, inverse: \PersistedEpisode.podcast) 35 | var episodes: [PersistedEpisode] 36 | 37 | init(id: ItemIdentifier, name: String, authors: [String], overview: String? = nil, genres: [String], addedAt: Date, released: String?, explicit: Bool, publishingType: Podcast.PodcastType?, totalEpisodeCount: Int, episodes: [PersistedEpisode]) { 38 | _id = id.description 39 | self.name = name 40 | self.authors = authors 41 | self.overview = overview 42 | self.genres = genres 43 | self.addedAt = addedAt 44 | self.released = released 45 | self.explicit = explicit 46 | self.publishingType = publishingType 47 | self.totalEpisodeCount = totalEpisodeCount 48 | self.episodes = episodes 49 | } 50 | 51 | var id: ItemIdentifier { 52 | .init(string: _id) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Multiplatform/CarPlay/Panels/CarPlayPodcastListController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CarPlayPodcastListController.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 20.10.24. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import CarPlay 10 | import ShelfPlayback 11 | 12 | @MainActor 13 | class CarPlayPodcastListController { 14 | private let interfaceController: CPInterfaceController 15 | private let library: Library 16 | 17 | let template: CPListTemplate 18 | 19 | private var itemControllers = [CarPlayItemController]() 20 | 21 | init(interfaceController: CPInterfaceController, library: Library) { 22 | self.interfaceController = interfaceController 23 | self.library = library 24 | 25 | template = .init(title: library.name, sections: [], assistantCellConfiguration: .none) 26 | 27 | template.emptyViewTitleVariants = [String(localized: "item.empty")] 28 | template.emptyViewSubtitleVariants = [String(localized: "item.empty.description")] 29 | 30 | if #available(iOS 18.4, *) { 31 | template.showsSpinnerWhileEmpty = true 32 | } 33 | 34 | updateSections() 35 | } 36 | 37 | private nonisolated func updateSections() { 38 | Task { 39 | let podcasts = try await ABSClient[library.connectionID].podcasts(from: library.id, sortOrder: Defaults[.podcastsSortOrder], ascending: Defaults[.podcastsAscending], limit: nil, page: nil).0 40 | 41 | await MainActor.run { 42 | itemControllers = podcasts.map { CarPlayPodcastItemController(interfaceController: interfaceController, podcast: $0) } 43 | template.updateSections([CPListSection(items: itemControllers.map(\.row))]) 44 | 45 | if #available(iOS 18.4, *) { 46 | template.showsSpinnerWhileEmpty = false 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ShelfPlayerKit/Foundation/Utility/TintColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TintColor.swift 3 | // ShelfPlayerKit 4 | // 5 | // Created by Rasmus Krämer on 01.06.25. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public enum TintColor: Identifiable, Codable, Defaults.Serializable, CaseIterable { 12 | case shelfPlayer 13 | 14 | case yellow 15 | case red 16 | case purple 17 | case violet 18 | case blue 19 | case aqua 20 | case mint 21 | case green 22 | case black 23 | 24 | public var id: Self { self } 25 | 26 | public var color: Color { 27 | switch self { 28 | case .shelfPlayer: 29 | Color(red: 1, green: 0.8, blue: 0) 30 | case .yellow: 31 | .yellow 32 | case .purple: 33 | .purple 34 | case .red: 35 | .red 36 | case .violet: 37 | .indigo 38 | case .blue: 39 | .blue 40 | case .aqua: 41 | .cyan 42 | case .green: 43 | .green 44 | case .mint: 45 | .mint 46 | case .black: 47 | .black 48 | } 49 | } 50 | 51 | public var accent: Color { 52 | switch self { 53 | case .shelfPlayer: 54 | .orange 55 | case .yellow: 56 | .orange 57 | case .red: 58 | .yellow 59 | case .purple: 60 | .blue 61 | case .violet: 62 | .blue 63 | case .blue: 64 | .purple 65 | case .aqua: 66 | .orange 67 | case .mint: 68 | .blue 69 | case .green: 70 | .blue 71 | case .black: 72 | .gray 73 | } 74 | } 75 | } 76 | --------------------------------------------------------------------------------