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