diff --git a/fastlane/Appfile b/fastlane/Appfile index d3d7b4b..7df6613 100644 --- a/fastlane/Appfile +++ b/fastlane/Appfile @@ -1 +1 @@ -app_identifier("com.penerbangwalet.flo") +app_identifier("com.penerbangwalet.flo,com.penerbangwalet.flo.watchkitapp") diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 4507d59..6a8a1d9 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -7,11 +7,19 @@ objects = { /* Begin PBXBuildFile section */ + 2F9D6FC45D72E9812C06DEE1 /* WatchLibraryResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D2299BFD0380D9B04BBFF5 /* WatchLibraryResponder.swift */; }; + 4BFDB7E71EDDA3BC9DB60860 /* ArtistDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D68C4A77696683D132DD518 /* ArtistDetailViewModel.swift */; }; + 520EF9C46F4E2CC1BC306993 /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2F567171E8CE853FC5F151 /* WatchConnectivityManager.swift */; }; + 55CFFAB52342C01E366F87B0 /* CarPlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2645E70081EEE2C0396E04D8 /* CarPlayCoordinator.swift */; }; + 5E64C3D71B02BE8FEDF5FF3C /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B2E217C6248B16912297F /* CarPlaySceneDelegate.swift */; }; + 6BDAFB58A4C77A82BCCFD0F4 /* CarPlay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE85DCBEE7ADB56E8BFD6338 /* CarPlay.framework */; }; + 7C09E28C1BFFDB5D5F124EC9 /* CarPlayNowPlayingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EEF17030A154EB98122A089 /* CarPlayNowPlayingManager.swift */; }; B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02A003E2F3666240024E8EC /* UIScreen+.swift */; }; B0BAAAA62F31F0A0002A5FBB /* RadiosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */; }; B0BAAAA92F3213AF002A5FBB /* RadiosViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */; }; B0BAAAAB2F3214F7002A5FBB /* Radio.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAA2F3214F7002A5FBB /* Radio.swift */; }; B0BAAAAD2F3216A0002A5FBB /* RadioService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAC2F321697002A5FBB /* RadioService.swift */; }; + C3C4F155C7432144F2925F57 /* ArtistRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0387072F26D2718279566F4 /* ArtistRadio.swift */; }; C401D09A2C5AED9F009F91C7 /* LocalFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */; }; C4051DFF2CD25BBA0039D062 /* ArtistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */; }; C4100A692CE78B25001BC9BE /* PlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4100A682CE78B21001BC9BE /* PlaylistView.swift */; }; @@ -81,10 +89,23 @@ C4F870D02CEFD25900312F8A /* StatCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F870CF2CEFD24D00312F8A /* StatCardView.swift */; }; C4FE524B2C14E1F70053763A /* UserDefaultsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FE524A2C14E1F70053763A /* UserDefaultsManager.swift */; }; C4FE524D2C14E71B0053763A /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FE524C2C14E71B0053763A /* KeychainManager.swift */; }; + FA138533DE126212552B3A51 /* CarPlayImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C89AD8466BDBA43F95F5DC /* CarPlayImageLoader.swift */; }; + FB380D022BD218AA1CE914B3 /* InAppPurchaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92A728780B0275FE854EC627 /* InAppPurchaseManager.swift */; }; + FD0E6B93BA00F1E355588795 /* PlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8A0D339760884271C69E36B /* PlaybackCoordinator.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0EEF17030A154EB98122A089 /* CarPlayNowPlayingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarPlayNowPlayingManager.swift; sourceTree = ""; }; + 13D2299BFD0380D9B04BBFF5 /* WatchLibraryResponder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchLibraryResponder.swift; sourceTree = ""; }; + 2645E70081EEE2C0396E04D8 /* CarPlayCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarPlayCoordinator.swift; sourceTree = ""; }; + 3D2F567171E8CE853FC5F151 /* WatchConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchConnectivityManager.swift; sourceTree = ""; }; + 566B2E217C6248B16912297F /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; + 5D68C4A77696683D132DD518 /* ArtistDetailViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ArtistDetailViewModel.swift; sourceTree = ""; }; + 7E2C854C4AFDB5F757411C4C /* flo.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = flo.entitlements; sourceTree = ""; }; + 80C89AD8466BDBA43F95F5DC /* CarPlayImageLoader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarPlayImageLoader.swift; sourceTree = ""; }; + 92A728780B0275FE854EC627 /* InAppPurchaseManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppPurchaseManager.swift; sourceTree = ""; }; B02A003E2F3666240024E8EC /* UIScreen+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+.swift"; sourceTree = ""; }; + B0387072F26D2718279566F4 /* ArtistRadio.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ArtistRadio.swift; sourceTree = ""; }; B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosView.swift; sourceTree = ""; }; B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosViewModel.swift; sourceTree = ""; }; B0BAAAAA2F3214F7002A5FBB /* Radio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radio.swift; sourceTree = ""; }; @@ -155,6 +176,8 @@ C4F870CF2CEFD24D00312F8A /* StatCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatCardView.swift; sourceTree = ""; }; C4FE524A2C14E1F70053763A /* UserDefaultsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsManager.swift; sourceTree = ""; }; C4FE524C2C14E71B0053763A /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; + CE85DCBEE7ADB56E8BFD6338 /* CarPlay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = CarPlay.framework; sourceTree = SDKROOT; }; + E8A0D339760884271C69E36B /* PlaybackCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlaybackCoordinator.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -168,12 +191,44 @@ C45F0E312CE5582C00F75C7A /* Nuke in Frameworks */, C45F0E2C2CE4CCEA00F75C7A /* Pulse in Frameworks */, C415F5512C11912800E3E1D2 /* KeychainAccess in Frameworks */, + 6BDAFB58A4C77A82BCCFD0F4 /* CarPlay.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 09524ACAFDF0F9A349F1CCA8 /* Artists */ = { + isa = PBXGroup; + children = ( + C4824D242CE9086E003EAB52 /* ArtistDetailView.swift */, + C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */, + 5D68C4A77696683D132DD518 /* ArtistDetailViewModel.swift */, + ); + name = Artists; + path = Artists; + sourceTree = ""; + }; + 3187E4E95236423EE8702B86 /* CarPlay */ = { + isa = PBXGroup; + children = ( + 566B2E217C6248B16912297F /* CarPlaySceneDelegate.swift */, + 2645E70081EEE2C0396E04D8 /* CarPlayCoordinator.swift */, + 0EEF17030A154EB98122A089 /* CarPlayNowPlayingManager.swift */, + 80C89AD8466BDBA43F95F5DC /* CarPlayImageLoader.swift */, + ); + name = CarPlay; + path = CarPlay; + sourceTree = ""; + }; + 6BEE21E914C1B4C06D14C578 /* Frameworks */ = { + isa = PBXGroup; + children = ( + CE85DCBEE7ADB56E8BFD6338 /* CarPlay.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; B0BAAAA72F32139D002A5FBB /* Radios */ = { isa = PBXGroup; children = ( @@ -210,6 +265,7 @@ C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */, C456D8F52F2FBD61002AAB8B /* LRCLIB.swift */, C456D8FB2F2FF397002AAB8B /* LyricsLine.swift */, + B0387072F26D2718279566F4 /* ArtistRadio.swift */, ); path = Models; sourceTree = ""; @@ -263,6 +319,10 @@ C4D7F84E2C7F2C5D00165EFD /* PlaybackService.swift */, B0BAAAAC2F321697002A5FBB /* RadioService.swift */, C456D8F72F2FBD64002AAB8B /* LRCLIBService.swift */, + 92A728780B0275FE854EC627 /* InAppPurchaseManager.swift */, + E8A0D339760884271C69E36B /* PlaybackCoordinator.swift */, + 3D2F567171E8CE853FC5F151 /* WatchConnectivityManager.swift */, + 13D2299BFD0380D9B04BBFF5 /* WatchLibraryResponder.swift */, ); path = Services; sourceTree = ""; @@ -284,6 +344,7 @@ C49134542C15C47F00CCF2EB /* README.md */, C4E8D95A2B763BA900C2353E /* flo */, C4E8D9592B763BA900C2353E /* Products */, + 6BEE21E914C1B4C06D14C578 /* Frameworks */, ); sourceTree = ""; }; @@ -318,15 +379,16 @@ C42E7E172CE7EF4D00505B4E /* PlaylistDetailView.swift */, C4F870CF2CEFD24D00312F8A /* StatCardView.swift */, C47876012C2BF15900184A33 /* AlbumsView.swift */, - C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */, C47876032C2BFFF900184A33 /* SongView.swift */, C4100A682CE78B21001BC9BE /* PlaylistView.swift */, - C4824D242CE9086E003EAB52 /* ArtistDetailView.swift */, C467AD502D3264AE00644E68 /* FloooViewModel.swift */, C429DB312D33C704009F2684 /* DownloadButtonView.swift */, C429DB2F2D33AE81009F2684 /* DownloadQueueView.swift */, C4DFFA202D32E769003B9C4E /* DownloadViewModel.swift */, C456D8FD2F300D37002AAB8B /* LyricsView.swift */, + 3187E4E95236423EE8702B86 /* CarPlay */, + 7E2C854C4AFDB5F757411C4C /* flo.entitlements */, + 09524ACAFDF0F9A349F1CCA8 /* Artists */, ); path = flo; sourceTree = ""; @@ -490,6 +552,16 @@ C4875E042C149F9A00D9BAEB /* APIManager.swift in Sources */, C4A4BF312C14433D00363290 /* HomeView.swift in Sources */, C4A4BF392C14445000363290 /* PreferencesView.swift in Sources */, + 5E64C3D71B02BE8FEDF5FF3C /* CarPlaySceneDelegate.swift in Sources */, + 55CFFAB52342C01E366F87B0 /* CarPlayCoordinator.swift in Sources */, + 7C09E28C1BFFDB5D5F124EC9 /* CarPlayNowPlayingManager.swift in Sources */, + FA138533DE126212552B3A51 /* CarPlayImageLoader.swift in Sources */, + 4BFDB7E71EDDA3BC9DB60860 /* ArtistDetailViewModel.swift in Sources */, + C3C4F155C7432144F2925F57 /* ArtistRadio.swift in Sources */, + FB380D022BD218AA1CE914B3 /* InAppPurchaseManager.swift in Sources */, + FD0E6B93BA00F1E355588795 /* PlaybackCoordinator.swift in Sources */, + 520EF9C46F4E2CC1BC306993 /* WatchConnectivityManager.swift in Sources */, + 2F9D6FC45D72E9812C06DEE1 /* WatchLibraryResponder.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -624,6 +696,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = flo/flo.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 205; @@ -634,7 +707,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = flo/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = ""; @@ -664,6 +737,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = flo/flo.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 205; @@ -674,7 +748,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = flo/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = ""; diff --git a/flo/App.swift b/flo/App.swift index 09186fa..539f510 100644 --- a/flo/App.swift +++ b/flo/App.swift @@ -10,18 +10,24 @@ import SwiftUI @main struct FloApp: App { + @StateObject private var inAppPurchaseManager = InAppPurchaseManager() + init() { do { try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) - try AVAudioSession.sharedInstance().setActive(true) } catch { print(error) } + + #if os(iOS) + WatchConnectivityManager.shared.start() + #endif } var body: some Scene { WindowGroup { ContentView() + .environmentObject(inAppPurchaseManager) } } } diff --git a/flo/ArtistDetailView.swift b/flo/ArtistDetailView.swift deleted file mode 100644 index 9b92f6c..0000000 --- a/flo/ArtistDetailView.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// ArtistDetailView.swift -// flo -// -// Created by rizaldy on 17/11/24. -// - -import SwiftUI - -struct ArtistDetailView: View { - @EnvironmentObject var viewModel: AlbumViewModel - - @State private var isExpanded = false - - var artist: Artist - - let columns = [ - GridItem(.flexible()), - GridItem(.flexible()), - ] - - func stripBiography(biography: String) -> String { - guard let regex = try? NSRegularExpression(pattern: "]*>.*?") else { - return biography.isEmpty ? "No biography available" : biography - } - - let range = NSRange(location: 0, length: biography.utf16.count) - - let stripped = regex.stringByReplacingMatches( - in: biography, range: range, withTemplate: "") - - return stripped == "" ? "No biography available" : stripped - } - - var body: some View { - ScrollView { - VStack(alignment: .leading) { - Text(artist.name) - .customFont(.title) - .fontWeight(.bold) - .multilineTextAlignment(.leading) - .padding(.bottom, 3) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(stripBiography(biography: artist.biography ?? "")) - .customFont(.subheadline) - .lineSpacing(3) - .multilineTextAlignment(.leading) - .lineLimit(isExpanded ? nil : 3) - .onTapGesture { - isExpanded.toggle() - } - } - .padding() - .onAppear { - viewModel.fetchAlbumsByArtist(id: artist.id) - } - - LazyVGrid(columns: columns) { - ForEach(viewModel.artistAlbums) { album in - NavigationLink { - AlbumView(viewModel: viewModel) - .onAppear { - viewModel.setActiveAlbum(album: album) - } - } label: { - AlbumsView(viewModel: viewModel, album: album) - } - } - }.padding(.bottom, 100) - } - } -} diff --git a/flo/Artists/ArtistDetailView.swift b/flo/Artists/ArtistDetailView.swift new file mode 100644 index 0000000..63293a4 --- /dev/null +++ b/flo/Artists/ArtistDetailView.swift @@ -0,0 +1,146 @@ +// +// ArtistDetailView.swift +// flo +// +// Created by rizaldy on 17/11/24. +// + +import SwiftUI + +struct ArtistDetailView: View { + @EnvironmentObject var viewModel: AlbumViewModel + @EnvironmentObject var playerViewModel: PlayerViewModel + + @StateObject var artistDetailViewModel = ArtistDetailViewModel() + + @State private var isExpanded = false + @State private var displayAlert: Bool = false + + let artist: Artist + + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + ] + + func stripBiography(biography: String) -> String { + guard let regex = try? NSRegularExpression(pattern: "]*>.*?") else { + return biography.isEmpty ? "No biography available" : biography + } + + let range = NSRange(location: 0, length: biography.utf16.count) + + let stripped = regex.stringByReplacingMatches( + in: biography, range: range, withTemplate: "") + + return stripped == "" ? "No biography available" : stripped + } + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + Text(artist.name) + .customFont(.title) + .fontWeight(.bold) + .multilineTextAlignment(.leading) + .padding(.bottom, 3) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(stripBiography(biography: artist.biography ?? "")) + .customFont(.subheadline) + .lineSpacing(3) + .multilineTextAlignment(.leading) + .lineLimit(isExpanded ? nil : 3) + .onTapGesture { + isExpanded.toggle() + } + } + .padding() + .onAppear { + viewModel.fetchAlbumsByArtist(id: artist.id) + } + HStack { + Button(action: { + artistDetailViewModel.fetchArtistRadio(artist: artist) + }) { + HStack { + if artistDetailViewModel.isLoadingRadio { + ProgressView() + .tint(Color(UIColor.systemBackground)) + } else { + Image(systemName: "dot.radiowaves.up.forward") + Text("Play Artist Radio") + } + } + .font(.subheadline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.accentColor) + .cornerRadius(20) + } + .disabled(artistDetailViewModel.isLoadingRadio || artistDetailViewModel.isLoadingTopSongs) + + Button(action: { + artistDetailViewModel.fetchTopSongs(artist: artist) + }) { + HStack { + if artistDetailViewModel.isLoadingTopSongs { + ProgressView() + .tint(Color(UIColor.systemBackground)) + } else { + Image(systemName: "dot.radiowaves.up.forward") + Text("Play Top Songs") + } + } + .font(.subheadline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.accentColor) + .cornerRadius(20) + } + .disabled(artistDetailViewModel.isLoadingRadio || artistDetailViewModel.isLoadingTopSongs) + } + .foregroundStyle(.background) + .frame(maxWidth: .infinity, minHeight: 40) + .padding(.horizontal) + .padding(.bottom, 8) + + LazyVGrid(columns: columns) { + ForEach(viewModel.artistAlbums) { album in + NavigationLink { + AlbumView(viewModel: viewModel) + .onAppear { + viewModel.setActiveAlbum(album: album) + } + } label: { + AlbumsView(viewModel: viewModel, album: album) + } + } + }.padding(.bottom, 100) + } + .onReceive(artistDetailViewModel.playableSongs) { songs in + if songs.isEmpty { + displayAlert = true + } else { + let playable = RadioEntity( + id: artist.id, + name: "\(artist.name) Radio", + songs: songs, + artist: artist.name + ) + playerViewModel.playItem(item: playable, isFromLocal: false) + } + } + .alert("Artist Radio", isPresented: $displayAlert) { + Button("OK") { + artistDetailViewModel.errorMessage = nil + } + } message: { + Text(artistDetailViewModel.errorMessage ?? "") + } + } +} diff --git a/flo/Artists/ArtistDetailViewModel.swift b/flo/Artists/ArtistDetailViewModel.swift new file mode 100644 index 0000000..c55455a --- /dev/null +++ b/flo/Artists/ArtistDetailViewModel.swift @@ -0,0 +1,59 @@ +// flo + +import Foundation +import Combine + +class ArtistDetailViewModel: ObservableObject { + var playableSongs: PassthroughSubject<[Song], Never> = .init() + + @Published var isLoadingRadio = false + @Published var isLoadingTopSongs = false + @Published var errorMessage: String? = nil + + func fetchArtistRadio(artist: Artist) { + isLoadingRadio = true + + RadioService.shared.getSimilarSongs(id: artist.id) { [weak self] result in + DispatchQueue.main.async { + guard let self else { return } + + self.isLoadingRadio = false + + switch result { + case .success(let songs): + if songs.isEmpty { + self.errorMessage = "No similar songs found for this artist." + } + + self.playableSongs.send(songs) + case .failure(_): + self.errorMessage = "Failed to load Artist Radio. Please try again." + self.playableSongs.send([]) + } + } + } + } + + func fetchTopSongs(artist: Artist) { + isLoadingTopSongs = true + + RadioService.shared.getTopSongs(artistName: artist.name, count: 20) { [weak self] result in + DispatchQueue.main.async { + guard let self else { return } + + self.isLoadingTopSongs = false + + switch result { + case .success(let songs): + if songs.isEmpty { + self.errorMessage = "No top songs found for this artist." + } + self.playableSongs.send(songs) + case .failure(_): + self.errorMessage = "Failed to load Artist Top Songs. Please try again." + self.playableSongs.send([]) + } + } + } + } +} diff --git a/flo/ArtistsView.swift b/flo/Artists/ArtistsView.swift similarity index 100% rename from flo/ArtistsView.swift rename to flo/Artists/ArtistsView.swift diff --git a/flo/CarPlay/CarPlayCoordinator.swift b/flo/CarPlay/CarPlayCoordinator.swift new file mode 100644 index 0000000..658fce0 --- /dev/null +++ b/flo/CarPlay/CarPlayCoordinator.swift @@ -0,0 +1,618 @@ +// +// CarPlayCoordinator.swift +// flo +// + +import CarPlay +import Combine + +class CarPlayCoordinator { + private let interfaceController: CPInterfaceController + private let playerVM = PlayerViewModel.shared + private var nowPlayingManager: CarPlayNowPlayingManager? + + init(interfaceController: CPInterfaceController) { + self.interfaceController = interfaceController + } + + func start() { + nowPlayingManager = CarPlayNowPlayingManager( + playerVM: playerVM, interfaceController: interfaceController) + nowPlayingManager?.configure() + + let tabBar = CPTabBarTemplate(templates: [ + makeLibraryTab(), + makePlaylistsTab(), + makeRadioTab(), + makeDownloadsTab(), + ]) + + interfaceController.setRootTemplate(tabBar, animated: true, completion: nil) + } + + func stop() { + nowPlayingManager?.teardown() + nowPlayingManager = nil + } + + // MARK: - Now Playing Navigation + + private func showNowPlaying() { + if !(interfaceController.topTemplate is CPNowPlayingTemplate) { + interfaceController.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil) + } + } + + // MARK: - Library Tab + + private func makeLibraryTab() -> CPListTemplate { + let albumsItem = CPListItem( + text: "Albums", detailText: nil, + image: UIImage(systemName: "square.stack")) + albumsItem.handler = { [weak self] _, completion in + self?.showAlbumsList() + completion() + } + + let artistsItem = CPListItem( + text: "Artists", detailText: nil, + image: UIImage(systemName: "music.mic")) + artistsItem.handler = { [weak self] _, completion in + self?.showArtistsList() + completion() + } + + let songsItem = CPListItem( + text: "Songs", detailText: nil, + image: UIImage(systemName: "music.note")) + songsItem.handler = { [weak self] _, completion in + self?.showSongsList() + completion() + } + + let section = CPListSection(items: [albumsItem, artistsItem, songsItem]) + let template = CPListTemplate(title: "Library", sections: [section]) + template.tabImage = UIImage(systemName: "square.grid.2x2") + + return template + } + + // MARK: - Albums + + private func showAlbumsList() { + let loadingTemplate = CPListTemplate(title: "Albums", sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getAlbum { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let albums): + let items = albums.map { album -> CPListItem in + let item = CPListItem( + text: album.name, + detailText: album.artist + ) + item.handler = { [weak self] _, completion in + self?.showAlbumDetail(album: album, isDownloaded: false) + completion() + } + let coverURL = AlbumService.shared.getAlbumCover( + artistName: album.artist, + albumName: album.name, + albumId: album.id + ) + CarPlayImageLoader.loadImage(from: coverURL) { image in + item.setImage(image) + } + return item + } + loadingTemplate.updateSections([CPListSection(items: items)]) + + case .failure: + let errorItem = CPListItem(text: "Failed to load albums", detailText: "Tap to retry") + errorItem.handler = { [weak self] _, completion in + self?.interfaceController.popTemplate(animated: false, completion: nil) + self?.showAlbumsList() + completion() + } + loadingTemplate.updateSections([CPListSection(items: [errorItem])]) + } + } + } + } + + // MARK: - Album Detail + + private func showAlbumDetail(album: Album, isDownloaded: Bool) { + let detailTemplate = CPListTemplate(title: album.name, sections: []) + interfaceController.pushTemplate(detailTemplate, animated: true, completion: nil) + + AlbumService.shared.getSongFromAlbum(id: album.id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + + var albumWithSongs = album + + switch result { + case .success(let songs): + let localSongs = AlbumService.shared.getSongsByAlbumId(albumId: album.id) + let remoteSongs = songs.filter { song in + !localSongs.contains(where: { $0.id == song.id }) + } + albumWithSongs.songs = + (localSongs + remoteSongs).sorted { + if $0.discNumber == $1.discNumber { + return $0.trackNumber < $1.trackNumber + } + return $0.discNumber < $1.discNumber + } + case .failure: + albumWithSongs.songs = AlbumService.shared.getSongsByAlbumId(albumId: album.id) + } + + self.buildAlbumDetailSections( + template: detailTemplate, + album: albumWithSongs, + isDownloaded: isDownloaded + ) + } + } + } + + private func buildAlbumDetailSections( + template: CPListTemplate, album: Album, isDownloaded: Bool + ) { + let playAllItem = CPListItem( + text: "Play All", + detailText: "\(album.songs.count) tracks", + image: UIImage(systemName: "play.fill") + ) + playAllItem.handler = { [weak self] _, completion in + self?.playerVM.playItem(item: album, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } + + let shuffleItem = CPListItem( + text: "Shuffle", + detailText: nil, + image: UIImage(systemName: "shuffle") + ) + shuffleItem.handler = { [weak self] _, completion in + self?.playerVM.shuffleItem(item: album, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } + + let actionSection = CPListSection(items: [playAllItem, shuffleItem]) + + let trackItems = album.songs.enumerated().map { (idx, song) -> CPListItem in + let item = CPListItem( + text: song.title, + detailText: song.artist + ) + item.handler = { [weak self] _, completion in + self?.playerVM.playBySong(idx: idx, item: album, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } + return item + } + let trackSection = CPListSection( + items: trackItems, + header: "Tracks", + sectionIndexTitle: nil + ) + + template.updateSections([actionSection, trackSection]) + } + + // MARK: - Artists + + private func showArtistsList() { + let loadingTemplate = CPListTemplate(title: "Artists", sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getArtists { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let artists): + let items = artists.map { artist -> CPListItem in + let item = CPListItem( + text: artist.name, + detailText: "\(artist.albumCount) albums" + ) + item.handler = { [weak self] _, completion in + self?.showArtistAlbums(artist: artist) + completion() + } + return item + } + loadingTemplate.updateSections([CPListSection(items: items)]) + + case .failure: + let errorItem = CPListItem(text: "Failed to load artists", detailText: "Tap to retry") + errorItem.handler = { [weak self] _, completion in + self?.interfaceController.popTemplate(animated: false, completion: nil) + self?.showArtistsList() + completion() + } + loadingTemplate.updateSections([CPListSection(items: [errorItem])]) + } + } + } + } + + private func showArtistAlbums(artist: Artist) { + let loadingTemplate = CPListTemplate(title: artist.name, sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getAlbumsByArtist(id: artist.id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let albums): + let radioItem = CPListItem( + text: "Play Artist Radio", + detailText: nil, + image: UIImage(systemName: "dot.radiowaves.up.forward") + ) + radioItem.handler = { [weak self] _, completion in + self?.playArtistRadio(artist: artist) + completion() + } + + let topSongsItem = CPListItem( + text: "Play Top Songs", + detailText: nil, + image: UIImage(systemName: "star.fill") + ) + topSongsItem.handler = { [weak self] _, completion in + self?.playArtistTopSongs(artist: artist) + completion() + } + + let actionSection = CPListSection(items: [radioItem, topSongsItem]) + + let albumItems = albums.map { album -> CPListItem in + let item = CPListItem( + text: album.name, + detailText: album.minYear > 0 ? "\(album.minYear)" : nil + ) + item.handler = { [weak self] _, completion in + self?.showAlbumDetail(album: album, isDownloaded: false) + completion() + } + let coverURL = AlbumService.shared.getAlbumCover( + artistName: album.artist, + albumName: album.name, + albumId: album.id + ) + CarPlayImageLoader.loadImage(from: coverURL) { image in + item.setImage(image) + } + return item + } + let albumSection = CPListSection( + items: albumItems, + header: "Albums", + sectionIndexTitle: nil + ) + + loadingTemplate.updateSections([actionSection, albumSection]) + + case .failure: + loadingTemplate.updateSections([ + CPListSection(items: [ + CPListItem(text: "Failed to load albums", detailText: nil) + ]) + ]) + } + } + } + } + + // MARK: - Artist Radio & Top Songs + + private func playArtistRadio(artist: Artist) { + RadioService.shared.getSimilarSongs(id: artist.id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let songs) where !songs.isEmpty: + let playable = RadioEntity( + id: artist.id, + name: "\(artist.name) Radio", + songs: songs, + artist: artist.name + ) + self.playerVM.playItem(item: playable, isFromLocal: false) + self.showNowPlaying() + default: + break + } + } + } + } + + private func playArtistTopSongs(artist: Artist) { + RadioService.shared.getTopSongs(artistName: artist.name, count: 20) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let songs) where !songs.isEmpty: + let playable = RadioEntity( + id: artist.id, + name: "\(artist.name) Top Songs", + songs: songs, + artist: artist.name + ) + self.playerVM.playItem(item: playable, isFromLocal: false) + self.showNowPlaying() + default: + break + } + } + } + } + + // MARK: - Songs + + private func showSongsList() { + let loadingTemplate = CPListTemplate(title: "Songs", sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getAllSongs { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let songs): + let items = songs.enumerated().map { (idx, song) -> CPListItem in + let item = CPListItem( + text: song.title, + detailText: song.artist + ) + item.handler = { [weak self] _, completion in + guard let self = self else { return } + let allTracks = Playlist(name: "All Tracks", songs: songs) + self.playerVM.playBySong(idx: idx, item: allTracks, isFromLocal: false) + self.showNowPlaying() + completion() + } + return item + } + loadingTemplate.updateSections([CPListSection(items: items)]) + + case .failure: + let errorItem = CPListItem(text: "Failed to load songs", detailText: "Tap to retry") + errorItem.handler = { [weak self] _, completion in + self?.interfaceController.popTemplate(animated: false, completion: nil) + self?.showSongsList() + completion() + } + loadingTemplate.updateSections([CPListSection(items: [errorItem])]) + } + } + } + } + + // MARK: - Playlists Tab + + private func makePlaylistsTab() -> CPListTemplate { + let template = CPListTemplate(title: "Playlists", sections: []) + template.tabImage = UIImage(systemName: "music.note.list") + + AlbumService.shared.getPlaylists { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let playlists): + let items = playlists.map { playlist -> CPListItem in + let item = CPListItem( + text: playlist.name, + detailText: playlist.comment.isEmpty ? playlist.ownerName : playlist.comment + ) + item.handler = { [weak self] _, completion in + self?.showPlaylistDetail(playlist: playlist) + completion() + } + return item + } + template.updateSections([CPListSection(items: items)]) + + case .failure: + template.updateSections([ + CPListSection(items: [ + CPListItem(text: "Failed to load playlists", detailText: nil) + ]) + ]) + } + } + } + + return template + } + + private func showPlaylistDetail(playlist: Playlist) { + let detailTemplate = CPListTemplate(title: playlist.name, sections: []) + interfaceController.pushTemplate(detailTemplate, animated: true, completion: nil) + + AlbumService.shared.getSongsByPlaylist(id: playlist.id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + + var playlistWithSongs = playlist + + switch result { + case .success(let songs): + playlistWithSongs.songs = songs + case .failure: + playlistWithSongs.songs = [] + } + + let isDownloaded = AlbumService.shared.checkIfAlbumDownloaded(albumID: playlist.id) + self.buildPlaylistDetailSections( + template: detailTemplate, + playlist: playlistWithSongs, + isDownloaded: isDownloaded + ) + } + } + } + + private func buildPlaylistDetailSections( + template: CPListTemplate, playlist: Playlist, isDownloaded: Bool + ) { + let playAllItem = CPListItem( + text: "Play All", + detailText: "\(playlist.songs.count) tracks", + image: UIImage(systemName: "play.fill") + ) + playAllItem.handler = { [weak self] _, completion in + self?.playerVM.playItem(item: playlist, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } + + let shuffleItem = CPListItem( + text: "Shuffle", + detailText: nil, + image: UIImage(systemName: "shuffle") + ) + shuffleItem.handler = { [weak self] _, completion in + self?.playerVM.shuffleItem(item: playlist, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } + + let actionSection = CPListSection(items: [playAllItem, shuffleItem]) + + let trackItems = playlist.songs.enumerated().map { (idx, song) -> CPListItem in + let item = CPListItem( + text: song.title, + detailText: song.artist + ) + item.handler = { [weak self] _, completion in + self?.playerVM.playBySong(idx: idx, item: playlist, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } + return item + } + let trackSection = CPListSection( + items: trackItems, + header: "Tracks", + sectionIndexTitle: nil + ) + + template.updateSections([actionSection, trackSection]) + } + + // MARK: - Radio Tab + + private func makeRadioTab() -> CPListTemplate { + let template = CPListTemplate(title: "Radio", sections: []) + template.tabImage = UIImage(systemName: "radio") + + RadioService.shared.getAllRadios { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let radios): + if radios.isEmpty { + template.updateSections([ + CPListSection(items: [ + CPListItem(text: "No radio stations", detailText: nil) + ]) + ]) + return + } + + let items = radios.map { radio -> CPListItem in + let item = CPListItem( + text: radio.name, + detailText: nil, + image: UIImage(systemName: "dot.radiowaves.up.forward") + ) + item.handler = { [weak self] _, completion in + self?.playerVM.playRadioItem(radio: radio) + self?.showNowPlaying() + completion() + } + return item + } + template.updateSections([CPListSection(items: items)]) + + case .failure: + template.updateSections([ + CPListSection(items: [ + CPListItem(text: "Failed to load radios", detailText: nil) + ]) + ]) + } + } + } + + return template + } + + // MARK: - Downloads Tab + + private func makeDownloadsTab() -> CPListTemplate { + let template = CPListTemplate(title: "Downloads", sections: []) + template.tabImage = UIImage(systemName: "arrow.down.circle") + + AlbumService.shared.getDownloadedAlbum { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let albums): + let filtered = albums.filter { album in + !AlbumService.shared.getSongsByAlbumId(albumId: album.id).isEmpty + } + + if filtered.isEmpty { + template.updateSections([ + CPListSection(items: [ + CPListItem(text: "No downloads", detailText: "Download music from the app") + ]) + ]) + return + } + + let items = filtered.map { album -> CPListItem in + let item = CPListItem( + text: album.name, + detailText: album.artist + ) + item.handler = { [weak self] _, completion in + self?.showAlbumDetail(album: album, isDownloaded: true) + completion() + } + let coverURL = AlbumService.shared.getAlbumCover( + artistName: album.artist, + albumName: album.name, + albumId: album.id + ) + CarPlayImageLoader.loadImage(from: coverURL) { image in + item.setImage(image) + } + return item + } + template.updateSections([CPListSection(items: items)]) + + case .failure: + template.updateSections([ + CPListSection(items: [ + CPListItem(text: "No downloads available", detailText: nil) + ]) + ]) + } + } + } + + return template + } +} diff --git a/flo/CarPlay/CarPlayImageLoader.swift b/flo/CarPlay/CarPlayImageLoader.swift new file mode 100644 index 0000000..f4ceb51 --- /dev/null +++ b/flo/CarPlay/CarPlayImageLoader.swift @@ -0,0 +1,53 @@ +// +// CarPlayImageLoader.swift +// flo +// + +import UIKit + +enum CarPlayImageLoader { + private static let targetSize = CGSize(width: 90, height: 90) + private static var cache = NSCache() + + static func loadImage(from path: String, completion: @escaping (UIImage?) -> Void) { + guard !path.isEmpty else { + completion(nil) + return + } + + if let cached = cache.object(forKey: path as NSString) { + completion(cached) + return + } + + DispatchQueue.global(qos: .utility).async { + let image: UIImage? + + if path.hasPrefix("/") { + image = UIImage(contentsOfFile: path) + } else if let url = URL(string: path), let data = try? Data(contentsOf: url) { + image = UIImage(data: data) + } else { + image = nil + } + + let resized = image.flatMap { resize($0, to: targetSize) } + + if let resized = resized { + cache.setObject(resized, forKey: path as NSString) + } + + DispatchQueue.main.async { + completion(resized) + } + } + } + + private static func resize(_ image: UIImage, to size: CGSize) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + image.draw(in: CGRect(origin: .zero, size: size)) + let resized = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return resized + } +} diff --git a/flo/CarPlay/CarPlayNowPlayingManager.swift b/flo/CarPlay/CarPlayNowPlayingManager.swift new file mode 100644 index 0000000..2a99767 --- /dev/null +++ b/flo/CarPlay/CarPlayNowPlayingManager.swift @@ -0,0 +1,104 @@ +// +// CarPlayNowPlayingManager.swift +// flo +// + +import CarPlay +import Combine + +class CarPlayNowPlayingManager: NSObject { + private let playerVM: PlayerViewModel + private var cancellables = Set() + private weak var interfaceController: CPInterfaceController? + + init(playerVM: PlayerViewModel, interfaceController: CPInterfaceController) { + self.playerVM = playerVM + self.interfaceController = interfaceController + super.init() + } + + func configure() { + let nowPlaying = CPNowPlayingTemplate.shared + nowPlaying.add(self) + + nowPlaying.isUpNextButtonEnabled = true + nowPlaying.upNextTitle = "Up Next" + + updateButtons(on: nowPlaying) + + playerVM.$isShuffling + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateButtons(on: nowPlaying) + } + .store(in: &cancellables) + + playerVM.$playbackMode + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateButtons(on: nowPlaying) + } + .store(in: &cancellables) + } + + func teardown() { + cancellables.removeAll() + CPNowPlayingTemplate.shared.remove(self) + } + + private func updateButtons(on template: CPNowPlayingTemplate) { + let shuffleButton = CPNowPlayingShuffleButton { [weak self] _ in + self?.playerVM.shuffleCurrentQueue() + } + + let repeatButton = CPNowPlayingRepeatButton { [weak self] _ in + self?.playerVM.setPlaybackMode() + } + + template.updateNowPlayingButtons([shuffleButton, repeatButton]) + } +} + +// MARK: - CPNowPlayingTemplateObserver + +extension CarPlayNowPlayingManager: CPNowPlayingTemplateObserver { + func nowPlayingTemplateUpNextButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) { + guard let interfaceController = interfaceController else { return } + + let queue = playerVM.queue + let activeIdx = playerVM.activeQueueIdx + + let upcomingItems = queue.enumerated().compactMap { (idx, entity) -> CPListItem? in + guard idx > activeIdx else { return nil } + + let item = CPListItem( + text: entity.songName ?? "Unknown", + detailText: entity.artistName ?? "" + ) + item.handler = { [weak self] _, completion in + self?.playerVM.playFromQueue(idx: idx) + completion() + } + return item + } + + if upcomingItems.isEmpty { + let emptyItem = CPListItem(text: "No upcoming tracks", detailText: nil) + let template = CPListTemplate( + title: "Up Next", + sections: [CPListSection(items: [emptyItem])] + ) + interfaceController.pushTemplate(template, animated: true, completion: nil) + } else { + let template = CPListTemplate( + title: "Up Next", + sections: [CPListSection(items: upcomingItems)] + ) + interfaceController.pushTemplate(template, animated: true, completion: nil) + } + } + + func nowPlayingTemplateAlbumArtistButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) { + // Not used + } +} diff --git a/flo/CarPlay/CarPlaySceneDelegate.swift b/flo/CarPlay/CarPlaySceneDelegate.swift new file mode 100644 index 0000000..476188d --- /dev/null +++ b/flo/CarPlay/CarPlaySceneDelegate.swift @@ -0,0 +1,27 @@ +// +// CarPlaySceneDelegate.swift +// flo +// + +import CarPlay +import UIKit + +class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { + private var coordinator: CarPlayCoordinator? + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { + coordinator = CarPlayCoordinator(interfaceController: interfaceController) + coordinator?.start() + } + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didDisconnectInterfaceController interfaceController: CPInterfaceController + ) { + coordinator?.stop() + coordinator = nil + } +} diff --git a/flo/ContentView.swift b/flo/ContentView.swift index c434947..d96fd4f 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -15,7 +15,7 @@ struct ContentView: View { @State private var tabViewID = UUID() @StateObject private var authViewModel = AuthViewModel() - @StateObject private var playerViewModel = PlayerViewModel() + @StateObject private var playerViewModel = PlayerViewModel.shared @StateObject private var albumViewModel = AlbumViewModel() @StateObject private var floooViewModel = FloooViewModel() @StateObject private var downloadViewModel = DownloadViewModel() @@ -74,9 +74,10 @@ struct ContentView: View { Spacer() if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { - let bottomPaddingForSmallerScreens: CGFloat = UIScreen.screenWidth <= 375 ? 32 : 0 + let bottomPaddingForSmallerScreens: CGFloat = UIScreen.screenWidth <= 390 ? 32 : 0 + FloatingPlayerView(viewModel: playerViewModel) - .padding(.bottom, 50) + .padding(.bottom, 40 + bottomPaddingForSmallerScreens) .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) .offset( x: self.floatingPlayerOffsetX, y: isPlayerExpanded ? UIScreen.main.bounds.height : 0 @@ -107,10 +108,12 @@ struct ContentView: View { self.isSwipping = false } ) - .padding(.bottom, bottomPaddingForSmallerScreens) } } } + .onAppear { + PlaybackCoordinator.shared.attach(playerViewModel: playerViewModel) + } } } diff --git a/flo/FloatingPlayerView.swift b/flo/FloatingPlayerView.swift index 2e35895..8ded213 100644 --- a/flo/FloatingPlayerView.swift +++ b/flo/FloatingPlayerView.swift @@ -122,7 +122,7 @@ struct FloatingPlayerView: View { .shadow(color: .black.opacity(0.15), radius: 16, x: 0, y: 6) .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) .padding(.horizontal, 16) - .padding(.bottom, 8) + .padding(.bottom, UIScreen.screenWidth <= 390 ? 8 : 0) } } diff --git a/flo/Info.plist b/flo/Info.plist index 9899ea6..5bf6929 100644 --- a/flo/Info.plist +++ b/flo/Info.plist @@ -12,5 +12,33 @@ audio + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneClassName + UIWindowScene + + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneConfigurationName + CarPlay Configuration + UISceneClassName + CPTemplateApplicationScene + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + + + + diff --git a/flo/Navigation/HomeView.swift b/flo/Navigation/HomeView.swift index 84d757b..27a60e6 100644 --- a/flo/Navigation/HomeView.swift +++ b/flo/Navigation/HomeView.swift @@ -106,19 +106,19 @@ struct HomeView: View { Text("Listening Activity (all time)").customFont(.title2).fontWeight(.bold) .multilineTextAlignment(.leading) - let statCardSpacing: CGFloat = UIScreen.screenWidth <= 375 ? 8 : 16 + let statCardSpacing: CGFloat = UIScreen.screenWidth <= 390 ? 8 : 16 + let isSmallScreen = UIScreen.screenWidth <= 390 - EqualHeightHStack(alignment: .top, spacing: statCardSpacing) { - EqualHeightItem { + // Use regular HStack on small screens to avoid geometry calculation issues with minimumScaleFactor + if isSmallScreen { + HStack(alignment: .top, spacing: statCardSpacing) { StatCard( title: "Total Listens", value: floooViewModel.totalPlay.description, icon: "headphones", color: .purple ) - } - - EqualHeightItem { + StatCard( title: "Top Artist", value: floooViewModel.stats?.topArtist ?? "N/A", @@ -127,6 +127,27 @@ struct HomeView: View { showArrow: true ) } + } else { + EqualHeightHStack(alignment: .top, spacing: statCardSpacing) { + EqualHeightItem { + StatCard( + title: "Total Listens", + value: floooViewModel.totalPlay.description, + icon: "headphones", + color: .purple + ) + } + + EqualHeightItem { + StatCard( + title: "Top Artist", + value: floooViewModel.stats?.topArtist ?? "N/A", + icon: "music.mic", + color: .blue, + showArrow: true + ) + } + } } HStack(alignment: .top, spacing: 16) { diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 5646c54..96d6c05 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -7,12 +7,84 @@ import SwiftUI +@MainActor +final class AppIconViewModel: ObservableObject { + @Published var selectedIconID = "default" + @Published var errorMessage = "" + @Published var showError = false + @Published var isChangingIcon = false + + func syncCurrentIcon() { + selectedIconID = UIApplication.shared.alternateIconName ?? "default" + } + + func changeIcon(to iconName: String?) { + if isChangingIcon { + return + } + + if (UIApplication.shared.alternateIconName ?? "default") == (iconName ?? "default") { + return + } + + isChangingIcon = true + applyIcon(iconName, attempt: 1) + } + + private func applyIcon(_ iconName: String?, attempt: Int) { + UIApplication.shared.setAlternateIconName(iconName) { error in + DispatchQueue.main.async { + guard let error else { + self.isChangingIcon = false + self.syncCurrentIcon() + + return + } + + let nsError = error as NSError + let isTemporaryBusyError = nsError.domain == NSPOSIXErrorDomain && nsError.code == 35 + + if isTemporaryBusyError && attempt < 4 { + let delay = 0.25 * Double(attempt) + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.applyIcon(iconName, attempt: attempt + 1) + } + + return + } + + self.isChangingIcon = false + self.syncCurrentIcon() + + if nsError.domain == "UIApplicationErrorDomain", nsError.code == 4 { + return + } + + self.errorMessage = + "\(error.localizedDescription)\n(\(nsError.domain) code \(nsError.code))" + + self.showError = true + } + } + } +} + struct PreferencesView: View { + struct AppIconOption: Identifiable { + let id: String + let displayName: String + let previewImageName: String + let iconName: String? + } + + @StateObject private var appIconViewModel = AppIconViewModel() @ObservedObject var authViewModel: AuthViewModel @State private var storeCredsInKeychain = false @State private var optimizeLocalStorageAlert = false @State private var showLoginSheet = false @State private var showCustomLRCLIBServer = false + @State private var showFloPlusSheet = false @State private var accentColor = Color(.accent) @State private var playerColor = Color(.player) @@ -20,6 +92,7 @@ struct PreferencesView: View { @EnvironmentObject var floooViewModel: FloooViewModel @EnvironmentObject var playerViewModel: PlayerViewModel + @EnvironmentObject var inAppPurchaseManager: InAppPurchaseManager let themeColors = ["Blue", "Green", "Red", "Ohio"] let presetExperimentalLRCLIBServer: [(label: String, url: String)] = [ @@ -27,11 +100,33 @@ struct PreferencesView: View { ("lrclib.flooo.club", "https://lrclib.flooo.club"), ] + let appIconOptions: [AppIconOption] = [ + AppIconOption( + id: "default", displayName: "flo", previewImageName: "AppIconPreviewDefault", iconName: nil), + AppIconOption( + id: "AppIconAlt1", displayName: "flo+", previewImageName: "AppIconPreviewAlt1", + iconName: "AppIconAlt1"), + AppIconOption( + id: "AppIconAlt2", displayName: "flo+", previewImageName: "AppIconPreviewAlt2", + iconName: "AppIconAlt2"), + AppIconOption( + id: "AppIconAlt3", displayName: "flo_robot", previewImageName: "AppIconPreviewAlt3", + iconName: "AppIconAlt3"), + ] + @State private var experimentalMaxBitrate = UserDefaultsManager.maxBitRate @State private var experimentalPlayerBackground = UserDefaultsManager.playerBackground @State private var experimentalLRCLIBIntegration = UserDefaultsManager.LRCLIBServerURL @State private var customLRCLIBServer = "" + var floPlusPriceLabel: String { + if let price = inAppPurchaseManager.floPlusProduct?.displayPrice { + return price + } + + return inAppPurchaseManager.isLoadingProduct ? "Loading..." : "Unavailable" + } + var shouldShowLoginSheet: Binding { Binding( get: { @@ -172,6 +267,43 @@ struct PreferencesView: View { } } + Section(header: Text("App Icon")) { + if UIApplication.shared.supportsAlternateIcons { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(appIconOptions) { option in + Button(action: { + appIconViewModel.changeIcon(to: option.iconName) + }) { + VStack(spacing: 8) { + Image(option.previewImageName) + .resizable() + .scaledToFit() + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke( + appIconViewModel.selectedIconID == option.id + ? Color.accentColor : Color.secondary.opacity(0.25), + lineWidth: appIconViewModel.selectedIconID == option.id ? 2 : 1 + ) + ) + } + .frame(width: 88) + } + } + .buttonStyle(.plain) + .disabled(appIconViewModel.isChangingIcon) + } + } + } else { + Text("Alternate app icons are not supported on this device.") + .font(.caption) + .foregroundColor(.gray) + } + } + // TODO: finish this later Section(header: Text("Experimental")) { VStack(alignment: .leading, spacing: 4) { @@ -291,6 +423,24 @@ struct PreferencesView: View { } Section(header: Text("Development")) { + + // TODO(@fariz): uncomment this on 2.2 + // if !UserDefaultsManager.floPlus { + // VStack(alignment: .leading, spacing: 6) { + // Button(action: { + // showFloPlusSheet = true + // }) { + // Text("Purchase flo+") + // } + // } + // } else { + // VStack(alignment: .leading, spacing: 6) { + // Text("flo+ purchased") + // Text("Thank you for supporting flo!").font(.caption) + // .foregroundColor(.gray) + // } + // } + Button(action: { if let url = URL(string: "https://client.flooo.club/about") { UIApplication.shared.open(url) @@ -381,6 +531,10 @@ struct PreferencesView: View { }.onAppear { floooViewModel.getLocalStorageInformation() + Task { + await inAppPurchaseManager.loadFloPlusProduct() + } + if authViewModel.isLoggedIn { self.floooViewModel.checkScanStatus() self.floooViewModel.checkAccountLinkStatus() @@ -389,6 +543,22 @@ struct PreferencesView: View { if UserDefaultsManager.enableDebug { floooViewModel.getUserDefaults() } + + appIconViewModel.syncCurrentIcon() + } + .alert("Unable to Change App Icon", isPresented: $appIconViewModel.showError) { + Button("OK", role: .cancel) {} + } message: { + Text(appIconViewModel.errorMessage) + } + .sheet(isPresented: $showFloPlusSheet) { + FloPlusSheet(showSheet: $showFloPlusSheet) + .environmentObject(inAppPurchaseManager) + } + .alert("Unable to Purchase flo+", isPresented: $inAppPurchaseManager.showPurchaseError) { + Button("OK", role: .cancel) {} + } message: { + Text(inAppPurchaseManager.purchaseErrorMessage) } .alert("LRCLIB Server URL", isPresented: $showCustomLRCLIBServer) { Button("Cancel", role: .cancel) { @@ -412,11 +582,105 @@ struct PreferencesView: View { } } +struct FloPlusSheet: View { + @Binding var showSheet: Bool + @EnvironmentObject var inAppPurchaseManager: InAppPurchaseManager + + private var floPlusPriceLabel: String { + if let price = inAppPurchaseManager.floPlusProduct?.displayPrice { + return price + } + + return inAppPurchaseManager.isLoadingProduct ? "Loading..." : "Unavailable" + } + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 18) { + Spacer() + + Image("AppIconPreviewAlt1") + .resizable() + .scaledToFit() + .frame(width: 88, height: 88) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.secondary.opacity(0.25), lineWidth: 1) + ) + + Text("Purchase flo+") + .font(.title2) + .fontWeight(.bold) + + Text("Help fund flo development") + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 12) { + Label("The full version of flo is always Free and OSS", systemImage: "heart") + Label("Get a dedicated channel on flo Campfire", systemImage: "cloud") + Label("Some other things yet to come", systemImage: "sparkles") + } + + Spacer() + + Button(action: { + Task { + await inAppPurchaseManager.purchaseFloPlus() + + if UserDefaultsManager.floPlus { + showSheet = false + } + } + }) { + HStack { + Text("Purchase flo+ for \(floPlusPriceLabel)") + .fontWeight(.semibold) + + if inAppPurchaseManager.isPurchasing { + Spacer() + ProgressView().controlSize(.small) + } + } + .frame(maxWidth: .infinity) + } + .padding() + .buttonStyle(.borderedProminent) + .disabled(inAppPurchaseManager.isPurchasing) + + Button(action: { + Task { + await inAppPurchaseManager.restorePurchases() + } + }) { + HStack { + Text("Restore purchases") + + if inAppPurchaseManager.isRestoring { + Spacer() + ProgressView().controlSize(.small) + } + } + .frame(maxWidth: .infinity) + } + .disabled(inAppPurchaseManager.isRestoring) + } + .padding(20) + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } +} + struct PreferencesView_Previews: PreviewProvider { @State static var authViewModel: AuthViewModel = AuthViewModel() @State static var floooViewModel: FloooViewModel = FloooViewModel() + @State static var inAppPurchaseManager: InAppPurchaseManager = InAppPurchaseManager( + startObservingTransactions: false) static var previews: some View { PreferencesView(authViewModel: authViewModel).environmentObject(floooViewModel) + .environmentObject(inAppPurchaseManager) } } diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index bf3703d..c3b3dc3 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -11,6 +11,8 @@ import MediaPlayer import SwiftUI class PlayerViewModel: ObservableObject { + static let shared = PlayerViewModel() + private var player: AVPlayer? private var playerItem: AVPlayerItem? private var timeObserverToken: Any? @@ -194,6 +196,8 @@ class PlayerViewModel: ObservableObject { self.shouldHidePlayer = false self.isLocallySaved = false + try? AVAudioSession.sharedInstance().setActive(true) + self.resetLyrics() if let timeObserverToken = timeObserverToken { @@ -314,7 +318,7 @@ class PlayerViewModel: ObservableObject { let artwork = self.makeNowPlayingArtwork() DispatchQueue.main.async { - var nowPlayingInfo = [String: Any]() + var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]() nowPlayingInfo[MPMediaItemPropertyTitle] = title nowPlayingInfo[MPMediaItemPropertyArtist] = artist @@ -433,6 +437,7 @@ class PlayerViewModel: ObservableObject { self.isFinished = false self.isPlaying = true self.updateNowPlayingInfo(progress: self.progress, rate: 1.0) + MPNowPlayingInfoCenter.default().playbackState = .playing } func pause() { @@ -440,6 +445,7 @@ class PlayerViewModel: ObservableObject { self.isPlaying = false self.updateNowPlayingInfo(progress: self.progress, rate: 0.0) + MPNowPlayingInfoCenter.default().playbackState = .paused } func stop() { @@ -455,6 +461,8 @@ class PlayerViewModel: ObservableObject { return } + self.progress = progress + let newTime = CMTime( seconds: progress * totalDuration, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) @@ -651,6 +659,8 @@ class PlayerViewModel: ObservableObject { UserDefaultsManager.removeObject(key: UserDefaultsKeys.nowPlayingProgress) MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } func resetLyrics() { diff --git a/flo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/flo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json index dd793e5..d3926f7 100644 --- a/flo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/flo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom" : "universal" + }, + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "200", + "green" : "152", + "red" : "153" + } + }, + "idiom" : "watch" } ], "info" : { diff --git a/flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 6daaedc..8810922 100644 --- a/flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -5,6 +5,12 @@ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" + }, + { + "filename" : "flo 1.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" } ], "info" : { diff --git a/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo 1.png b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo 1.png new file mode 100644 index 0000000..d055849 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo 1.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/Contents.json b/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/Contents.json new file mode 100644 index 0000000..618d73d --- /dev/null +++ b/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "flo_alt_1.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/flo_alt_1.png b/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/flo_alt_1.png new file mode 100644 index 0000000..183b6a5 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/flo_alt_1.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/Contents.json b/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/Contents.json new file mode 100644 index 0000000..d24a016 --- /dev/null +++ b/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "flo_alt_2.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/flo_alt_2.png b/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/flo_alt_2.png new file mode 100644 index 0000000..8cbf0bc Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/flo_alt_2.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/Contents.json b/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/Contents.json new file mode 100644 index 0000000..99cb09b --- /dev/null +++ b/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "flo_alt_3.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/flo_alt_3.png b/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/flo_alt_3.png new file mode 100644 index 0000000..77f8e31 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/flo_alt_3.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconPreviewAlt1.imageset/Contents.json b/flo/Resources/Assets.xcassets/AppIconPreviewAlt1.imageset/Contents.json new file mode 100644 index 0000000..6462fcd --- /dev/null +++ b/flo/Resources/Assets.xcassets/AppIconPreviewAlt1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "flo_alt_1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/flo/Resources/Assets.xcassets/AppIconPreviewAlt1.imageset/flo_alt_1.png b/flo/Resources/Assets.xcassets/AppIconPreviewAlt1.imageset/flo_alt_1.png new file mode 100644 index 0000000..183b6a5 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconPreviewAlt1.imageset/flo_alt_1.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconPreviewAlt2.imageset/Contents.json b/flo/Resources/Assets.xcassets/AppIconPreviewAlt2.imageset/Contents.json new file mode 100644 index 0000000..df1fc6f --- /dev/null +++ b/flo/Resources/Assets.xcassets/AppIconPreviewAlt2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "flo_alt_2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/flo/Resources/Assets.xcassets/AppIconPreviewAlt2.imageset/flo_alt_2.png b/flo/Resources/Assets.xcassets/AppIconPreviewAlt2.imageset/flo_alt_2.png new file mode 100644 index 0000000..8cbf0bc Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconPreviewAlt2.imageset/flo_alt_2.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconPreviewAlt3.imageset/Contents.json b/flo/Resources/Assets.xcassets/AppIconPreviewAlt3.imageset/Contents.json new file mode 100644 index 0000000..48c8c56 --- /dev/null +++ b/flo/Resources/Assets.xcassets/AppIconPreviewAlt3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "flo_alt_3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/flo/Resources/Assets.xcassets/AppIconPreviewAlt3.imageset/flo_alt_3.png b/flo/Resources/Assets.xcassets/AppIconPreviewAlt3.imageset/flo_alt_3.png new file mode 100644 index 0000000..77f8e31 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconPreviewAlt3.imageset/flo_alt_3.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconPreviewDefault.imageset/Contents.json b/flo/Resources/Assets.xcassets/AppIconPreviewDefault.imageset/Contents.json new file mode 100644 index 0000000..8e993d3 --- /dev/null +++ b/flo/Resources/Assets.xcassets/AppIconPreviewDefault.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "flo_preview_default.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/flo/Resources/Assets.xcassets/AppIconPreviewDefault.imageset/flo_preview_default.png b/flo/Resources/Assets.xcassets/AppIconPreviewDefault.imageset/flo_preview_default.png new file mode 100644 index 0000000..d055849 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconPreviewDefault.imageset/flo_preview_default.png differ diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 2da47ed..66944af 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -237,6 +237,26 @@ } } }, + "Alternate app icons are not supported on this device." : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikon alternatif aplikasi tidak didukung di perangkat ini." + } + } + } + }, + "App Icon" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikon Aplikasi" + } + } + } + }, "App Version" : { "localizations" : { "en" : { @@ -253,6 +273,16 @@ } } }, + "Artist Radio" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artist Radio" + } + } + } + }, "Artists" : { "localizations" : { "en" : { @@ -553,6 +583,17 @@ } } }, + "flo+ purchased" : { + "extractionState" : "stale", + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo+ telah terbeli" + } + } + } + }, "Font Family" : { "localizations" : { "en" : { @@ -611,6 +652,16 @@ } } }, + "Get a dedicated channel on flo Campfire" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dapatkan channel khusus di flo Campfire" + } + } + } + }, "Going off the grid?" : { "localizations" : { "en" : { @@ -627,6 +678,16 @@ } } }, + "Help fund flo development" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bantu danai pengembangan flo" + } + } + } + }, "Home" : { "localizations" : { "en" : { @@ -982,6 +1043,19 @@ } } } + }, + "Play Artist Radio" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mainkan Artist Radio" + } + } + } + }, + "Play Top Songs" : { + }, "Player color" : { "localizations" : { @@ -1047,6 +1121,26 @@ } } }, + "Purchase flo+" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beli flo+" + } + } + } + }, + "Purchase flo+ for %@" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beli flo+ seharga %@" + } + } + } + }, "Radios" : { "localizations" : { "id" : { @@ -1123,6 +1217,16 @@ } } }, + "Restore purchases" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulihkan pembelian" + } + } + } + }, "Retry all Failed Queue" : { "localizations" : { "id" : { @@ -1309,6 +1413,16 @@ } } }, + "Some other things yet to come" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beberapa hal lain yang akan datang" + } + } + } + }, "Songs" : { "localizations" : { "en" : { @@ -1357,6 +1471,17 @@ } } }, + "Thank you for supporting flo!" : { + "extractionState" : "stale", + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terima kasih telah mendukung flo!" + } + } + } + }, "Thanks for choosing flo!" : { "localizations" : { "en" : { @@ -1373,6 +1498,16 @@ } } }, + "The full version of flo is always Free and OSS" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versi lengkap flo selalu gratis dan OSS" + } + } + } + }, "The password is stored securely in Keychain" : { "localizations" : { "en" : { @@ -1499,6 +1634,26 @@ } } }, + "Unable to Change App Icon" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gagal mengubah ikon aplikasi" + } + } + } + }, + "Unable to Purchase flo+" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gagal membeli flo+" + } + } + } + }, "UserDefaults.%@" : { "localizations" : { "id" : { diff --git a/flo/Shared/Models/Album.swift b/flo/Shared/Models/Album.swift index 10fc793..28be295 100644 --- a/flo/Shared/Models/Album.swift +++ b/flo/Shared/Models/Album.swift @@ -44,6 +44,7 @@ struct Album: Codable, Identifiable, Playable { case name case albumArtist case artist + case albumCover case genre case minYear case songs @@ -64,6 +65,7 @@ struct Album: Codable, Identifiable, Playable { self.artist = self.albumArtist } + self.albumCover = try container.decodeIfPresent(String.self, forKey: .albumCover) ?? "" self.genre = try container.decode(String.self, forKey: .genre) self.minYear = try container.decode(Int.self, forKey: .minYear) self.songs = try container.decodeIfPresent([Song].self, forKey: .songs) ?? [] @@ -83,15 +85,17 @@ struct Album: Codable, Identifiable, Playable { self.minYear = minYear } - init(from playlist: PlaylistEntity) { - self.id = playlist.id ?? UUID().uuidString - self.name = playlist.name ?? "Unknown Album" - self.albumArtist = playlist.albumArtist ?? playlist.artistName ?? "Unknown Artist" - self.artist = playlist.artistName ?? "Unknown Artist" - self.genre = playlist.genre ?? "Unknown Genre" - self.minYear = Int(playlist.minYear) - self.albumCover = playlist.albumCover ?? "" - } + #if os(iOS) + init(from playlist: PlaylistEntity) { + self.id = playlist.id ?? UUID().uuidString + self.name = playlist.name ?? "Unknown Album" + self.albumArtist = playlist.albumArtist ?? playlist.artistName ?? "Unknown Artist" + self.artist = playlist.artistName ?? "Unknown Artist" + self.genre = playlist.genre ?? "Unknown Genre" + self.minYear = Int(playlist.minYear) + self.albumCover = playlist.albumCover ?? "" + } + #endif init(from playlist: Playlist) { self.id = playlist.id diff --git a/flo/Shared/Models/ArtistRadio.swift b/flo/Shared/Models/ArtistRadio.swift new file mode 100644 index 0000000..db3241e --- /dev/null +++ b/flo/Shared/Models/ArtistRadio.swift @@ -0,0 +1,119 @@ +// flo + +import Foundation + +// MARK: - Similar Songs List + +struct SimilarSongsList: SubsonicResponseData { + static var key: String { "similarSongs2" } + let song: [Song] + + private enum CodingKeys: String, CodingKey { + case song + } + + private enum SubsonicSongKeys: String, CodingKey { + case id, title, artist, albumId, album, track, discNumber, bitRate, samplingRate, suffix, + duration, mediaFileId + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard var songsContainer = try? container.nestedUnkeyedContainer(forKey: .song) else { + self.song = [] + return + } + + var songs: [Song] = [] + while !songsContainer.isAtEnd { + let s = try songsContainer.nestedContainer(keyedBy: SubsonicSongKeys.self) + songs.append( + Song( + id: try s.decode(String.self, forKey: .id), + title: try s.decode(String.self, forKey: .title), + albumId: try s.decodeIfPresent(String.self, forKey: .albumId) ?? "", + albumName: try s.decodeIfPresent(String.self, forKey: .album) ?? "", + artist: try s.decode(String.self, forKey: .artist), + trackNumber: try s.decodeIfPresent(Int.self, forKey: .track) ?? 0, + discNumber: try s.decodeIfPresent(Int.self, forKey: .discNumber) ?? 0, + bitRate: try s.decodeIfPresent(Int.self, forKey: .bitRate) ?? 0, + sampleRate: try s.decodeIfPresent(Int.self, forKey: .samplingRate) ?? 0, + suffix: try s.decodeIfPresent(String.self, forKey: .suffix) ?? "", + duration: try s.decode(Double.self, forKey: .duration), + mediaFileId: try s.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "" + )) + } + self.song = songs + } +} + +struct SimilarSongsResponse: Codable { + let subsonicResponse: SubsonicResponse + + private enum CodingKeys: String, CodingKey { + case subsonicResponse = "subsonic-response" + } + + var songs: [Song] { + return subsonicResponse.data?.song ?? [] + } +} + +// MARK: - Artist Top Songs (getTopSongs) + +struct TopSongsList: SubsonicResponseData { + static var key: String { "topSongs" } + let song: [Song] + + private enum CodingKeys: String, CodingKey { + case song + } + + private enum SubsonicSongKeys: String, CodingKey { + case id, title, artist, albumId, album, track, discNumber, bitRate, samplingRate, suffix, + duration, mediaFileId + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard var songsContainer = try? container.nestedUnkeyedContainer(forKey: .song) else { + self.song = [] + return + } + + var songs: [Song] = [] + while !songsContainer.isAtEnd { + let s = try songsContainer.nestedContainer(keyedBy: SubsonicSongKeys.self) + songs.append( + Song( + id: try s.decode(String.self, forKey: .id), + title: try s.decode(String.self, forKey: .title), + albumId: try s.decodeIfPresent(String.self, forKey: .albumId) ?? "", + albumName: try s.decodeIfPresent(String.self, forKey: .album) ?? "", + artist: try s.decode(String.self, forKey: .artist), + trackNumber: try s.decodeIfPresent(Int.self, forKey: .track) ?? 0, + discNumber: try s.decodeIfPresent(Int.self, forKey: .discNumber) ?? 0, + bitRate: try s.decodeIfPresent(Int.self, forKey: .bitRate) ?? 0, + sampleRate: try s.decodeIfPresent(Int.self, forKey: .samplingRate) ?? 0, + suffix: try s.decodeIfPresent(String.self, forKey: .suffix) ?? "", + duration: try s.decode(Double.self, forKey: .duration), + mediaFileId: try s.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "" + )) + } + self.song = songs + } +} + +struct TopSongsResponse: Codable { + let subsonicResponse: SubsonicResponse + + private enum CodingKeys: String, CodingKey { + case subsonicResponse = "subsonic-response" + } + + var songs: [Song] { + return subsonicResponse.data?.song ?? [] + } +} diff --git a/flo/Shared/Models/Song.swift b/flo/Shared/Models/Song.swift index ee9fa27..71cdaaa 100644 --- a/flo/Shared/Models/Song.swift +++ b/flo/Shared/Models/Song.swift @@ -61,9 +61,10 @@ struct Song: Codable, Identifiable, Hashable { self.title = try container.decode(String.self, forKey: .title) self.artist = try container.decode(String.self, forKey: .artist) self.albumId = try container.decode(String.self, forKey: .albumId) - self.albumName = try container.decodeIfPresent(String.self, forKey: .album) - ?? container.decodeIfPresent(String.self, forKey: .albumName) - ?? "" + self.albumName = + try container.decodeIfPresent(String.self, forKey: .album) + ?? container.decodeIfPresent(String.self, forKey: .albumName) + ?? "" self.trackNumber = try container.decode(Int.self, forKey: .trackNumber) self.discNumber = try container.decode(Int.self, forKey: .discNumber) @@ -112,33 +113,35 @@ struct Song: Codable, Identifiable, Hashable { self.mediaFileId = mediaFileId } - init(from song: SongEntity) { - self.id = song.id ?? "" - self.title = song.title ?? "N/A" - self.artist = song.artistName ?? "N/A" - self.albumId = song.albumId ?? "" + #if os(iOS) + init(from song: SongEntity) { + self.id = song.id ?? "" + self.title = song.title ?? "N/A" + self.artist = song.artistName ?? "N/A" + self.albumId = song.albumId ?? "" - if let storedAlbumName = song.albumName, !storedAlbumName.isEmpty { - self.albumName = storedAlbumName - } else if let fileURL = song.fileURL { - let parts = fileURL.split(separator: "/") + if let storedAlbumName = song.albumName, !storedAlbumName.isEmpty { + self.albumName = storedAlbumName + } else if let fileURL = song.fileURL { + let parts = fileURL.split(separator: "/") - if parts.count >= 3 { - self.albumName = String(parts[2]) + if parts.count >= 3 { + self.albumName = String(parts[2]) + } else { + self.albumName = "" + } } else { self.albumName = "" } - } else { - self.albumName = "" - } - self.trackNumber = Int(song.trackNumber) - self.discNumber = Int(song.discNumber) - self.bitRate = Int(song.bitRate) - self.sampleRate = Int(song.sampleRate) - self.suffix = song.suffix ?? "N/A" - self.duration = song.duration - self.fileUrl = song.fileURL ?? "" - self.mediaFileId = song.mediaFileId ?? "" - } + self.trackNumber = Int(song.trackNumber) + self.discNumber = Int(song.discNumber) + self.bitRate = Int(song.bitRate) + self.sampleRate = Int(song.sampleRate) + self.suffix = song.suffix ?? "N/A" + self.duration = song.duration + self.fileUrl = song.fileURL ?? "" + self.mediaFileId = song.mediaFileId ?? "" + } + #endif } diff --git a/flo/Shared/Services/InAppPurchaseManager.swift b/flo/Shared/Services/InAppPurchaseManager.swift new file mode 100644 index 0000000..4221a26 --- /dev/null +++ b/flo/Shared/Services/InAppPurchaseManager.swift @@ -0,0 +1,183 @@ +// +// InAppPurchaseManager.swift +// flo +// +// Created by rizaldy on 22/02/26. +// + +import Foundation +import StoreKit + +@MainActor +final class InAppPurchaseManager: ObservableObject { + @Published var isPurchasing = false + @Published var isRestoring = false + @Published var isLoadingProduct = false + @Published var floPlusProduct: Product? + @Published var purchaseErrorMessage = "" + @Published var showPurchaseError = false + + private let floPlusProductID = "flo.plus" + private var transactionUpdatesTask: Task? + + init(startObservingTransactions: Bool = true) { + guard startObservingTransactions else { + return + } + + transactionUpdatesTask = observeTransactionUpdates() + + Task { + await loadFloPlusProduct() + await refreshFloPlusEntitlement() + } + } + + deinit { + transactionUpdatesTask?.cancel() + } + + func purchaseFloPlus() async { + guard !isPurchasing else { + return + } + + isPurchasing = true + defer { + isPurchasing = false + } + + do { + let product = try await fetchFloPlusProduct() + + let result = try await product.purchase() + + switch result { + case .success(let verificationResult): + let transaction = try verify(verificationResult) + await transaction.finish() + await refreshFloPlusEntitlement() + case .pending, .userCancelled: + break + @unknown default: + break + } + } catch { + purchaseErrorMessage = error.localizedDescription + showPurchaseError = true + } + } + + func loadFloPlusProduct() async { + guard !isLoadingProduct else { + return + } + + isLoadingProduct = true + defer { isLoadingProduct = false } + + do { + floPlusProduct = try await fetchFloPlusProduct() + } catch { + purchaseErrorMessage = error.localizedDescription + showPurchaseError = true + } + } + + func restorePurchases() async { + guard !isRestoring else { + return + } + + isRestoring = true + defer { isRestoring = false } + + do { + try await AppStore.sync() + await refreshFloPlusEntitlement() + } catch { + purchaseErrorMessage = error.localizedDescription + showPurchaseError = true + } + } + + func refreshFloPlusEntitlement() async { + var hasFloPlus = false + let now = Date() + + for await result in Transaction.currentEntitlements { + guard let transaction = try? verify(result) else { + continue + } + + if transaction.productID != floPlusProductID { + continue + } + + if transaction.revocationDate != nil { + continue + } + + if let expirationDate = transaction.expirationDate, expirationDate < now { + continue + } + + hasFloPlus = true + break + } + + UserDefaultsManager.floPlus = hasFloPlus + } + + private func observeTransactionUpdates() -> Task { + return Task { [weak self] in + guard let self else { + return + } + + for await result in Transaction.updates { + guard let transaction = try? self.verify(result) else { + continue + } + + await transaction.finish() + await self.refreshFloPlusEntitlement() + } + } + } + + private func fetchFloPlusProduct() async throws -> Product { + let products = try await Product.products(for: [floPlusProductID]) + + guard let product = products.first else { + throw PurchaseError.productNotFound + } + + return product + } + + private func verify(_ verificationResult: VerificationResult) throws -> T { + switch verificationResult { + case .verified(let safeResult): + return safeResult + case .unverified: + throw PurchaseError.verificationFailed + } + } +} + +extension InAppPurchaseManager { + enum PurchaseError: LocalizedError { + case productNotFound + case verificationFailed + + var errorDescription: String? { + switch self { + case .productNotFound: + return "flo+ product was not found. Check the product ID in App Store Connect." + case .verificationFailed: + return "Unable to verify purchase transaction." + } + } + } +} diff --git a/flo/Shared/Services/KeychainManager.swift b/flo/Shared/Services/KeychainManager.swift index 2505eed..23ef89a 100644 --- a/flo/Shared/Services/KeychainManager.swift +++ b/flo/Shared/Services/KeychainManager.swift @@ -10,6 +10,7 @@ import KeychainAccess class KeychainManager { private static let keychain = Keychain(service: KeychainKeys.service) + .accessibility(.afterFirstUnlockThisDeviceOnly) static func getAuthCredsAndPasswords() -> [String: Any] { var keychainData: [String: Any] = [:] diff --git a/flo/Shared/Services/LocalFileManager.swift b/flo/Shared/Services/LocalFileManager.swift index 48994c0..1b70be7 100644 --- a/flo/Shared/Services/LocalFileManager.swift +++ b/flo/Shared/Services/LocalFileManager.swift @@ -120,6 +120,8 @@ class LocalFileManager { } try self.fileManager.moveItem(at: source, to: target) + try? self.fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: target.path) completion(.success(target)) } catch { completion(.failure(error)) @@ -139,6 +141,8 @@ class LocalFileManager { let fileURL = target.appendingPathComponent(fileName) try content.write(to: fileURL) + try? self.fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: fileURL.path) completion(.success(fileURL)) } catch { diff --git a/flo/Shared/Services/PlaybackCoordinator.swift b/flo/Shared/Services/PlaybackCoordinator.swift new file mode 100644 index 0000000..d147657 --- /dev/null +++ b/flo/Shared/Services/PlaybackCoordinator.swift @@ -0,0 +1,229 @@ +// +// PlaybackCoordinator.swift +// flo +// +// Created by Codex on 17/02/26. +// + +import Foundation + +final class PlaybackCoordinator { + static let shared = PlaybackCoordinator() + + weak var playerViewModel: PlayerViewModel? + + private init() {} + + func attach(playerViewModel: PlayerViewModel) { + self.playerViewModel = playerViewModel + } + + func handleWatchCommand(_ message: [String: Any]) { + guard let action = message["action"] as? String else { return } + + switch action { + case "play": + DispatchQueue.main.async { + self.playerViewModel?.play() + } + case "pause": + DispatchQueue.main.async { + self.playerViewModel?.pause() + } + case "next": + DispatchQueue.main.async { + self.playerViewModel?.nextSong() + } + case "previous": + DispatchQueue.main.async { + self.playerViewModel?.prevSong() + } + case "playAlbum": + handlePlayAlbum(message) + case "playPlaylist": + handlePlayPlaylist(message) + case "playSong": + handlePlaySong(message) + case "playRadio": + handlePlayRadio(message) + default: + break + } + } + + private func handlePlayAlbum(_ message: [String: Any]) { + guard let albumId = message["id"] as? String else { return } + + let albumName = message["name"] as? String ?? "" + let albumArtist = message["artist"] as? String ?? "" + + AlbumService.shared.getSongFromAlbum(id: albumId) { result in + switch result { + case .success(let songs): + let album = Album( + id: albumId, + name: albumName, + albumArtist: albumArtist, + artist: albumArtist, + songs: songs + ) + self.play(item: album, startIndex: 0) + case .failure: + break + } + } + } + + private func handlePlayPlaylist(_ message: [String: Any]) { + guard let playlistId = message["id"] as? String else { return } + + let playlistName = message["name"] as? String ?? "" + let ownerName = message["ownerName"] as? String ?? "" + + AlbumService.shared.getSongsByPlaylist(id: playlistId) { result in + switch result { + case .success(let songs): + var playlist = Playlist( + id: playlistId, + name: playlistName, + comment: "", + isPublic: false, + ownerName: ownerName, + songs: songs + ) + playlist.songs = songs + self.play(item: playlist, startIndex: 0) + case .failure: + break + } + } + } + + private func handlePlaySong(_ message: [String: Any]) { + guard + let contextId = message["contextId"] as? String, + let songId = message["songId"] as? String, + let contextType = message["contextType"] as? String + else { return } + + if contextType == "album" { + AlbumService.shared.getSongFromAlbum(id: contextId) { result in + switch result { + case .success(let songs): + let index = Self.resolveSongIndex(songId: songId, in: songs) + + let album = Album( + id: contextId, name: message["contextName"] as? String ?? "", songs: songs) + self.play(item: album, startIndex: index) + case .failure: + break + } + } + } else if contextType == "playlist" { + AlbumService.shared.getSongsByPlaylist(id: contextId) { result in + switch result { + case .success(let songs): + let index = Self.resolveSongIndex(songId: songId, in: songs) + + var playlist = Playlist( + id: contextId, + name: message["contextName"] as? String ?? "", + comment: "", + isPublic: false, + ownerName: message["ownerName"] as? String ?? "", + songs: songs + ) + playlist.songs = songs + + self.play(item: playlist, startIndex: index) + case .failure: + break + } + } + } else if contextType == "allSongs" { + AlbumService.shared.getAllSongs { result in + switch result { + case .success(let songs): + let index = Self.resolveSongIndex(songId: songId, in: songs) + + var playlist = Playlist( + id: contextId, + name: message["contextName"] as? String ?? "All Songs", + comment: "", + isPublic: false, + ownerName: "", + songs: songs + ) + playlist.songs = songs + + self.play(item: playlist, startIndex: index) + case .failure: + break + } + } + } + } + + private func handlePlayRadio(_ message: [String: Any]) { + guard + let radioId = message["id"] as? String, + let name = message["name"] as? String, + let streamUrl = message["streamUrl"] as? String + else { return } + + let radio = Radio(id: radioId, name: name, streamUrl: streamUrl) + let playable = radio.toPlayable() + + play(item: playable, startIndex: 0) + } + + private static func resolveSongIndex(songId: String, in songs: [Song]) -> Int { + if let index = songs.firstIndex(where: { $0.id == songId || $0.mediaFileId == songId }) { + return index + } + + return 0 + } + + private func play(item: T, startIndex: Int) { + let queue = PlaybackService.shared.addToQueue(item: item) + + DispatchQueue.main.async { + self.playerViewModel?.addToQueue(idx: startIndex, item: queue, playAudio: true) + } + } + + func currentNowPlayingPayload() -> [String: Any] { + guard let playerViewModel, playerViewModel.hasNowPlaying() else { + return ["hasNowPlaying": false] + } + + let nowPlaying = playerViewModel.nowPlaying + let songId = nowPlaying.id ?? "" + let albumId = nowPlaying.albumId ?? "" + let albumName = nowPlaying.albumName ?? "" + let artistName = nowPlaying.artistName ?? "" + let title = nowPlaying.songName ?? "" + + let coverArtUrl = Self.coverArtUrl(albumId: albumId) + + return [ + "hasNowPlaying": true, + "songId": songId, + "albumId": albumId, + "albumName": albumName, + "artistName": artistName, + "title": title, + "contextName": nowPlaying.contextName ?? "", + "isPlaying": playerViewModel.isPlaying, + "coverArt": coverArtUrl, + ] + } + + private static func coverArtUrl(albumId: String) -> String { + let token = AuthService.shared.getCreds(key: "subsonicToken") + + return + "\(UserDefaultsManager.serverBaseURL)\(API.SubsonicEndpoint.coverArt)\(token)&id=al-\(albumId)&size=300" + } +} diff --git a/flo/Shared/Services/RadioService.swift b/flo/Shared/Services/RadioService.swift index 2f4dbd6..1744b8d 100644 --- a/flo/Shared/Services/RadioService.swift +++ b/flo/Shared/Services/RadioService.swift @@ -11,7 +11,7 @@ class RadioService { } func getAllRadios(completion: @escaping (Result<[Radio], Error>) -> Void) { - + APIManager.shared.SubsonicEndpointRequest(endpoint: API.SubsonicEndpoint.radios, parameters: nil) { (response: DataResponse) in switch response.result { @@ -22,4 +22,32 @@ class RadioService { } } } + + func getSimilarSongs(id: String, count: Int = 100, completion: @escaping (Result<[Song], Error>) -> Void) { + let params: [String: Any] = ["id": id, "count": count] + + APIManager.shared.SubsonicEndpointRequest(endpoint: API.SubsonicEndpoint.similarSongs, parameters: params) { + (response: DataResponse) in + switch response.result { + case .success(let result): + completion(.success(result.songs)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func getTopSongs(artistName: String, count: Int = 20, completion: @escaping (Result<[Song], Error>) -> Void) { + let params: [String: Any] = ["artist": artistName, "count": count] + + APIManager.shared.SubsonicEndpointRequest(endpoint: API.SubsonicEndpoint.topSongs, parameters: params) { + (response: DataResponse) in + switch response.result { + case .success(let result): + completion(.success(result.songs)) + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/flo/Shared/Services/UserDefaultsManager.swift b/flo/Shared/Services/UserDefaultsManager.swift index 507412a..a50e3e1 100644 --- a/flo/Shared/Services/UserDefaultsManager.swift +++ b/flo/Shared/Services/UserDefaultsManager.swift @@ -17,6 +17,7 @@ class UserDefaultsManager { UserDefaultsKeys.nowPlayingProgress, UserDefaultsKeys.queueActiveIdx, UserDefaultsKeys.playbackMode, + UserDefaultsKeys.floPlus, ] for key in keys { @@ -117,4 +118,13 @@ class UserDefaultsManager { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.LRCLIBServerURL) } } + + static var floPlus: Bool { + get { + return UserDefaults.standard.bool(forKey: UserDefaultsKeys.floPlus) + } + set { + UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.floPlus) + } + } } diff --git a/flo/Shared/Services/WatchConnectivityManager.swift b/flo/Shared/Services/WatchConnectivityManager.swift new file mode 100644 index 0000000..a5a99c5 --- /dev/null +++ b/flo/Shared/Services/WatchConnectivityManager.swift @@ -0,0 +1,285 @@ +// +// WatchConnectivityManager.swift +// flo +// +// Created by Codex on 17/02/26. +// + +import Combine +import Foundation + +#if os(iOS) + import WatchConnectivity + + final class WatchConnectivityManager: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityManager() + + private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil + + private override init() { + super.init() + } + + func start() { + session?.delegate = self + session?.activate() + } + + func session( + _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) {} + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + PlaybackCoordinator.shared.handleWatchCommand(message) + } + + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + if message["request"] != nil { + WatchLibraryResponder.shared.handle(message: message, replyHandler: replyHandler) + } else { + PlaybackCoordinator.shared.handleWatchCommand(message) + + replyHandler(["result": "ok"]) + } + } + } +#endif + +#if os(watchOS) + import WatchConnectivity + + final class WatchConnectivityManager: NSObject, WCSessionDelegate, ObservableObject { + static let shared = WatchConnectivityManager() + + @Published var isReachable = false + @Published var isActivated = false + @Published var isServerOnline = false + + private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil + + private override init() { + super.init() + + session?.delegate = self + session?.activate() + + isReachable = session?.isReachable ?? false + isActivated = session?.activationState == .activated + } + + func sendMessage(_ message: [String: Any]) { + guard let session else { return } + + if session.activationState != .activated { + if session.activationState == .notActivated { + session.activate() + } + + return + } + + if session.isReachable { + session.sendMessage(message, replyHandler: nil, errorHandler: nil) + } + } + + func requestLibrary( + type: String, + parameters: [String: Any] = [:], + completion: @escaping (Result) -> Void + ) { + guard let session else { + completion(.failure(WatchConnectivityError(message: "Session unavailable."))) + + return + } + + guard session.activationState == .activated else { + if session.activationState == .notActivated { + session.activate() + } + + completion(.failure(WatchConnectivityError(message: "WCSession not activated."))) + + return + } + + guard session.isReachable else { + completion(.failure(WatchConnectivityError(message: "iPhone is not reachable."))) + + return + } + + var message = parameters + message["request"] = type + + session.sendMessage(message) { reply in + if let result = reply["result"] as? String, result == "ok", let data = reply["data"] { + completion(.success(data)) + } else { + let message = reply["message"] as? String ?? "Unexpected response." + + completion(.failure(WatchConnectivityError(message: message))) + } + } errorHandler: { error in + completion(.failure(error)) + } + } + + func requestNowPlaying(completion: @escaping (Result<[String: Any], Error>) -> Void) { + guard let session else { + completion(.failure(WatchConnectivityError(message: "Session unavailable."))) + + return + } + + guard session.activationState == .activated else { + if session.activationState == .notActivated { + session.activate() + } + + completion(.failure(WatchConnectivityError(message: "WCSession not activated."))) + + return + } + + guard session.isReachable else { + completion(.failure(WatchConnectivityError(message: "iPhone is not reachable."))) + + return + } + + session.sendMessage(["request": "nowPlaying"]) { reply in + if let result = reply["result"] as? String, result == "ok", + let data = reply["data"] as? [String: Any] + { + completion(.success(data)) + } else { + let message = reply["message"] as? String ?? "Unexpected response." + + completion(.failure(WatchConnectivityError(message: message))) + } + } errorHandler: { error in + completion(.failure(error)) + } + } + + func requestServerStatus(completion: @escaping (Bool) -> Void) { + guard let session else { + completion(false) + + return + } + + guard session.activationState == .activated else { + if session.activationState == .notActivated { + session.activate() + } + + completion(false) + + return + } + + guard session.isReachable else { + completion(false) + + return + } + + session.sendMessage(["request": "serverStatus"]) { reply in + if let result = reply["result"] as? String, + result == "ok", + let data = reply["data"] as? [String: Any], + let isOnline = data["isOnline"] as? Bool + { + completion(isOnline) + } else { + completion(false) + } + } errorHandler: { _ in + completion(false) + } + } + + func refreshServerStatus() { + requestServerStatus { [weak self] isOnline in + DispatchQueue.main.async { + self?.isServerOnline = isOnline + } + } + } + + func ping(completion: ((Result) -> Void)? = nil) { + guard let session else { + completion?(.failure(WatchConnectivityError(message: "Session unavailable."))) + + return + } + + guard session.activationState == .activated else { + if session.activationState == .notActivated { + session.activate() + } + + completion?(.failure(WatchConnectivityError(message: "WCSession not activated."))) + + return + } + + guard session.isReachable else { + completion?(.failure(WatchConnectivityError(message: "iPhone is not reachable."))) + + return + } + + session.sendMessage(["request": "ping"]) { reply in + if let result = reply["result"] as? String, result == "ok" { + completion?(.success(())) + } else { + let message = reply["message"] as? String ?? "Unexpected response." + + completion?(.failure(WatchConnectivityError(message: message))) + } + } errorHandler: { error in + completion?(.failure(error)) + } + } + + struct WatchConnectivityError: LocalizedError { + let message: String + + var errorDescription: String? { + message + } + } + + func session( + _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + DispatchQueue.main.async { + self.isReachable = session.isReachable + self.isActivated = activationState == .activated + } + } + + func sessionReachabilityDidChange(_ session: WCSession) { + DispatchQueue.main.async { + self.isReachable = session.isReachable + self.isActivated = session.activationState == .activated + } + } + } +#endif diff --git a/flo/Shared/Services/WatchLibraryResponder.swift b/flo/Shared/Services/WatchLibraryResponder.swift new file mode 100644 index 0000000..65dbc94 --- /dev/null +++ b/flo/Shared/Services/WatchLibraryResponder.swift @@ -0,0 +1,175 @@ +// +// WatchLibraryResponder.swift +// flo +// +// Created by Codex on 17/02/26. +// + +import Foundation + +final class WatchLibraryResponder { + static let shared = WatchLibraryResponder() + + private init() {} + + func handle(message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + guard let request = message["request"] as? String else { + replyHandler(["result": "error", "message": "Invalid request."]) + + return + } + + switch request { + case "albums": + AlbumService.shared.getAlbum { result in + switch result { + case .success(let albums): + let mapped = albums.map { album -> Album in + var updated = album + updated.albumCover = self.coverArtUrl(albumId: album.id) + + return updated + } + + self.reply(with: mapped, replyHandler: replyHandler) + case .failure: + replyHandler(["result": "error", "message": "Failed to load albums."]) + } + } + case "artists": + AlbumService.shared.getArtists { result in + switch result { + case .success(let artists): + self.reply(with: artists, replyHandler: replyHandler) + case .failure: + replyHandler(["result": "error", "message": "Failed to load artists."]) + } + } + case "playlists": + AlbumService.shared.getPlaylists { result in + switch result { + case .success(let playlists): + self.reply(with: playlists, replyHandler: replyHandler) + case .failure: + replyHandler(["result": "error", "message": "Failed to load playlists."]) + } + } + case "songs": + AlbumService.shared.getAllSongs { result in + switch result { + case .success(let songs): + self.reply(with: songs, replyHandler: replyHandler) + case .failure: + replyHandler(["result": "error", "message": "Failed to load songs."]) + } + } + case "radios": + RadioService.shared.getAllRadios { result in + switch result { + case .success(let radios): + self.reply(with: radios, replyHandler: replyHandler) + case .failure: + replyHandler(["result": "error", "message": "Failed to load radios."]) + } + } + case "albumSongs": + guard let albumId = message["id"] as? String else { + replyHandler(["result": "error", "message": "Missing album id."]) + + return + } + AlbumService.shared.getSongFromAlbum(id: albumId) { result in + switch result { + case .success(let songs): + self.reply(with: songs, replyHandler: replyHandler) + case .failure: + replyHandler(["result": "error", "message": "Failed to load songs."]) + } + } + case "playlistSongs": + guard let playlistId = message["id"] as? String else { + replyHandler(["result": "error", "message": "Missing playlist id."]) + + return + } + AlbumService.shared.getSongsByPlaylist(id: playlistId) { result in + switch result { + case .success(let songs): + self.reply(with: songs, replyHandler: replyHandler) + case .failure: + replyHandler(["result": "error", "message": "Failed to load songs."]) + } + } + case "artistAlbums": + guard let artistId = message["id"] as? String else { + replyHandler(["result": "error", "message": "Missing artist id."]) + + return + } + AlbumService.shared.getAlbumsByArtist(id: artistId) { result in + switch result { + case .success(let albums): + let mapped = albums.map { album -> Album in + var updated = album + updated.albumCover = self.coverArtUrl(albumId: album.id) + + return updated + } + + self.reply(with: mapped, replyHandler: replyHandler) + case .failure: + replyHandler(["result": "error", "message": "Failed to load albums."]) + } + } + case "nowPlaying": + let payload = PlaybackCoordinator.shared.currentNowPlayingPayload() + + replyHandler(["result": "ok", "data": payload]) + case "ping": + replyHandler(["result": "ok"]) + case "serverStatus": + ScanStatusService.shared.getScanStatus { result in + switch result { + case .success: + replyHandler(["result": "ok", "data": ["isOnline": true]]) + case .failure(let error): + replyHandler([ + "result": "ok", + "data": [ + "isOnline": false, + "message": error.localizedDescription, + ], + ]) + } + } + default: + replyHandler(["result": "error", "message": "Unknown request."]) + } + } + + private func reply(with payload: T, replyHandler: @escaping ([String: Any]) -> Void) + { + guard let json = encodeToJSON(payload) else { + replyHandler(["result": "error", "message": "Failed to encode response."]) + + return + } + + replyHandler(["result": "ok", "data": json]) + } + + private func encodeToJSON(_ payload: T) -> Any? { + guard let data = try? JSONEncoder().encode(payload) else { + return nil + } + + return try? JSONSerialization.jsonObject(with: data) + } + + private func coverArtUrl(albumId: String) -> String { + let token = AuthService.shared.getCreds(key: "subsonicToken") + + return + "\(UserDefaultsManager.serverBaseURL)\(API.SubsonicEndpoint.coverArt)\(token)&id=al-\(albumId)&size=300" + } +} diff --git a/flo/Shared/Utils/Constants.swift b/flo/Shared/Utils/Constants.swift index e004b9f..48a1a81 100644 --- a/flo/Shared/Utils/Constants.swift +++ b/flo/Shared/Utils/Constants.swift @@ -29,6 +29,8 @@ struct API { static let download = "/rest/download" static let scrobble = "/rest/scrobble" static let radios = "/rest/getInternetRadioStations" + static let similarSongs = "/rest/getSimilarSongs2" + static let topSongs = "/rest/getTopSongs" } } @@ -54,6 +56,7 @@ enum UserDefaultsKeys { static let playerBackground = "playerBackground" static let saveLoginInfo = "saveLoginInfo" static let LRCLIBServerURL = "LRCLIBServerURL" + static let floPlus = "floPlus" } enum KeychainKeys { diff --git a/flo/Watch/FloWatchApp.swift b/flo/Watch/FloWatchApp.swift new file mode 100644 index 0000000..6b0f879 --- /dev/null +++ b/flo/Watch/FloWatchApp.swift @@ -0,0 +1,19 @@ +// +// FloWatchApp.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) +import SwiftUI + +@main +struct FloWatchApp: App { + var body: some Scene { + WindowGroup { + WatchRootView() + } + } +} +#endif diff --git a/flo/Watch/WatchAlbumDetailView.swift b/flo/Watch/WatchAlbumDetailView.swift new file mode 100644 index 0000000..d1a1bbf --- /dev/null +++ b/flo/Watch/WatchAlbumDetailView.swift @@ -0,0 +1,73 @@ +// +// WatchAlbumDetailView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import SwiftUI + + struct WatchAlbumDetailView: View { + let album: Album + + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + + var body: some View { + let songs = libraryViewModel.albumSongs[album.id] ?? [] + + List { + Section { + HStack(spacing: 10) { + WatchCoverArtView( + coverArt: album.albumCover, + size: 44 + ) + + VStack(alignment: .leading, spacing: 2) { + Text(album.name) + .font(.headline) + Text(album.artist) + .font(.caption) + .foregroundColor(.secondary) + } + } + + if !songs.isEmpty { + Button("Play All") { + playerViewModel.playAlbum(album, songs: songs) + } + } + } + + if libraryViewModel.isLoading { + ProgressView() + } + + if let errorMessage = libraryViewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + } + + Section("Songs") { + ForEach(Array(songs.enumerated()), id: \.offset) { _, song in + Button { + playerViewModel.playSong(song, inAlbum: album) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.body) + .lineLimit(1) + } + } + } + } + } + .navigationTitle(album.name) + .onAppear { + libraryViewModel.loadSongs(for: album) + } + } + } +#endif diff --git a/flo/Watch/WatchAlbumsView.swift b/flo/Watch/WatchAlbumsView.swift new file mode 100644 index 0000000..4d0986f --- /dev/null +++ b/flo/Watch/WatchAlbumsView.swift @@ -0,0 +1,62 @@ +// +// WatchAlbumsView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import SwiftUI + + struct WatchAlbumsView: View { + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + + var body: some View { + List { + if libraryViewModel.isLoading { + ProgressView() + } + + if let errorMessage = libraryViewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + } + + ForEach(libraryViewModel.albums) { album in + NavigationLink { + WatchAlbumDetailView( + album: album, + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + HStack(spacing: 10) { + WatchCoverArtView( + coverArt: album.albumCover, + size: 36 + ) + + VStack(alignment: .leading, spacing: 2) { + Text(album.name) + .font(.headline) + .lineLimit(1) + + Text(album.artist) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + } + .navigationTitle("Albums") + .onAppear { + if libraryViewModel.albums.isEmpty { + libraryViewModel.loadAlbums() + } + } + } + } +#endif diff --git a/flo/Watch/WatchArtistDetailView.swift b/flo/Watch/WatchArtistDetailView.swift new file mode 100644 index 0000000..8311543 --- /dev/null +++ b/flo/Watch/WatchArtistDetailView.swift @@ -0,0 +1,63 @@ +// +// WatchArtistDetailView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import SwiftUI + + struct WatchArtistDetailView: View { + let artist: Artist + + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + + var body: some View { + let albums = libraryViewModel.artistAlbums[artist.id] ?? [] + + List { + if libraryViewModel.isLoading { + ProgressView() + } + + if let errorMessage = libraryViewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + } + + ForEach(albums) { album in + NavigationLink { + WatchAlbumDetailView( + album: album, + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + HStack(spacing: 10) { + WatchCoverArtView( + coverArt: album.albumCover, + size: 36 + ) + + VStack(alignment: .leading, spacing: 2) { + Text(album.name) + .font(.body) + .lineLimit(1) + + Text(album.minYear > 0 ? String(album.minYear) : album.artist) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } + .navigationTitle(artist.name) + .onAppear { + libraryViewModel.loadAlbums(for: artist) + } + } + } +#endif diff --git a/flo/Watch/WatchArtistsView.swift b/flo/Watch/WatchArtistsView.swift new file mode 100644 index 0000000..79d73d4 --- /dev/null +++ b/flo/Watch/WatchArtistsView.swift @@ -0,0 +1,59 @@ +// +// WatchArtistsView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import SwiftUI + + struct WatchArtistsView: View { + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + + private var filteredArtists: [Artist] { + libraryViewModel.artists.filter { artist in + artist.stats.albumartist != nil + } + } + + var body: some View { + List { + if libraryViewModel.isLoading { + ProgressView() + } + + if let errorMessage = libraryViewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + } + + ForEach(filteredArtists) { artist in + NavigationLink { + WatchArtistDetailView( + artist: artist, + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(artist.name) + .font(.headline) + .lineLimit(1) + Text("\(artist.albumCount) albums") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .navigationTitle("Artists") + .onAppear { + if libraryViewModel.artists.isEmpty { + libraryViewModel.loadArtists() + } + } + } + } +#endif diff --git a/flo/Watch/WatchCoverArtView.swift b/flo/Watch/WatchCoverArtView.swift new file mode 100644 index 0000000..483210b --- /dev/null +++ b/flo/Watch/WatchCoverArtView.swift @@ -0,0 +1,60 @@ +// +// WatchCoverArtView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) +import SwiftUI + +struct WatchCoverArtView: View { + let coverArt: String + let size: CGFloat + + var body: some View { + if let url = coverArtURL { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + placeholder + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + placeholder + @unknown default: + placeholder + } + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } else { + placeholder + } + } + + private var coverArtURL: URL? { + guard !coverArt.isEmpty else { + return nil + } + + if coverArt.hasPrefix("/") { + return URL(fileURLWithPath: coverArt) + } + + return URL(string: coverArt) + } + + private var placeholder: some View { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.gray.opacity(0.3)) + .frame(width: size, height: size) + .overlay( + Image(systemName: "music.note") + .foregroundColor(.secondary) + ) + } +} +#endif diff --git a/flo/Watch/WatchHomeView.swift b/flo/Watch/WatchHomeView.swift new file mode 100644 index 0000000..c42cc75 --- /dev/null +++ b/flo/Watch/WatchHomeView.swift @@ -0,0 +1,130 @@ +// +// WatchHomeView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import SwiftUI + + struct WatchHomeView: View { + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + @ObservedObject private var connectivity = WatchConnectivityManager.shared + + @State private var isNowPlayingPresented = false + + var body: some View { + NavigationStack { + List { + Section { + Button { + isNowPlayingPresented = true + } label: { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + if playerViewModel.nowPlayingTitle.isEmpty { + Text("Nothing Playing") + .font(.headline) + } else { + Text(playerViewModel.nowPlayingTitle) + .font(.headline) + .lineLimit(1) + + Text(playerViewModel.nowPlayingArtist) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + Spacer() + + if !playerViewModel.nowPlayingTitle.isEmpty { + if #available(watchOS 11.0, *) { + Image(systemName: "waveform") + .foregroundColor(.accentColor) + .symbolEffect(.bounce, options: .repeating) + } else { + Image(systemName: "waveform") + .foregroundColor(.accentColor) + } + } + } + } + } + + Section("Library") { + NavigationLink { + WatchAlbumsView( + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + HStack(spacing: 6) { + Image(systemName: "square.grid.2x2") + .foregroundColor(.accentColor) + Text("Albums") + } + } + NavigationLink { + WatchArtistsView( + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + HStack(spacing: 6) { + Image(systemName: "music.mic") + .foregroundColor(.accentColor) + Text("Artists") + } + } + NavigationLink { + WatchPlaylistsView( + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + HStack(spacing: 6) { + Image(systemName: "music.note.list") + .foregroundColor(.accentColor) + Text("Playlists") + } + } + NavigationLink { + WatchSongsView( + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + HStack(spacing: 6) { + Image(systemName: "music.note") + .foregroundColor(.accentColor) + Text("Songs") + } + } + NavigationLink { + WatchRadiosView( + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + HStack(spacing: 6) { + Image(systemName: "radio") + .foregroundColor(.accentColor) + Text("Radios") + } + } + } + } + .sheet(isPresented: $isNowPlayingPresented) { + WatchNowPlayingView(playerViewModel: playerViewModel) + } + .onAppear { + playerViewModel.refreshNowPlaying() + connectivity.refreshServerStatus() + } + } + } + } +#endif diff --git a/flo/Watch/WatchLibraryViewModel.swift b/flo/Watch/WatchLibraryViewModel.swift new file mode 100644 index 0000000..7350b0d --- /dev/null +++ b/flo/Watch/WatchLibraryViewModel.swift @@ -0,0 +1,165 @@ +// +// WatchLibraryViewModel.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import Combine + import Foundation + + @MainActor + final class WatchLibraryViewModel: ObservableObject { + @Published var albums: [Album] = [] + @Published var artists: [Artist] = [] + @Published var playlists: [Playlist] = [] + @Published var songs: [Song] = [] + @Published var radios: [Radio] = [] + @Published var albumSongs: [String: [Song]] = [:] + @Published var playlistSongs: [String: [Song]] = [:] + @Published var artistAlbums: [String: [Album]] = [:] + @Published var isLoading = false + @Published var errorMessage: String? + + private let connectivity = WatchConnectivityManager.shared + + func loadAlbums() { + requestLibrary(type: "albums") { (result: Result<[Album], Error>) in + switch result { + case .success(let albums): + self.albums = albums + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + + func loadArtists() { + requestLibrary(type: "artists") { (result: Result<[Artist], Error>) in + switch result { + case .success(let artists): + self.artists = artists + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + + func loadPlaylists() { + requestLibrary(type: "playlists") { (result: Result<[Playlist], Error>) in + switch result { + case .success(let playlists): + self.playlists = playlists + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + + func loadAllSongs() { + requestLibrary(type: "songs") { (result: Result<[Song], Error>) in + switch result { + case .success(let songs): + self.songs = songs + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + + func loadRadios() { + requestLibrary(type: "radios") { (result: Result<[Radio], Error>) in + switch result { + case .success(let radios): + self.radios = radios + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + + func loadSongs(for album: Album) { + if albumSongs[album.id] != nil { + return + } + + requestLibrary(type: "albumSongs", parameters: ["id": album.id]) { + (result: Result<[Song], Error>) in + switch result { + case .success(let songs): + self.albumSongs[album.id] = songs + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + + func loadSongs(for playlist: Playlist) { + if playlistSongs[playlist.id] != nil { + return + } + + requestLibrary(type: "playlistSongs", parameters: ["id": playlist.id]) { + (result: Result<[Song], Error>) in + switch result { + case .success(let songs): + self.playlistSongs[playlist.id] = songs + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + + func loadAlbums(for artist: Artist) { + if artistAlbums[artist.id] != nil { + return + } + + requestLibrary(type: "artistAlbums", parameters: ["id": artist.id]) { + (result: Result<[Album], Error>) in + switch result { + case .success(let albums): + self.artistAlbums[artist.id] = albums + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + + private func requestLibrary( + type: String, + parameters: [String: Any] = [:], + completion: @escaping (Result<[T], Error>) -> Void + ) { + isLoading = true + errorMessage = nil + + connectivity.requestLibrary(type: type, parameters: parameters) { result in + DispatchQueue.main.async { + self.isLoading = false + + switch result { + case .success(let payload): + guard let items: [T] = Self.decodeArray(from: payload) else { + completion(.failure(NSError(domain: "flo.watch", code: -1))) + + return + } + completion(.success(items)) + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + private static func decodeArray(from payload: Any) -> [T]? { + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { + return nil + } + + return try? JSONDecoder().decode([T].self, from: data) + } + } +#endif diff --git a/flo/Watch/WatchNowPlayingView.swift b/flo/Watch/WatchNowPlayingView.swift new file mode 100644 index 0000000..7375a89 --- /dev/null +++ b/flo/Watch/WatchNowPlayingView.swift @@ -0,0 +1,73 @@ +// +// WatchNowPlayingView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import SwiftUI + + struct WatchNowPlayingView: View { + @ObservedObject var playerViewModel: WatchPlayerViewModel + + var body: some View { + ScrollView { + VStack(spacing: 10) { + if !playerViewModel.nowPlayingTitle.isEmpty { + if !playerViewModel.coverArt.isEmpty { + WatchCoverArtView(coverArt: playerViewModel.coverArt, size: 76) + } + + VStack(spacing: 2) { + Text(playerViewModel.nowPlayingTitle) + .font(.headline) + .lineLimit(2) + + Text(playerViewModel.nowPlayingArtist) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + + HStack(spacing: 16) { + Button { + playerViewModel.previous() + } label: { + Image(systemName: "backward.fill") + } + + Button { + playerViewModel.togglePlayPause() + } label: { + Image(systemName: playerViewModel.isPlaying ? "pause.fill" : "play.fill") + } + + Button { + playerViewModel.next() + } label: { + Image(systemName: "forward.fill") + } + } + .buttonStyle(.bordered) + .buttonBorderShape(.circle) + .controlSize(.large) + } else { + Text("Nothing Playing") + .font(.headline) + .foregroundColor(.secondary) + + Button("Refresh") { + playerViewModel.refreshNowPlaying() + } + .font(.caption) + } + } + .padding(.horizontal, 8) + } + .onAppear { + playerViewModel.refreshNowPlaying() + } + } + } +#endif diff --git a/flo/Watch/WatchPlayerViewModel.swift b/flo/Watch/WatchPlayerViewModel.swift new file mode 100644 index 0000000..80b585f --- /dev/null +++ b/flo/Watch/WatchPlayerViewModel.swift @@ -0,0 +1,192 @@ +// +// WatchPlayerViewModel.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import Combine + import Foundation + + @MainActor + final class WatchPlayerViewModel: ObservableObject { + @Published var nowPlayingTitle: String = "" + @Published var nowPlayingArtist: String = "" + @Published var contextTitle: String = "" + @Published var isPlaying: Bool = false + @Published var coverArt: String = "" + + private let connectivity = WatchConnectivityManager.shared + + func playAlbum(_ album: Album, songs: [Song]) { + contextTitle = album.name + nowPlayingTitle = songs.first?.title ?? album.name + nowPlayingArtist = songs.first?.artist ?? album.artist + coverArt = album.albumCover + + isPlaying = true + + connectivity.sendMessage([ + "action": "playAlbum", + "id": album.id, + "name": album.name, + "artist": album.artist, + ]) + + refreshNowPlaying() + } + + func playPlaylist(_ playlist: Playlist, songs: [Song]) { + contextTitle = playlist.name + nowPlayingTitle = songs.first?.title ?? playlist.name + nowPlayingArtist = songs.first?.artist ?? playlist.ownerName + + isPlaying = true + + connectivity.sendMessage([ + "action": "playPlaylist", + "id": playlist.id, + "name": playlist.name, + "ownerName": playlist.ownerName, + ]) + + refreshNowPlaying() + } + + func playSong(_ song: Song, inAlbum album: Album) { + contextTitle = album.name + nowPlayingTitle = song.title + nowPlayingArtist = song.artist + coverArt = album.albumCover + + isPlaying = true + + connectivity.sendMessage([ + "action": "playSong", + "contextType": "album", + "contextId": album.id, + "contextName": album.name, + "songId": song.mediaFileId.isEmpty ? song.id : song.mediaFileId, + ]) + + refreshNowPlaying() + } + + func playSong(_ song: Song, inPlaylist playlist: Playlist) { + contextTitle = playlist.name + nowPlayingTitle = song.title + nowPlayingArtist = song.artist + + isPlaying = true + + connectivity.sendMessage([ + "action": "playSong", + "contextType": "playlist", + "contextId": playlist.id, + "contextName": playlist.name, + "ownerName": playlist.ownerName, + "songId": song.mediaFileId.isEmpty ? song.id : song.mediaFileId, + ]) + + refreshNowPlaying() + } + + func playSongAll(_ song: Song) { + contextTitle = "All Songs" + nowPlayingTitle = song.title + nowPlayingArtist = song.artist + + isPlaying = true + + connectivity.sendMessage([ + "action": "playSong", + "contextType": "allSongs", + "contextId": "allSongs", + "contextName": "All Songs", + "songId": song.mediaFileId.isEmpty ? song.id : song.mediaFileId, + ]) + + refreshNowPlaying() + } + + func playRadio(_ radio: Radio) { + contextTitle = "Radio" + nowPlayingTitle = radio.name + nowPlayingArtist = radio.streamUrl + + isPlaying = true + + connectivity.sendMessage([ + "action": "playRadio", + "id": radio.id, + "name": radio.name, + "streamUrl": radio.streamUrl, + ]) + + refreshNowPlaying() + } + + func togglePlayPause() { + if isPlaying { + pause() + } else { + play() + } + } + + func play() { + isPlaying = true + connectivity.sendMessage(["action": "play"]) + refreshNowPlaying() + } + + func pause() { + isPlaying = false + connectivity.sendMessage(["action": "pause"]) + refreshNowPlaying() + } + + func next() { + connectivity.sendMessage(["action": "next"]) + refreshNowPlaying() + } + + func previous() { + connectivity.sendMessage(["action": "previous"]) + refreshNowPlaying() + } + + func refreshNowPlaying() { + connectivity.requestNowPlaying { result in + DispatchQueue.main.async { + switch result { + case .success(let payload): + self.applyNowPlayingPayload(payload) + case .failure: + break + } + } + } + } + + private func applyNowPlayingPayload(_ payload: [String: Any]) { + let hasNowPlaying = payload["hasNowPlaying"] as? Bool ?? false + + guard hasNowPlaying else { + nowPlayingTitle = "" + nowPlayingArtist = "" + contextTitle = "" + coverArt = "" + isPlaying = false + return + } + + nowPlayingTitle = payload["title"] as? String ?? "" + nowPlayingArtist = payload["artistName"] as? String ?? "" + contextTitle = payload["contextName"] as? String ?? "" + coverArt = payload["coverArt"] as? String ?? "" + isPlaying = payload["isPlaying"] as? Bool ?? false + } + } +#endif diff --git a/flo/Watch/WatchPlaylistDetailView.swift b/flo/Watch/WatchPlaylistDetailView.swift new file mode 100644 index 0000000..4145899 --- /dev/null +++ b/flo/Watch/WatchPlaylistDetailView.swift @@ -0,0 +1,72 @@ +// +// WatchPlaylistDetailView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import SwiftUI + + struct WatchPlaylistDetailView: View { + let playlist: Playlist + + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + + var body: some View { + let songs = libraryViewModel.playlistSongs[playlist.id] ?? [] + + List { + Section { + VStack(alignment: .leading, spacing: 2) { + Text(playlist.name) + .font(.headline) + + Text(playlist.ownerName) + .font(.caption) + .foregroundColor(.secondary) + } + + if !songs.isEmpty { + Button("Play All") { + playerViewModel.playPlaylist(playlist, songs: songs) + } + } + } + + if libraryViewModel.isLoading { + ProgressView() + } + + if let errorMessage = libraryViewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + } + + Section("Songs") { + ForEach(Array(songs.enumerated()), id: \.offset) { _, song in + Button { + playerViewModel.playSong(song, inPlaylist: playlist) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.body) + .lineLimit(1) + + Text(song.artist) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + } + .navigationTitle(playlist.name) + .onAppear { + libraryViewModel.loadSongs(for: playlist) + } + } + } +#endif diff --git a/flo/Watch/WatchPlaylistsView.swift b/flo/Watch/WatchPlaylistsView.swift new file mode 100644 index 0000000..251a297 --- /dev/null +++ b/flo/Watch/WatchPlaylistsView.swift @@ -0,0 +1,54 @@ +// +// WatchPlaylistsView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) +import SwiftUI + +struct WatchPlaylistsView: View { + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + + var body: some View { + List { + if libraryViewModel.isLoading { + ProgressView() + } + + if let errorMessage = libraryViewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + } + + ForEach(libraryViewModel.playlists) { playlist in + NavigationLink { + WatchPlaylistDetailView( + playlist: playlist, + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(playlist.name) + .font(.headline) + .lineLimit(1) + Text(playlist.ownerName) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + .navigationTitle("Playlists") + .onAppear { + if libraryViewModel.playlists.isEmpty { + libraryViewModel.loadPlaylists() + } + } + } +} +#endif diff --git a/flo/Watch/WatchRadiosView.swift b/flo/Watch/WatchRadiosView.swift new file mode 100644 index 0000000..ea7a9da --- /dev/null +++ b/flo/Watch/WatchRadiosView.swift @@ -0,0 +1,58 @@ +// +// WatchRadiosView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) +import SwiftUI + +struct WatchRadiosView: View { + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + + var body: some View { + List { + if libraryViewModel.isLoading { + ProgressView() + } + + if let errorMessage = libraryViewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + } + + ForEach(libraryViewModel.radios) { radio in + Button { + playerViewModel.playRadio(radio) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(radio.name) + .font(.body) + .lineLimit(1) + Text(displayHost(radio.streamUrl)) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + .navigationTitle("Radios") + .onAppear { + if libraryViewModel.radios.isEmpty { + libraryViewModel.loadRadios() + } + } + } + + private func displayHost(_ urlString: String) -> String { + guard let url = URL(string: urlString), let host = url.host, !host.isEmpty else { + return urlString + } + + return host + } +} +#endif diff --git a/flo/Watch/WatchRootView.swift b/flo/Watch/WatchRootView.swift new file mode 100644 index 0000000..5e5d0cc --- /dev/null +++ b/flo/Watch/WatchRootView.swift @@ -0,0 +1,54 @@ +// +// WatchRootView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) + import SwiftUI + + struct WatchRootView: View { + @StateObject private var libraryViewModel = WatchLibraryViewModel() + @StateObject private var playerViewModel = WatchPlayerViewModel() + + @ObservedObject private var connectivity = WatchConnectivityManager.shared + + var body: some View { + if connectivity.isReachable { + WatchHomeView( + libraryViewModel: libraryViewModel, + playerViewModel: playerViewModel + ) + } else { + VStack(spacing: 8) { + Image(systemName: "iphone") + .font(.title2) + + Text("Open flo on your phone") + .font(.headline) + .multilineTextAlignment(.center) + + Text(statusMessage) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button("Retry") { + connectivity.ping() + } + .font(.caption) + } + .padding() + } + } + + private var statusMessage: String { + if !connectivity.isActivated { + return "Activating Watch session" + } + + return "Connect to sync your library" + } + } +#endif diff --git a/flo/Watch/WatchSongsView.swift b/flo/Watch/WatchSongsView.swift new file mode 100644 index 0000000..5b1ff53 --- /dev/null +++ b/flo/Watch/WatchSongsView.swift @@ -0,0 +1,50 @@ +// +// WatchSongsView.swift +// flo +// +// Created by Codex on 17/02/26. +// + +#if os(watchOS) +import SwiftUI + +struct WatchSongsView: View { + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel + + var body: some View { + List { + if libraryViewModel.isLoading { + ProgressView() + } + + if let errorMessage = libraryViewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + } + + ForEach(libraryViewModel.songs) { song in + Button { + playerViewModel.playSongAll(song) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.body) + .lineLimit(1) + Text(song.artist) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + .navigationTitle("Songs") + .onAppear { + if libraryViewModel.songs.isEmpty { + libraryViewModel.loadAllSongs() + } + } + } +} +#endif diff --git a/flo/flo.entitlements b/flo/flo.entitlements new file mode 100644 index 0000000..cd9ef49 --- /dev/null +++ b/flo/flo.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.carplay-audio + + +