├── 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 |
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 |
--------------------------------------------------------------------------------