├── .gitignore ├── AmpFin.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── Multiplatform.xcscheme │ └── Siri Extension.xcscheme ├── AmpFinKit ├── .gitignore ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ ├── IDETemplateMacros.plist │ │ └── xcschemes │ │ └── AmpFinKit.xcscheme ├── Package.swift └── Sources │ ├── AFExtension │ ├── INMediaUserContext+Donate.swift │ ├── Item+Favorite.swift │ ├── MediaResolver │ │ ├── MediaResolver+Albums.swift │ │ ├── MediaResolver+Artists.swift │ │ ├── MediaResolver+Convert.swift │ │ ├── MediaResolver+Playlists.swift │ │ ├── MediaResolver+Tracks.swift │ │ └── MediaResolver.swift │ └── Playlist+Add.swift │ ├── AFFoundation │ ├── Album.swift │ ├── Artist.swift │ ├── Extensions │ │ └── Sequence+Parallel.swift │ ├── Fixtures │ │ ├── Album+Fixture.swift │ │ ├── Artist+Fixture.swift │ │ ├── Cover+Fixture.swift │ │ ├── Playlist+Fixture.swift │ │ └── Track+Fixture.swift │ ├── Item.swift │ ├── Playlist.swift │ ├── Track.swift │ └── Utility │ │ ├── Cover.swift │ │ ├── FeatureToggle.swift │ │ ├── RepeatMode.swift │ │ ├── Session.swift │ │ └── SortOrder.swift │ ├── AFNetwork │ ├── Convert │ │ ├── Album+Convert.swift │ │ ├── Artist+Convert.swift │ │ ├── Cover+Convert.swift │ │ ├── Playlist+Convert.swift │ │ ├── Session+Convert.swift │ │ └── Track+Convert.swift │ ├── Extensions │ │ ├── Cover+Image.swift │ │ ├── Date+Parse.swift │ │ ├── SortOrder+Jellyfin.swift │ │ └── String+Random.swift │ ├── HTTP │ │ ├── JellyfinClient+FeatureFlags.swift │ │ ├── JellyfinClient+Headers.swift │ │ ├── JellyfinClient+Request.swift │ │ ├── JellyfinClient+Utility.swift │ │ └── JellyfinClient.swift │ ├── Methods │ │ ├── JellyfinClient+Album.swift │ │ ├── JellyfinClient+Artist.swift │ │ ├── JellyfinClient+Item.swift │ │ ├── JellyfinClient+Playlist.swift │ │ ├── JellyfinClient+Progress.swift │ │ ├── JellyfinClient+QuickConnect.swift │ │ ├── JellyfinClient+Session.swift │ │ ├── JellyfinClient+Track.swift │ │ └── JellyfinClient+User.swift │ ├── Models │ │ ├── JellyfinItem.swift │ │ ├── JellyfinMessage.swift │ │ ├── JellyfinSession.swift │ │ └── JellyfinUtility.swift │ └── Websocket │ │ ├── JellyfinWebSocket+Notifications.swift │ │ ├── JellyfinWebSocket.swift │ │ ├── WebSocket+Handler.swift │ │ └── WebSocket+Methods.swift │ ├── AFOffline │ ├── Extensions │ │ ├── Album+Convert.swift │ │ ├── Playlist+Convert.swift │ │ └── Track+Convert.swift │ ├── Filesystem │ │ ├── DownloadManager+Cleanup.swift │ │ ├── DownloadManager+Handler.swift │ │ ├── DownloadManager+Parent.swift │ │ ├── DownloadManager+Track.swift │ │ └── DownloadManager.swift │ ├── ItemOfflineTracker.swift │ ├── LegacyPersistenceManager.swift │ ├── Models │ │ ├── Helper │ │ │ ├── OfflineParent.swift │ │ │ └── Reduced.swift │ │ ├── OfflineAlbum.swift │ │ ├── OfflinePlaylist.swift │ │ ├── OfflineTrack.swift │ │ └── Utility │ │ │ ├── OfflineFavorite.swift │ │ │ ├── OfflineLyrics.swift │ │ │ └── OfflinePlay.swift │ ├── OfflineManager │ │ ├── OfflineManager+Album.swift │ │ ├── OfflineManager+Artist.swift │ │ ├── OfflineManager+Cleanup.swift │ │ ├── OfflineManager+Favorites.swift │ │ ├── OfflineManager+Parent.swift │ │ ├── OfflineManager+Playlist.swift │ │ ├── OfflineManager+Track.swift │ │ └── OfflineManager.swift │ └── PersistenceManager.swift │ ├── AFPlayback │ ├── AudioEndpoint.swift │ ├── AudioPlayer │ │ ├── AudioPlayer+Notifications.swift │ │ ├── AudioPlayer+Playback.swift │ │ ├── AudioPlayer.swift │ │ └── Private │ │ │ ├── AudioPlayer+Commands.swift │ │ │ ├── AudioPlayer+Helper.swift │ │ │ └── AudioPlayer+Observers.swift │ ├── Extensions │ │ ├── AVAssetTrack+Format.swift │ │ ├── Default+Keys.swift │ │ ├── Item+Mix.swift │ │ ├── MPVolumeView+Volume.swift │ │ └── RepeatMode+Next.swift │ ├── LocalAudioEndpoint │ │ ├── LocalAudioEndpoint+Helper.swift │ │ ├── LocalAudioEndpoint+Observers.swift │ │ ├── LocalAudioEndpoint+Playback.swift │ │ ├── LocalAudioEndpoint+Queue.swift │ │ ├── LocalAudioEndpoint+Widget.swift │ │ ├── LocalAudioEndpoint.swift │ │ └── PlaybackReporter.swift │ ├── PlaybackInfo.swift │ └── RemoteAudioEndpoint │ │ ├── RemoteAudioEndpoint+Metadata.swift │ │ ├── RemoteAudioEndpoint+Playback.swift │ │ ├── RemoteAudioEndpoint.swift │ │ └── silence.wav │ └── AmpFinKit │ └── AmpFinKit.swift ├── Configuration ├── Base.xcconfig ├── Debug.xcconfig.template └── Release.xcconfig ├── LICENSE ├── Multiplatform ├── Account │ ├── Account+Remote.swift │ ├── AccountSheet.swift │ └── CustomHeaderEditView.swift ├── Album │ ├── AlbumLoadView.swift │ ├── AlbumView+Additional.swift │ ├── AlbumView+Header.swift │ ├── AlbumView+Toolbar.swift │ ├── AlbumView.swift │ └── AlbumViewModel.swift ├── AppDelegate.swift ├── Artist │ ├── ArtistLoadView.swift │ ├── ArtistView+Header.swift │ ├── ArtistView+Toolbar.swift │ └── ArtistView.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── AmpFin (Dark).png │ │ ├── AmpFin (Monochrome).png │ │ ├── AmpFin.png │ │ └── Contents.json │ ├── Contents.json │ └── Logo.imageset │ │ ├── Contents.json │ │ └── music.png ├── Collections │ ├── Albums │ │ ├── AlbumContextMenuModifier.swift │ │ ├── AlbumCover.swift │ │ ├── AlbumGrid.swift │ │ ├── AlbumListRow.swift │ │ └── AlbumRow.swift │ ├── Artists │ │ ├── ArtistList.swift │ │ └── ArtistListRow.swift │ ├── Playlists │ │ ├── PlaylistAddSheet.swift │ │ ├── PlaylistListRow.swift │ │ └── PlaylistsList.swift │ └── Tracks │ │ ├── TrackCollection.swift │ │ ├── TrackGrid.swift │ │ ├── TrackList.swift │ │ ├── TrackListButtons.swift │ │ ├── TrackListRow.swift │ │ └── TrackTable.swift ├── Common │ ├── ErrorView.swift │ ├── LoadingView.swift │ └── UnavailableWrapper.swift ├── Entitlements.entitlements ├── Info.plist ├── InfoPlist.xcstrings ├── Library │ ├── AlbumsView.swift │ ├── ArtistsView.swift │ ├── LibraryView.swift │ ├── PlaylistsView.swift │ ├── SearchView.swift │ └── TracksView.swift ├── Localizable.xcstrings ├── Login │ ├── LoginFormView.swift │ ├── LoginQuickConnectView.swift │ ├── LoginView.swift │ ├── LoginViewModel.swift │ └── WelcomeView.swift ├── MultiplatformApp.swift ├── Navigation │ ├── ContentView.swift │ ├── Navigation.swift │ ├── Sidebar │ │ ├── Sidebar+Links.swift │ │ ├── Sidebar+Selection.swift │ │ └── Sidebar.swift │ ├── Tabs.swift │ └── XRTabs.swift ├── NowPlaying │ ├── Background.swift │ ├── Buttons.swift │ ├── Compact │ │ ├── CompactModifier.swift │ │ └── CompactTabBarBackgroundModifier.swift │ ├── Controls.swift │ ├── Lyrics.swift │ ├── Modifiers │ │ ├── ContextMenuModifier.swift │ │ ├── SafeAreaModifier.swift │ │ └── SymbolButtonStyle.swift │ ├── NowPlaying.swift │ ├── Queue.swift │ ├── Regular │ │ ├── RegularBarModifier.swift │ │ └── RegularView.swift │ ├── Sliders │ │ ├── Slider.swift │ │ └── VolumeSlider.swift │ ├── Title.swift │ └── ViewModel.swift ├── Playlist │ ├── PlaylistLoadView.swift │ ├── PlaylistView+Header.swift │ ├── PlaylistView+Toolbar.swift │ ├── PlaylistView.swift │ └── PlaylistViewModel.swift ├── PrivacyInfo.xcprivacy ├── Settings.bundle │ ├── Acknowledgements.plist │ └── Root.plist ├── Track │ └── LyricsSheet.swift ├── Utility │ ├── DisplayContext.swift │ ├── DownloadIndicator.swift │ ├── Extensions │ │ ├── Array+Repeat.swift │ │ ├── Color+IsLight.swift │ │ ├── ContentShapeKinds+HoverMenuInteraction.swift │ │ ├── Defaults+Keys.swift │ │ ├── Double+Duration.swift │ │ ├── MainActor+withAnimation.swift │ │ ├── UIApplication+Tap.swift │ │ ├── UINavigationController+Gesture.swift │ │ └── UIScreen+Radius.swift │ ├── HoverEffectModifier.swift │ ├── Intents │ │ ├── AddMediaHandler.swift │ │ ├── Base.lproj │ │ │ └── Intents.intentdefinition │ │ ├── PlayMediaHandler.swift │ │ ├── SpotlightHelper.swift │ │ ├── de.lproj │ │ │ └── Intents.strings │ │ └── en.lproj │ │ │ └── Intents.strings │ ├── ItemImage.swift │ ├── LibraryDataProviders │ │ ├── LibraryDataProvider.swift │ │ ├── MockLibraryDataProvider.swift │ │ ├── OfflineLibraryDataProvider.swift │ │ └── OnlineLibraryDataProivder.swift │ ├── QueueButtons.swift │ └── SortSelector.swift ├── de.lproj │ └── AppIntentVocabulary.plist └── en.lproj │ └── AppIntentVocabulary.plist ├── PRIVACY.md ├── README.md ├── Screenshots ├── Album (iOS).png ├── Album (iPadOS).png ├── Albums (iOS).png ├── Albums (iPadOS).png ├── Library (iOS).png ├── Lyrics (iOS).png ├── Lyrics (iPadOS).png ├── Player (iOS).png ├── Playlist (iOS).png ├── Playlist (iPadOS).png ├── Queue (iOS).png ├── Queue (iPadOS).png └── Tracks (iPadOS).png └── Siri Extension ├── Entitlements.entitlements ├── Handler+Add.swift ├── Handler+Play.swift ├── Handler+Search.swift ├── Info.plist ├── IntentHandler.swift ├── PrivacyInfo.xcprivacy └── String+Distance.swift /AmpFin.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /AmpFin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /AmpFin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "defaults", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/sindresorhus/Defaults", 7 | "state" : { 8 | "revision" : "38925e3cfacf3fb89a81a35b1cd44fd5a5b7e0fa", 9 | "version" : "8.2.0" 10 | } 11 | }, 12 | { 13 | "identity" : "fluidgradient", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/Cindori/FluidGradient.git", 16 | "state" : { 17 | "revision" : "9ddda4cf23671ef0228e88681ec6210cb3e0d7f7", 18 | "version" : "1.0.0" 19 | } 20 | }, 21 | { 22 | "identity" : "nuke", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/kean/Nuke.git", 25 | "state" : { 26 | "revision" : "0ead44350d2737db384908569c012fe67c421e4d", 27 | "version" : "12.8.0" 28 | } 29 | }, 30 | { 31 | "identity" : "rfkit", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/rasmuslos/RFKit", 34 | "state" : { 35 | "branch" : "main", 36 | "revision" : "6cc0648ca3804a16ff6e3b94e141957705e63306" 37 | } 38 | }, 39 | { 40 | "identity" : "starscream", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/daltoniam/Starscream.git", 43 | "state" : { 44 | "revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a", 45 | "version" : "4.0.8" 46 | } 47 | } 48 | ], 49 | "version" : 2 50 | } 51 | -------------------------------------------------------------------------------- /AmpFinKit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /AmpFinKit/.swiftpm/xcode/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | FILEHEADER 7 | 8 | // ___FILENAME___ 9 | // ___WORKSPACENAME___ 10 | // 11 | // Created by ___FULLUSERNAME___ on ___DATE___ at ___TIME___. 12 | // 13 | 14 | 15 | -------------------------------------------------------------------------------- /AmpFinKit/.swiftpm/xcode/xcshareddata/xcschemes/AmpFinKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /AmpFinKit/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | private let offlineCondition: TargetDependencyCondition? = .when(platforms: [.iOS, .watchOS, .visionOS, .macOS, .macCatalyst]) 6 | 7 | let package = Package( 8 | name: "AmpFinKit", 9 | platforms: [ 10 | .iOS(.v17), 11 | .tvOS(.v17), 12 | .macOS(.v14), 13 | .watchOS(.v10), 14 | .visionOS(.v1), 15 | ], 16 | products: [ 17 | .library(name: "AmpFinKit", targets: ["AmpFinKit"]), 18 | .library(name: "AFPlayback", targets: ["AFPlayback"]), 19 | ], 20 | dependencies: [ 21 | .package(url: "https://github.com/daltoniam/Starscream.git", from: .init(4, 0, 8)), 22 | .package(url: "https://github.com/sindresorhus/Defaults.git", from: .init(8, 2, 0)), 23 | ], 24 | targets: [ 25 | // Umbrella library 26 | .target(name: "AmpFinKit", dependencies: [ 27 | .targetItem(name: "AFFoundation", condition: .none), 28 | .targetItem(name: "AFExtension", condition: .none), 29 | .targetItem(name: "AFNetwork", condition: .none), 30 | 31 | .targetItem(name: "AFOffline", condition: offlineCondition), 32 | ]), 33 | 34 | // Foundation 35 | .target(name: "AFFoundation", dependencies: [ 36 | .byName(name: "Defaults"), 37 | .byName(name: "Starscream"), 38 | ]), 39 | .target(name: "AFExtension", dependencies: [ 40 | .targetItem(name: "AFFoundation", condition: .none), 41 | .targetItem(name: "AFNetwork", condition: .none), 42 | 43 | .targetItem(name: "AFOffline", condition: offlineCondition), 44 | ]), 45 | 46 | // Network 47 | .target(name: "AFNetwork", dependencies: [ 48 | .targetItem(name: "AFFoundation", condition: .none), 49 | ]), 50 | 51 | // Offline 52 | .target(name: "AFOffline", dependencies: [ 53 | .targetItem(name: "AFFoundation", condition: .none), 54 | .targetItem(name: "AFNetwork", condition: .none), 55 | ]), 56 | 57 | // Playback 58 | .target(name: "AFPlayback", dependencies: [ 59 | .targetItem(name: "AFFoundation", condition: .none), 60 | .targetItem(name: "AFExtension", condition: .none), 61 | 62 | .targetItem(name: "AFOffline", condition: offlineCondition), 63 | 64 | .byName(name: "Defaults"), 65 | ], resources: [.process("RemoteAudioEndpoint/silence.wav")]), 66 | ] 67 | ) 68 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/INMediaUserContext+Donate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 07.01.24. 6 | // 7 | 8 | import Foundation 9 | import Intents 10 | import AFNetwork 11 | 12 | @available(macOS, unavailable) 13 | public extension INMediaUserContext { 14 | static func donate() { 15 | Task { 16 | let trackCount = try await JellyfinClient.shared.tracks(limit: 1, startIndex: 0, sortOrder: .added, ascending: true).1 17 | let context = INMediaUserContext() 18 | 19 | context.numberOfLibraryItems = trackCount 20 | context.subscriptionStatus = .subscribed 21 | context.becomeCurrent() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/Item+Favorite.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | import AFNetwork 11 | 12 | #if canImport(AFOffline) 13 | import AFOffline 14 | #endif 15 | 16 | public extension Item { 17 | var favorite: Bool { 18 | get { 19 | _favorite 20 | } 21 | set { 22 | #if canImport(AFOffline) 23 | _favorite = newValue 24 | 25 | OfflineManager.shared.update(favorite: newValue, itemId: self.id) 26 | 27 | Task { 28 | do { 29 | try await JellyfinClient.shared.favorite(newValue, identifier: self.id) 30 | } catch { 31 | OfflineManager.shared.cache(favorite: newValue, itemId: self.id) 32 | } 33 | } 34 | #else 35 | Task { 36 | do { 37 | try await JellyfinClient.shared.favorite(newValue, identifier: self.id) 38 | _favorite = newValue 39 | } catch {} 40 | } 41 | #endif 42 | 43 | NotificationCenter.default.post(name: Self.affinityChangedNotification, object: id, userInfo: [ 44 | "favorite": newValue, 45 | ]) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/MediaResolver/MediaResolver+Albums.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaResolver+Albums.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 26.04.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | import AFNetwork 11 | #if canImport(AFOffline) 12 | import AFOffline 13 | #endif 14 | 15 | @available(macOS, unavailable) 16 | public extension MediaResolver { 17 | func search(albumName name: String?, artistName artist: String?, runOffline: Bool) async throws -> [Album] { 18 | guard let name = name else { throw ResolveError.missing } 19 | 20 | var result = [Album]() 21 | 22 | #if canImport(AFOffline) 23 | if let offlineAlbums = try? OfflineManager.shared.albums(search: name) { 24 | result += offlineAlbums 25 | } 26 | #endif 27 | 28 | if !runOffline, let fetchedAlbums = try? await JellyfinClient.shared.albums(limit: 0, startIndex: 0, sortOrder: .lastPlayed, ascending: false, search: name).0 { 29 | result += fetchedAlbums.filter { !result.contains($0) } 30 | } 31 | 32 | result = result.filter { 33 | if let artist = artist { 34 | if !$0.artists.reduce(false, { $0 || $1.name.localizedStandardContains(artist) }) { 35 | return false 36 | } 37 | } 38 | 39 | return true 40 | } 41 | 42 | guard !result.isEmpty else { 43 | throw ResolveError.empty 44 | } 45 | 46 | return result 47 | } 48 | 49 | func tracks(albumId identifier: String) async throws -> [Track] { 50 | #if canImport(AFOffline) 51 | if let tracks = try? OfflineManager.shared.tracks(albumId: identifier) { 52 | return tracks 53 | } 54 | #endif 55 | 56 | return try await JellyfinClient.shared.tracks(albumId: identifier) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/MediaResolver/MediaResolver+Artists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaResolver+Artists.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 26.04.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | import AFNetwork 11 | #if canImport(AFOffline) 12 | import AFOffline 13 | #endif 14 | 15 | @available(macOS, unavailable) 16 | public extension MediaResolver { 17 | func search(artistName name: String?, runOffline: Bool) async throws -> [Artist] { 18 | guard let name = name else { throw ResolveError.missing } 19 | 20 | guard !runOffline else { 21 | throw ResolveError.missing 22 | } 23 | 24 | let result = try await JellyfinClient.shared.artists(search: name) 25 | 26 | guard !result.isEmpty else { 27 | throw ResolveError.empty 28 | } 29 | 30 | return result 31 | } 32 | 33 | func tracks(artistId identifier: String) async throws -> [Track] { 34 | #if canImport(AFOffline) 35 | if let tracks = try? OfflineManager.shared.tracks(artistId: identifier) { 36 | return tracks.shuffled() 37 | } 38 | #endif 39 | 40 | return try await JellyfinClient.shared.tracks(artistId: identifier, sortOrder: .random, ascending: true) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/MediaResolver/MediaResolver+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaResolver+Convert.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 26.04.24. 6 | // 7 | 8 | import Foundation 9 | import Intents 10 | import AFFoundation 11 | 12 | @available(macOS, unavailable) 13 | public extension MediaResolver { 14 | func convert(items: [Item]) async -> [INMediaItem] { 15 | await items.parallelMap(convert) 16 | } 17 | func convert(item: Item) async -> INMediaItem { 18 | var artist: String? 19 | 20 | if let track = item as? Track { 21 | artist = track.artistName 22 | } else if let album = item as? Album { 23 | artist = album.artistName 24 | } 25 | 26 | return INMediaItem( 27 | identifier: item.id, 28 | title: item.name, 29 | type: convert(type: item.type), 30 | artwork: await convert(cover: item.cover), 31 | artist: artist) 32 | } 33 | } 34 | 35 | @available(macOS, unavailable) 36 | private extension MediaResolver { 37 | func convert(type: Item.ItemType) -> INMediaItemType { 38 | switch type { 39 | case .album: 40 | return .album 41 | case .artist: 42 | return .artist 43 | case .track: 44 | return .song 45 | case .playlist: 46 | return .playlist 47 | } 48 | } 49 | 50 | func convert(cover: Cover?) async -> INImage? { 51 | guard let cover = cover else { 52 | return nil 53 | } 54 | 55 | if cover.type == .local { 56 | return INImage(url: cover.url) 57 | } 58 | 59 | guard let image = await cover.systemImage, let data = image.pngData() else { 60 | return nil 61 | } 62 | 63 | return INImage(imageData: data) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/MediaResolver/MediaResolver+Playlists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaResolver+Playlists.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 26.04.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | import AFNetwork 11 | #if canImport(AFOffline) 12 | import AFOffline 13 | #endif 14 | 15 | @available(macOS, unavailable) 16 | public extension MediaResolver { 17 | func search(playlistName name: String?, runOffline: Bool) async throws -> [Playlist] { 18 | guard let name = name else { throw ResolveError.missing } 19 | 20 | var result = [Playlist]() 21 | 22 | #if canImport(AFOffline) 23 | if let offlinePlaylists = try? OfflineManager.shared.playlists().filter({ $0.name.localizedStandardContains(name) }) { 24 | result += offlinePlaylists 25 | } 26 | #endif 27 | 28 | if !runOffline, let fetchedPlaylists = try? await JellyfinClient.shared.playlists(limit: 0, sortOrder: .lastPlayed, ascending: false, search: name) { 29 | result += fetchedPlaylists.filter { !result.contains($0) } 30 | } 31 | 32 | guard !result.isEmpty else { 33 | throw ResolveError.empty 34 | } 35 | 36 | return result 37 | } 38 | 39 | func tracks(playlistId identifier: String) async throws -> [Track] { 40 | #if canImport(AFOffline) 41 | if let tracks = try? OfflineManager.shared.tracks(playlistId: identifier) { 42 | return tracks 43 | } 44 | #endif 45 | 46 | return try await JellyfinClient.shared.tracks(playlistId: identifier) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/MediaResolver/MediaResolver+Tracks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaResolver+Tracks.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 26.04.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | import AFNetwork 11 | #if canImport(AFOffline) 12 | import AFOffline 13 | #endif 14 | 15 | @available(macOS, unavailable) 16 | public extension MediaResolver { 17 | func search(trackName name: String?, albumName album: String?, artistName artist: String?, runOffline: Bool) async throws -> [Track] { 18 | guard let name = name else { throw ResolveError.missing } 19 | 20 | var result = [Track]() 21 | 22 | #if canImport(AFOffline) 23 | if let offlineTracks = try? OfflineManager.shared.tracks(search: name) { 24 | result += offlineTracks 25 | } 26 | #endif 27 | 28 | if !runOffline, let fetchedTracks = try? await JellyfinClient.shared.tracks(limit: 0, startIndex: 0, sortOrder: .lastPlayed, ascending: false, search: name).0 { 29 | result += fetchedTracks.filter { !result.contains($0) } 30 | } 31 | 32 | result = result.filter { 33 | if let album = album, let name = $0.album.name, !name.localizedStandardContains(album) { 34 | return false 35 | } 36 | 37 | if let artist = artist, !$0.artists.reduce(false, { $0 || $1.name.localizedStandardContains(artist) }) { 38 | return false 39 | } 40 | 41 | return true 42 | } 43 | 44 | guard !result.isEmpty else { 45 | throw ResolveError.empty 46 | } 47 | 48 | return result 49 | } 50 | 51 | func track(id trackId: String) async throws -> Track { 52 | #if canImport(AFOffline) 53 | if let track = try? OfflineManager.shared.track(identifier: trackId) { 54 | return track 55 | } 56 | #endif 57 | 58 | return try await JellyfinClient.shared.track(identifier: trackId) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/MediaResolver/MediaResolver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaResolver.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 13.01.24. 6 | // 7 | 8 | import Foundation 9 | import Intents 10 | 11 | @available(macOS, unavailable) 12 | public final class MediaResolver { 13 | private init() {} 14 | 15 | public enum ResolveError: Error { 16 | case empty 17 | case missing 18 | case notFound 19 | } 20 | } 21 | 22 | @available(macOS, unavailable) 23 | public extension MediaResolver { 24 | static let shared = MediaResolver() 25 | } 26 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFExtension/Playlist+Add.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 04.01.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | import AFNetwork 11 | #if canImport(AFOffline) 12 | import AFOffline 13 | #endif 14 | 15 | public extension Playlist { 16 | func add(trackIds: [String]) async throws { 17 | try await JellyfinClient.shared.add(trackIds: trackIds, playlistId: id) 18 | 19 | #if canImport(AFOffline) 20 | if OfflineManager.shared.offlineStatus(playlistId: id) != .none { 21 | try await OfflineManager.shared.download(playlist: self) 22 | } 23 | #endif 24 | 25 | trackCount = try await JellyfinClient.shared.tracks(playlistId: id).count 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFFoundation/Album.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Album.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public final class Album: Item { 11 | public let overview: String? 12 | public let genres: [String] 13 | 14 | public let releaseDate: Date? 15 | public let artists: [ReducedArtist] 16 | 17 | public let playCount: Int 18 | public let lastPlayed: Date? 19 | 20 | public init(id: String, name: String, cover: Cover? = nil, favorite: Bool, overview: String?, genres: [String], releaseDate: Date?, artists: [ReducedArtist], playCount: Int, lastPlayed: Date?) { 21 | self.overview = overview 22 | self.genres = genres 23 | self.releaseDate = releaseDate 24 | self.artists = artists 25 | self.playCount = playCount 26 | self.lastPlayed = lastPlayed 27 | 28 | super.init(id: id, type: .album, name: name, cover: cover, favorite: favorite) 29 | } 30 | 31 | private enum CodingKeys: String, CodingKey { 32 | case overview 33 | case genres 34 | case releaseDate 35 | case artists 36 | case playCount 37 | case lastPlayed 38 | } 39 | 40 | public required init(from decoder: Decoder) throws { 41 | let container = try decoder.container(keyedBy: CodingKeys.self) 42 | self.overview = try container.decodeIfPresent(String.self, forKey: .overview) 43 | self.genres = try container.decodeIfPresent([String].self, forKey: .genres) ?? [] 44 | self.releaseDate = try container.decodeIfPresent(Date.self, forKey: .releaseDate) 45 | self.artists = try container.decodeIfPresent([ReducedArtist].self, forKey: .artists) ?? [] 46 | self.playCount = try container.decode(Int.self, forKey: .playCount) 47 | self.lastPlayed = try container.decodeIfPresent(Date.self, forKey: .lastPlayed) 48 | 49 | try super.init(from: decoder) 50 | } 51 | } 52 | 53 | public extension Album { 54 | var artistName: String? { 55 | get { 56 | guard !artists.isEmpty else { 57 | return nil 58 | } 59 | 60 | return artists.map { $0.name }.joined(separator: String(", ")) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFFoundation/Artist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Artist.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public final class Artist: Item { 11 | public let overview: String? 12 | 13 | public init(id: String, name: String, cover: Cover? = nil, favorite: Bool, overview: String?) { 14 | self.overview = overview 15 | super.init(id: id, type: .artist, name: name, cover: cover, favorite: favorite) 16 | } 17 | 18 | private enum CodingKeys: String, CodingKey { 19 | case overview 20 | } 21 | 22 | public required init(from decoder: Decoder) throws { 23 | let container = try decoder.container(keyedBy: CodingKeys.self) 24 | self.overview = try container.decodeIfPresent(String.self, forKey: .overview) 25 | 26 | try super.init(from: decoder) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFFoundation/Extensions/Sequence+Parallel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // Taken from https://gist.github.com/DougGregor/92a2e4f6e11f6d733fb5065e9d1c880f 4 | 5 | public extension Collection { 6 | func parallelMap(parallelism requestedParallelism: Int? = nil, _ transform: @escaping (Element) async throws -> T) async rethrows -> [T] { 7 | let defaultParallelism = 5 8 | let parallelism = requestedParallelism ?? defaultParallelism 9 | 10 | let n = self.count 11 | if n == 0 { 12 | return [] 13 | } 14 | 15 | return try await withThrowingTaskGroup(of: (Int, T).self) { group in 16 | var result = Array(repeatElement(nil, count: n)) 17 | 18 | var i = self.startIndex 19 | var submitted = 0 20 | 21 | func submitNext() async throws { 22 | if i == self.endIndex { return } 23 | 24 | group.addTask { [submitted, i] in 25 | let value = try await transform(self[i]) 26 | return (submitted, value) 27 | } 28 | submitted += 1 29 | formIndex(after: &i) 30 | } 31 | 32 | for _ in 0.. Bool { 24 | lhs.url == rhs.url 25 | } 26 | } 27 | 28 | public extension Cover { 29 | enum CoverType: Codable, Hashable { 30 | case local 31 | case remote 32 | case mock 33 | } 34 | 35 | enum CoverSize: Codable, Hashable { 36 | case small 37 | case normal 38 | case custom(size: Int) 39 | } 40 | } 41 | 42 | public extension Cover.CoverSize { 43 | var dimensions: Int { 44 | switch self { 45 | case .small: 46 | 200 47 | case .normal: 48 | 800 49 | case .custom(let size): 50 | size 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFFoundation/Utility/FeatureToggle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 15.05.24. 6 | // 7 | 8 | public var AFKIT_ENABLE_ALL_FEATURES = true 9 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFFoundation/Utility/RepeatMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 21.05.24. 6 | // 7 | 8 | import Foundation 9 | import Defaults 10 | 11 | public enum RepeatMode: Int, Identifiable, Equatable, Codable, CaseIterable, _DefaultsSerializable { 12 | case none = 0 13 | case track = 1 14 | case queue = 2 15 | case infinite = 3 16 | 17 | public var id: Int { 18 | rawValue 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFFoundation/Utility/SortOrder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 15.05.24. 6 | // 7 | 8 | import Foundation 9 | import Defaults 10 | 11 | public enum ItemSortOrder: CaseIterable, Codable, Defaults.Serializable { 12 | case name 13 | case album 14 | case albumArtist 15 | case artist 16 | case added 17 | case plays 18 | case lastPlayed 19 | case released 20 | case runtime 21 | case random 22 | } 23 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Convert/Album+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumItem+Convert.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Album { 12 | convenience init(_ from: JellyfinItem) { 13 | var lastPlayed: Date? 14 | 15 | if let lastPlayedDate = from.UserData?.LastPlayedDate { 16 | lastPlayed = Date(lastPlayedDate) 17 | } 18 | 19 | self.init( 20 | id: from.Id, 21 | name: from.Name!, 22 | cover: .init(imageTags: from.ImageTags!, id: from.Id), 23 | favorite: from.UserData!.IsFavorite, 24 | overview: from.Overview, 25 | genres: from.Genres ?? [], 26 | releaseDate: Date(from.PremiereDate), 27 | artists: from.AlbumArtists?.map { ReducedArtist(id: $0.Id, name: $0.Name) } ?? [], 28 | playCount: from.UserData!.PlayCount, 29 | lastPlayed: lastPlayed) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Convert/Artist+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistItem+Convert.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Artist { 12 | convenience init(_ from: JellyfinItem) { 13 | self.init( 14 | id: from.Id, 15 | name: from.Name!, 16 | cover: .init(imageTags: from.ImageTags!, id: from.Id), 17 | favorite: from.UserData!.IsFavorite, 18 | overview: from.Overview) 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Convert/Cover+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemCover+Convert.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | extension Cover { 12 | internal init?(imageTags: ImageTags, id: String, size: CoverSize = .normal) { 13 | guard let primaryImageTag = imageTags.Primary else { 14 | return nil 15 | } 16 | 17 | self.init(type: .remote, size: size, url: Self.url(itemId: id, imageTag: primaryImageTag, size: size)) 18 | } 19 | 20 | public static func url(itemId: String, imageTag: String?, size: CoverSize = .normal, quality: Int = 96) -> URL { 21 | let dimensions = String(size.dimensions) 22 | var query = [ 23 | URLQueryItem(name: "fillHeight", value: dimensions), 24 | URLQueryItem(name: "fillWidth", value: dimensions), 25 | 26 | URLQueryItem(name: "quality", value: String(quality)), 27 | URLQueryItem(name: "token", value: JellyfinClient.shared.token), 28 | ] 29 | 30 | if let imageTag = imageTag { 31 | query.append(URLQueryItem(name: "tag", value: imageTag)) 32 | } 33 | 34 | return JellyfinClient.shared.serverUrl.appending(path: "Items").appending(path: itemId).appending(path: "Images").appending(path: "Primary").appending(queryItems: query) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Convert/Playlist+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 01.01.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Playlist { 12 | convenience init(_ from: JellyfinItem) { 13 | var runtime: Double? 14 | 15 | if let runTimeTicks = from.RunTimeTicks { 16 | runtime = Double(runTimeTicks / 10_000_000) 17 | } 18 | 19 | self.init( 20 | id: from.Id, 21 | name: from.Name!, 22 | cover: .init(imageTags: from.ImageTags!, id: from.Id), 23 | favorite: from.UserData!.IsFavorite, 24 | duration: runtime ?? 0, 25 | trackCount: from.ChildCount ?? 0) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Convert/Session+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Session { 12 | convenience init(_ from: JellyfinSession) { 13 | var nowPlaying: Track? 14 | 15 | if let nowPlayingItem = from.NowPlayingItem { 16 | nowPlaying = .init(nowPlayingItem) 17 | } 18 | 19 | self.init( 20 | id: from.Id, 21 | name: from.DeviceName, 22 | client: from.Client, 23 | clientId: from.DeviceId, 24 | nowPlaying: nowPlaying, 25 | position: Double(from.PlayState.PositionTicks ?? 0) / 10_000_000, 26 | canSeek: from.PlayState.CanSeek, 27 | canSetVolume: from.Capabilities.SupportedCommands.contains("SetVolume"), 28 | isPaused: from.PlayState.IsPaused, 29 | isMuted: from.PlayState.IsMuted, 30 | volumeLevel: Float(from.PlayState.VolumeLevel ?? 0) / 100, 31 | repeatMode: from.PlayState.RepeatMode == "RepeatAll" ? .queue : from.PlayState.RepeatMode == "RepeatOne" ? .track : .none, 32 | shuffled: from.PlayState.PlaybackOrder == "Shuffle") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Convert/Track+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Track+Convert.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Track { 12 | convenience init?(_ from: JellyfinItem, fallbackIndex: Int = 0, coverSize: Cover.CoverSize = .normal) { 13 | guard let albumId = from.AlbumId else { 14 | return nil 15 | } 16 | 17 | var cover: Cover? 18 | 19 | if from.ImageTags!.Primary != nil { 20 | cover = .init(imageTags: from.ImageTags!, id: from.Id, size: coverSize) 21 | } else if let imageTag = from.AlbumPrimaryImageTag { 22 | cover = .init(imageTags: .init(Primary: imageTag), id: albumId, size: coverSize) 23 | } 24 | 25 | var runtime: Double? 26 | 27 | if let runTimeTicks = from.RunTimeTicks { 28 | runtime = Double(runTimeTicks / 10_000_000) 29 | } 30 | 31 | self.init( 32 | id: from.Id, 33 | name: from.Name ?? "Unknown Track", 34 | cover: cover, 35 | favorite: from.UserData?.IsFavorite ?? false, 36 | album: ReducedAlbum( 37 | id: albumId, 38 | name: from.Album, 39 | artists: from.AlbumArtists!.map { ReducedArtist(id: $0.Id, name: $0.Name) } 40 | ), 41 | artists: from.ArtistItems!.map { ReducedArtist(id: $0.Id, name: $0.Name) }, 42 | lufs: from.LUFS, 43 | index: Index(index: from.IndexNumber ?? fallbackIndex, disk: from.ParentIndexNumber ?? 1), 44 | runtime: runtime ?? 0, 45 | playCount: from.UserData?.PlayCount ?? 0, 46 | releaseDate: Date(from.PremiereDate)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Extensions/Cover+Image.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cover+Image.swift 3 | // AmpFin 4 | // 5 | // Created by Rasmus Krämer on 03.09.24 at 15:31. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | #if canImport(UIKit) 12 | import UIKit 13 | #endif 14 | 15 | public extension Cover { 16 | var systemImage: PlatformImage? { 17 | get async { 18 | var request = URLRequest(url: url) 19 | 20 | for header in JellyfinClient.shared.customHTTPHeaders { 21 | request.setValue(header.value, forHTTPHeaderField: header.key) 22 | } 23 | 24 | guard let (data, _) = try? await URLSession.shared.data(for: request) else { 25 | return nil 26 | } 27 | 28 | return PlatformImage(data: data) 29 | } 30 | } 31 | 32 | #if canImport(UIKit) 33 | typealias PlatformImage = UIImage 34 | #endif 35 | } 36 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Extensions/Date+Parse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Parse.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | 10 | internal extension Date { 11 | init?(_ from: String?) { 12 | guard let from = from, let date = from.components(separatedBy: "T").first else { 13 | return nil 14 | } 15 | 16 | let formatter = DateFormatter() 17 | formatter.locale = Locale(identifier: "en_US_POSIX") 18 | formatter.dateFormat = "yyyy-MM-dd" 19 | 20 | guard let timestamp = formatter.date(from: date)?.timeIntervalSince1970 else { 21 | return nil 22 | } 23 | 24 | self.init(timeIntervalSince1970: timestamp) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Extensions/SortOrder+Jellyfin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 21.05.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension ItemSortOrder { 12 | var value: String { 13 | switch self { 14 | case .name: 15 | "Name" 16 | case .album: 17 | "Album,SortName" 18 | case .albumArtist: 19 | "AlbumArtist,Album,SortName" 20 | case .artist: 21 | "Artist,Album,SortName" 22 | case .added: 23 | "DateCreated,SortName" 24 | case .plays: 25 | "PlayCount,SortName" 26 | case .lastPlayed: 27 | "DatePlayed,SortName" 28 | case .released: 29 | "ProductionYear,PremiereDate,SortName" 30 | case .runtime: 31 | "Runtime,AlbumArtist,Album,SortName" 32 | case .random: 33 | "Random" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Extensions/String+Random.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Random.swift 3 | // MusicKit 4 | // 5 | // Created by Rasmus Krämer on 23.12.23. 6 | // 7 | 8 | import Foundation 9 | 10 | internal extension String { 11 | init(length: Int) { 12 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 13 | self.init((0.. ServerVersion? { 20 | let components = version.split(separator: ".") 21 | 22 | guard components.count == 3 else { 23 | return nil 24 | } 25 | 26 | guard let major = Int(components[0]), let minor = Int(components[1]), let patch = Int(components[2]) else { 27 | return nil 28 | } 29 | 30 | return (major, minor, patch) 31 | } 32 | } 33 | 34 | public extension JellyfinClient { 35 | func updateCachedServerVersion() async throws { 36 | let version = try await serverVersion() 37 | let parsed = parseServerVersion(version) 38 | 39 | serverVersion = parsed 40 | Self.defaults.set(serializedLastKnownServerVersion, forKey: "lastKnownServerVersion") 41 | } 42 | 43 | func supports(_ featureFlag: FeatureFlag) -> Bool { 44 | guard let serverVersion else { 45 | return false 46 | } 47 | 48 | switch featureFlag { 49 | case .audioRemuxing: 50 | // Required 10.10+ 51 | return serverVersion.major >= 10 && serverVersion.minor >= 10; 52 | case .sharedPlaylists, .lyrics: 53 | // Required 10.9+ 54 | return serverVersion.major >= 10 && serverVersion.minor >= 9 55 | case .quickConnect: 56 | // Required 10.7+, but AmpFin will only support 10.9+ 57 | return serverVersion.major >= 10 && serverVersion.minor >= 9 58 | } 59 | } 60 | 61 | enum FeatureFlag: Identifiable, Hashable, Codable { 62 | case lyrics 63 | case sharedPlaylists 64 | case quickConnect 65 | case audioRemuxing 66 | 67 | public var id: Self { 68 | self 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/HTTP/JellyfinClient+Headers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JellyfinClient+Headers.swift 3 | // AmpFin 4 | // 5 | // Created by Rasmus Krämer on 03.09.24 at 15:23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension JellyfinClient { 11 | var customHTTPHeaders: [CustomHTTPHeader] { 12 | get { 13 | if let customHTTPHeaders = _customHTTPHeaders { 14 | return customHTTPHeaders 15 | } 16 | 17 | if let object = Self.defaults.object(forKey: "customHTTPHeaders") as? Data, let headers = try? JSONDecoder().decode([CustomHTTPHeader].self, from: object) { 18 | _customHTTPHeaders = headers 19 | return headers 20 | } 21 | 22 | return [] 23 | } 24 | set { 25 | _customHTTPHeaders = newValue 26 | 27 | if let object = try? JSONEncoder().encode(newValue) { 28 | Self.defaults.set(object, forKey: "customHTTPHeaders") 29 | } 30 | } 31 | } 32 | 33 | var customHTTPHeaderDictionary: [String: String] { 34 | customHTTPHeaders.reduce(into: [:]) { result, header in 35 | result[header.key] = header.value 36 | } 37 | } 38 | 39 | struct CustomHTTPHeader: Codable { 40 | public var key: String 41 | public var value: String 42 | 43 | public init(key: String, value: String) { 44 | self.key = key 45 | self.value = value 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/HTTP/JellyfinClient+Utility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiError.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import Foundation 9 | 10 | internal extension JellyfinClient { 11 | struct ClientRequest { 12 | var path: String 13 | var method: String 14 | var body: Any? 15 | var query: [URLQueryItem]? 16 | 17 | var userPrefix = false 18 | // TODO: can be removed in 10.9 19 | var userId = false 20 | 21 | public init(path: String, method: String, body: Any? = nil, query: [URLQueryItem]? = nil, userPrefix: Bool = false, userId: Bool = false) { 22 | self.path = path 23 | self.method = method 24 | self.body = body 25 | self.query = query 26 | self.userPrefix = userPrefix 27 | self.userId = userId 28 | } 29 | } 30 | 31 | struct EmptyResponse: Decodable {} 32 | } 33 | 34 | public extension JellyfinClient { 35 | enum ClientError: Error { 36 | case parseFailed 37 | case unknownMessage 38 | 39 | case invalidServerUrl 40 | case invalidHttpBody 41 | case invalidResponse 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Methods/JellyfinClient+Album.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JellyfinClient+Items.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | private let albumQuery = [ 12 | URLQueryItem(name: "Recursive", value: "true"), 13 | URLQueryItem(name: "ImageTypeLimit", value: "1"), 14 | URLQueryItem(name: "EnableImageTypes", value: "Primary"), 15 | URLQueryItem(name: "IncludeItemTypes", value: "MusicAlbum"), 16 | URLQueryItem(name: "Fields", value: "Genres,Overview,PremiereDate,AlbumArtists,People"), 17 | ] 18 | 19 | public extension JellyfinClient { 20 | func albums(limit: Int, startIndex: Int, sortOrder: ItemSortOrder, ascending: Bool, favoriteOnly: Bool = false, artistId: String? = nil, search: String? = nil) async throws -> ([Album], Int) { 21 | var query = [ 22 | URLQueryItem(name: "SortBy", value: sortOrder.value), 23 | URLQueryItem(name: "SortOrder", value: ascending ? "Ascending" : "Descending"), 24 | ] 25 | 26 | query += albumQuery 27 | 28 | if limit > 0 { 29 | query.append(URLQueryItem(name: "limit", value: String(limit))) 30 | } 31 | if startIndex > 0 { 32 | query.append(URLQueryItem(name: "startIndex", value: String(startIndex))) 33 | } 34 | 35 | if favoriteOnly { 36 | query.append(URLQueryItem(name: "Filters", value: "IsFavorite")) 37 | } 38 | 39 | if let artistId = artistId { 40 | query.append(URLQueryItem(name: "AlbumArtistIds", value: artistId)) 41 | } 42 | if let search = search { 43 | query.append(URLQueryItem(name: "searchTerm", value: search)) 44 | } 45 | 46 | let response = try await request(ClientRequest(path: "Items", method: "GET", query: query, userPrefix: true)) 47 | 48 | return ( 49 | response.Items.map(Album.init), 50 | response.TotalRecordCount 51 | ) 52 | } 53 | 54 | func albums(similarToAlbumId albumId: String) async throws -> [Album] { 55 | var query = [ 56 | URLQueryItem(name: "Fields", value: "Genres,Overview,PremiereDate"), 57 | URLQueryItem(name: "limit", value: String(10)), 58 | ] 59 | 60 | query += albumQuery 61 | 62 | let response = try await request(ClientRequest(path: "Items/\(albumId)/Similar", method: "GET", query: query, userId: true)) 63 | return response.Items.map(Album.init) 64 | } 65 | 66 | func album(identifier: String) async throws -> Album { 67 | let album = try await request(ClientRequest(path: "Items/\(identifier)", method: "GET", query: albumQuery, userPrefix: true)) 68 | return Album(album) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Methods/JellyfinClient+Artist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | public extension JellyfinClient { 12 | func artists(limit: Int, startIndex: Int, albumOnly: Bool, search: String?) async throws -> ([Artist], Int) { 13 | var query = [ 14 | URLQueryItem(name: "SortBy", value: ItemSortOrder.name.value), 15 | URLQueryItem(name: "SortOrder", value: "Ascending") 16 | ] 17 | 18 | if limit > 0 { 19 | query.append(URLQueryItem(name: "limit", value: String(limit))) 20 | } 21 | if startIndex > 0 { 22 | query.append(URLQueryItem(name: "startIndex", value: String(startIndex))) 23 | } 24 | if let search = search { 25 | query.append(URLQueryItem(name: "searchTerm", value: search)) 26 | } 27 | 28 | let response = try await request(ClientRequest(path: albumOnly ? "Artists/AlbumArtists" : "Artists", method: "GET", query: query, userId: true)) 29 | return (response.Items.map(Artist.init), response.TotalRecordCount) 30 | } 31 | 32 | func artists(search: String) async throws -> [Artist] { 33 | let response = try await request(ClientRequest(path: "Artists", method: "GET", query: [ 34 | URLQueryItem(name: "Limit", value: "20"), 35 | URLQueryItem(name: "Recursive", value: "true"), 36 | URLQueryItem(name: "searchTerm", value: search), 37 | ], userId: true)) 38 | 39 | return response.Items.map(Artist.init) 40 | } 41 | 42 | func artist(identifier: String) async throws -> Artist { 43 | guard let artist = try await request(ClientRequest(path: "Items/\(identifier)", method: "GET", userPrefix: true)) else { 44 | throw ClientError.invalidResponse 45 | } 46 | 47 | return .init(artist) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Methods/JellyfinClient+Item.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 25.12.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension JellyfinClient { 11 | func delete(identifier: String) async throws { 12 | let _ = try await request(ClientRequest(path: "Items/\(identifier)", method: "DELETE")) 13 | } 14 | 15 | func favorite(_ favorite: Bool, identifier: String) async throws { 16 | let _ = try await request(ClientRequest(path: "FavoriteItems/\(identifier)", method: favorite ? "POST" : "DELETE", userPrefix: true)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Methods/JellyfinClient+Progress.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JellyfinClient+Progress.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 07.09.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | public extension JellyfinClient { 12 | func playbackStarted(identifier: String, queueIds: [String]) async throws { 13 | let _ = try await request(ClientRequest(path: "sessions/playing", method: "POST", body: [ 14 | "PositionTicks": 0, 15 | "ItemId": identifier, 16 | "NowPlayingQueue": queueIds.enumerated().map { [ 17 | "Id": $1, 18 | "PlaylistItemId": "playlistItem\($0)" 19 | ] } 20 | ])) 21 | } 22 | 23 | func progress(identifier: String, position: Double, paused: Bool, repeatMode: RepeatMode, shuffled: Bool, volume: Float) async throws { 24 | let _ = try await request(ClientRequest(path: "sessions/playing/progress", method: "POST", body: [ 25 | "ItemId": identifier, 26 | "CanSeek": true, 27 | "IsPaused": paused, 28 | "VolumeLevel": Int(volume * 100), 29 | "IsMuted": volume == 0, 30 | "RepeatMode": repeatMode == .queue ? "RepeatAll" : repeatMode == .track ? "RepeatOne" : "RepeatNone", 31 | "ShuffleMode": shuffled ? "Shuffle" : "Sorted", 32 | "PlaybackRate": 1, 33 | "PositionTicks": UInt64(position * 10_000_000), 34 | ])) 35 | } 36 | 37 | func playbackStopped(identifier: String, positionSeconds: Double, playSessionId: String?) async throws { 38 | var requestBody: [String : Any] = [ 39 | "ItemId": identifier, 40 | "PositionTicks": Int64(positionSeconds * 10_000_000), 41 | ] 42 | 43 | if let playSessionId { 44 | requestBody["PlaySessionId"] = playSessionId 45 | } 46 | 47 | let _ = try await request(ClientRequest(path: "Sessions/Playing/Stopped", method: "POST", body: requestBody)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Methods/JellyfinClient+QuickConnect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JellyfinClient+QuickConnect.swift 3 | // AmpFin 4 | // 5 | // Created by Daniel Cuevas on 9/28/24 at 1:39 AM. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension JellyfinClient { 11 | var quickConnectAvailable: Bool { 12 | get async { 13 | guard supports(.quickConnect) else { 14 | return false 15 | } 16 | 17 | return (try? await request(ClientRequest(path: "/QuickConnect/Enabled", method: "GET"))) ?? false 18 | } 19 | } 20 | 21 | func initiateQuickConnect() async throws -> (String, String) { 22 | let response = try await request(ClientRequest(path: "/QuickConnect/initiate", method: "POST")) 23 | return (response.Code, response.Secret) 24 | } 25 | 26 | func verifyQuickConnect(secret: String) async -> Bool { 27 | guard let response = try? await request(ClientRequest(path: "/QuickConnect/Connect", method: "GET", query: [ 28 | URLQueryItem(name: "Secret", value: secret), 29 | ])) else { 30 | return false 31 | } 32 | 33 | return response.Authenticated 34 | } 35 | 36 | func login(secret: String) async throws -> (String, String) { 37 | let response = try await request(ClientRequest(path: "Users/AuthenticateWithQuickConnect", method: "POST", body: [ 38 | "Secret": secret 39 | ])) 40 | 41 | return (response.AccessToken, response.User.Id) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Methods/JellyfinClient+User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 25.12.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension JellyfinClient { 11 | func serverVersion() async throws -> String { 12 | let response = try await request(ClientRequest(path: "system/info/public", method: "GET")) 13 | return response.Version 14 | } 15 | 16 | func login(username: String, password: String) async throws -> (String, String) { 17 | let response = try await request(ClientRequest(path: "Users/authenticatebyname", method: "POST", body: [ 18 | "Username": username, 19 | "Pw": password, 20 | ])) 21 | 22 | return (response.AccessToken, response.User.Id) 23 | } 24 | 25 | func userData() async throws -> (String, String, String, Bool) { 26 | let response = try await request(ClientRequest(path: "/Users/\(userId)", method: "GET")) 27 | 28 | return ( 29 | response.Name, 30 | response.ServerId, 31 | response.Id, 32 | response.HasPassword 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Models/JellyfinItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 15.05.24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal struct JellyfinItem: Codable { 11 | let Id: String 12 | let Name: String? 13 | let SortName: String? 14 | 15 | let Overview: String? 16 | let Genres: [String]? 17 | 18 | let ChildCount: Int? 19 | let RunTimeTicks: UInt64? 20 | 21 | let MediaType: String? 22 | 23 | let PremiereDate: String? 24 | let AlbumArtists: [JellyfinArtist]? 25 | 26 | let UserData: UserData? 27 | let ImageTags: ImageTags? 28 | 29 | let PlaylistItemId: String? 30 | 31 | let IndexNumber: Int? 32 | let ParentIndexNumber: Int? 33 | 34 | let ArtistItems: [JellyfinArtist]? 35 | 36 | let Album: String? 37 | let AlbumId: String? 38 | 39 | // TODO: remove 40 | let AlbumPrimaryImageTag: String? 41 | 42 | let LUFS: Float? 43 | let MediaStreams: [MediaStream]? 44 | } 45 | 46 | internal struct JellyfinItemsResponse: Codable { 47 | let Items: [JellyfinItem] 48 | let TotalRecordCount: Int 49 | } 50 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Models/JellyfinMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 25.12.23. 6 | // 7 | 8 | import Foundation 9 | 10 | internal extension JellyfinWebSocket { 11 | struct Message: Codable { 12 | let MessageType: String 13 | let MessageId: String 14 | 15 | } 16 | 17 | struct PlayStateMessage: Codable { 18 | let Data: MessageData? 19 | 20 | struct MessageData: Codable { 21 | let Command: String? 22 | let SeekPositionTicks: UInt64? 23 | } 24 | } 25 | struct PlayMessage: Codable { 26 | let Data: MessageData? 27 | 28 | struct MessageData: Codable { 29 | let ItemIds: [String]? 30 | let PlayCommand: String? 31 | let StartIndex: Int? 32 | } 33 | } 34 | 35 | struct SessionMessage: Codable { 36 | let Data: [JellyfinSession]? 37 | } 38 | 39 | struct GeneralCommandMessage: Codable { 40 | let Name: String? 41 | let Arguments: Arguments? 42 | 43 | struct Arguments: Codable { 44 | let RepeatMode: String? 45 | let ShuffleMode: String? 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Models/JellyfinSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 15.05.24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal struct JellyfinSession: Codable { 11 | let Id: String 12 | let Client: String 13 | 14 | let DeviceId: String 15 | let DeviceName: String 16 | 17 | let PlayState: JellyfinPlayState 18 | let Capabilities: JellyfinCapabilities 19 | 20 | let NowPlayingItem: JellyfinItem? 21 | // let NowPlayingQueueFullItems: [JellyfinTrackItem] 22 | 23 | struct JellyfinPlayState: Codable { 24 | let PositionTicks: Int64? 25 | let CanSeek: Bool 26 | let IsPaused: Bool 27 | let IsMuted: Bool 28 | let VolumeLevel: Int? 29 | let RepeatMode: String 30 | // only available in 10.9 31 | let PlaybackOrder: String? 32 | } 33 | struct JellyfinCapabilities: Codable { 34 | let PlayableMediaTypes: [String] 35 | let SupportedCommands: [String] 36 | let SupportsMediaControl: Bool 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Models/JellyfinUtility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JellyfinClient+Other.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import Foundation 9 | 10 | internal struct UserData: Codable { 11 | let PlayCount: Int 12 | let IsFavorite: Bool 13 | let LastPlayedDate: String? 14 | } 15 | 16 | internal struct JellyfinArtist: Codable { 17 | let Id: String 18 | let Name: String 19 | } 20 | 21 | internal struct ImageTags: Codable { 22 | let Primary: String? 23 | } 24 | 25 | internal struct MediaStream: Codable { 26 | let `Type`: String? 27 | 28 | let Codec: String? 29 | let BitRate: Int? 30 | let BitDepth: Int? 31 | let Channels: Int? 32 | let SampleRate: Int? 33 | } 34 | 35 | public enum PlayStateCommand: String { 36 | case play = "Unpause" 37 | case pause = "Pause" 38 | case next = "NextTrack" 39 | case previous = "PreviousTrack" 40 | case stop = "Stop" 41 | } 42 | 43 | public enum PlayCommand: String { 44 | case next = "PlayNext" 45 | case last = "PlayLast" 46 | } 47 | 48 | // MARK: Responses 49 | 50 | internal struct PublicServerInfoResponse: Codable { 51 | let LocalAddress: String 52 | let ServerName: String 53 | let Version: String 54 | let ProductName: String 55 | let OperatingSystem: String 56 | let Id: String 57 | let StartupWizardCompleted: Bool 58 | } 59 | 60 | internal struct PublicServerInfoV10_6Response: Codable { 61 | let LocalAddress: String 62 | let ServerName: String 63 | let Version: String 64 | let ProductName: String 65 | let OperatingSystem: String 66 | let Id: String 67 | } 68 | 69 | 70 | 71 | internal struct AuthenticateByNameOrQuickConnectResponse: Codable { 72 | let AccessToken: String 73 | let User: User 74 | 75 | struct User: Codable { 76 | let Id: String 77 | } 78 | } 79 | 80 | internal struct UserDataResponse: Codable { 81 | let Name: String 82 | let ServerId: String 83 | let Id: String 84 | let HasPassword: Bool 85 | } 86 | 87 | internal struct LyricsResponse: Codable { 88 | let Lyrics: [Line] 89 | 90 | struct Line: Codable { 91 | let Start: Int64 92 | let Text: String 93 | } 94 | } 95 | 96 | internal struct QuickConnectResponse: Codable { 97 | let AppName: String 98 | let AppVersion: String 99 | let Authenticated: Bool 100 | let Code: String 101 | let DateAdded: String 102 | let DeviceId: String 103 | let DeviceName: String 104 | let Secret: String 105 | } 106 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Websocket/JellyfinWebSocket+Notifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 25.12.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension JellyfinWebSocket { 11 | static let disconnectedNotification = NSNotification.Name("io.rfk.ampfin.socket.disconnect") 12 | 13 | static let playCommandIssuedNotification = NSNotification.Name("io.rfk.ampfin.socket.command.play") 14 | static let playStateCommandIssuedNotification = NSNotification.Name("io.rfk.ampfin.socket.command.playState") 15 | 16 | static let sessionUpdateNotification = NSNotification.Name("io.rfk.ampfin.socket.session.update") 17 | } 18 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Websocket/JellyfinWebSocket.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JellyfinWebSocket.swift 3 | // MusicKit 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import Starscream 10 | import OSLog 11 | 12 | @Observable 13 | public final class JellyfinWebSocket { 14 | var socket: WebSocket! 15 | public internal(set) var connected = false 16 | 17 | var reconnectTimeout: UInt64 = 5 18 | var reconnectTask: Task<(), Error>? 19 | 20 | var observedClientId: String? 21 | 22 | let logger = Logger(subsystem: "io.rfk.ampfin", category: "WebSocket") 23 | 24 | private init() {} 25 | } 26 | 27 | public extension JellyfinWebSocket { 28 | static let shared = JellyfinWebSocket() 29 | } 30 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFNetwork/Websocket/WebSocket+Methods.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 21.05.24. 6 | // 7 | 8 | import Foundation 9 | import Starscream 10 | 11 | public extension JellyfinWebSocket { 12 | func connect() { 13 | guard let serverUrl = JellyfinClient.shared.serverUrl, let token = JellyfinClient.shared._token else { 14 | return 15 | } 16 | 17 | var request = URLRequest(url: serverUrl.appending(path: "socket").appending(queryItems: [ 18 | URLQueryItem(name: "apiKey", value: token), 19 | URLQueryItem(name: "deviceId", value: JellyfinClient.shared.clientId), 20 | ])) 21 | request.timeoutInterval = 5 22 | 23 | socket = WebSocket(request: request) 24 | socket.delegate = self 25 | socket.connect() 26 | } 27 | func reconnect(resetTimer: Bool) { 28 | if resetTimer { 29 | reconnectTimeout = 5 30 | } else { 31 | reconnectTimeout = min(reconnectTimeout * 2, 60) 32 | } 33 | 34 | NotificationCenter.default.post(name: Self.disconnectedNotification, object: nil) 35 | 36 | logger.info("Reconnecting WebSocket in \(self.reconnectTimeout) seconds") 37 | 38 | socket.disconnect() 39 | connected = false 40 | 41 | reconnectTask = Task { 42 | try await Task.sleep(nanoseconds: reconnectTimeout * NSEC_PER_SEC) 43 | logger.info("Attempting WebSocket reconnect") 44 | 45 | socket.connect() 46 | } 47 | } 48 | 49 | func beginObservingSessionUpdated(clientId: String) { 50 | observedClientId = clientId 51 | socket.write(string: "{\"MessageType\":\"SessionsStart\",\"Data\":\"100,800\"}") 52 | } 53 | func stopObservingSessionUpdated() { 54 | observedClientId = nil 55 | socket.write(string: "{\"MessageType\":\"SessionsStop\"}") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Extensions/Album+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File 2.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Album { 12 | convenience init(_ from: OfflineAlbum) { 13 | self.init( 14 | id: from.id, 15 | name: from.name, 16 | cover: Cover(type: .local, size: .normal, url: DownloadManager.shared.coverURL(parentId: from.id)), 17 | favorite: from.favorite, 18 | overview: from.overview, 19 | genres: from.genres, 20 | releaseDate: from.released, 21 | artists: from.artists.map { .init(id: $0.artistIdentifier, name: $0.artistName) }, 22 | playCount: -1, 23 | lastPlayed: nil) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Extensions/Playlist+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 02.01.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Playlist { 12 | convenience init(_ from: OfflinePlaylist) { 13 | self.init( 14 | id: from.id, 15 | name: from.name, 16 | cover: Cover(type: .local, size: .normal, url: DownloadManager.shared.coverURL(parentId: from.id)), 17 | favorite: from.favorite, 18 | duration: from.duration, 19 | trackCount: from.trackCount) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Extensions/Track+Convert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Track { 12 | convenience init(_ from: OfflineTrack) { 13 | self.init( 14 | id: from.id, 15 | name: from.name, 16 | cover: Cover(type: .local, size: .normal, url: DownloadManager.shared.coverURL(parentId: from.album.albumIdentifier)), 17 | favorite: from.favorite, 18 | album: ReducedAlbum( 19 | id: from.album.albumIdentifier, 20 | name: from.album.albumName, 21 | artists: from.album.albumArtists.map { .init(id: $0.artistIdentifier, name: $0.artistName) }), 22 | artists: from.artists.map { .init(id: $0.artistIdentifier, name: $0.artistName) }, 23 | lufs: nil, 24 | index: Index(index: 0, disk: 0), 25 | runtime: from.runtime, 26 | playCount: -1, 27 | releaseDate: from.released) 28 | } 29 | 30 | convenience init(_ from: OfflineTrack, parent: OfflineParent) { 31 | self.init( 32 | id: from.id, 33 | name: from.name, 34 | cover: Cover(type: .local, size: .normal, url: DownloadManager.shared.coverURL(parentId: from.album.albumIdentifier)), 35 | favorite: from.favorite, 36 | album: ReducedAlbum( 37 | id: from.album.albumIdentifier, 38 | name: from.album.albumName, 39 | artists: from.album.albumArtists.map { .init(id: $0.artistIdentifier, name: $0.artistName) }), 40 | artists: from.artists.map { .init(id: $0.artistIdentifier, name: $0.artistName) }, 41 | lufs: nil, 42 | index: Track.Index(index: (parent.childrenIdentifiers.firstIndex(of: from.id) ?? -1) + 1, disk: 0), 43 | runtime: from.runtime, 44 | playCount: -1, 45 | releaseDate: from.released) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Filesystem/DownloadManager+Cleanup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManager+Cleanup.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 27.09.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension DownloadManager { 11 | func cleanupDirectory() throws { 12 | let contents = try FileManager.default.contentsOfDirectory(at: documents, includingPropertiesForKeys: nil) 13 | 14 | for entity in contents { 15 | try FileManager.default.removeItem(at: entity) 16 | } 17 | 18 | createDirectories() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Filesystem/DownloadManager+Parent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManager+Download.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | extension DownloadManager { 12 | func coverDownloaded(parentId: String) -> Bool { 13 | FileManager.default.fileExists(atPath: coverURL(parentId: parentId).absoluteString) 14 | } 15 | 16 | func downloadCover(parentId: String, cover: Cover) async throws { 17 | let request = URLRequest(url: cover.url) 18 | 19 | let (location, _) = try await URLSession.shared.download(for: request) 20 | var destination = coverURL(parentId: parentId) 21 | var values = URLResourceValues() 22 | 23 | values.isExcludedFromBackup = true 24 | try? destination.setResourceValues(values) 25 | 26 | try FileManager.default.moveItem(at: location, to: destination) 27 | } 28 | 29 | func deleteCover(parentId: String) throws { 30 | try FileManager.default.removeItem(at: coverURL(parentId: parentId)) 31 | } 32 | 33 | func coverURL(parentId: String) -> URL { 34 | covers.appending(path: "\(parentId).png") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Filesystem/DownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManager.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | public final class DownloadManager: NSObject { 12 | var urlSession: URLSession! 13 | 14 | let tracks: URL 15 | let covers: URL 16 | 17 | let documents: URL 18 | 19 | let logger = Logger(subsystem: "io.rfk.ampfin", category: "Download") 20 | 21 | override init() { 22 | documents = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) 23 | 24 | tracks = documents.appending(path: "tracks") 25 | covers = documents.appending(path: "covers") 26 | 27 | super.init() 28 | 29 | createDirectories() 30 | 31 | let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background") 32 | config.isDiscretionary = false 33 | config.sessionSendsLaunchEvents = true 34 | 35 | urlSession = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue()) 36 | } 37 | 38 | func createDirectories() { 39 | try! FileManager.default.createDirectory(at: covers, withIntermediateDirectories: true) 40 | try! FileManager.default.createDirectory(at: tracks, withIntermediateDirectories: true) 41 | 42 | var tracks = tracks 43 | var covers = covers 44 | 45 | var excludedFromBackupResourceValues = URLResourceValues() 46 | excludedFromBackupResourceValues.isExcludedFromBackup = true 47 | 48 | try? tracks.setResourceValues(excludedFromBackupResourceValues) 49 | try? covers.setResourceValues(excludedFromBackupResourceValues) 50 | } 51 | } 52 | 53 | public extension DownloadManager { 54 | static let shared = DownloadManager() 55 | } 56 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/ItemOfflineTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | import OSLog 11 | 12 | @Observable 13 | public final class ItemOfflineTracker { 14 | let itemId: String 15 | let itemType: Item.ItemType 16 | 17 | var _status: OfflineStatus? = nil 18 | var token: Any? = nil 19 | 20 | let logger = Logger(subsystem: "io.rfk.ampfin", category: "Item") 21 | 22 | init(itemId: String, itemType: Item.ItemType) { 23 | self.itemId = itemId 24 | self.itemType = itemType 25 | } 26 | 27 | deinit { 28 | if let token = token { 29 | NotificationCenter.default.removeObserver(token) 30 | } 31 | } 32 | } 33 | 34 | public extension ItemOfflineTracker { 35 | var status: OfflineStatus { 36 | get { 37 | if _status == nil { 38 | token = NotificationCenter.default.addObserver(forName: OfflineManager.itemDownloadStatusChanged, object: nil, queue: nil) { [weak self] notification in 39 | if notification.object as? String == self?.itemId { 40 | guard let status = self?.checkOfflineStatus() else { 41 | return 42 | } 43 | 44 | if self?._status != status { 45 | self?._status = status 46 | } 47 | } 48 | } 49 | 50 | _status = checkOfflineStatus() 51 | 52 | logger.info("Enabled offline tracking for \(self.itemId)") 53 | } 54 | 55 | return _status! 56 | } 57 | } 58 | 59 | enum OfflineStatus: Equatable { 60 | case none 61 | case working 62 | case downloaded 63 | } 64 | } 65 | 66 | internal extension ItemOfflineTracker { 67 | func checkOfflineStatus() -> OfflineStatus { 68 | if itemType == .track { 69 | return OfflineManager.shared.offlineStatus(trackId: itemId) 70 | } else if itemType == .album { 71 | return OfflineManager.shared.offlineStatus(albumId: itemId) 72 | } else if itemType == .playlist { 73 | return OfflineManager.shared.offlineStatus(playlistId: itemId) 74 | } 75 | 76 | return .none 77 | } 78 | } 79 | 80 | public extension Item { 81 | var offlineTracker: ItemOfflineTracker { 82 | ItemOfflineTracker(itemId: id, itemType: type) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Models/Helper/OfflineParent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 02.01.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal protocol OfflineParent { 12 | var id: String { get } 13 | var childrenIdentifiers: [String] { get set } 14 | } 15 | 16 | internal extension OfflineParent { 17 | var trackCount: Int { 18 | childrenIdentifiers.count 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Models/Helper/Reduced.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 03.06.24. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | internal extension Track { 12 | struct OfflineReducedAlbum: Codable { 13 | let albumIdentifier: String 14 | let albumName: String? 15 | 16 | let albumArtists: [Item.OfflineReducedArtist] 17 | } 18 | } 19 | 20 | internal extension Item { 21 | struct OfflineReducedArtist: Codable { 22 | let artistIdentifier: String 23 | let artistName: String 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Models/OfflineAlbum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OfflineAlbum.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import AFFoundation 11 | 12 | @Model 13 | final class OfflineAlbumV2: OfflineParent { 14 | @Attribute(.unique) 15 | let id: String 16 | let name: String 17 | 18 | let overview: String? 19 | let genres: [String] 20 | 21 | let released: Date? 22 | let artists: [Item.OfflineReducedArtist] 23 | 24 | var favorite: Bool 25 | var lastPlayed: Date? 26 | 27 | var childrenIdentifiers: [String] 28 | 29 | init(id: String, name: String, overview: String?, genres: [String], released: Date?, artists: [Item.OfflineReducedArtist], favorite: Bool, childrenIdentifiers: [String]) { 30 | self.id = id 31 | self.name = name 32 | self.overview = overview 33 | self.genres = genres 34 | self.released = released 35 | self.artists = artists 36 | self.favorite = favorite 37 | self.childrenIdentifiers = childrenIdentifiers 38 | } 39 | } 40 | 41 | internal typealias OfflineAlbum = OfflineAlbumV2 42 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Models/OfflinePlaylist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 02.01.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | @Model 12 | final internal class OfflinePlaylistV2: OfflineParent { 13 | @Attribute(.unique) 14 | let id: String 15 | let name: String 16 | 17 | var favorite: Bool 18 | var duration: Double 19 | 20 | var childrenIdentifiers: [String] 21 | 22 | var lastPlayed: Date? 23 | 24 | init(id: String, name: String, favorite: Bool, duration: Double, childrenIdentifiers: [String]) { 25 | self.id = id 26 | self.name = name 27 | self.favorite = favorite 28 | self.duration = duration 29 | self.childrenIdentifiers = childrenIdentifiers 30 | } 31 | } 32 | 33 | internal typealias OfflinePlaylist = OfflinePlaylistV2 34 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Models/OfflineTrack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OfflineTrack.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import AFFoundation 11 | 12 | @Model 13 | final internal class OfflineTrackV2 { 14 | @Attribute(.unique) 15 | let id: String 16 | let name: String 17 | 18 | let released: Date? 19 | 20 | let album: Track.OfflineReducedAlbum 21 | let artists: [Item.OfflineReducedArtist] 22 | 23 | var favorite: Bool 24 | let runtime: Double 25 | 26 | var container: Container! 27 | var downloadId: Int? 28 | 29 | var lastPlayed: Date? 30 | 31 | init(id: String, name: String, released: Date?, album: Track.OfflineReducedAlbum, artists: [Item.OfflineReducedArtist], favorite: Bool, runtime: Double, downloadId: Int? = nil) { 32 | self.id = id 33 | self.name = name 34 | self.album = album 35 | self.released = released 36 | self.artists = artists 37 | self.favorite = favorite 38 | self.downloadId = downloadId 39 | self.runtime = runtime 40 | 41 | container = nil 42 | } 43 | } 44 | 45 | internal extension OfflineTrackV2 { 46 | enum Container: String, Codable { 47 | case aac = "aac" 48 | case m4a = "m4a" 49 | case mp3 = "mp3" 50 | case wav = "wav" 51 | case aiff = "aiff" 52 | case flac = "flac" 53 | case webma = "webma" 54 | } 55 | } 56 | 57 | internal typealias OfflineTrack = OfflineTrackV2 58 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Models/Utility/OfflineFavorite.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OfflineFavorite.swift 3 | // 4 | // Created by Rasmus Krämer on 19.11.23. 5 | // 6 | 7 | import Foundation 8 | import SwiftData 9 | 10 | @Model 11 | final internal class OfflineFavoriteV2 { 12 | @Attribute(.unique) 13 | let itemIdentifier: String 14 | var value: Bool 15 | 16 | init(itemIdentifier: String, value: Bool) { 17 | self.itemIdentifier = itemIdentifier 18 | self.value = value 19 | } 20 | } 21 | 22 | internal extension OfflineFavoriteV2 { 23 | @Attribute(.unique) 24 | var id: String { 25 | itemIdentifier 26 | } 27 | } 28 | 29 | internal typealias OfflineFavorite = OfflineFavoriteV2 30 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Models/Utility/OfflineLyrics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OfflineLyrics.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 20.10.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import AFFoundation 11 | 12 | @Model 13 | final internal class OfflineLyricsV2 { 14 | @Attribute(.unique) 15 | let trackIdentifier: String 16 | let contents: Track.Lyrics 17 | 18 | init(trackIdentifier: String, contents: Track.Lyrics) { 19 | self.trackIdentifier = trackIdentifier 20 | self.contents = contents 21 | } 22 | } 23 | 24 | internal extension OfflineLyricsV2 { 25 | @Attribute(.unique) 26 | var id: String { 27 | trackIdentifier 28 | } 29 | } 30 | 31 | internal typealias OfflineLyrics = OfflineLyricsV2 32 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/Models/Utility/OfflinePlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OfflinePlay.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 24.09.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | @Model 12 | final internal class OfflinePlayV2 { 13 | let trackIdentifier: String 14 | let position: Double 15 | let date: Date 16 | 17 | public init(trackIdentifier: String, position: Double, date: Date) { 18 | self.trackIdentifier = trackIdentifier 19 | self.position = position 20 | self.date = date 21 | } 22 | } 23 | 24 | internal typealias OfflinePlay = OfflinePlayV2 25 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/OfflineManager/OfflineManager+Artist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 06.01.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import AFFoundation 11 | 12 | // MARK: Public (Higher Order) 13 | 14 | public extension OfflineManager { 15 | func tracks(artistId identifier: String) throws -> [Track] { 16 | let context = ModelContext(PersistenceManager.shared.modelContainer) 17 | let descriptor = FetchDescriptor(predicate: #Predicate { $0.artists.contains(where: { $0.artistIdentifier == identifier }) }) 18 | let tracks = try context.fetch(descriptor) 19 | 20 | return tracks.map(Track.init) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/OfflineManager/OfflineManager+Parent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 02.01.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import AFFoundation 11 | 12 | // MARK: Internal (Helper) 13 | 14 | internal extension OfflineManager { 15 | func offlineTracks(parent: OfflineParent, context: ModelContext) throws -> [OfflineTrack] { 16 | var tracks = try parent.childrenIdentifiers.map { try offlineTrack(trackId: $0, context: context) } 17 | 18 | tracks.sort { 19 | let lhs = parent.childrenIdentifiers.firstIndex(of: $0.id)! 20 | let rhs = parent.childrenIdentifiers.firstIndex(of: $1.id)! 21 | 22 | return lhs < rhs 23 | } 24 | 25 | return tracks 26 | } 27 | 28 | func parentIds(childId: String, context: ModelContext) throws -> [String] { 29 | var parents = [OfflineParent]() 30 | 31 | parents += try offlineAlbums(context: context) 32 | parents += try offlinePlaylists(context: context) 33 | 34 | return parents.filter { $0.childrenIdentifiers.contains(childId) }.map { $0.id } 35 | } 36 | 37 | func downloadInProgress(parent: OfflineParent, context: ModelContext) throws -> Bool { 38 | try offlineTracks(parent: parent, context: context).reduce(false) { $1.downloadId == nil ? $0 : true } 39 | } 40 | 41 | func reduceToChildrenIdentifiers(parents: [OfflineParent]) -> Set { 42 | var result = Set() 43 | 44 | for parent in parents { 45 | for trackId in parent.childrenIdentifiers { 46 | result.insert(trackId) 47 | } 48 | } 49 | 50 | return result 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/OfflineManager/OfflineManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OfflineManager.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import OSLog 11 | 12 | public struct OfflineManager { 13 | static let logger = Logger(subsystem: "io.rfk.ampfin", category: "Offline") 14 | 15 | public static let itemDownloadStatusChanged = Notification.Name.init("io.rfk.ampfin.download.updated") 16 | 17 | private init() {} 18 | 19 | public func cache(position: Double, trackId: String) { 20 | let context = ModelContext(PersistenceManager.shared.modelContainer) 21 | let play = OfflinePlay(trackIdentifier: trackId, position: position, date: Date()) 22 | 23 | context.insert(play) 24 | } 25 | } 26 | 27 | internal extension OfflineManager { 28 | enum OfflineError: Error { 29 | case notFound 30 | } 31 | } 32 | 33 | public extension OfflineManager { 34 | static let shared = OfflineManager() 35 | } 36 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFOffline/PersistenceManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistenceManager.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import AFFoundation 11 | 12 | public struct PersistenceManager { 13 | public let modelContainer: ModelContainer 14 | 15 | private init() { 16 | let schema = Schema([ 17 | OfflineTrack.self, 18 | OfflineAlbum.self, 19 | OfflinePlaylist.self, 20 | 21 | OfflineLyrics.self, 22 | OfflinePlay.self, 23 | OfflineFavorite.self, 24 | ], version: .init(2, 0, 0)) 25 | 26 | let modelConfiguration = ModelConfiguration("AmpFin_Migrated", schema: schema, isStoredInMemoryOnly: false, allowsSave: true, groupContainer: AFKIT_ENABLE_ALL_FEATURES ? .identifier("group.io.rfk.ampfin") : .none) 27 | modelContainer = try! ModelContainer(for: schema, configurations: [modelConfiguration]) 28 | } 29 | } 30 | 31 | public extension PersistenceManager { 32 | static let shared = PersistenceManager() 33 | } 34 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/AudioEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | protocol AudioEndpoint { 12 | var playing: Bool { get set } 13 | var volume: Float { get set } 14 | 15 | var duration: Double { get } 16 | var currentTime: Double { get set } 17 | 18 | var history: [Track] { get } 19 | var nowPlaying: Track? { get } 20 | 21 | var queue: [Track] { get } 22 | var infiniteQueue: [Track]? { get } 23 | 24 | var buffering: Bool { get } 25 | 26 | var shuffled: Bool { get set } 27 | var repeatMode: RepeatMode { get set } 28 | 29 | var mediaInfo: Track.MediaInfo? { get async } 30 | var outputRoute: AudioPlayer.AudioRoute { get } 31 | 32 | var allowQueueLater: Bool { get } 33 | 34 | func seek(to seconds: Double) async 35 | 36 | func startPlayback(tracks: [Track], startIndex: Int, shuffle: Bool) 37 | func stopPlayback() 38 | 39 | func advance() 40 | func rewind() 41 | 42 | func removePlayed(at index: Int) 43 | 44 | func remove(at index: Int) -> Track? 45 | func queue(_ track: Track, after index: Int, updateUnalteredQueue: Bool) 46 | func queue(_ tracks: [Track], after index: Int) 47 | 48 | func move(from index: Int, to destination: Int) 49 | 50 | func skip(to index: Int) 51 | func restorePlayed(upTo index: Int) 52 | } 53 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/AudioPlayer/AudioPlayer+Notifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 10.07.24. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension AudioPlayer { 11 | static let trackDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.track") 12 | static let playingDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.playing") 13 | 14 | static let bufferingDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.buffering") 15 | static let playbackInfoDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.playbackInfo") 16 | 17 | static let timeDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.time") 18 | static let queueDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.queue") 19 | static let queueModeDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.queueMode") 20 | 21 | static let volumeDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.volume") 22 | static let bitrateDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.bitrate") 23 | 24 | static let routeDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.route") 25 | static let sourceDidChangeNotification = Notification.Name("io.rfk.ampfin.audioPlayer.updates.source") 26 | 27 | static let forwardsNotification = Notification.Name("io.rfk.ampfin.audioPlayer.forwards") 28 | static let backwardsNotification = Notification.Name("io.rfk.ampfin.audioPlayer.backwards") 29 | } 30 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/AudioPlayer/Private/AudioPlayer+Helper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 22.05.24. 6 | // 7 | 8 | import Foundation 9 | import AVKit 10 | 11 | internal extension AudioPlayer { 12 | @available(macOS, unavailable) 13 | static func setupAudioSession() { 14 | do { 15 | try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) 16 | } catch { 17 | logger.fault("Failed to setup audio session") 18 | } 19 | } 20 | 21 | @available(macOS, unavailable) 22 | static func updateAudioSession(active: Bool) { 23 | do { 24 | #if !os(macOS) 25 | try AVAudioSession.sharedInstance().setActive(active) 26 | #endif 27 | try AVAudioSession.sharedInstance().setSupportsMultichannelContent(true) 28 | } catch { 29 | logger.fault("Failed to update audio session") 30 | } 31 | 32 | #if os(visionOS) 33 | do { 34 | try AVAudioSession.sharedInstance().setIntendedSpatialExperience(.fixed(soundStageSize: .large)) 35 | } catch { 36 | logger.fault("Failed to set immersive soundstage") 37 | } 38 | #endif 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/Extensions/AVAssetTrack+Format.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AVAssetTrack+Format.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 01.11.23. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | 11 | internal extension AVAssetTrack { 12 | func mediaFormat() async -> String? { 13 | if let descriptions = try? await load(.formatDescriptions), let first = descriptions.first { 14 | return CMFormatDescriptionGetMediaSubType(first).toString() 15 | } 16 | 17 | return nil 18 | } 19 | } 20 | 21 | private extension FourCharCode { 22 | func toString() -> String { 23 | let bytes: [CChar] = [ 24 | CChar((self >> 24) & 0xff), 25 | CChar((self >> 16) & 0xff), 26 | CChar((self >> 8) & 0xff), 27 | CChar(self & 0xff), 28 | 0 29 | ] 30 | let result = String(cString: bytes) 31 | let characterSet = CharacterSet.whitespaces 32 | return result.trimmingCharacters(in: characterSet) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/Extensions/Default+Keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 05.06.24. 6 | // 7 | 8 | import Foundation 9 | import Defaults 10 | import AFFoundation 11 | 12 | extension Defaults.Keys { 13 | static let repeatMode = Key("repeatMode", default: RepeatMode.none) 14 | 15 | static let maxDownloadBitrate = Key("bitrate_downloads", default: -1) 16 | static let maxStreamingBitrate = Key("bitrate_streaming", default: -1) 17 | static let maxConstrainedBitrate = Key("bitrate_constrained", default: -1) 18 | static let defaultBTDeviceIcon = Key("defaultBTDeviceIcon", default: "hifispeaker") 19 | } 20 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/Extensions/Item+Mix.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 24.12.23. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | import AFNetwork 11 | 12 | public extension Item { 13 | func startInstantMix() async throws { 14 | let tracks = try await JellyfinClient.shared.tracks(instantMixBaseId: id) 15 | AudioPlayer.current.startPlayback(tracks: tracks, startIndex: 0, shuffle: false, playbackInfo: .init(container: self)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/Extensions/MPVolumeView+Volume.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MPVolumeView+Volume.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 07.09.23. 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 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/Extensions/RepeatMode+Next.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepeatMode+Next.swift 3 | // AmpFin 4 | // 5 | // Created by Rasmus Krämer on 15.08.24 at 11:08. 6 | // 7 | 8 | import Foundation 9 | import AFFoundation 10 | 11 | public extension RepeatMode { 12 | var next: RepeatMode { 13 | switch self { 14 | case .none: 15 | return .queue 16 | case .queue: 17 | return .track 18 | case .track: 19 | if AudioPlayer.current.infiniteQueue == nil { 20 | return .none 21 | } 22 | 23 | return .infinite 24 | case .infinite: 25 | return .none 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/LocalAudioEndpoint/PlaybackReporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaybackReporter.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 24.09.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import OSLog 11 | import AFFoundation 12 | import AFNetwork 13 | 14 | #if canImport(AFOffline) 15 | import AFOffline 16 | #endif 17 | 18 | public final class PlaybackReporter { 19 | let trackId: String 20 | var playSessionId: String 21 | 22 | var currentTime: Double = 0 23 | 24 | init(trackId: String, playSessionId: String, queue: [Track]) { 25 | self.trackId = trackId 26 | self.playSessionId = playSessionId 27 | 28 | Task { 29 | try? await JellyfinClient.shared.playbackStarted(identifier: trackId, queueIds: queue.map { $0.id }) 30 | } 31 | } 32 | deinit { 33 | PlaybackReporter.playbackStopped(trackId: trackId, currentTime: currentTime, playSessionId: playSessionId) 34 | } 35 | 36 | func update(positionSeconds: Double, paused: Bool, repeatMode: RepeatMode, shuffled: Bool, volume: Float, scheduled: Bool) { 37 | guard positionSeconds.isFinite && !positionSeconds.isNaN && positionSeconds > 0 else { 38 | return 39 | } 40 | 41 | currentTime = positionSeconds 42 | 43 | if scheduled { 44 | if paused { 45 | return 46 | } 47 | 48 | if Int(positionSeconds) % 20 != 0 { 49 | return 50 | } 51 | } 52 | 53 | Task { 54 | try? await JellyfinClient.shared.progress( 55 | identifier: trackId, 56 | position: currentTime, 57 | paused: paused, 58 | repeatMode: repeatMode, 59 | shuffled: shuffled, 60 | volume: volume) 61 | } 62 | } 63 | } 64 | 65 | extension PlaybackReporter { 66 | static func playbackStopped(trackId: String, currentTime: Double, playSessionId: String?) { 67 | Task { 68 | do { 69 | try await JellyfinClient.shared.playbackStopped(identifier: trackId, positionSeconds: currentTime, playSessionId: playSessionId) 70 | } catch { 71 | #if canImport(AFOffline) 72 | OfflineManager.shared.cache(position: currentTime, trackId: trackId) 73 | #endif 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /AmpFinKit/Sources/AFPlayback/RemoteAudioEndpoint/silence.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/AmpFinKit/Sources/AFPlayback/RemoteAudioEndpoint/silence.wav -------------------------------------------------------------------------------- /AmpFinKit/Sources/AmpFinKit/AmpFinKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 23.01.24. 6 | // 7 | 8 | @_exported import AFFoundation 9 | @_exported import AFExtension 10 | 11 | @_exported import AFNetwork 12 | 13 | #if canImport(AFOffline) 14 | @_exported import AFOffline 15 | #endif 16 | -------------------------------------------------------------------------------- /Configuration/Base.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Base.xcconfig 3 | // AmpFin 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 = 1.6.5 12 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = ENABLE_ALL_FEATURES 13 | -------------------------------------------------------------------------------- /Configuration/Debug.xcconfig.template: -------------------------------------------------------------------------------- 1 | // 2 | // Debug.xcconfig 3 | // AmpFin 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 | 14 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG ENABLE_ALL_FEATURES 15 | 16 | DEVELOPMENT_TEAM = ABC123456 17 | BUNDLE_ID_PREFIX = me.change 18 | -------------------------------------------------------------------------------- /Configuration/Release.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Config.xcconfig 3 | // AmpFin 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 | -------------------------------------------------------------------------------- /Multiplatform/Account/CustomHeaderEditView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeaderEditView.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 03.09.24. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | internal struct CustomHeaderEditView: View { 12 | @State private var current = JellyfinClient.shared.customHTTPHeaders 13 | 14 | private var trimmed: [JellyfinClient.CustomHTTPHeader] { 15 | current.filter { !$0.key.isEmpty && !$0.value.isEmpty } 16 | } 17 | 18 | var body: some View { 19 | List { 20 | ForEach(Array(current.enumerated()), id: \.offset) { (index, pair) in 21 | Section { 22 | Group { 23 | TextField("login.customHTTPHeaders.key", text: .init(get: { pair.key }, set: { current[index].key = $0 })) 24 | TextField("login.customHTTPHeaders.value", text: .init(get: { pair.value }, set: { current[index].value = $0 })) 25 | } 26 | .fontDesign(.monospaced) 27 | .autocorrectionDisabled() 28 | .textInputAutocapitalization(.never) 29 | 30 | Button(role: .destructive) { 31 | current.remove(at: index) 32 | } label: { 33 | Text("login.customHTTPHeaders.remove") 34 | } 35 | } 36 | } 37 | } 38 | .navigationTitle("login.customHTTPHeaders") 39 | .navigationBarTitleDisplayMode(.inline) 40 | .toolbar { 41 | ToolbarItemGroup(placement: .topBarTrailing) { 42 | Button { 43 | current.append(.init(key: "", value: "")) 44 | } label: { 45 | Label("login.customHTTPHeaders.add", systemImage: "plus") 46 | .labelStyle(.iconOnly) 47 | } 48 | 49 | Button { 50 | JellyfinClient.shared.customHTTPHeaders = trimmed 51 | } label: { 52 | Label("login.customHTTPHeaders.save", systemImage: "checkmark") 53 | .labelStyle(.titleOnly) 54 | } 55 | } 56 | } 57 | .onAppear { 58 | if current.isEmpty { 59 | current.append(.init(key: "", value: "")) 60 | } 61 | } 62 | } 63 | } 64 | 65 | #Preview { 66 | NavigationStack { 67 | CustomHeaderEditView() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Multiplatform/Album/AlbumLoadView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumLoadView.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 09.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct AlbumLoadView: View { 12 | @Environment(\.dismiss) private var dismiss 13 | @Environment(\.libraryDataProvider) private var dataProvider 14 | 15 | let albumId: String 16 | 17 | @State private var album: Album? 18 | @State private var failed = false 19 | 20 | @State private var didPost = false 21 | 22 | var body: some View { 23 | if failed { 24 | ErrorView() 25 | .onAppear { 26 | if dataProvider.albumNotFoundFallbackToLibrary && !didPost { 27 | dismiss() 28 | Navigation.navigate(albumId: albumId) 29 | 30 | didPost = true 31 | } 32 | } 33 | .refreshable { await loadAlbum() } 34 | } else if let album { 35 | AlbumView(album: album) 36 | } else { 37 | LoadingView() 38 | .task { await loadAlbum() } 39 | .refreshable { await loadAlbum() } 40 | } 41 | } 42 | 43 | private func loadAlbum() async { 44 | guard let album = try? await dataProvider.album(identifier: albumId) else { 45 | failed = true 46 | return 47 | } 48 | 49 | self.album = album 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Multiplatform/Album/AlbumView+Additional.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumView+Additional.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 17.10.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | internal extension AlbumView { 12 | struct AdditionalAlbums: View { 13 | @Environment(AlbumViewModel.self) private var albumViewModel 14 | 15 | var body: some View { 16 | if let first = albumViewModel.album.artists.first, !albumViewModel.albumsReleasedSameArtist.isEmpty { 17 | AlbumRow(title: String(localized: "album.similar \(first.name)"), albums: albumViewModel.albumsReleasedSameArtist, displayContext: .artist) 18 | .padding(.vertical, 12) 19 | } 20 | 21 | if !albumViewModel.similarAlbums.isEmpty { 22 | AlbumRow(title: String(localized: "album.similar"), albums: albumViewModel.similarAlbums) 23 | .padding(.vertical, 12) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Multiplatform/Album/AlbumView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumView.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | import AFPlayback 11 | 12 | struct AlbumView: View { 13 | @Environment(\.colorScheme) private var colorScheme 14 | @Environment(\.libraryDataProvider) private var dataProvider 15 | 16 | @State private var viewModel: AlbumViewModel 17 | 18 | init(album: Album) { 19 | viewModel = .init(album) 20 | } 21 | 22 | var body: some View { 23 | List { 24 | Header() 25 | .padding(.bottom, 4) 26 | 27 | TrackList(tracks: viewModel.tracks, container: viewModel.album) 28 | .padding(.horizontal, 20) 29 | 30 | if let overview = viewModel.album.overview, overview.trimmingCharacters(in: .whitespacesAndNewlines) != "" { 31 | Text(overview) 32 | } 33 | 34 | VStack(alignment: .leading) { 35 | if let releaseDate = viewModel.album.releaseDate { 36 | Text(releaseDate, style: .date) 37 | } 38 | 39 | Text(viewModel.runtime.duration) 40 | } 41 | .font(.subheadline) 42 | .listRowSeparator(.hidden, edges: .top) 43 | .foregroundStyle(.secondary) 44 | 45 | AdditionalAlbums() 46 | .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) 47 | } 48 | .listStyle(.plain) 49 | .scrollIndicators(.hidden) 50 | .ignoresSafeArea(edges: .top) 51 | .modifier(ToolbarModifier()) 52 | .environment(viewModel) 53 | .environment(\.displayContext, .album) 54 | .modifier(NowPlaying.SafeAreaModifier()) 55 | .sensoryFeedback(.error, trigger: viewModel.errorFeedback) 56 | .task { 57 | viewModel.dataProvider = dataProvider 58 | await viewModel.load() 59 | } 60 | .refreshable { 61 | await viewModel.load() 62 | } 63 | .userActivity("io.rfk.ampfin.album") { 64 | $0.title = viewModel.album.name 65 | $0.isEligibleForHandoff = true 66 | $0.persistentIdentifier = viewModel.album.id 67 | $0.targetContentIdentifier = "album:\(viewModel.album.id)" 68 | $0.userInfo = [ 69 | "albumId": viewModel.album.id 70 | ] 71 | $0.webpageURL = URL(string: JellyfinClient.shared.serverUrl.appending(path: "web").absoluteString + "#")!.appending(path: "details").appending(queryItems: [ 72 | .init(name: "id", value: viewModel.album.id), 73 | ]) 74 | } 75 | } 76 | } 77 | 78 | #Preview { 79 | NavigationStack { 80 | AlbumView(album: Album.fixture) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Multiplatform/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 21.09.23. 6 | // 7 | 8 | import UIKit 9 | import Intents 10 | 11 | final class AppDelegate: NSObject, UIApplicationDelegate { 12 | private var backgroundCompletionHandler: (() -> Void)? = nil 13 | 14 | func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { 15 | backgroundCompletionHandler = completionHandler 16 | } 17 | 18 | func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { 19 | Task { @MainActor in 20 | guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let backgroundCompletionHandler = appDelegate.backgroundCompletionHandler else { 21 | return 22 | } 23 | 24 | backgroundCompletionHandler() 25 | } 26 | } 27 | 28 | func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? { 29 | switch intent { 30 | case is INPlayMediaIntent: 31 | return PlayMediaHandler() 32 | case is INAddMediaIntent: 33 | return AddMediaHandler() 34 | default: 35 | return nil 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Multiplatform/Artist/ArtistLoadView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistLoadView.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct ArtistLoadView: View { 12 | @Environment(\.libraryDataProvider) private var dataProvider 13 | 14 | let artistId: String 15 | 16 | @State private var failed = false 17 | @State private var artist: Artist? 18 | 19 | var body: some View { 20 | if failed { 21 | ErrorView() 22 | .refreshable { await loadArtist() } 23 | } else if let artist { 24 | ArtistView(artist: artist) 25 | } else { 26 | LoadingView() 27 | .task { await loadArtist() } 28 | .refreshable { await loadArtist() } 29 | } 30 | } 31 | 32 | private func loadArtist() async { 33 | guard let artist = try? await dataProvider.artist(identifier: artistId) else { 34 | failed = true 35 | return 36 | } 37 | 38 | self.artist = artist 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Multiplatform/Artist/ArtistView+Toolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistView+Toolbar.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 08.04.24. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import AmpFinKit 11 | import AFPlayback 12 | 13 | internal extension ArtistView { 14 | struct Toolbar: ViewModifier { 15 | let artist: Artist 16 | 17 | @Binding var sortOrder: ItemSortOrder 18 | @Binding var ascending: Bool 19 | 20 | func body(content: Content) -> some View { 21 | content 22 | .navigationTitle(artist.name) 23 | .toolbar { 24 | ToolbarItem(placement: .topBarTrailing) { 25 | SortSelector(sortOrder: $sortOrder, ascending: $ascending) 26 | } 27 | ToolbarItem(placement: .topBarTrailing) { 28 | Button { 29 | artist.favorite.toggle() 30 | } label: { 31 | Label("favorite", systemImage: artist.favorite ? "star.fill" : "star") 32 | .labelStyle(.iconOnly) 33 | .contentTransition(.symbolEffect(.replace)) 34 | } 35 | } 36 | } 37 | .modifier(AdditionalToolbarModifier(artist: artist)) 38 | } 39 | } 40 | } 41 | 42 | 43 | private struct AdditionalToolbarModifier: ViewModifier { 44 | @Environment(\.libraryDataProvider) private var dataProvider 45 | 46 | @Default(.artistInstantMix) private var artistInstantMix 47 | 48 | let artist: Artist 49 | 50 | func body(content: Content) -> some View { 51 | if artist.cover == nil { 52 | content 53 | .toolbar { 54 | ToolbarItem(placement: .topBarTrailing) { 55 | Button { 56 | Task { 57 | if artistInstantMix { 58 | try? await artist.startInstantMix() 59 | } else { 60 | let tracks = try await dataProvider.tracks(artistId: artist.id, sortOrder: .random, ascending: true) 61 | AudioPlayer.current.startPlayback(tracks: tracks, startIndex: 0, shuffle: false, playbackInfo: .init(container: artist)) 62 | } 63 | } 64 | } label: { 65 | Label("queue.mix", systemImage: "play.circle.fill") 66 | .labelStyle(.iconOnly) 67 | } 68 | } 69 | } 70 | } else { 71 | content 72 | .ignoresSafeArea(edges: .top) 73 | } 74 | } 75 | } 76 | 77 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "extended-srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.765", 9 | "green" : "0.361", 10 | "red" : "0.667" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/AppIcon.appiconset/AmpFin (Dark).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Multiplatform/Assets.xcassets/AppIcon.appiconset/AmpFin (Dark).png -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/AppIcon.appiconset/AmpFin (Monochrome).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Multiplatform/Assets.xcassets/AppIcon.appiconset/AmpFin (Monochrome).png -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/AppIcon.appiconset/AmpFin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Multiplatform/Assets.xcassets/AppIcon.appiconset/AmpFin.png -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AmpFin.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "AmpFin (Dark).png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "AmpFin (Monochrome).png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/Logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "music.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Multiplatform/Assets.xcassets/Logo.imageset/music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Multiplatform/Assets.xcassets/Logo.imageset/music.png -------------------------------------------------------------------------------- /Multiplatform/Collections/Albums/AlbumCover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumGridItem.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | internal struct AlbumCover: View { 12 | @Environment(\.redactionReasons) private var redactionReasons 13 | @Environment(\.displayContext) private var displayContext 14 | 15 | let album: Album 16 | 17 | var body: some View { 18 | VStack(alignment: .leading, spacing: 0) { 19 | ItemImage(cover: album.cover) 20 | 21 | HStack(alignment: .top, spacing: 0) { 22 | VStack(alignment: .leading, spacing: 1) { 23 | Text(album.name) 24 | .bold() 25 | .font(.footnote) 26 | .lineLimit(1) 27 | 28 | Group { 29 | switch displayContext { 30 | case .artist: 31 | if let releaseDate = album.releaseDate { 32 | Text(releaseDate, format: .dateTime.year()) 33 | } 34 | default: 35 | if let artistName = album.artistName { 36 | Text(artistName) 37 | } else { 38 | Text(verbatim: "") 39 | } 40 | } 41 | } 42 | .font(.caption) 43 | .lineLimit(1) 44 | .foregroundStyle(.secondary) 45 | } 46 | 47 | if album.favorite { 48 | Spacer(minLength: 4) 49 | 50 | Image(systemName: "star") 51 | .symbolVariant(.fill) 52 | .font(.caption2) 53 | .foregroundStyle(.tint) 54 | } 55 | } 56 | .padding(.top, 8) 57 | } 58 | .padding(8) 59 | .contentShape(.hoverMenuInteraction, .rect(cornerRadius: 12)) 60 | .modifier(AlbumContextMenuModifier(album: album)) 61 | .padding(-8) 62 | } 63 | } 64 | 65 | internal extension AlbumCover { 66 | static let placeholder: some View = AlbumCover(album: .init( 67 | id: "placeholder", 68 | name: "Placeholder", 69 | cover: nil, 70 | favorite: false, 71 | overview: nil, 72 | genres: [], 73 | releaseDate: nil, 74 | artists: [.init(id: "placeholder", name: "Placeholder")], 75 | playCount: 0, 76 | lastPlayed: nil) 77 | ).redacted(reason: .placeholder) 78 | } 79 | 80 | #Preview { 81 | AlbumCover(album: .fixture) 82 | } 83 | 84 | #Preview { 85 | AlbumCover.placeholder 86 | } 87 | -------------------------------------------------------------------------------- /Multiplatform/Collections/Albums/AlbumGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumGrid.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct AlbumGrid: View { 12 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 13 | 14 | let albums: [Album] 15 | 16 | /// Expected album count used to display placeholders 17 | var count: Int = 0 18 | /// Function invoked when the users reaches the last loaded albums. Should mutate the `albums` parameter 19 | var loadMore: (() -> Void)? = nil 20 | 21 | private var minimumWidth: CGFloat { 22 | horizontalSizeClass == .compact ? 160.0 : 200.0 23 | } 24 | 25 | var body: some View { 26 | LazyVGrid(columns: [GridItem(.adaptive(minimum: minimumWidth, maximum: 400), spacing: 12)], spacing: 16) { 27 | ForEach(albums) { album in 28 | NavigationLink(value: album) { 29 | AlbumCover(album: album) 30 | } 31 | .buttonStyle(.plain) 32 | .onAppear { 33 | if album == albums.last { 34 | loadMore?() 35 | } 36 | } 37 | } 38 | 39 | ForEach(0..<(max(0, count - albums.count)), id: \.hashValue) { _ in 40 | AlbumCover.placeholder 41 | .onAppear { loadMore?() } 42 | } 43 | } 44 | } 45 | } 46 | 47 | #Preview { 48 | AlbumGrid(albums: [ 49 | Album.fixture, 50 | Album.fixture, 51 | Album.fixture, 52 | Album.fixture, 53 | ]) 54 | .padding() 55 | } 56 | 57 | #Preview { 58 | AlbumGrid(albums: [ 59 | Album.fixture, 60 | Album.fixture, 61 | Album.fixture, 62 | Album.fixture, 63 | ], count: 200) 64 | .padding() 65 | } 66 | -------------------------------------------------------------------------------- /Multiplatform/Collections/Albums/AlbumListRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumListRow.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 09.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct AlbumListRow: View { 12 | let album: Album 13 | 14 | var body: some View { 15 | HStack(spacing: 0) { 16 | ItemImage(cover: album.cover) 17 | .frame(width: 60) 18 | .padding(.trailing, 8) 19 | 20 | VStack(alignment: .leading, spacing: 2) { 21 | Text(album.name) 22 | .lineLimit(1) 23 | .font(.body) 24 | 25 | if let artistName = album.artistName { 26 | Text(artistName) 27 | .lineLimit(1) 28 | .font(.callout) 29 | .foregroundStyle(.secondary) 30 | } 31 | } 32 | 33 | Spacer(minLength: 8) 34 | 35 | DownloadIndicator(item: album) 36 | } 37 | .modifier(AlbumContextMenuModifier(album: album)) 38 | } 39 | } 40 | 41 | 42 | #Preview { 43 | List { 44 | AlbumListRow(album: .fixture) 45 | AlbumListRow(album: .fixture) 46 | AlbumListRow(album: .fixture) 47 | AlbumListRow(album: .fixture) 48 | AlbumListRow(album: .fixture) 49 | } 50 | .listStyle(.plain) 51 | } 52 | -------------------------------------------------------------------------------- /Multiplatform/Collections/Artists/ArtistList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistList.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct ArtistList: View { 12 | let artists: [Artist] 13 | 14 | var count = 0 15 | var loadMore: LoadCallback? = nil 16 | 17 | var body: some View { 18 | ForEach(artists) { artist in 19 | ArtistListRow(artist: artist) 20 | .listRowInsets(.init(top: 8, leading: 0, bottom: 8, trailing: 0)) 21 | .onAppear { 22 | if artist == artists.last { 23 | loadMore?() 24 | } 25 | } 26 | } 27 | 28 | ForEach(0..<(max(0, count - artists.count)), id: \.hashValue) { _ in 29 | ArtistListRow.placeholder 30 | .onAppear { loadMore?() } 31 | } 32 | } 33 | } 34 | 35 | internal extension ArtistList { 36 | typealias LoadCallback = (() -> Void) 37 | } 38 | 39 | #Preview { 40 | NavigationStack { 41 | List { 42 | ArtistList(artists: [ 43 | Artist.fixture, 44 | Artist.fixture, 45 | Artist.fixture, 46 | Artist.fixture, 47 | Artist.fixture, 48 | Artist.fixture, 49 | Artist.fixture, 50 | Artist.fixture, 51 | Artist.fixture, 52 | Artist.fixture, 53 | Artist.fixture, 54 | Artist.fixture, 55 | Artist.fixture, 56 | ]) 57 | .padding(.horizontal, 20) 58 | } 59 | .listStyle(.plain) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Multiplatform/Collections/Artists/ArtistListRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistListItem.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | internal struct ArtistListRow: View { 12 | let artist: Artist 13 | 14 | var body: some View { 15 | NavigationLink(value: artist) { 16 | HStack(spacing: 0) { 17 | ItemImage(cover: artist.cover) 18 | .frame(width: 44) 19 | .clipShape(.rect(cornerRadius: .infinity)) 20 | .padding(.trailing, 8) 21 | 22 | Text(artist.name) 23 | } 24 | } 25 | } 26 | } 27 | 28 | internal extension ArtistListRow { 29 | typealias Expand = (() -> Void) 30 | 31 | // NavigationLink cannot be disabled by allowHitsTesting, make a non-link version for placeholder 32 | static let placeholder: some View = HStack { 33 | ItemImage(cover: nil) 34 | .clipShape(RoundedRectangle(cornerRadius: .infinity)) 35 | .frame(width: 44) 36 | .padding(.trailing, 8) 37 | 38 | Text("placeholder") 39 | }.redacted(reason: .placeholder) 40 | } 41 | -------------------------------------------------------------------------------- /Multiplatform/Collections/Playlists/PlaylistsList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistsList.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 01.01.24. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct PlaylistsList: View { 12 | let playlists: [Playlist] 13 | 14 | var body: some View { 15 | ForEach(playlists) { playlist in 16 | NavigationLink(value: playlist) { 17 | PlaylistListRow(playlist: playlist) 18 | } 19 | .listRowInsets(.init(top: 8, leading: 0, bottom: 8, trailing: 0)) 20 | } 21 | } 22 | } 23 | 24 | #Preview { 25 | NavigationStack { 26 | List { 27 | PlaylistsList(playlists: [ 28 | Playlist.fixture, 29 | Playlist.fixture, 30 | Playlist.fixture, 31 | Playlist.fixture, 32 | Playlist.fixture, 33 | Playlist.fixture, 34 | Playlist.fixture, 35 | ]) 36 | .padding(.horizontal, 20) 37 | } 38 | .listStyle(.plain) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Multiplatform/Collections/Tracks/TrackGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackGrid.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 01.05.24. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | import AFPlayback 11 | 12 | struct TrackGrid: View { 13 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 14 | 15 | let tracks: [Track] 16 | let container: Item? 17 | 18 | var amount = 2 19 | 20 | private var count: Int { 21 | horizontalSizeClass == .compact ? 1 : 2 22 | } 23 | 24 | var body: some View { 25 | ScrollView(.horizontal, showsIndicators: false) { 26 | LazyHGrid(rows: [GridItem(.flexible(), spacing: 8)].repeated(count: min(tracks.count, amount)), spacing: 0) { 27 | ForEach(Array(tracks.enumerated()), id: \.element) { index, track in 28 | Group { 29 | TrackListRow(track: track) { 30 | AudioPlayer.current.startPlayback(tracks: tracks, startIndex: index, shuffle: false, playbackInfo: .init(container: container)) 31 | } 32 | .containerRelativeFrame(.horizontal) { length, _ in 33 | let minimum = horizontalSizeClass == .compact ? 300 : 450.0 34 | 35 | let amount = CGFloat(Int(length / minimum)) 36 | let available = length - 12 * (amount - 1) 37 | 38 | return max(minimum, available / amount) 39 | } 40 | .padding(.trailing, 12) 41 | } 42 | } 43 | } 44 | .scrollTargetLayout() 45 | } 46 | .scrollTargetBehavior(.viewAligned) 47 | .scrollClipDisabled() 48 | .padding(.horizontal, 20) 49 | } 50 | } 51 | 52 | #Preview { 53 | TrackGrid(tracks: [ 54 | .fixture, 55 | .fixture, 56 | .fixture, 57 | .fixture, 58 | .fixture, 59 | .fixture, 60 | .fixture, 61 | ], container: nil) 62 | } 63 | -------------------------------------------------------------------------------- /Multiplatform/Collections/Tracks/TrackList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackList.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | import AFPlayback 11 | 12 | internal struct TrackList: View { 13 | let tracks: [Track] 14 | var count: Int = 0 15 | 16 | var container: Item? = nil 17 | var preview: Bool = false 18 | 19 | var deleteCallback: TrackCollection.DeleteCallback = nil 20 | var moveCallback: TrackCollection.MoveCallback = nil 21 | var loadMore: TrackCollection.LoadCallback = nil 22 | 23 | private var album: Album? { 24 | container as? Album 25 | } 26 | private var sorted: [Track] { 27 | if album != nil { 28 | return tracks.sorted { $0.index < $1.index } 29 | } else { 30 | return tracks 31 | } 32 | } 33 | 34 | var body: some View { 35 | ForEach(sorted) { track in 36 | TrackListRow(track: track, container: container, preview: preview, deleteCallback: deleteCallback) { 37 | if let index = tracks.firstIndex(where: { $0.id == track.id }) { 38 | AudioPlayer.current.startPlayback(tracks: tracks, startIndex: index, shuffle: false, playbackInfo: .init(container: container)) 39 | } 40 | } 41 | .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) 42 | .padding(.vertical, album != nil ? 8 : 4) 43 | .onAppear { 44 | if track == tracks.last { 45 | loadMore?() 46 | } 47 | } 48 | } 49 | .onDelete { tracks in 50 | tracks.map { sorted[$0] }.forEach { 51 | deleteCallback?($0) 52 | } 53 | } 54 | .onMove { from, to in 55 | from.map { tracks[$0] }.forEach { 56 | moveCallback?($0, to) 57 | } 58 | } 59 | .deleteDisabled(deleteCallback == nil) 60 | .moveDisabled(moveCallback == nil) 61 | 62 | ForEach(0..<(min(10000, max(0, count - tracks.count))), id: \.hashValue) { _ in 63 | TrackListRow.placeholder 64 | .listRowInsets(.init(top: 8, leading: 0, bottom: 8, trailing: 0)) 65 | .onAppear { 66 | loadMore?() 67 | } 68 | } 69 | } 70 | } 71 | 72 | #Preview { 73 | NavigationStack { 74 | List { 75 | TrackList(tracks: [Track.fixture], container: nil) 76 | .padding(.horizontal, 20) 77 | } 78 | .listStyle(.plain) 79 | } 80 | .environment(NowPlaying.ViewModel()) 81 | } 82 | -------------------------------------------------------------------------------- /Multiplatform/Collections/Tracks/TrackListButtons.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackListButtons.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TrackListButtons: View { 11 | var background: Material = .ultraThickMaterial 12 | 13 | let startPlayback: (_ shuffle: Bool) -> () 14 | 15 | var body: some View { 16 | HStack(spacing: 12) { 17 | TrackListButton(icon: "play.fill", label: "queue.play", background: background) { 18 | startPlayback(false) 19 | } 20 | 21 | TrackListButton(icon: "shuffle", label: "queue.shuffle", background: background) { 22 | startPlayback(true) 23 | } 24 | } 25 | } 26 | } 27 | 28 | private struct TrackListButton : View { 29 | let icon: String 30 | let label: LocalizedStringKey 31 | 32 | let background: Material 33 | let callback: () -> Void 34 | 35 | var body: some View { 36 | ZStack { 37 | // This horrible abomination ensures that both buttons have the same height 38 | Label(String("TEXT"), systemImage: "shuffle") 39 | .opacity(0) 40 | 41 | Label(label, systemImage: icon) 42 | } 43 | .frame(maxWidth: .infinity) 44 | .padding(.vertical, 12) 45 | .bold() 46 | .foregroundColor(.accentColor) 47 | .background(background) 48 | .clipShape(RoundedRectangle(cornerRadius: 12)) 49 | .contentShape(.hoverMenuInteraction, RoundedRectangle(cornerRadius: 12)) 50 | .hoverEffect(.lift) 51 | .onTapGesture { 52 | callback() 53 | } 54 | } 55 | } 56 | 57 | #Preview { 58 | TrackListButtons { _ in } 59 | } 60 | -------------------------------------------------------------------------------- /Multiplatform/Common/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal struct ErrorView: View { 11 | var body: some View { 12 | UnavailableWrapper { 13 | ContentUnavailableView("error.unavailable.title", systemImage: "xmark", description: Text("error.unavailable.text")) 14 | } 15 | } 16 | } 17 | 18 | #Preview { 19 | ErrorView() 20 | } 21 | -------------------------------------------------------------------------------- /Multiplatform/Common/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingView.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal struct LoadingView: View { 11 | var body: some View { 12 | UnavailableWrapper { 13 | VStack(spacing: 4) { 14 | ProgressView() 15 | Text("loading") 16 | .foregroundStyle(.gray) 17 | } 18 | } 19 | } 20 | } 21 | 22 | #Preview { 23 | LoadingView() 24 | } 25 | -------------------------------------------------------------------------------- /Multiplatform/Common/UnavailableWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnavailableWrapper.swift 3 | // AmpFin 4 | // 5 | // Created by Rasmus Krämer on 20.11.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal 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/Entitlements.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.siri 6 | 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.application-groups 10 | 11 | group.${BUNDLE_ID_PREFIX}.ampfin 12 | 13 | com.apple.security.files.user-selected.read-only 14 | 15 | com.apple.security.network.client 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Multiplatform/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | INAlternativeAppNames 6 | 7 | 8 | INAlternativeAppName 9 | Amp Fin 10 | INAlternativeAppNamePronunciationHint 11 | ampfin 12 | 13 | 14 | UIApplicationSceneManifest 15 | 16 | UIApplicationSupportsMultipleScenes 17 | 18 | 19 | ITSAppUsesNonExemptEncryption 20 | 21 | NSAppTransportSecurity 22 | 23 | NSAllowsArbitraryLoads 24 | 25 | NSAllowsArbitraryLoadsUsageDescription 26 | 27 | NSATSExceptionThirdPartyServiceConnectionUsage 28 | 29 | 30 | 31 | NSUserActivityTypes 32 | 33 | io.rfk.ampfin.artist 34 | io.rfk.ampfin.album 35 | io.rfk.ampfin.playlist 36 | io.rfk.ampfin.track 37 | 38 | UIBackgroundModes 39 | 40 | audio 41 | 42 | CoreSpotlightContinuation 43 | 44 | AVInitialRouteSharingPolicy 45 | LongFormAudio 46 | CADisableMinimumFrameDurationOnPhone 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /Multiplatform/InfoPlist.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "Amp Fin" : { 5 | 6 | }, 7 | "ampfin" : { 8 | 9 | }, 10 | "CFBundleDisplayName" : { 11 | "comment" : "Bundle display name", 12 | "extractionState" : "extracted_with_value", 13 | "localizations" : { 14 | "en" : { 15 | "stringUnit" : { 16 | "state" : "new", 17 | "value" : "AmpFin" 18 | } 19 | } 20 | } 21 | }, 22 | "CFBundleName" : { 23 | "comment" : "Bundle name", 24 | "extractionState" : "extracted_with_value", 25 | "localizations" : { 26 | "en" : { 27 | "stringUnit" : { 28 | "state" : "new", 29 | "value" : "AmpFin" 30 | } 31 | } 32 | } 33 | }, 34 | "NSAppleMusicUsageDescription" : { 35 | "comment" : "Privacy - Media Library Usage Description", 36 | "extractionState" : "extracted_with_value", 37 | "localizations" : { 38 | "en" : { 39 | "stringUnit" : { 40 | "state" : "new", 41 | "value" : "Hello, World!" 42 | } 43 | } 44 | } 45 | }, 46 | "NSSiriUsageDescription" : { 47 | "comment" : "Privacy - Siri Usage Description", 48 | "extractionState" : "extracted_with_value", 49 | "localizations" : { 50 | "de" : { 51 | "stringUnit" : { 52 | "state" : "translated", 53 | "value" : "Spiele deine Musik mit Siri" 54 | } 55 | }, 56 | "en" : { 57 | "stringUnit" : { 58 | "state" : "new", 59 | "value" : "Play your music using Siri" 60 | } 61 | } 62 | } 63 | } 64 | }, 65 | "version" : "1.0" 66 | } -------------------------------------------------------------------------------- /Multiplatform/Library/PlaylistsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistsView.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 01.01.24. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct PlaylistsView: View { 12 | @Environment(\.libraryDataProvider) private var dataProvider 13 | 14 | @State private var failed = false 15 | @State private var playlists = [Playlist]() 16 | 17 | var body: some View { 18 | Group { 19 | if !playlists.isEmpty { 20 | List { 21 | PlaylistsList(playlists: playlists) 22 | .padding(.horizontal, 20) 23 | } 24 | .listStyle(.plain) 25 | } else if failed { 26 | ErrorView() 27 | } else { 28 | LoadingView() 29 | .task { await loadPlaylists() } 30 | } 31 | } 32 | .navigationTitle("title.playlists") 33 | .modifier(NowPlaying.SafeAreaModifier()) 34 | .refreshable { await loadPlaylists() } 35 | } 36 | 37 | private func loadPlaylists() async { 38 | failed = false 39 | 40 | guard let playlists = try? await dataProvider.playlists(search: nil) else { 41 | failed = true 42 | return 43 | } 44 | 45 | self.playlists = playlists 46 | } 47 | } 48 | 49 | #Preview { 50 | NavigationStack { 51 | PlaylistsView() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Multiplatform/Login/LoginQuickConnectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginQuickConnectView.swift 3 | // AmpFin 4 | // 5 | // Created by Daniel Cuevas on 9/27/24. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | extension LoginView { 12 | struct LoginQuickConnectView: View { 13 | @Environment(LoginViewModel.self) private var viewModel 14 | 15 | var body: some View { 16 | Group { 17 | if let code = viewModel.quickConnectCode { 18 | ScrollView { 19 | ContentUnavailableView(String(code), systemImage: "person.wave.2", description: Text("login.quickConnect.help")) 20 | .symbolEffect(.variableColor) 21 | .onTapGesture { 22 | UIPasteboard.general.string = code 23 | } 24 | } 25 | .contentMargins(.top, 160) 26 | .onAppear { 27 | viewModel.waitForQuickConnectUpdate() 28 | } 29 | .onDisappear { 30 | viewModel.stopWaitForQuickConnectUpdate() 31 | } 32 | } else if viewModel.quickConnectFailed { 33 | ErrorView() 34 | } else { 35 | LoadingView() 36 | .task { 37 | await viewModel.initiateQuickConnect() 38 | } 39 | } 40 | } 41 | .navigationTitle("login.quickConnect.title") 42 | .navigationBarTitleDisplayMode(.inline) 43 | .background(.background.secondary) 44 | .safeAreaInset(edge: .bottom) { 45 | Link("login.quickConnect.link", destination: URL(string: "https://jellyfin.org/docs/general/server/quick-connect")!) 46 | } 47 | .refreshable { 48 | await viewModel.initiateQuickConnect() 49 | } 50 | } 51 | } 52 | } 53 | 54 | #Preview { 55 | NavigationStack { 56 | LoginView.LoginQuickConnectView() 57 | } 58 | .environment(LoginView.LoginViewModel()) 59 | } 60 | -------------------------------------------------------------------------------- /Multiplatform/Login/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 05.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct LoginView: View { 12 | @State private var viewModel = LoginViewModel() 13 | 14 | var body: some View { 15 | WelcomeView(loginSheetPresented: $viewModel.sheetPresented) 16 | .sheet(isPresented: $viewModel.sheetPresented, content: { 17 | NavigationStack { 18 | switch viewModel.flowStep { 19 | case .server, .credentials: 20 | LoginFormView() 21 | case .serverLoading, .credentialsLoading: 22 | LoadingView() 23 | } 24 | } 25 | }) 26 | .transition(.opacity) 27 | .animation(.smooth, value: viewModel.flowStep) 28 | .environment(viewModel) 29 | } 30 | } 31 | 32 | #Preview { 33 | LoginView() 34 | } 35 | -------------------------------------------------------------------------------- /Multiplatform/Login/WelcomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WelcomeView.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 16.11.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension LoginView { 11 | struct WelcomeView: View { 12 | @Binding var loginSheetPresented: Bool 13 | 14 | var body: some View { 15 | VStack(spacing: 4) { 16 | Spacer() 17 | 18 | Image("Logo") 19 | .resizable() 20 | .aspectRatio(1, contentMode: .fit) 21 | .frame(width: 100) 22 | .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) 23 | .padding(.bottom, 40) 24 | 25 | Text("login.welcome") 26 | .font(.headline) 27 | Text("login.text") 28 | .font(.subheadline) 29 | 30 | Button { 31 | loginSheetPresented.toggle() 32 | } label: { 33 | Text("login.prompt") 34 | .padding(.vertical, 12) 35 | .padding(.horizontal, 44) 36 | .foregroundColor(.white) 37 | .background(Color.accentColor) 38 | .font(.headline) 39 | .clipShape(RoundedRectangle(cornerRadius: 8)) 40 | .contentShape(.hoverMenuInteraction, RoundedRectangle(cornerRadius: 8)) 41 | } 42 | .buttonStyle(.plain) 43 | .padding(20) 44 | 45 | Spacer() 46 | } 47 | } 48 | } 49 | } 50 | 51 | #Preview { 52 | LoginView.WelcomeView(loginSheetPresented: .constant(false)) 53 | } 54 | -------------------------------------------------------------------------------- /Multiplatform/MultiplatformApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicApp.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 05.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import Nuke 10 | import Defaults 11 | import AmpFinKit 12 | 13 | @main 14 | struct MultiplatformApp: App { 15 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 16 | 17 | init() { 18 | #if !ENABLE_ALL_FEATURES 19 | AFKIT_ENABLE_ALL_FEATURES = false 20 | #endif 21 | 22 | ImagePipeline.shared = ImagePipeline(configuration: .withDataCache) 23 | } 24 | 25 | var body: some Scene { 26 | WindowGroup { 27 | ContentView() 28 | #if targetEnvironment(macCatalyst) 29 | .onAppear { 30 | UIApplication.shared.connectedScenes 31 | .compactMap { $0 as? UIWindowScene } 32 | .forEach { $0.titlebar?.titleVisibility = .hidden } 33 | } 34 | #endif 35 | } 36 | .modelContainer(PersistenceManager.shared.modelContainer) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Multiplatform/Navigation/Sidebar/Sidebar+Links.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sidebar+Section.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 08.04.24. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import AmpFinKit 11 | 12 | internal extension Sidebar { 13 | struct LibraryLinks: View { 14 | @Default private var expanded: Bool 15 | 16 | let provider: DataProvider 17 | 18 | init(provider: DataProvider) { 19 | self.provider = provider 20 | _expanded = Default(.providerExpanded(provider)) 21 | } 22 | 23 | var body: some View { 24 | Section(provider.title, isExpanded: $expanded) { 25 | ForEach(provider.panels, id: \.hashValue) { panel in 26 | NavigationLink(value: Selection(provider: provider, panel: panel)) { 27 | Label(panel.title!, systemImage: panel.icon!) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | 34 | struct PlaylistLinks: View { 35 | @Default(.playlistSectionExpanded) private var playlistSectionExpanded 36 | 37 | var provider: DataProvider? 38 | 39 | @State private var playlists = [Playlist]() 40 | 41 | var body: some View { 42 | if !playlists.isEmpty { 43 | Section("section.playlists", isExpanded: $playlistSectionExpanded) { 44 | ForEach(playlists) { playlist in 45 | NavigationLink(value: Selection( 46 | provider: provider ?? (OfflineManager.shared.offlineStatus(playlistId: playlist.id) == .downloaded ? .offline : .online), 47 | panel: .playlist(id: playlist.id))) { 48 | HStack { 49 | ItemImage(cover: playlist.cover) 50 | .frame(width: 40) 51 | .padding(.trailing, 3) 52 | 53 | Text(playlist.name) 54 | .lineLimit(1) 55 | } 56 | } 57 | } 58 | } 59 | } else { 60 | Color.clear 61 | .task { await fetchPlaylists() } 62 | } 63 | } 64 | 65 | private func fetchPlaylists() async { 66 | if let playlists = try? await provider?.libraryProvider.playlists(search: nil) { 67 | self.playlists = playlists 68 | } else if let playlists = try? await OnlineLibraryDataProvider().playlists(search: nil) { 69 | self.playlists = playlists 70 | } else if let playlists = try? await OfflineLibraryDataProvider().playlists(search: nil) { 71 | self.playlists = playlists 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Multiplatform/Navigation/XRTabs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XRTabs.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 03.05.24. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | 11 | struct XRTabs: View { 12 | @Default(.searchTab) private var searchTab 13 | 14 | @State private var search = "" 15 | 16 | var body: some View { 17 | TabView { 18 | Sidebar(provider: .online) 19 | .tabItem { 20 | Label("tab.libarary", systemImage: "rectangle.stack.fill") 21 | } 22 | 23 | Sidebar(provider: .offline) 24 | .tabItem { 25 | Label("tab.downloads", systemImage: "arrow.down") 26 | } 27 | 28 | NavigationStack { 29 | SearchView(search: $search, searchTab: $searchTab, selected: .constant(true)) 30 | .modifier(Navigation.DestinationModifier()) 31 | } 32 | .environment(\.libraryDataProvider, searchTab.dataProvider) 33 | .tabItem { 34 | Label("tab.search", systemImage: "magnifyingglass") 35 | } 36 | } 37 | } 38 | } 39 | 40 | #Preview { 41 | XRTabs() 42 | } 43 | -------------------------------------------------------------------------------- /Multiplatform/NowPlaying/Background.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlayingBackground.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 09.04.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Defaults 11 | import AmpFinKit 12 | import AFPlayback 13 | 14 | internal extension NowPlaying { 15 | struct Background: View { 16 | @Default(.haltNowPlayingBackground) private var haltNowPlayingBackground 17 | @Environment(ViewModel.self) private var viewModel 18 | 19 | var body: some View { 20 | ZStack { 21 | Color.black 22 | Color.gray.opacity(0.8) 23 | 24 | ZStack { 25 | if let cover = viewModel.nowPlaying?.cover { 26 | GeometryReader { proxy in 27 | ItemImage(cover: cover) 28 | .id(cover.url) 29 | .aspectRatio(contentMode: .fill) 30 | .frame(width: proxy.size.width + proxy.safeAreaInsets.leading + proxy.safeAreaInsets.trailing, 31 | height: proxy.size.height + proxy.safeAreaInsets.top + proxy.safeAreaInsets.bottom) 32 | .blur(radius: 150) 33 | 34 | if #available(iOS 18, *), false { 35 | MeshGradient(width: 4, height: 4, points: viewModel.colors.map { _ in .init(.random(in: 0...4), .random(in: 0...4)) }, colors: viewModel.colors, smoothsColors: true) 36 | } 37 | } 38 | } 39 | } 40 | .overlay(.black.opacity(0.2)) 41 | } 42 | .allowsHitTesting(false) 43 | .ignoresSafeArea(edges: .all) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Multiplatform/NowPlaying/Compact/CompactTabBarBackgroundModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlayingBar.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 07.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | internal extension NowPlaying { 12 | struct CompactTabBarBackgroundModifier: ViewModifier { 13 | @Environment(ViewModel.self) private var viewModel 14 | 15 | func body(content: Content) -> some View { 16 | content 17 | .safeAreaInset(edge: .bottom) { 18 | // Tab bar background 19 | if viewModel.nowPlaying != nil { 20 | Rectangle() 21 | .frame(height: 300) 22 | .mask { 23 | VStack(spacing: 0) { 24 | LinearGradient(colors: [.black.opacity(0), .black], startPoint: .top, endPoint: .bottom) 25 | .frame(height: 50) 26 | 27 | Rectangle() 28 | .frame(height: 250) 29 | } 30 | } 31 | .foregroundStyle(.bar) 32 | .padding(.bottom, -225) 33 | .allowsHitTesting(false) 34 | .toolbarBackground(.hidden, for: .tabBar) 35 | .ignoresSafeArea(.keyboard) 36 | .ignoresSafeArea(edges: .all) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Multiplatform/NowPlaying/Modifiers/SymbolButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SymbolButtonStyle.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 08.09.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SymbolButtonStyle: ButtonStyle { 11 | var active: Bool 12 | var heavy = false 13 | 14 | private var color: Color { 15 | heavy ? .black.opacity(0.2) : .white.opacity(0.25) 16 | } 17 | 18 | func makeBody(configuration: Configuration) -> some View { 19 | configuration.label 20 | .aspectRatio(1, contentMode: .fit) 21 | .padding(7) 22 | .background(active ? color : .clear) 23 | .modifier(ForegroundStyleModifier(active: active, heavy: heavy)) 24 | .clipShape(RoundedRectangle(cornerRadius: 7)) 25 | .animation(.easeInOut, value: active) 26 | } 27 | } 28 | 29 | private struct ForegroundStyleModifier: ViewModifier { 30 | let active: Bool 31 | let heavy: Bool 32 | 33 | func body(content: Content) -> some View { 34 | if heavy { 35 | content 36 | .foregroundStyle(active ? .primary : .secondary) 37 | } else { 38 | content 39 | .foregroundStyle(active ? .thickMaterial : .thinMaterial) 40 | } 41 | } 42 | } 43 | 44 | #Preview { 45 | Button { 46 | 47 | } label: { 48 | Image(systemName: "shuffle") 49 | } 50 | .buttonStyle(SymbolButtonStyle(active: false)) 51 | } 52 | 53 | 54 | #Preview { 55 | Button { 56 | 57 | } label: { 58 | Image(systemName: "shuffle") 59 | } 60 | .buttonStyle(SymbolButtonStyle(active: true)) 61 | } 62 | 63 | 64 | #Preview { 65 | Button { 66 | 67 | } label: { 68 | Image(systemName: "shuffle") 69 | } 70 | .buttonStyle(SymbolButtonStyle(active: false, heavy: true)) 71 | } 72 | 73 | 74 | #Preview { 75 | Button { 76 | 77 | } label: { 78 | Image(systemName: "shuffle") 79 | } 80 | .buttonStyle(SymbolButtonStyle(active: true, heavy: false)) 81 | } 82 | -------------------------------------------------------------------------------- /Multiplatform/NowPlaying/NowPlaying.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlaying.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 03.05.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import AmpFinKit 11 | import AFPlayback 12 | 13 | internal struct NowPlaying { 14 | private init() {} 15 | 16 | enum Tab { 17 | case cover 18 | case lyrics 19 | case queue 20 | } 21 | } 22 | 23 | internal extension NowPlaying { 24 | static let widthChangeNotification = NSNotification.Name("io.rfk.ampfin.sidebar.width.changed") 25 | static let offsetChangeNotification = NSNotification.Name("io.rfk.ampfin.sidebar.offset.changed") 26 | } 27 | -------------------------------------------------------------------------------- /Multiplatform/NowPlaying/Sliders/VolumeSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VolumeSlider.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 07.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import AFPlayback 10 | 11 | internal extension NowPlaying { 12 | struct VolumeSlider: View { 13 | @Binding var dragging: Bool 14 | 15 | @State private var volume = Double(AudioPlayer.current.volume) 16 | 17 | var body: some View { 18 | HStack { 19 | Slider(percentage: .init() { volume } set: { AudioPlayer.current.volume = Float($0) }, dragging: $dragging) 20 | } 21 | .foregroundStyle(.thinMaterial) 22 | .saturation(1.6) 23 | .animation(.easeInOut, value: dragging) 24 | .onReceive(NotificationCenter.default.publisher(for: AudioPlayer.volumeDidChangeNotification)) { _ in 25 | if !dragging { 26 | volume = Double(AudioPlayer.current.volume) 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Multiplatform/Playlist/PlaylistLoadView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistLoadView.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 09.04.24. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | struct PlaylistLoadView: View { 12 | @Environment(\.libraryDataProvider) private var dataProvider 13 | 14 | let playlistId: String 15 | 16 | @State private var failed = false 17 | @State private var playlist: Playlist? 18 | 19 | var body: some View { 20 | if failed { 21 | ErrorView() 22 | .refreshable { await loadPlaylist() } 23 | } else if let playlist { 24 | PlaylistView(playlist: playlist) 25 | } else { 26 | LoadingView() 27 | .task { await loadPlaylist() } 28 | .refreshable { await loadPlaylist() } 29 | } 30 | } 31 | 32 | private func loadPlaylist() async { 33 | guard let playlist = try? await dataProvider.playlist(identifier: playlistId) else { 34 | failed = true 35 | return 36 | } 37 | 38 | self.playlist = playlist 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Multiplatform/Playlist/PlaylistView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistView.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 01.01.24. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | import AFPlayback 11 | 12 | struct PlaylistView: View { 13 | @Environment(\.libraryDataProvider) private var dataProvider 14 | @Environment(\.dismiss) private var dismiss 15 | 16 | @State private var viewModel: PlaylistViewModel 17 | 18 | init(playlist: Playlist) { 19 | viewModel = .init(playlist) 20 | } 21 | 22 | var body: some View { 23 | List { 24 | Header() 25 | .padding(.bottom, 8) 26 | 27 | TrackList(tracks: viewModel.tracks, container: viewModel.playlist, preview: viewModel.editMode == .active, deleteCallback: viewModel.removeTrack, moveCallback: viewModel.moveTrack) 28 | .padding(.horizontal, 20) 29 | } 30 | .listStyle(.plain) 31 | .ignoresSafeArea(edges: .top) 32 | .navigationTitle(viewModel.playlist.name) 33 | .sensoryFeedback(.error, trigger: viewModel.errorFeedback) 34 | .alert("playlist.delete.alert", isPresented: $viewModel.deleteAlertPresented) { 35 | Button(role: .cancel) { 36 | viewModel.deleteAlertPresented = false 37 | } label: { 38 | Text("cancel") 39 | } 40 | Button(role: .destructive) { 41 | viewModel.delete() 42 | } label: { 43 | Text("playlist.delete.finalize") 44 | } 45 | } 46 | .modifier(ToolbarModifier()) 47 | .environment(\.editMode, $viewModel.editMode) 48 | .environment(\.displayContext, .playlist) 49 | .environment(viewModel) 50 | .modifier(NowPlaying.SafeAreaModifier()) 51 | .task { 52 | viewModel.dataProvider = dataProvider 53 | await viewModel.load() 54 | } 55 | .refreshable { 56 | await viewModel.load() 57 | } 58 | .onChange(of: viewModel.dismiss) { 59 | 60 | } 61 | .userActivity("io.rfk.ampfin.playlist") { 62 | $0.title = viewModel.playlist.name 63 | $0.isEligibleForHandoff = true 64 | $0.persistentIdentifier = viewModel.playlist.id 65 | $0.targetContentIdentifier = "playlist:\(viewModel.playlist.id)" 66 | $0.userInfo = [ 67 | "playlistId": viewModel.playlist.id 68 | ] 69 | $0.webpageURL = URL(string: JellyfinClient.shared.serverUrl.appending(path: "web").absoluteString + "#")!.appending(path: "details").appending(queryItems: [ 70 | .init(name: "id", value: viewModel.playlist.id), 71 | ]) 72 | } 73 | } 74 | } 75 | 76 | #Preview { 77 | NavigationStack { 78 | PlaylistView(playlist: Playlist.fixture) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /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/Utility/DisplayContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DisplayContext.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 25.07.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | internal enum DisplayContext: Identifiable, Equatable, Hashable { 12 | case unknown 13 | case album 14 | case artist 15 | case playlist 16 | case favorite 17 | case search 18 | 19 | var id: Self { 20 | self 21 | } 22 | } 23 | 24 | internal extension EnvironmentValues { 25 | @Entry var displayContext: DisplayContext = .unknown 26 | } 27 | -------------------------------------------------------------------------------- /Multiplatform/Utility/DownloadIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadIndicator.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 03.10.23. 6 | // 7 | 8 | import SwiftUI 9 | import AmpFinKit 10 | 11 | internal struct DownloadIndicator: View { 12 | let item: Item 13 | 14 | @State private var offlineTracker: ItemOfflineTracker? 15 | 16 | var body: some View { 17 | Group { 18 | if offlineTracker?.status == .downloaded { 19 | Label("downloaded", systemImage: "arrow.down.circle.fill") 20 | .labelStyle(.iconOnly) 21 | .font(.caption2) 22 | .foregroundStyle(.secondary) 23 | } else if offlineTracker?.status == .working { 24 | ProgressView() 25 | .scaleEffect(0.5) 26 | } 27 | } 28 | .padding(.horizontal, 4) 29 | .foregroundStyle(.secondary) 30 | 31 | if offlineTracker == nil { 32 | Color.clear 33 | .frame(width: 0, height: 0) 34 | .onAppear { 35 | offlineTracker = item.offlineTracker 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Extensions/Array+Repeat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Repeat.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 01.05.24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal extension Array { 11 | init(repeating: [Element], count: Int) { 12 | self.init([[Element]](repeating: repeating, count: count).flatMap{$0}) 13 | } 14 | func repeated(count: Int) -> [Element] { 15 | return [Element](repeating: self, count: count) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Extensions/Color+IsLight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+IsLight.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal extension UIColor { 11 | func isLight(threshold: Float = 0.5) -> Bool { 12 | let originalCGColor = self.cgColor 13 | let RGBCGColor = originalCGColor.converted(to: CGColorSpaceCreateDeviceRGB(), intent: .defaultIntent, options: nil) 14 | 15 | guard let components = RGBCGColor?.components, components.count >= 3 else { 16 | return false 17 | } 18 | 19 | let brightness = Float(((components[0] * 299) + (components[1] * 587) + (components[2] * 114)) / 1000) 20 | return (brightness > threshold) 21 | } 22 | } 23 | 24 | internal extension Color { 25 | func isLight(threshold: Float = 0.5) -> Bool { 26 | UIColor(self).isLight() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Extensions/ContentShapeKinds+HoverMenuInteraction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentShapeKinds+HoverMenuInteraction.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 04.05.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension ContentShapeKinds { 11 | static let hoverMenuInteraction: Self = [.contextMenuPreview, .hoverEffect, .interaction] 12 | } 13 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Extensions/Defaults+Keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defaults+Keys.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 08.04.24. 6 | // 7 | 8 | import Foundation 9 | import Defaults 10 | import AmpFinKit 11 | 12 | internal extension Defaults.Keys { 13 | static let migratedToNewDatastore = Key("migratedToNewDatastore_n1u3enjoieqgurfjciuqw0ayj", default: false) 14 | 15 | // MARK: Sort 16 | static let sortAscending_tracks = Key("sortAscending_tracks", default: false) 17 | static let sortOrder_tracks = Key("sortOrder_tracks", default: .added) 18 | 19 | static let sortAscending_albums = Key("sortAscending_albums", default: false) 20 | static let sortOrder_albums = Key("sortOrder_albums", default: .added) 21 | 22 | // MARK: Navigation 23 | 24 | static let searchTab = Key("searchTab", default: .online) 25 | static let activeTab = Key("activeTab", default: .library) 26 | 27 | static let sidebarSelection = Key("sidebarSelection") 28 | static let playlistSectionExpanded = Key("playlistSectionExpanded", default: true) 29 | 30 | static func providerExpanded(_ provider: Sidebar.DataProvider) -> Key { 31 | .init("providerExpanded_\(provider.hashValue)", default: true) 32 | } 33 | 34 | // MARK: Spotlight 35 | 36 | static let lastSpotlightDonation = Key("lastSpotlightDonation", default: 0) 37 | static let lastSpotlightDonationCompletion = Key("lastSpotlightDonationCompletion", default: 0) 38 | 39 | // MARK: Settings 40 | 41 | static let artistInstantMix = Key("artistInstantMix", default: false) 42 | static let libraryRandomAlbums = Key("libraryRandomAlbums", default: false) 43 | static let haltNowPlayingBackground = Key("haltNowPlayingBackground", default: false) 44 | static let newPlaylistDefaultPrivate = Key("newPlaylistDefaultPrivate", default: false) 45 | } 46 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Extensions/Double+Duration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Double+Duration.swift 3 | // iOS 4 | // 5 | // Created by Rasmus Krämer on 13.03.24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Double { 11 | var duration: String { 12 | let formatter = DateComponentsFormatter() 13 | formatter.allowedUnits = [.hour, .minute] 14 | formatter.unitsStyle = .short 15 | 16 | return formatter.string(from: TimeInterval(self))! 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Extensions/MainActor+withAnimation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainActor+withAnimation.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 20.08.24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | internal extension MainActor { 12 | static func withAnimation(_ animation: Animation? = nil, _ body: @MainActor @escaping () -> T) async { 13 | let _ = await MainActor.run { 14 | SwiftUI.withAnimation(animation) { 15 | body() 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Extensions/UIApplication+Tap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplication+Tap.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 16.08.24. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | internal extension UIApplication { 12 | func addGestureRecognizer() { 13 | guard let window = (connectedScenes.first as? UIWindowScene)?.windows.first else { return } 14 | 15 | let tapGesture = UITapGestureRecognizer(target: window, action: nil) 16 | 17 | tapGesture.requiresExclusiveTouchType = false 18 | tapGesture.cancelsTouchesInView = false 19 | tapGesture.delegate = self 20 | 21 | window.addGestureRecognizer(tapGesture) 22 | } 23 | 24 | static let tapGestureFiredNotification = Notification.Name("io.rfk.ampfin.tapGestureFiredNotification") 25 | } 26 | 27 | extension UIApplication: UIGestureRecognizerDelegate { 28 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { 29 | NotificationCenter.default.post(name: Self.tapGestureFiredNotification, object: nil) 30 | return true 31 | } 32 | 33 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { 34 | return true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Extensions/UINavigationController+Gesture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UINavigationController+Gesture.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 07.09.23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UINavigationController: 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/Extensions/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 | // Lets do a little UIKit tomfoolery 12 | // Taken from https://github.com/kylebshr/ScreenCorners/tree/main 13 | 14 | @available(tvOS, unavailable) 15 | @available(macOS, unavailable) 16 | @available(watchOS, unavailable) 17 | @available(visionOS, unavailable) 18 | extension UIScreen { 19 | private static let cornerRadiusKey: String = { 20 | let components = ["Radius", "Corner", "display", "_"] 21 | return components.reversed().joined() 22 | }() 23 | 24 | public var displayCornerRadius: CGFloat { 25 | guard let cornerRadius = self.value(forKey: Self.cornerRadiusKey) as? CGFloat else { 26 | assertionFailure("Failed to detect screen corner radius") 27 | return 0 28 | } 29 | 30 | return cornerRadius 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Multiplatform/Utility/HoverEffectModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonHoverEffectModifier.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 04.05.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal struct HoverEffectModifier: ViewModifier { 11 | var padding: CGFloat = 8 12 | var cornerRadius: CGFloat = 12 13 | 14 | var hoverEffect: HoverEffect = .highlight 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .padding(padding) 19 | .contentShape(.hoverMenuInteraction, .rect(cornerRadius: cornerRadius)) 20 | .hoverEffect(hoverEffect) 21 | .padding(-padding) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Intents/AddMediaHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddMediaHandler.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 27.04.24. 6 | // 7 | 8 | import Foundation 9 | import Intents 10 | import AmpFinKit 11 | import AFPlayback 12 | 13 | final class AddMediaHandler: NSObject, INAddMediaIntentHandling { 14 | func handle(intent: INAddMediaIntent) async -> INAddMediaIntentResponse { 15 | let trackId: String 16 | 17 | if let mediaItems = intent.mediaItems, let mediaItem = mediaItems.first, let identifier = mediaItem.identifier { 18 | trackId = identifier 19 | } else if intent.mediaSearch?.reference == .currentlyPlaying, let nowPlaying = AudioPlayer.current.nowPlaying { 20 | trackId = nowPlaying.id 21 | } else { 22 | return .init(code: .failure, userActivity: nil) 23 | } 24 | 25 | guard let destination = intent.mediaDestination, case INMediaDestination.playlist(let playlistName) = destination else { 26 | return .init(code: .failure, userActivity: nil) 27 | } 28 | 29 | guard let playlist = try? await MediaResolver.shared.search(playlistName: playlistName, runOffline: intent.mediaSearch?.reference == .my).first else { 30 | return .init(code: .failure, userActivity: nil) 31 | } 32 | 33 | do { 34 | try await playlist.add(trackIds: [trackId]) 35 | return .init(code: .success, userActivity: nil) 36 | } catch { 37 | return .init(code: .failure, userActivity: nil) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Intents/PlayMediaHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayMediaHandler.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 26.04.24. 6 | // 7 | 8 | import Foundation 9 | import Intents 10 | import AmpFinKit 11 | import AFPlayback 12 | 13 | final class PlayMediaHandler: NSObject, INPlayMediaIntentHandling { 14 | func handle(intent: INPlayMediaIntent) async -> INPlayMediaIntentResponse { 15 | if intent.resumePlayback == true && AudioPlayer.current.nowPlaying != nil { 16 | AudioPlayer.current.playing = true 17 | return .init(code: .success, userActivity: nil) 18 | } 19 | 20 | guard let mediaItem = intent.mediaItems?.first, let identifier = mediaItem.identifier else { 21 | return .init(code: .failure, userActivity: nil) 22 | } 23 | 24 | var tracks = [Track]() 25 | 26 | do { 27 | if mediaItem.type == .album { 28 | tracks = try await MediaResolver.shared.tracks(albumId: identifier) 29 | } else if mediaItem.type == .artist { 30 | tracks = try await MediaResolver.shared.tracks(artistId: identifier) 31 | } else if mediaItem.type == .playlist { 32 | tracks = try await MediaResolver.shared.tracks(playlistId: identifier) 33 | } else if mediaItem.type == .song { 34 | tracks = [try await MediaResolver.shared.track(id: identifier)] 35 | } 36 | 37 | guard !tracks.isEmpty else { 38 | throw MediaResolver.ResolveError.empty 39 | } 40 | 41 | if intent.playbackQueueLocation == .unknown || intent.playbackQueueLocation == .now { 42 | AudioPlayer.current.startPlayback(tracks: tracks, startIndex: 0, shuffle: intent.playShuffled ?? false, playbackInfo: .init(container: nil, preventDonation: true)) 43 | } else { 44 | AudioPlayer.current.queue(tracks, after: intent.playbackQueueLocation == .next ? 0 : AudioPlayer.current.queue.count, playbackInfo: .init(container: nil, preventDonation: true)) 45 | 46 | if let shuffled = intent.playShuffled { 47 | AudioPlayer.current.shuffled = shuffled 48 | } 49 | } 50 | 51 | switch intent.playbackRepeatMode { 52 | case .none: 53 | AudioPlayer.current.repeatMode = .none 54 | break 55 | case .all: 56 | AudioPlayer.current.repeatMode = .queue 57 | break 58 | case .one: 59 | AudioPlayer.current.repeatMode = .track 60 | break 61 | default: 62 | break 63 | } 64 | 65 | return .init(code: .success, userActivity: nil) 66 | } catch { 67 | return .init(code: .failure, userActivity: nil) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Intents/de.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Suchanfrage"; 2 | 3 | "5oBQUn" = "${mediaItems}"; 4 | 5 | "ApUiZd" = "${mediaItems}"; 6 | 7 | "ErAaNp" = "Wiederholungsmodus"; 8 | 9 | "G3J6fP" = "Spiele Medien"; 10 | 11 | "H25xHi" = "Setzte ${mediaContainer} fort"; 12 | 13 | "KcCcQu" = "Spiele ${mediaItems}"; 14 | 15 | "LdrEPN" = "Eine Anfrage Medien zu spielen"; 16 | 17 | "MbU5ep" = "Zufällige Wiedergabe von ${mediaItems}"; 18 | 19 | "No0K9w" = "fortsetzten"; 20 | 21 | "Pk1Fkx" = "nicht fortsetzten"; 22 | 23 | "QqdHiR" = "zufällig"; 24 | 25 | "RvX3Zd" = "Geschwindigkeit"; 26 | 27 | "WEjMGd" = "Spiele ${mediaContainer}"; 28 | 29 | "WktFNa" = "Zufällig"; 30 | 31 | "YYfsKp" = "Spiele ${mediaContainer}"; 32 | 33 | "cV2HLF" = "Wiedergabeposition"; 34 | 35 | "cfJexL" = "Lieder"; 36 | 37 | "f3d3kn" = "Spiele"; 38 | 39 | "mCAocc" = "Nicht zufällig"; 40 | 41 | "mIvv3D" = "Setzte ${mediaItems} fort"; 42 | 43 | "nAv25k" = "Zufällige Wiedergabe von ${mediaContainer}"; 44 | 45 | "nNozW1" = "Zufällige Wiedergabe von ${mediaContainer}"; 46 | 47 | "o6CuZu" = "Setzte ${mediaContainer} fort"; 48 | 49 | "rvNMpm" = "Fortsetzten"; 50 | 51 | "sLPQxZ" = "Setzte ${mediaContainer} fort"; 52 | 53 | "vUAqfM" = "${mediaItems}"; 54 | 55 | "xdnqvn" = "Container"; 56 | 57 | -------------------------------------------------------------------------------- /Multiplatform/Utility/Intents/en.lproj/Intents.strings: -------------------------------------------------------------------------------- 1 | "1uT6VF" = "Media Search"; 2 | 3 | "5oBQUn" = "${mediaItems}"; 4 | 5 | "ApUiZd" = "${mediaItems}"; 6 | 7 | "ErAaNp" = "Repeat Mode"; 8 | 9 | "G3J6fP" = "Play Media"; 10 | 11 | "H25xHi" = "Resume ${mediaContainer}"; 12 | 13 | "KcCcQu" = "Play ${mediaItems}"; 14 | 15 | "LdrEPN" = "A request to play media."; 16 | 17 | "MbU5ep" = "Shuffle ${mediaItems}"; 18 | 19 | "No0K9w" = "resume"; 20 | 21 | "Pk1Fkx" = "don't resume"; 22 | 23 | "QqdHiR" = "shuffled"; 24 | 25 | "RvX3Zd" = "Playback Speed"; 26 | 27 | "WEjMGd" = "Play ${mediaContainer}"; 28 | 29 | "WktFNa" = "Shuffled"; 30 | 31 | "YYfsKp" = "Play ${mediaContainer}"; 32 | 33 | "cV2HLF" = "Queue Location"; 34 | 35 | "cfJexL" = "Items"; 36 | 37 | "f3d3kn" = "Play"; 38 | 39 | "mCAocc" = "not shuffled"; 40 | 41 | "mIvv3D" = "Resume ${mediaItems}"; 42 | 43 | "nAv25k" = "Shuffle ${mediaContainer}"; 44 | 45 | "nNozW1" = "Shuffle ${mediaContainer}"; 46 | 47 | "o6CuZu" = "Resume ${mediaContainer}"; 48 | 49 | "rvNMpm" = "Resume"; 50 | 51 | "sLPQxZ" = "Resume ${mediaContainer}"; 52 | 53 | "vUAqfM" = "${mediaItems}"; 54 | 55 | "xdnqvn" = "Container"; 56 | 57 | -------------------------------------------------------------------------------- /Multiplatform/Utility/ItemImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemImage.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import NukeUI 10 | import AmpFinKit 11 | 12 | internal struct ItemImage: View { 13 | @Environment(\.redactionReasons) private var redactionReasons 14 | 15 | let cover: Cover? 16 | var cornerRadius: CGFloat? = nil 17 | var priority: ImageRequest.Priority = .normal 18 | 19 | private var request: ImageRequest? { 20 | guard let url = cover?.url else { 21 | return nil 22 | } 23 | 24 | var urlRequest = URLRequest(url: url) 25 | 26 | for header in JellyfinClient.shared.customHTTPHeaders { 27 | urlRequest.setValue(header.value, forHTTPHeaderField: header.key) 28 | } 29 | 30 | return .init(urlRequest: urlRequest, priority: priority) 31 | } 32 | 33 | var body: some View { 34 | GeometryReader { proxy in 35 | let cornerRadius: CGFloat = cornerRadius ?? proxy.size.width > 140 ? 8 : 6 36 | 37 | LazyImage(request: request) { phase in 38 | if let image = phase.image { 39 | image 40 | .resizable() 41 | .clipped() 42 | } else { 43 | ZStack { 44 | if !redactionReasons.contains(.placeholder) { 45 | Image(systemName: "music.note") 46 | .resizable() 47 | .scaledToFit() 48 | .frame(maxWidth: 40) 49 | .foregroundStyle(.gray.opacity(0.5)) 50 | .padding(12) 51 | .opacity(redactionReasons.isEmpty ? 1 : 0) 52 | } 53 | } 54 | .frame(maxWidth: .infinity, maxHeight: .infinity) 55 | .aspectRatio(1, contentMode: .fit) 56 | .background(.gray.opacity(0.1)) 57 | .clipShape(.rect(cornerRadius: cornerRadius, style: .continuous)) 58 | .contentShape(.hoverMenuInteraction, .rect(cornerRadius: cornerRadius, style: .continuous)) 59 | } 60 | } 61 | .aspectRatio(1, contentMode: .fit) 62 | .clipShape(.rect(cornerRadius: cornerRadius)) 63 | .contentShape(.hoverMenuInteraction, .rect(cornerRadius: cornerRadius)) 64 | } 65 | .aspectRatio(contentMode: .fit) 66 | .frame(maxWidth: .infinity, maxHeight: .infinity).aspectRatio(contentMode: .fit) 67 | } 68 | } 69 | 70 | #Preview { 71 | ItemImage(cover: nil) 72 | } 73 | 74 | #Preview { 75 | ItemImage(cover: nil) 76 | .redacted(reason: .placeholder) 77 | } 78 | 79 | #Preview { 80 | ItemImage(cover: .fixture) 81 | } 82 | -------------------------------------------------------------------------------- /Multiplatform/Utility/LibraryDataProviders/LibraryDataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryItemDataProvider.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import AmpFinKit 11 | 12 | public protocol LibraryDataProvider { 13 | var supportsArtistLookup: Bool { get } 14 | var supportsAdvancedFilters: Bool { get } 15 | var albumNotFoundFallbackToLibrary: Bool { get } 16 | 17 | // MARK: Tracks 18 | 19 | func tracks(limit: Int, startIndex: Int, sortOrder: ItemSortOrder, ascending: Bool, favoriteOnly: Bool, search: String?) async throws -> ([Track], Int) 20 | 21 | // MARK: Albums 22 | 23 | func recentAlbums() async throws -> [Album] 24 | func randomAlbums() async throws -> [Album] 25 | 26 | func album(identifier: String) async throws -> Album 27 | func albums(limit: Int, startIndex: Int, sortOrder: ItemSortOrder, ascending: Bool, search: String?) async throws -> ([Album], Int) 28 | 29 | func tracks(albumId: String) async throws -> [Track] 30 | 31 | // MARK: Artists 32 | 33 | func artist(identifier: String) async throws -> Artist 34 | func artists(limit: Int, startIndex: Int, albumOnly: Bool, search: String?) async throws -> ([Artist], Int) 35 | 36 | func tracks(artistId: String, sortOrder: ItemSortOrder, ascending: Bool) async throws -> [Track] 37 | func albums(artistId: String, limit: Int, startIndex: Int, sortOrder: ItemSortOrder, ascending: Bool) async throws -> ([Album], Int) 38 | 39 | // MARK: Playlists 40 | 41 | func playlist(identifier: String) async throws -> Playlist 42 | func playlists(search: String?) async throws -> [Playlist] 43 | func tracks(playlistId: String) async throws -> [Track] 44 | } 45 | 46 | // MARK: Environment 47 | 48 | public extension EnvironmentValues { 49 | @Entry var libraryDataProvider: LibraryDataProvider = MockLibraryDataProvider() 50 | } 51 | -------------------------------------------------------------------------------- /Multiplatform/Utility/QueueButtons.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueueButtons.swift 3 | // Multiplatform 4 | // 5 | // Created by Rasmus Krämer on 25.07.24. 6 | // 7 | 8 | import SwiftUI 9 | import AFPlayback 10 | 11 | internal struct QueueButtons: View { 12 | let action: (Bool) -> Void 13 | 14 | @State private var feedback = false 15 | 16 | var body: some View { 17 | QueueNextButton { 18 | action(true) 19 | } 20 | QueueLaterButton { 21 | action(false) 22 | } 23 | } 24 | } 25 | 26 | internal struct QueueNextButton: View { 27 | @State private var feedback = false 28 | 29 | let action: () -> Void 30 | 31 | var body: some View { 32 | Button { 33 | feedback.toggle() 34 | action() 35 | } label: { 36 | Label("queue.next", systemImage: "text.line.first.and.arrowtriangle.forward") 37 | } 38 | .sensoryFeedback(.success, trigger: feedback) 39 | } 40 | } 41 | internal struct QueueLaterButton: View { 42 | @Environment(NowPlaying.ViewModel.self) private var nowPlayingViewModel 43 | 44 | @State private var feedback = false 45 | 46 | var hideName = false 47 | let action: () -> Void 48 | 49 | var body: some View { 50 | if nowPlayingViewModel.allowQueueLater { 51 | Button { 52 | feedback.toggle() 53 | action() 54 | } label: { 55 | Label("queue.last", systemImage: "text.line.last.and.arrowtriangle.forward") 56 | 57 | if !hideName, let lastName = nowPlayingViewModel.queue.last?.name { 58 | Text("queue.last.name \(lastName)") 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | #Preview { 66 | QueueButtons() { _ in } 67 | } 68 | -------------------------------------------------------------------------------- /Multiplatform/Utility/SortSelector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SortSelector.swift 3 | // Music 4 | // 5 | // Created by Rasmus Krämer on 06.09.23. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import AmpFinKit 11 | 12 | internal struct SortSelector: View { 13 | @Environment(\.libraryDataProvider) private var dataProvider 14 | 15 | @Binding var sortOrder: ItemSortOrder 16 | @Binding var ascending: Bool 17 | 18 | private var supported: [ItemSortOrder] { 19 | if dataProvider.supportsAdvancedFilters { 20 | return ItemSortOrder.allCases 21 | } else { 22 | return [.name, .album, .albumArtist, .artist, .added, .released, .random] 23 | } 24 | } 25 | 26 | var body: some View { 27 | Menu { 28 | ForEach(supported, id: \.hashValue) { option in 29 | Toggle(option.title, isOn: .init(get: { sortOrder == option }, set: { 30 | if $0 { 31 | sortOrder = option 32 | } 33 | })) 34 | } 35 | 36 | Divider() 37 | 38 | Toggle("ascending", isOn: $ascending) 39 | } label: { 40 | Label("sort", systemImage: "arrowshape.\(ascending ? "up" : "down")") 41 | .labelStyle(.iconOnly) 42 | .symbolVariant(.circle) 43 | .contentTransition(.symbolEffect(.automatic)) 44 | } 45 | } 46 | } 47 | 48 | private extension ItemSortOrder { 49 | var title: LocalizedStringKey { 50 | switch self { 51 | case .added: 52 | "sort.added" 53 | case .album: 54 | "sort.album" 55 | case .albumArtist: 56 | "sort.albumArtist" 57 | case .artist: 58 | "sort.artist" 59 | case .name: 60 | "sort.name" 61 | case .plays: 62 | "sort.plays" 63 | case .lastPlayed: 64 | "sort.lastPlayed" 65 | case .released: 66 | "sort.released" 67 | case .runtime: 68 | "sort.runtime" 69 | case .random: 70 | "sort.random" 71 | } 72 | } 73 | } 74 | 75 | #Preview { 76 | @Previewable @State var sortOrder: ItemSortOrder = .random 77 | @Previewable @State var ascending: Bool = true 78 | 79 | SortSelector(sortOrder: $sortOrder, ascending: $ascending) 80 | } 81 | -------------------------------------------------------------------------------- /Multiplatform/de.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | New item 9 | 10 | IntentName 11 | INAddMediaIntent 12 | IntentExamples 13 | 14 | Füge das Lied zu meiner Playlist "Mein-Jam" hinzu 15 | Füge Explorers von Muse zur Playlist "Mein-Jam" hinzu 16 | 17 | 18 | 19 | IntentName 20 | INPlayMediaIntent 21 | IntentExamples 22 | 23 | Spiele Money, Money, Money von Abba 24 | 25 | 26 | 27 | IntentName 28 | INSearchForMediaIntent 29 | IntentExamples 30 | 31 | Zeige mir Lieder vom Daft Punk 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Multiplatform/en.lproj/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INAddMediaIntent 10 | IntentExamples 11 | 12 | Add this song to the playlist "my-jam" 13 | Add Explorers by Muse to the playlist "my-jam" 14 | 15 | 16 | 17 | IntentName 18 | INPlayMediaIntent 19 | IntentExamples 20 | 21 | Play Money, Money, Money by Abba 22 | 23 | 24 | 25 | IntentName 26 | INSearchForMediaIntent 27 | IntentExamples 28 | 29 | Show my songs by Daft Punk 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy 2 | 3 | The app itself does not collect any data and only connects to your Jellyfin instance 4 | -------------------------------------------------------------------------------- /Screenshots/Album (iOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Album (iOS).png -------------------------------------------------------------------------------- /Screenshots/Album (iPadOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Album (iPadOS).png -------------------------------------------------------------------------------- /Screenshots/Albums (iOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Albums (iOS).png -------------------------------------------------------------------------------- /Screenshots/Albums (iPadOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Albums (iPadOS).png -------------------------------------------------------------------------------- /Screenshots/Library (iOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Library (iOS).png -------------------------------------------------------------------------------- /Screenshots/Lyrics (iOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Lyrics (iOS).png -------------------------------------------------------------------------------- /Screenshots/Lyrics (iPadOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Lyrics (iPadOS).png -------------------------------------------------------------------------------- /Screenshots/Player (iOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Player (iOS).png -------------------------------------------------------------------------------- /Screenshots/Playlist (iOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Playlist (iOS).png -------------------------------------------------------------------------------- /Screenshots/Playlist (iPadOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Playlist (iPadOS).png -------------------------------------------------------------------------------- /Screenshots/Queue (iOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Queue (iOS).png -------------------------------------------------------------------------------- /Screenshots/Queue (iPadOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Queue (iPadOS).png -------------------------------------------------------------------------------- /Screenshots/Tracks (iPadOS).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasmuslos/AmpFin/fbc375234878adb0be3438db8461b006c10db12b/Screenshots/Tracks (iPadOS).png -------------------------------------------------------------------------------- /Siri Extension/Entitlements.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.${BUNDLE_ID_PREFIX}.ampfin 10 | 11 | com.apple.security.network.client 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Siri Extension/Handler+Add.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Handler+Add.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 27.04.24. 6 | // 7 | 8 | import Foundation 9 | import Intents 10 | import AFFoundation 11 | import AFExtension 12 | import AFNetwork 13 | 14 | extension IntentHandler: INAddMediaIntentHandling { 15 | func handle(intent: INAddMediaIntent) async -> INAddMediaIntentResponse { 16 | .init(code: .handleInApp, userActivity: nil) 17 | } 18 | 19 | func resolveMediaItems(for intent: INAddMediaIntent) async -> [INAddMediaMediaItemResolutionResult] { 20 | guard JellyfinClient.shared.authorized else { 21 | return [.unsupported(forReason: .loginRequired)] 22 | } 23 | 24 | guard let mediaSearch = intent.mediaSearch else { 25 | return [.unsupported(forReason: .unsupportedMediaType)] 26 | } 27 | 28 | if mediaSearch.reference == .currentlyPlaying { 29 | return [] 30 | } 31 | 32 | do { 33 | let items = try await resolveMediaItems(mediaSearch: mediaSearch) 34 | 35 | var resolved = [INAddMediaMediaItemResolutionResult]() 36 | for item in items { 37 | resolved.append(.init(mediaItemResolutionResult: .success(with: item))) 38 | } 39 | 40 | return resolved 41 | } catch SearchError.notFound { 42 | return [.unsupported()] 43 | } catch SearchError.unsupportedMediaType { 44 | return [.unsupported(forReason: .unsupportedMediaType)] 45 | } catch { 46 | print(error) 47 | return [.unsupported(forReason: .serviceUnavailable)] 48 | } 49 | } 50 | 51 | func resolveMediaDestination(for intent: INAddMediaIntent) async -> INAddMediaMediaDestinationResolutionResult { 52 | guard let mediaDestination = intent.mediaDestination, let playlistName = mediaDestination.playlistName else { 53 | return .unsupported(forReason: .playlistNameNotFound) 54 | } 55 | 56 | do { 57 | var playlists = try await MediaResolver.shared.search(playlistName: playlistName, runOffline: intent.mediaSearch?.reference == .my) 58 | playlists.sort { $0.name.levenshteinDistanceScore(to: playlistName) > $1.name.levenshteinDistanceScore(to: playlistName) } 59 | 60 | guard let playlist = playlists.first else { 61 | throw MediaResolver.ResolveError.missing 62 | } 63 | 64 | return .success(with: .playlist(playlist.name)) 65 | } catch { 66 | return .unsupported(forReason: .playlistNameNotFound) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Siri Extension/Handler+Play.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Handler+Play.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 26.04.24. 6 | // 7 | 8 | import Foundation 9 | import Intents 10 | import AFFoundation 11 | import AFExtension 12 | import AFNetwork 13 | 14 | extension IntentHandler: INPlayMediaIntentHandling { 15 | func handle(intent: INPlayMediaIntent) async -> INPlayMediaIntentResponse { 16 | return .init(code: .handleInApp, userActivity: nil) 17 | } 18 | 19 | func resolveMediaItems(for intent: INPlayMediaIntent) async -> [INPlayMediaMediaItemResolutionResult] { 20 | guard JellyfinClient.shared.authorized else { 21 | return [.unsupported(forReason: .loginRequired)] 22 | } 23 | 24 | if let mediaItems = intent.mediaItems, !mediaItems.isEmpty { 25 | return INPlayMediaMediaItemResolutionResult.successes(with: mediaItems) 26 | } 27 | 28 | guard let mediaSearch = intent.mediaSearch else { 29 | if intent.resumePlayback == true { 30 | return [] 31 | } 32 | 33 | return [.unsupported(forReason: .unsupportedMediaType)] 34 | } 35 | 36 | do { 37 | let items = try await resolveMediaItems(mediaSearch: mediaSearch) 38 | 39 | var resolved = [INPlayMediaMediaItemResolutionResult]() 40 | for item in items { 41 | resolved.append(.init(mediaItemResolutionResult: .success(with: item))) 42 | } 43 | 44 | return resolved 45 | } catch SearchError.unsupportedMediaType { 46 | return [.unsupported(forReason: .unsupportedMediaType)] 47 | } catch SearchError.notFound { 48 | return [.unsupported()] 49 | } catch { 50 | return [.unsupported(forReason: .serviceUnavailable)] 51 | } 52 | } 53 | 54 | func resolvePlaybackSpeed(for intent: INPlayMediaIntent) async -> INPlayMediaPlaybackSpeedResolutionResult { 55 | let speed = intent.playbackSpeed ?? 1 56 | 57 | if speed > 1 { 58 | return .unsupported(forReason: .aboveMaximum) 59 | } else if speed < 1 { 60 | return .unsupported(forReason: .belowMinimum) 61 | } 62 | 63 | return .success(with: 1) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Siri Extension/Handler+Search.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Handler+Search.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 26.04.24. 6 | // 7 | 8 | import Foundation 9 | import Intents 10 | import AFFoundation 11 | import AFNetwork 12 | 13 | extension IntentHandler: INSearchForMediaIntentHandling { 14 | func handle(intent: INSearchForMediaIntent) async -> INSearchForMediaIntentResponse { 15 | guard let item = intent.mediaItems?.first, let identifier = item.identifier else { 16 | return .init(code: .failure, userActivity: nil) 17 | } 18 | 19 | var activity: NSUserActivity 20 | 21 | switch item.type { 22 | case .album: 23 | activity = .init(activityType: "io.rfk.ampfin.album") 24 | activity.userInfo = [ 25 | "albumId": identifier, 26 | ] 27 | case .artist: 28 | activity = .init(activityType: "io.rfk.ampfin.artist") 29 | activity.userInfo = [ 30 | "artistId": identifier, 31 | ] 32 | case .playlist: 33 | activity = .init(activityType: "io.rfk.ampfin.playlist") 34 | activity.userInfo = [ 35 | "playlistId": identifier, 36 | ] 37 | case .song: 38 | activity = .init(activityType: "io.rfk.ampfin.track") 39 | activity.userInfo = [ 40 | "trackId": identifier, 41 | ] 42 | 43 | default: 44 | return .init(code: .failure, userActivity: nil) 45 | } 46 | 47 | activity.title = item.title 48 | activity.persistentIdentifier = identifier 49 | 50 | return .init(code: .continueInApp, userActivity: activity) 51 | } 52 | 53 | func resolveMediaItems(for intent: INSearchForMediaIntent) async -> [INSearchForMediaMediaItemResolutionResult] { 54 | guard JellyfinClient.shared.authorized else { 55 | return [.unsupported(forReason: .loginRequired)] 56 | } 57 | 58 | guard let mediaSearch = intent.mediaSearch else { 59 | return [.unsupported(forReason: .unsupportedMediaType)] 60 | } 61 | 62 | do { 63 | let items = try await resolveMediaItems(mediaSearch: mediaSearch) 64 | 65 | var resolved = [INSearchForMediaMediaItemResolutionResult]() 66 | for item in items { 67 | resolved.append(.init(mediaItemResolutionResult: .success(with: item))) 68 | } 69 | 70 | return resolved 71 | } catch SearchError.unsupportedMediaType { 72 | return [.unsupported(forReason: .unsupportedMediaType)] 73 | } catch SearchError.notFound { 74 | return [.unsupported()] 75 | } catch { 76 | return [.unsupported(forReason: .serviceUnavailable)] 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Siri Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | IntentsRestrictedWhileLocked 10 | 11 | IntentsRestrictedWhileProtectedDataUnavailable 12 | 13 | IntentsSupported 14 | 15 | INAddMediaIntent 16 | INPlayMediaIntent 17 | INSearchForMediaIntent 18 | 19 | SupportedMediaCategories 20 | 21 | INMediaCategoryMusic 22 | 23 | 24 | NSExtensionPointIdentifier 25 | com.apple.intents-service 26 | NSExtensionPrincipalClass 27 | $(PRODUCT_MODULE_NAME).IntentHandler 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Siri Extension/IntentHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntentHandler.swift 3 | // Siri Extension 4 | // 5 | // Created by Rasmus Krämer on 06.01.24. 6 | // 7 | 8 | import Intents 9 | import AFFoundation 10 | import AFExtension 11 | 12 | final internal class IntentHandler: INExtension { 13 | override func handler(for intent: INIntent) -> Any? { 14 | self 15 | } 16 | 17 | func resolveMediaItems(mediaSearch: INMediaSearch) async throws -> [INMediaItem] { 18 | guard let primaryName = mediaSearch.mediaName ?? mediaSearch.albumName ?? mediaSearch.artistName else { 19 | throw SearchError.unsupportedMediaType 20 | } 21 | 22 | let performOfflineSearch = mediaSearch.reference == .my 23 | 24 | var results = [Item]() 25 | let unknownType = mediaSearch.mediaType == .music || mediaSearch.mediaType == .unknown 26 | 27 | if !unknownType && !(mediaSearch.mediaType == .album || mediaSearch.mediaType == .artist || mediaSearch.mediaType == .playlist || mediaSearch.mediaType == .song) { 28 | throw SearchError.unsupportedMediaType 29 | } 30 | 31 | if mediaSearch.mediaType == .album || unknownType { 32 | if let albums = try? await MediaResolver.shared.search(albumName: primaryName, artistName: mediaSearch.artistName, runOffline: performOfflineSearch) { 33 | results += albums 34 | } 35 | } 36 | if mediaSearch.mediaType == .artist || unknownType { 37 | if let artists = try? await MediaResolver.shared.search(artistName: primaryName, runOffline: performOfflineSearch) { 38 | results += artists 39 | } 40 | } 41 | if mediaSearch.mediaType == .playlist || unknownType { 42 | if let playlists = try? await MediaResolver.shared.search(playlistName: primaryName, runOffline: performOfflineSearch) { 43 | results += playlists 44 | } 45 | } 46 | if mediaSearch.mediaType == .song || unknownType { 47 | if let tracks = try? await MediaResolver.shared.search(trackName: primaryName, albumName: mediaSearch.albumName, artistName: mediaSearch.artistName, runOffline: performOfflineSearch) { 48 | results += tracks 49 | } 50 | } 51 | 52 | guard !results.isEmpty else { 53 | throw SearchError.notFound 54 | } 55 | 56 | results.sort { $0.name.levenshteinDistanceScore(to: primaryName) > $1.name.levenshteinDistanceScore(to: primaryName) } 57 | 58 | return await MediaResolver.shared.convert(items: results) 59 | } 60 | 61 | enum SearchError: Error { 62 | case notFound 63 | case unsupportedMediaType 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Siri Extension/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryUserDefaults 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | 1C8F.1 13 | 14 | 15 | 16 | NSPrivacyTrackingDomains 17 | 18 | NSPrivacyTracking 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Siri Extension/String+Distance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Rasmus Krämer on 06.01.24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal extension String { 11 | func levenshteinDistanceScore(to string: String) -> Double { 12 | let firstString = self.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) 13 | let secondString = string.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) 14 | 15 | let empty = [Int](repeating:0, count: secondString.count) 16 | var last = [Int](0...secondString.count) 17 | 18 | for (i, tLett) in firstString.enumerated() { 19 | var cur = [i + 1] + empty 20 | for (j, sLett) in secondString.enumerated() { 21 | cur[j + 1] = tLett == sLett ? last[j] : Swift.min(last[j], last[j + 1], cur[j])+1 22 | } 23 | last = cur 24 | } 25 | 26 | let lowestScore = max(firstString.count, secondString.count) 27 | 28 | if let validDistance = last.last { 29 | return 1 - (Double(validDistance) / Double(lowestScore)) 30 | } 31 | 32 | return 0.0 33 | } 34 | } 35 | --------------------------------------------------------------------------------