diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 4434abe..35467e9 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02A003E2F3666240024E8EC /* UIScreen+.swift */; }; B0AD2E712F4B037400577062 /* ArtistDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E6E2F4B037400577062 /* ArtistDetailView.swift */; }; B0AD2E722F4B037400577062 /* ArtistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E6F2F4B037400577062 /* ArtistsView.swift */; }; + B0AD2E782F4B037D00577062 /* ArtistImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E792F4B037D00577062 /* ArtistImageView.swift */; }; B0AD2E742F4B037C00577062 /* ArtistDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E732F4B037C00577062 /* ArtistDetailViewModel.swift */; }; B0AD2E762F4B2B6900577062 /* ArtistRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E752F4B2B6900577062 /* ArtistRadio.swift */; }; B0AD2E772F4B2B6900577062 /* ArtistRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E752F4B2B6900577062 /* ArtistRadio.swift */; }; @@ -36,6 +37,10 @@ C4100A6B2CE78B62001BC9BE /* Playlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4100A6A2CE78B5F001BC9BE /* Playlist.swift */; }; C4120FD92C15D58E00E712BE /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4120FD82C15D58E00E712BE /* Errors.swift */; }; C4120FDD2C15E1C300E712BE /* Song.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4120FDC2C15E1C300E712BE /* Song.swift */; }; + C4EXPLICIT012F4B0001AAAAAA /* ExplicitStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EXPLICIT002F4B0001AAAAAA /* ExplicitStatus.swift */; }; + C4EXPLICIT022F4B0001AAAAAA /* ExplicitStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EXPLICIT002F4B0001AAAAAA /* ExplicitStatus.swift */; }; + C4EXPLICIT042F4B0001AAAAAA /* ExplicitBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EXPLICIT032F4B0001AAAAAA /* ExplicitBadge.swift */; }; + C4EXPLICIT052F4B0001AAAAAA /* ExplicitBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EXPLICIT032F4B0001AAAAAA /* ExplicitBadge.swift */; }; C41470BB2CE6580B008BDCE8 /* Artist.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41470BA2CE65808008BDCE8 /* Artist.swift */; }; C415F54E2C11908100E3E1D2 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C415F54D2C11908100E3E1D2 /* AuthViewModel.swift */; }; C415F5512C11912800E3E1D2 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = C415F5502C11912800E3E1D2 /* KeychainAccess */; }; @@ -100,8 +105,11 @@ C467AD532D3267D000644E68 /* Subsonic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AD522D3267CE00644E68 /* Subsonic.swift */; }; C467AD552D329C8B00644E68 /* AccountLinkStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AD542D329C8500644E68 /* AccountLinkStatus.swift */; }; C46B8DD72CF4B89000B40644 /* Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46B8DD62CF4B88E00B40644 /* Stats.swift */; }; + C46B8DD92CF4B89100B40644 /* LibraryDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46B8DD82CF4B89100B40644 /* LibraryDestination.swift */; }; + C46B8DDB2CF4B89200B40644 /* LibraryNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46B8DDA2CF4B89200B40644 /* LibraryNavigation.swift */; }; C46C85C42C07114200903F54 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46C85C32C07114200903F54 /* LoginView.swift */; }; C47876022C2BF15900184A33 /* AlbumsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47876012C2BF15900184A33 /* AlbumsView.swift */; }; + C4PLSTVW012F4B0001AAAAAA /* PlaylistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4PLSTVW002F4B0001AAAAAA /* PlaylistsView.swift */; }; C47876042C2BFFF900184A33 /* SongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47876032C2BFFF900184A33 /* SongView.swift */; }; C4824D232CE8C41F003EAB52 /* Playable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4824D222CE8C41D003EAB52 /* Playable.swift */; }; C4824D272CE908DC003EAB52 /* SongsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4824D262CE908DA003EAB52 /* SongsView.swift */; }; @@ -133,6 +141,9 @@ C4F0B0A22F3A111100ABC002 /* AirPlayRoutePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */; }; C4F1A0012F4A000100AAAAAA /* InAppPurchaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1A0022F4A000100AAAAAA /* InAppPurchaseManager.swift */; }; C4F870CE2CEFCC5E00312F8A /* FloooService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F870CD2CEFCC5B00312F8A /* FloooService.swift */; }; + 5C0FF0022F8B0001000000A2 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0FF0012F8B0001000000A1 /* NetworkMonitor.swift */; }; + 5C0FF0042F8B0001000000A4 /* ScrobbleQueueManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0FF0032F8B0001000000A3 /* ScrobbleQueueManager.swift */; }; + 5C0FF0062F8B0001000000A6 /* ScrobbleQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0FF0052F8B0001000000A5 /* ScrobbleQueueView.swift */; }; 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 */; }; @@ -179,6 +190,7 @@ B02A003E2F3666240024E8EC /* UIScreen+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+.swift"; sourceTree = ""; }; B0AD2E6E2F4B037400577062 /* ArtistDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistDetailView.swift; sourceTree = ""; }; B0AD2E6F2F4B037400577062 /* ArtistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistsView.swift; sourceTree = ""; }; + B0AD2E792F4B037D00577062 /* ArtistImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistImageView.swift; sourceTree = ""; }; B0AD2E732F4B037C00577062 /* ArtistDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistDetailViewModel.swift; sourceTree = ""; }; B0AD2E752F4B2B6900577062 /* ArtistRadio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistRadio.swift; sourceTree = ""; }; B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosView.swift; sourceTree = ""; }; @@ -190,6 +202,8 @@ C4100A6A2CE78B5F001BC9BE /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = ""; }; C4120FD82C15D58E00E712BE /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; C4120FDC2C15E1C300E712BE /* Song.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Song.swift; sourceTree = ""; }; + C4EXPLICIT002F4B0001AAAAAA /* ExplicitStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplicitStatus.swift; sourceTree = ""; }; + C4EXPLICIT032F4B0001AAAAAA /* ExplicitBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplicitBadge.swift; sourceTree = ""; }; C41470BA2CE65808008BDCE8 /* Artist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Artist.swift; sourceTree = ""; }; C415F54D2C11908100E3E1D2 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; C415F5592C11953000E3E1D2 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; @@ -234,8 +248,11 @@ C467AD522D3267CE00644E68 /* Subsonic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subsonic.swift; sourceTree = ""; }; C467AD542D329C8500644E68 /* AccountLinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountLinkStatus.swift; sourceTree = ""; }; C46B8DD62CF4B88E00B40644 /* Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stats.swift; sourceTree = ""; }; + C46B8DD82CF4B89100B40644 /* LibraryDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryDestination.swift; sourceTree = ""; }; + C46B8DDA2CF4B89200B40644 /* LibraryNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryNavigation.swift; sourceTree = ""; }; C46C85C32C07114200903F54 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; C47876012C2BF15900184A33 /* AlbumsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsView.swift; sourceTree = ""; }; + C4PLSTVW002F4B0001AAAAAA /* PlaylistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsView.swift; sourceTree = ""; }; C47876032C2BFFF900184A33 /* SongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongView.swift; sourceTree = ""; }; C4824D222CE8C41D003EAB52 /* Playable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playable.swift; sourceTree = ""; }; C4824D262CE908DA003EAB52 /* SongsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongsView.swift; sourceTree = ""; }; @@ -268,6 +285,9 @@ C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirPlayRoutePicker.swift; sourceTree = ""; }; C4F1A0022F4A000100AAAAAA /* InAppPurchaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseManager.swift; sourceTree = ""; }; C4F870CD2CEFCC5B00312F8A /* FloooService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloooService.swift; sourceTree = ""; }; + 5C0FF0012F8B0001000000A1 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + 5C0FF0032F8B0001000000A3 /* ScrobbleQueueManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobbleQueueManager.swift; sourceTree = ""; }; + 5C0FF0052F8B0001000000A5 /* ScrobbleQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobbleQueueView.swift; sourceTree = ""; }; 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 = ""; }; @@ -326,6 +346,7 @@ children = ( B0AD2E732F4B037C00577062 /* ArtistDetailViewModel.swift */, B0AD2E6E2F4B037400577062 /* ArtistDetailView.swift */, + B0AD2E792F4B037D00577062 /* ArtistImageView.swift */, B0AD2E6F2F4B037400577062 /* ArtistsView.swift */, ); path = Artists; @@ -357,12 +378,14 @@ C467AD542D329C8500644E68 /* AccountLinkStatus.swift */, C467AD522D3267CE00644E68 /* Subsonic.swift */, C46B8DD62CF4B88E00B40644 /* Stats.swift */, + C46B8DD82CF4B89100B40644 /* LibraryDestination.swift */, C4824D222CE8C41D003EAB52 /* Playable.swift */, C4100A6A2CE78B5F001BC9BE /* Playlist.swift */, C41470BA2CE65808008BDCE8 /* Artist.swift */, C4289F492C12392B00C3A4FD /* Album.swift */, C446A6B62C08DE8800CC9787 /* UserAuth.swift */, C4120FDC2C15E1C300E712BE /* Song.swift */, + C4EXPLICIT002F4B0001AAAAAA /* ExplicitStatus.swift */, C49495802C1C25E5006B4D1E /* ScanStatus.swift */, B0BAAAAA2F3214F7002A5FBB /* Radio.swift */, C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */, @@ -381,8 +404,10 @@ C456D8F92F2FF33B002AAB8B /* LRCParser.swift */, C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */, C415F5592C11953000E3E1D2 /* Constants.swift */, + C46B8DDA2CF4B89200B40644 /* LibraryNavigation.swift */, C415F5632C11AA8700E3E1D2 /* Fonts.swift */, C49134522C15BE0C00CCF2EB /* Strings.swift */, + C4EXPLICIT032F4B0001AAAAAA /* ExplicitBadge.swift */, C4120FD82C15D58E00E712BE /* Errors.swift */, 50C9128A2F5DD9990087EE61 /* IAPLoginView.swift */, ); @@ -436,6 +461,8 @@ isa = PBXGroup; children = ( C4F870CD2CEFCC5B00312F8A /* FloooService.swift */, + 5C0FF0012F8B0001000000A1 /* NetworkMonitor.swift */, + 5C0FF0032F8B0001000000A3 /* ScrobbleQueueManager.swift */, C4875DFF2C149D9000D9BAEB /* AlbumService.swift */, C4CACHE002D7B0000003B9C4F /* StreamCacheManager.swift */, 0C2A3B4C5D6E7F8A9B0C1D2E /* LibraryCacheManager.swift */, @@ -513,12 +540,14 @@ C42E7E172CE7EF4D00505B4E /* PlaylistDetailView.swift */, C4F870CF2CEFD24D00312F8A /* StatCardView.swift */, C47876012C2BF15900184A33 /* AlbumsView.swift */, + C4PLSTVW002F4B0001AAAAAA /* PlaylistsView.swift */, B0AD2E702F4B037400577062 /* Artists */, C47876032C2BFFF900184A33 /* SongView.swift */, C4100A682CE78B21001BC9BE /* PlaylistView.swift */, C467AD502D3264AE00644E68 /* FloooViewModel.swift */, C429DB312D33C704009F2684 /* DownloadButtonView.swift */, C429DB2F2D33AE81009F2684 /* DownloadQueueView.swift */, + 5C0FF0052F8B0001000000A5 /* ScrobbleQueueView.swift */, C4DFFA202D32E769003B9C4E /* DownloadViewModel.swift */, C456D8FD2F300D37002AAB8B /* LyricsView.swift */, C42B25662F44533D00E62008 /* Watch */, @@ -668,6 +697,8 @@ C42B25AA2F445C8900E62008 /* UserAuth.swift in Sources */, C42B25AB2F445C8900E62008 /* LyricsLine.swift in Sources */, C42B25AC2F445C8900E62008 /* Song.swift in Sources */, + C4EXPLICIT022F4B0001AAAAAA /* ExplicitStatus.swift in Sources */, + C4EXPLICIT052F4B0001AAAAAA /* ExplicitBadge.swift in Sources */, C42B25AD2F445C8900E62008 /* LRCLIB.swift in Sources */, C42B25BE2F4464AE00E62008 /* WatchRadiosView.swift in Sources */, C42B25AE2F445C8900E62008 /* AccountLinkStatus.swift in Sources */, @@ -716,23 +747,31 @@ B0BAAAAB2F3214F7002A5FBB /* Radio.swift in Sources */, C467AD552D329C8B00644E68 /* AccountLinkStatus.swift in Sources */, C4120FDD2C15E1C300E712BE /* Song.swift in Sources */, + C4EXPLICIT012F4B0001AAAAAA /* ExplicitStatus.swift in Sources */, + C4EXPLICIT042F4B0001AAAAAA /* ExplicitBadge.swift in Sources */, C467AD532D3267D000644E68 /* Subsonic.swift in Sources */, C47876022C2BF15900184A33 /* AlbumsView.swift in Sources */, + C4PLSTVW012F4B0001AAAAAA /* PlaylistsView.swift in Sources */, 50C912A42F648A440087EE61 /* IAPWebAuthView.swift in Sources */, C4824D272CE908DC003EAB52 /* SongsView.swift in Sources */, 0E1A2B3C4D5E6F7A8B9C0D1E /* LikedSongsView.swift in Sources */, C456D8FA2F2FF33E002AAB8B /* LRCParser.swift in Sources */, 50C912892F5DD9280087EE61 /* AuthMode.swift in Sources */, C4F870CE2CEFCC5E00312F8A /* FloooService.swift in Sources */, + 5C0FF0022F8B0001000000A2 /* NetworkMonitor.swift in Sources */, + 5C0FF0042F8B0001000000A4 /* ScrobbleQueueManager.swift in Sources */, + 5C0FF0062F8B0001000000A6 /* ScrobbleQueueView.swift in Sources */, C4DFFA212D32E76E003B9C4E /* DownloadViewModel.swift in Sources */, C4F1A0012F4A000100AAAAAA /* InAppPurchaseManager.swift in Sources */, C4120FD92C15D58E00E712BE /* Errors.swift in Sources */, C415F55A2C11953000E3E1D2 /* Constants.swift in Sources */, + C46B8DDB2CF4B89200B40644 /* LibraryNavigation.swift in Sources */, C401D09A2C5AED9F009F91C7 /* LocalFileManager.swift in Sources */, C4F870D02CEFD25900312F8A /* StatCardView.swift in Sources */, C4100A6B2CE78B62001BC9BE /* Playlist.swift in Sources */, C4A4BF332C14437700363290 /* LibraryView.swift in Sources */, C46B8DD72CF4B89000B40644 /* Stats.swift in Sources */, + C46B8DD92CF4B89100B40644 /* LibraryDestination.swift in Sources */, B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */, C4F0B0A22F3A111100ABC002 /* AirPlayRoutePicker.swift in Sources */, C415F5642C11AA8700E3E1D2 /* Fonts.swift in Sources */, @@ -774,6 +813,7 @@ C4A4BF312C14433D00363290 /* HomeView.swift in Sources */, C4A4BF392C14445000363290 /* PreferencesView.swift in Sources */, B0AD2E712F4B037400577062 /* ArtistDetailView.swift in Sources */, + B0AD2E782F4B037D00577062 /* ArtistImageView.swift in Sources */, B0AD2E722F4B037400577062 /* ArtistsView.swift in Sources */, 5E64C3D71B02BE8FEDF5FF3C /* CarPlaySceneDelegate.swift in Sources */, 55CFFAB52342C01E366F87B0 /* CarPlayCoordinator.swift in Sources */, diff --git a/flo/AlbumView.swift b/flo/AlbumView.swift index d2ec421..d98058c 100644 --- a/flo/AlbumView.swift +++ b/flo/AlbumView.swift @@ -29,6 +29,32 @@ struct AlbumView: View { var isDownloadScreen: Bool = false + @ViewBuilder + private var albumArtistLabel: some View { + let artistName = viewModel.album.albumArtist + + if let artist = viewModel.artistForNavigation( + id: viewModel.album.resolvedArtistId, name: artistName) + { + NavigationLink { + ArtistDetailView(artist: artist) + .environmentObject(viewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + } label: { + Text(artistName) + .customFont(.title3) + .multilineTextAlignment(.center) + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + } else { + Text(artistName) + .customFont(.title3) + .multilineTextAlignment(.center) + } + } + var body: some View { ScrollView { VStack { @@ -81,15 +107,19 @@ struct AlbumView: View { } VStack { - Text(viewModel.album.name) - .customFont(.title) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .padding(.bottom, 5) + HStack(alignment: .center, spacing: 8) { + Text(viewModel.album.name) + .customFont(.title) + .fontWeight(.bold) + .multilineTextAlignment(.center) - Text(viewModel.album.albumArtist) - .customFont(.title3) - .multilineTextAlignment(.center) + if viewModel.album.isExplicit { + ExplicitBadge() + } + } + .padding(.bottom, 5) + + albumArtistLabel .padding(.bottom, 10) HStack { @@ -225,6 +255,9 @@ struct AlbumView: View { } } } + .onAppear { + viewModel.getArtists() + } .onReceive(downloadViewModel.$downloadWatcher) { newValue in if newValue { viewModel.setActiveAlbum(album: viewModel.album) diff --git a/flo/AlbumViewModel.swift b/flo/AlbumViewModel.swift index eb174f3..46ac876 100644 --- a/flo/AlbumViewModel.swift +++ b/flo/AlbumViewModel.swift @@ -179,7 +179,8 @@ class AlbumViewModel: ObservableObject { // MARK: - Fetch methods func fetchAllSongs() { - fetchCached(current: songs, cacheKey: .songs, + fetchCached( + current: songs, cacheKey: .songs, assign: { self.songs = $0 }, request: AlbumService.shared.getAllSongs) } @@ -213,11 +214,22 @@ class AlbumViewModel: ObservableObject { } } - func getAlbumCoverArt(id: String, artistName: String = "", albumName: String = "", albumCover: String = "") -> String { + func getAlbumCoverArt( + id: String, artistName: String = "", albumName: String = "", albumCover: String = "" + ) -> String { return AlbumService.shared.getAlbumCover( artistName: artistName, albumName: albumName, albumId: id, albumCover: albumCover) } + func getPlaylistCoverArt(id: String, coverArtId: String? = nil) -> String { + let artId = coverArtId ?? id + return AlbumService.shared.getPlaylistCover(playlistId: artId) + } + + func getArtistCoverArt(id: String, imageURL: String = "") -> String { + return AlbumService.shared.getArtistCover(artistId: id, imageURL: imageURL) + } + func shareAlbum(description: String, completion: @escaping (String) -> Void) { AlbumService.shared.share(albumId: self.album.id, description: description, downloadable: false) { result in @@ -343,7 +355,8 @@ class AlbumViewModel: ObservableObject { } func fetchAlbums() { - fetchCached(current: albums, cacheKey: .albums, showsLoading: true, + fetchCached( + current: albums, cacheKey: .albums, showsLoading: true, assign: { self.albums = $0 }, request: AlbumService.shared.getAlbum) } @@ -383,34 +396,106 @@ class AlbumViewModel: ObservableObject { } func getPlaylists() { - fetchCached(current: playlists, cacheKey: .playlists, + fetchCached( + current: playlists, cacheKey: .playlists, assign: { self.playlists = $0 }, request: AlbumService.shared.getPlaylists) } func getArtists() { - fetchCached(current: artists, cacheKey: .artists, + fetchCached( + current: artists, cacheKey: .artists, assign: { self.artists = $0 }, request: AlbumService.shared.getArtists) } + func artistForNavigation(id: String = "", name: String) -> Artist? { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let hasName = !trimmedName.isEmpty && trimmedName != "N/A" + + if !id.isEmpty, let match = artists.first(where: { $0.id == id }) { + return match + } + + if hasName, + let match = artists.first(where: { + $0.name.caseInsensitiveCompare(trimmedName) == .orderedSame + }) + { + return match + } + + if !id.isEmpty { + return Artist.placeholder(id: id, name: hasName ? trimmedName : name) + } + + return nil + } + + func albumForNavigation(id: String = "", name: String, artist: String = "") -> Album? { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let hasName = !trimmedName.isEmpty && trimmedName != "N/A" + + if !id.isEmpty { + if let match = albums.first(where: { $0.id == id }) { + return match + } + + if let match = downloadedAlbums.first(where: { $0.id == id }) { + return match + } + + return Album( + id: id, + name: hasName ? trimmedName : name, + albumArtist: artist, + artist: artist + ) + } + + guard hasName else { return nil } + + let matchingAlbums = albums.filter { + $0.name.caseInsensitiveCompare(trimmedName) == .orderedSame + } + + if matchingAlbums.isEmpty { + return downloadedAlbums.first(where: { + $0.name.caseInsensitiveCompare(trimmedName) == .orderedSame + }) + } + + if artist.isEmpty || artist == "N/A" { + return matchingAlbums.first + } + + return matchingAlbums.first(where: { + $0.albumArtist.caseInsensitiveCompare(artist) == .orderedSame + || $0.artist.caseInsensitiveCompare(artist) == .orderedSame + }) ?? matchingAlbums.first + } + // MARK: - Async refresh variants @MainActor func refreshAlbums() async { - await refreshCached(cacheKey: .albums, assign: { self.albums = $0 }, + await refreshCached( + cacheKey: .albums, assign: { self.albums = $0 }, request: AlbumService.shared.getAlbum) } @MainActor func refreshArtists() async { - await refreshCached(cacheKey: .artists, assign: { self.artists = $0 }, + await refreshCached( + cacheKey: .artists, assign: { self.artists = $0 }, request: AlbumService.shared.getArtists) } @MainActor func refreshPlaylists() async { - await refreshCached(cacheKey: .playlists, assign: { self.playlists = $0 }, + await refreshCached( + cacheKey: .playlists, assign: { self.playlists = $0 }, request: AlbumService.shared.getPlaylists) } @MainActor func refreshAllSongs() async { - await refreshCached(cacheKey: .songs, assign: { self.songs = $0 }, + await refreshCached( + cacheKey: .songs, assign: { self.songs = $0 }, request: AlbumService.shared.getAllSongs) } diff --git a/flo/AlbumsView.swift b/flo/AlbumsView.swift index ba2f4e0..068e8e7 100644 --- a/flo/AlbumsView.swift +++ b/flo/AlbumsView.swift @@ -20,71 +20,51 @@ struct AlbumsView: View { if self.isDownloadScreen { if let image = UIImage( contentsOfFile: viewModel.getAlbumCoverArt( - id: album.id, artistName: album.artist, albumName: album.name, albumCover: album.albumCover)) + id: album.id, artistName: album.artist, albumName: album.name, + albumCover: album.albumCover)) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity, maxHeight: 300) - .clipShape( - RoundedRectangle(cornerRadius: 5, style: .continuous) - ) + albumArtwork(Image(uiImage: image)) } else { if let image = UIImage(named: "placeholder") { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity, maxHeight: 300) - .clipShape( - RoundedRectangle(cornerRadius: 5, style: .continuous) - ) + albumArtwork(Image(uiImage: image)) } } } else { if let image = UIImage( contentsOfFile: viewModel.getAlbumCoverArt(id: album.id, albumCover: album.albumCover)) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity, maxHeight: 300) - .clipShape( - RoundedRectangle(cornerRadius: 5, style: .continuous) - ) + albumArtwork(Image(uiImage: image)) } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt(id: album.id, albumCover: album.albumCover))) { state in + LazyImage( + url: URL( + string: viewModel.getAlbumCoverArt(id: album.id, albumCover: album.albumCover)) + ) { state in if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity, maxHeight: 300) - .clipShape( - RoundedRectangle(cornerRadius: 5, style: .continuous) - ) + albumArtwork(image) } else { if let image = UIImage(named: "placeholder") { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity, maxHeight: 300) - .clipShape( - RoundedRectangle(cornerRadius: 5, style: .continuous) - ) + albumArtwork(Image(uiImage: image)) } } } } } - Text(album.name) - .customFont(.caption1) - .fontWeight(.bold) - .foregroundColor(.primary) - .truncationMode(.tail) - .padding(.trailing, 20) - .lineLimit(1) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) + HStack(alignment: .center, spacing: 4) { + Text(album.name) + .customFont(.caption1) + .fontWeight(.bold) + .foregroundColor(.primary) + .truncationMode(.tail) + .lineLimit(1) + .multilineTextAlignment(.leading) + + if album.isExplicit { + ExplicitBadge(size: .compact) + } + } + .padding(.trailing, 20) + .frame(maxWidth: .infinity, alignment: .leading) Text(album.albumArtist) .customFont(.caption2) @@ -96,6 +76,19 @@ struct AlbumsView: View { }.padding() } } + + private func albumArtwork(_ image: Image) -> some View { + GeometryReader { proxy in + image + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.width) + .clipShape( + RoundedRectangle(cornerRadius: 5, style: .continuous) + ) + } + .aspectRatio(1, contentMode: .fit) + } } struct AlbumsView_Preview: PreviewProvider { diff --git a/flo/Artists/ArtistDetailView.swift b/flo/Artists/ArtistDetailView.swift index 1b8753b..4419b38 100644 --- a/flo/Artists/ArtistDetailView.swift +++ b/flo/Artists/ArtistDetailView.swift @@ -46,21 +46,26 @@ struct ArtistDetailView: View { 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() + HStack(alignment: .top, spacing: 16) { + ArtistImageView(artist: artist, size: 88) + + VStack(alignment: .leading, spacing: 6) { + Text(artist.name) + .customFont(.title) + .fontWeight(.bold) + .multilineTextAlignment(.leading) + .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 { diff --git a/flo/Artists/ArtistImageView.swift b/flo/Artists/ArtistImageView.swift new file mode 100644 index 0000000..19291ed --- /dev/null +++ b/flo/Artists/ArtistImageView.swift @@ -0,0 +1,44 @@ +// +// ArtistImageView.swift +// flo +// + +import NukeUI +import SwiftUI + +struct ArtistImageView: View { + @EnvironmentObject private var viewModel: AlbumViewModel + + let artist: Artist + var size: CGFloat = 44 + + var body: some View { + let imageURL = artist.mediumImageURL ?? artist.smallImageURL ?? artist.largeImageURL ?? "" + let hasImageSource = !artist.id.isEmpty || !imageURL.isEmpty + + if hasImageSource { + LazyImage(url: URL(string: viewModel.getArtistCoverArt(id: artist.id, imageURL: imageURL))) { + state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size) + .clipShape(Circle()) + } else { + placeholderImage + } + } + } else { + placeholderImage + } + } + + private var placeholderImage: some View { + Image("placeholder") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size) + .clipShape(Circle()) + } +} diff --git a/flo/Artists/ArtistsView.swift b/flo/Artists/ArtistsView.swift index becbc3e..aeddf2f 100644 --- a/flo/Artists/ArtistsView.swift +++ b/flo/Artists/ArtistsView.swift @@ -38,9 +38,12 @@ struct ArtistsView: View { } label: { VStack { HStack { + ArtistImageView(artist: artist) + Text(artist.name) .customFont(.headline) .multilineTextAlignment(.leading) + .padding(.leading, 10) Spacer() diff --git a/flo/AuthMode.swift b/flo/AuthMode.swift index c15caad..30ad5a3 100644 --- a/flo/AuthMode.swift +++ b/flo/AuthMode.swift @@ -11,15 +11,3 @@ enum AuthMode: String, Codable { case standard case iap } - -struct IAPAuthInfo: Codable { - let jwtAssertion: String - let userEmail: String? - let userId: String? - - init(jwtAssertion: String, userEmail: String? = nil, userId: String? = nil) { - self.jwtAssertion = jwtAssertion - self.userEmail = userEmail - self.userId = userId - } -} diff --git a/flo/AuthViewModel.swift b/flo/AuthViewModel.swift index 0916692..873b111 100644 --- a/flo/AuthViewModel.swift +++ b/flo/AuthViewModel.swift @@ -29,8 +29,6 @@ class AuthViewModel: ObservableObject { @Published var isLoggedIn: Bool = false @Published var authMode: AuthMode = .standard - @Published var iapJwtAssertion: String = "" - @Published var useIAPAuth: Bool = false static let shared = AuthViewModel() @@ -56,18 +54,29 @@ class AuthViewModel: ObservableObject { authMode = AuthService.shared.getAuthMode() - if UserDefaultsManager.saveLoginInfo { + if authMode == .iap { + user = UserAuth( + id: data.id, username: data.username, name: data.name, isAdmin: data.isAdmin, + lastFMApiKey: data.lastFMApiKey + ) + AuthService.shared.setCreds(data) + isLoggedIn = true + + AuthService.shared.verifySubsonicAccess(data, serverUrl: serverUrl) { result in + if case .invalid = result { + DispatchQueue.main.async { + self.logout() + } + } + } + } else if UserDefaultsManager.saveLoginInfo { do { password = try KeychainManager.getAuthPassword() ?? "" } catch { print("Error loading password from Keychain: \(error)") } - if authMode == .iap, let iapInfo = AuthService.shared.getIAPAuthInfo() { - loginWithIAP(jwtAssertion: iapInfo.jwtAssertion) - } else { - login() - } + login() } else { user = UserAuth( id: data.id, username: data.username, name: data.name, isAdmin: data.isAdmin, @@ -135,9 +144,7 @@ class AuthViewModel: ObservableObject { destroySavedPassword() if authMode == .iap { - try? KeychainManager.removeIAPAuthInfo() try? KeychainManager.removeAuthMode() - AuthService.shared.clearIAPAuthInfo() } UserDefaultsManager.removeObject(key: UserDefaultsKeys.serverURL) @@ -184,61 +191,4 @@ class AuthViewModel: ObservableObject { } } - func loginWithIAP(jwtAssertion: String? = nil) { - isSubmitting = true - - let jwt = jwtAssertion ?? iapJwtAssertion - - guard !jwt.isEmpty else { - DispatchQueue.main.async { - self.isSubmitting = false - self.alertMessage = "JWT assertion is required for IAP authentication" - self.showAlert = true - } - return - } - - AuthService.shared.loginWithIAP(serverUrl: serverUrl, jwtAssertion: jwt) { result in - switch result { - case .success(let data): - self.persistAuthData(data) - - self.authMode = .iap - - if UserDefaultsManager.saveLoginInfo { - self.destroySavedPassword() - } - - DispatchQueue.main.async { - self.isSubmitting = false - self.isLoggedIn = true - self.iapJwtAssertion = "" - self.serverUrl = "" - } - - case .failure(let error): - DispatchQueue.main.async { - self.isSubmitting = false - - switch error { - case .server(let message): - self.alertMessage = message - - case .unknown: - self.alertMessage = "Unknown error occurred during IAP authentication" - } - - self.showAlert = true - } - } - } - } - - func toggleAuthMode() { - useIAPAuth.toggle() - } - - func isUsingIAPAuth() -> Bool { - return authMode == .iap - } } diff --git a/flo/CarPlay/CarPlayCoordinator.swift b/flo/CarPlay/CarPlayCoordinator.swift index 319b7c2..bf538ca 100644 --- a/flo/CarPlay/CarPlayCoordinator.swift +++ b/flo/CarPlay/CarPlayCoordinator.swift @@ -106,7 +106,7 @@ import Combine case .success(let albums): let items = albums.map { album -> CPListItem in let item = CPListItem( - text: album.name, + text: album.explicitStatus.annotatedTitle(album.name), detailText: album.albumArtist.isEmpty ? album.artist : album.albumArtist ) item.handler = { [weak self] _, completion in @@ -208,7 +208,7 @@ import Combine let trackItems = album.songs.enumerated().map { idx, song -> CPListItem in let item = CPListItem( - text: song.title, + text: song.explicitStatus.annotatedTitle(song.title), detailText: song.artist ) item.handler = { [weak self] _, completion in @@ -304,7 +304,7 @@ import Combine let albumItems = albums.map { album -> CPListItem in let item = CPListItem( - text: album.name, + text: album.explicitStatus.annotatedTitle(album.name), detailText: album.minYear > 0 ? "\(album.minYear)" : nil ) item.handler = { [weak self] _, completion in @@ -416,7 +416,7 @@ import Combine case .success(let songs): let items = songs.enumerated().map { idx, song -> CPListItem in let item = CPListItem( - text: song.title, + text: song.explicitStatus.annotatedTitle(song.title), detailText: song.artist ) item.handler = { [weak self] _, completion in @@ -493,7 +493,7 @@ import Combine let trackItems = songs.enumerated().map { idx, song -> CPListItem in let item = CPListItem( - text: song.title, + text: song.explicitStatus.annotatedTitle(song.title), detailText: song.artist ) item.handler = { [weak self] _, completion in @@ -625,7 +625,7 @@ import Combine let trackItems = playlist.songs.enumerated().map { idx, song -> CPListItem in let item = CPListItem( - text: song.title, + text: song.explicitStatus.annotatedTitle(song.title), detailText: song.artist ) item.handler = { [weak self] _, completion in @@ -746,7 +746,7 @@ import Combine let items = filtered.map { album -> CPListItem in let item = CPListItem( - text: album.name, + text: album.explicitStatus.annotatedTitle(album.name), detailText: album.artist ) item.handler = { [weak self] _, completion in @@ -822,7 +822,7 @@ import Combine let trackItems = songs.enumerated().map { idx, song -> CPListItem in let item = CPListItem( - text: song.title, + text: song.explicitStatus.annotatedTitle(song.title), detailText: song.artist ) item.handler = { [weak self] _, completion in diff --git a/flo/ContentView.swift b/flo/ContentView.swift index ac12301..40dc784 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -16,6 +16,7 @@ struct ContentView: View { @State private var tabViewID = UUID() @StateObject private var authViewModel = AuthViewModel() + @StateObject private var libraryRouter = LibraryRouter() @ObservedObject private var playerViewModel = PlayerViewModel.shared @StateObject private var albumViewModel = AlbumViewModel() @StateObject private var floooViewModel = FloooViewModel() @@ -76,35 +77,54 @@ struct ContentView: View { } private var baseTabView: some View { - TabView { + TabView(selection: $libraryRouter.selectedTab) { HomeView(viewModel: authViewModel).tabItem { Label("Home", systemImage: "house") - }.environmentObject(floooViewModel) + } + .tag(AppTab.home) + .environmentObject(floooViewModel) + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .environmentObject(libraryRouter) if authViewModel.isLoggedIn { LibraryView(viewModel: albumViewModel).tabItem { Label("Library", systemImage: "square.grid.2x2") - }.environmentObject(playerViewModel).environmentObject(downloadViewModel) - .onAppear { - albumViewModel.fetchAlbums() - } + } + .tag(AppTab.library) + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .environmentObject(libraryRouter) + .onAppear { + albumViewModel.fetchAlbums() + } } DownloadsView(viewModel: albumViewModel).tabItem { Label("Downloads", systemImage: "arrow.down.circle") - }.environmentObject(playerViewModel).environmentObject(downloadViewModel).onAppear { + } + .tag(AppTab.downloads) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { albumViewModel.fetchDownloadedAlbums() }.badge(downloadViewModel.getRemainingDownloadItems()) PreferencesView(authViewModel: authViewModel).tabItem { Label("Preferences", systemImage: "gear") - }.environmentObject(playerViewModel).environmentObject(floooViewModel).environmentObject( - inAppPurchaseManager) + } + .tag(AppTab.preferences) + .environmentObject(playerViewModel) + .environmentObject(floooViewModel) + .environmentObject(inAppPurchaseManager) if UserDefaultsManager.enableDebug { ConsoleView().tabItem { Label("Debug", systemImage: "terminal") } + .tag(AppTab.debug) } } .id(tabViewID) @@ -151,31 +171,45 @@ struct ContentView: View { @available(iOS 18.0, *) private var sidebarTabView: some View { - TabView { - Tab("Home", systemImage: "house") { + TabView(selection: $libraryRouter.selectedTab) { + Tab("Home", systemImage: "house", value: AppTab.home) { sidebarTabContent( HomeView(viewModel: authViewModel) .environmentObject(floooViewModel) + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .environmentObject(libraryRouter) ) } if authViewModel.isLoggedIn { TabSection("Library") { - Tab("Albums", systemImage: "square.grid.2x2") { + Tab("Albums", systemImage: "square.grid.2x2", value: AppTab.library) { sidebarTabContent( LibraryView(viewModel: albumViewModel, showQuickNavigation: false) + .environmentObject(albumViewModel) .environmentObject(playerViewModel) .environmentObject(downloadViewModel) + .environmentObject(libraryRouter) .onAppear { albumViewModel.fetchAlbums() } ) } - Tab("Artists", systemImage: "music.mic") { + Tab("Artists", systemImage: "music.mic", value: AppTab.libraryArtists) { sidebarTabContent( - NavigationStack { + NavigationStack(path: $libraryRouter.artistsPath) { ArtistsView(artists: albumViewModel.artists) + .navigationDestination(for: LibraryDestination.self) { destination in + LibraryDestinationView( + destination: destination, + albumViewModel: albumViewModel, + playerViewModel: playerViewModel, + downloadViewModel: downloadViewModel + ) + } .onAppear { albumViewModel.getArtists() } @@ -183,10 +217,11 @@ struct ContentView: View { .environmentObject(albumViewModel) .environmentObject(playerViewModel) .environmentObject(downloadViewModel) + .environmentObject(libraryRouter) ) } - Tab("Liked Songs", systemImage: "heart.fill") { + Tab("Liked Songs", systemImage: "heart.fill", value: AppTab.likedSongs) { sidebarTabContent( NavigationStack { LikedSongsView() @@ -196,7 +231,7 @@ struct ContentView: View { ) } - Tab("Playlists", systemImage: "music.note.list") { + Tab("Playlists", systemImage: "music.note.list", value: AppTab.playlists) { sidebarTabContent( NavigationStack { PlaylistView() @@ -210,7 +245,7 @@ struct ContentView: View { ) } - Tab("Songs", systemImage: "music.note") { + Tab("Songs", systemImage: "music.note", value: AppTab.songs) { sidebarTabContent( NavigationStack { SongsView() @@ -223,7 +258,7 @@ struct ContentView: View { ) } - Tab("Radios", systemImage: "radio") { + Tab("Radios", systemImage: "radio", value: AppTab.radios) { sidebarTabContent( NavigationStack { RadiosView() @@ -234,7 +269,7 @@ struct ContentView: View { } } - Tab("Downloads", systemImage: "arrow.down.circle") { + Tab("Downloads", systemImage: "arrow.down.circle", value: AppTab.downloads) { sidebarTabContent( DownloadsView(viewModel: albumViewModel) .environmentObject(playerViewModel) @@ -246,7 +281,7 @@ struct ContentView: View { } .badge(downloadViewModel.getRemainingDownloadItems()) - Tab("Preferences", systemImage: "gear") { + Tab("Preferences", systemImage: "gear", value: AppTab.preferences) { sidebarTabContent( PreferencesView(authViewModel: authViewModel) .environmentObject(playerViewModel) @@ -256,7 +291,7 @@ struct ContentView: View { } if UserDefaultsManager.enableDebug { - Tab("Debug", systemImage: "terminal") { + Tab("Debug", systemImage: "terminal", value: AppTab.debug) { sidebarTabContent( ConsoleView() ) @@ -285,10 +320,16 @@ struct ContentView: View { rootTabView if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { - PlayerView(isExpanded: $isPlayerExpanded, viewModel: playerViewModel) - .ignoresSafeArea() - .offset(y: isPlayerExpanded ? 0 : offScreenY) - .animation(.spring(duration: 0.2), value: isPlayerExpanded) + PlayerView( + isExpanded: $isPlayerExpanded, + viewModel: playerViewModel, + albumViewModel: albumViewModel, + onOpenLibraryDestination: openLibraryDestinationFromPlayer + ) + .environmentObject(downloadViewModel) + .ignoresSafeArea() + .offset(y: isPlayerExpanded ? 0 : offScreenY) + .animation(.spring(duration: 0.2), value: isPlayerExpanded) } if !isPadSidebar { @@ -310,7 +351,7 @@ struct ContentView: View { #if targetEnvironment(macCatalyst) 10 #else - isPad ? 0 : (40 + bottomPadding) + return isPad ? 0 : (40 + bottomPadding) #endif }() @@ -355,6 +396,37 @@ struct ContentView: View { PlaybackCoordinator.shared.attach(playerViewModel: playerViewModel) } } + + private func openLibraryDestinationFromPlayer(_ destination: LibraryDestination) { + isPlayerExpanded = false + + let targetTab: AppTab + switch destination { + case .artist: + if isPadSidebar { + targetTab = .libraryArtists + } else if authViewModel.isLoggedIn { + targetTab = .library + } else { + targetTab = .home + } + case .album: + targetTab = authViewModel.isLoggedIn ? .library : .home + } + + libraryRouter.selectedTab = targetTab + + DispatchQueue.main.async { + switch targetTab { + case .libraryArtists: + libraryRouter.artistsPath.append(destination) + case .library: + libraryRouter.libraryPath.append(destination) + default: + libraryRouter.homePath.append(destination) + } + } + } } struct ContentView_Previews: PreviewProvider { diff --git a/flo/FloatingPlayerView.swift b/flo/FloatingPlayerView.swift index 94c75cc..a9808bf 100644 --- a/flo/FloatingPlayerView.swift +++ b/flo/FloatingPlayerView.swift @@ -81,11 +81,17 @@ struct FloatingPlayerView: View { .shadow(radius: 2) VStack(alignment: .leading, spacing: 1) { - Text(viewModel.nowPlaying.songName ?? "") - .foregroundColor(.accent) - .customFont(.callout) - .fontWeight(.bold) - .lineLimit(1) + HStack(alignment: .center, spacing: 6) { + Text(viewModel.nowPlaying.songName ?? "") + .foregroundColor(.accent) + .customFont(.callout) + .fontWeight(.bold) + .lineLimit(1) + + if ExplicitStatus(from: viewModel.nowPlaying.explicitStatus).isExplicit { + ExplicitBadge(size: .compact) + } + } Text(viewModel.nowPlaying.artistName ?? "") .customFont(.caption1) diff --git a/flo/FloooViewModel.swift b/flo/FloooViewModel.swift index a168bc1..2e29042 100644 --- a/flo/FloooViewModel.swift +++ b/flo/FloooViewModel.swift @@ -89,7 +89,7 @@ class FloooViewModel: ObservableObject { } } - func fetchAccountLinkStatus(completion: @escaping (AccountLinkStatus) -> Void) { + func fetchAccountLinkStatus(completion: @escaping (Result) -> Void) { return FloooService.shared.getAccountLinkStatuses { result in switch result { case .success(let status): @@ -97,19 +97,16 @@ class FloooViewModel: ObservableObject { self.isLastFmLinked = status.lastFM self.isScrobbleAccountStatusChecked = true - completion(status) + completion(.success(status.listenBrainz || status.lastFM)) case .failure(let error): - print("error>>>>", error) + completion(.failure(error)) } } } func checkAccountLinkStatus() { - self.fetchAccountLinkStatus { status in - self.isListenBrainzLinked = status.listenBrainz - self.isLastFmLinked = status.lastFM - } + self.fetchAccountLinkStatus { _ in } } func checkScanStatus() { @@ -139,30 +136,58 @@ class FloooViewModel: ObservableObject { } private func processScrobble(submission: Bool, nowPlaying: QueueEntity) { - guard let songId = nowPlaying.id else { return } + guard let songId = nowPlaying.id, !songId.isEmpty else { return } - if isScrobbleAccountStatusChecked { - let shouldSubmit = isListenBrainzLinked || isLastFmLinked + if !NetworkMonitor.shared.isOnline || !NetworkMonitor.shared.isServerReachable { + if isScrobbleAccountStatusChecked && !(isListenBrainzLinked || isLastFmLinked) { + return + } + + if submission { + ScrobbleQueueManager.shared.enqueue(nowPlaying: nowPlaying) + } + + return + } - if shouldSubmit { - sendScrobble(submission: submission, songId: songId) + if isScrobbleAccountStatusChecked { + if isListenBrainzLinked || isLastFmLinked { + sendScrobble(submission: submission, nowPlaying: nowPlaying) } } else { - fetchAccountLinkStatus { status in - let shouldSubmit = status.listenBrainz || status.lastFM + fetchAccountLinkStatus { [weak self] result in + guard let self = self else { return } - if shouldSubmit { - self.sendScrobble(submission: submission, songId: songId) + switch result { + case .success(true): + self.sendScrobble(submission: submission, nowPlaying: nowPlaying) + + case .success(false): + break + + case .failure: + if submission { + ScrobbleQueueManager.shared.enqueue(nowPlaying: nowPlaying) + } } } } } - private func sendScrobble(submission: Bool, songId: String) { + private func sendScrobble(submission: Bool, nowPlaying: QueueEntity) { + guard let songId = nowPlaying.id else { return } + FloooService.shared.scrobbleToBuiltinEndpoint(submission: submission, songId: songId) { result in - // TODO: handle when this fail - // TODO: also, add "check offline mode" later + switch result { + case .success: + break + + case .failure(let error): + if submission && FloooService.shared.shouldQueueOfflineScrobble(error) { + ScrobbleQueueManager.shared.enqueue(nowPlaying: nowPlaying) + } + } } } } diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift index 7f2cccd..fcea795 100644 --- a/flo/LyricsView.swift +++ b/flo/LyricsView.swift @@ -44,11 +44,17 @@ struct LyricsView: View { .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) VStack(alignment: .leading, spacing: 4) { - Text(viewModel.nowPlaying.songName ?? "") - .foregroundColor(.white) - .customFont(.body) - .fontWeight(.bold) - .lineLimit(1) + HStack(alignment: .center, spacing: 6) { + Text(viewModel.nowPlaying.songName ?? "") + .foregroundColor(.white) + .customFont(.body) + .fontWeight(.bold) + .lineLimit(1) + + if ExplicitStatus(from: viewModel.nowPlaying.explicitStatus).isExplicit { + ExplicitBadge(tint: .white.opacity(0.85), size: .compact) + } + } Text(viewModel.nowPlaying.artistName ?? "") .foregroundColor(.white.opacity(0.7)) diff --git a/flo/Navigation/CachedSongsView.swift b/flo/Navigation/CachedSongsView.swift index 2693b66..6da80ce 100644 --- a/flo/Navigation/CachedSongsView.swift +++ b/flo/Navigation/CachedSongsView.swift @@ -34,11 +34,17 @@ struct CachedSongsView: View { } VStack(alignment: .leading) { - Text(song.title) - .customFont(.headline) - .multilineTextAlignment(.leading) - .lineLimit(2) - .padding(.bottom, 3) + HStack(alignment: .center, spacing: 6) { + Text(song.title) + .customFont(.headline) + .multilineTextAlignment(.leading) + .lineLimit(2) + + if song.isExplicit { + ExplicitBadge(size: .compact) + } + } + .padding(.bottom, 3) Text(song.artist) .customFont(.subheadline) diff --git a/flo/Navigation/HomeView.swift b/flo/Navigation/HomeView.swift index c52cb15..f7ceabc 100644 --- a/flo/Navigation/HomeView.swift +++ b/flo/Navigation/HomeView.swift @@ -12,6 +12,10 @@ struct HomeView: View { @State private var showLoginSheet: Bool = false @EnvironmentObject var floooViewModel: FloooViewModel + @EnvironmentObject var albumViewModel: AlbumViewModel + @EnvironmentObject var playerViewModel: PlayerViewModel + @EnvironmentObject var downloadViewModel: DownloadViewModel + @EnvironmentObject var libraryRouter: LibraryRouter private enum ConnectionState { case online @@ -125,25 +129,11 @@ struct HomeView: View { color: .purple ) - StatCard( - title: "Top Artist", - value: floooViewModel.stats?.topArtist ?? "N/A", - icon: "music.mic", - color: .blue, - showArrow: true - ) + topArtistCard } HStack(alignment: .top, spacing: 16) { - StatCard( - title: "Top Album", - value: floooViewModel.stats?.topAlbum ?? "N/A", - subtitle: floooViewModel.stats?.topAlbumArtist ?? "N/A", - icon: "record.circle", - color: .pink, - isWide: true, - showArrow: true - ) + topAlbumCard } HStack(spacing: 16) { @@ -173,6 +163,91 @@ struct HomeView: View { } .onAppear { self.floooViewModel.getListeningHistory() + if viewModel.isLoggedIn { + self.albumViewModel.getArtists() + self.albumViewModel.fetchAlbums() + } + } + } + + @ViewBuilder + private var topArtistCard: some View { + let artistName = floooViewModel.stats?.topArtist ?? "N/A" + let canNavigate = viewModel.isLoggedIn && floooViewModel.stats?.hasNavigableTopArtist == true + let artist = + canNavigate + ? albumViewModel.artistForNavigation(name: artistName) + : nil + + if let artist { + NavigationLink { + ArtistDetailView(artist: artist) + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + } label: { + StatCard( + title: "Top Artist", + value: artistName, + icon: "music.mic", + color: .blue, + showArrow: true + ) + } + .buttonStyle(.plain) + } else { + StatCard( + title: "Top Artist", + value: artistName, + icon: "music.mic", + color: .blue + ) + } + } + + @ViewBuilder + private var topAlbumCard: some View { + let albumName = floooViewModel.stats?.topAlbum ?? "N/A" + let albumArtist = floooViewModel.stats?.topAlbumArtist ?? "N/A" + let canNavigate = viewModel.isLoggedIn && floooViewModel.stats?.hasNavigableTopAlbum == true + let album = + canNavigate + ? albumViewModel.albumForNavigation( + id: floooViewModel.stats?.topAlbumId ?? "", + name: albumName, + artist: albumArtist + ) + : nil + + if let album { + NavigationLink { + AlbumView(viewModel: albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { + albumViewModel.setActiveAlbum(album: album) + } + } label: { + StatCard( + title: "Top Album", + value: albumName, + subtitle: albumArtist, + icon: "record.circle", + color: .pink, + isWide: true, + showArrow: true + ) + } + .buttonStyle(.plain) + } else { + StatCard( + title: "Top Album", + value: albumName, + subtitle: albumArtist, + icon: "record.circle", + color: .pink, + isWide: true + ) } } @@ -186,15 +261,27 @@ struct HomeView: View { } var body: some View { - Group { - if UIDevice.current.userInterfaceIdiom == .pad { - AnyView(mainContent.fullScreenCover(isPresented: shouldShowLoginSheet()) { - loginContent - }) - } else { - AnyView(mainContent.sheet(isPresented: shouldShowLoginSheet()) { - loginContent - }) + NavigationStack(path: $libraryRouter.homePath) { + Group { + if UIDevice.current.userInterfaceIdiom == .pad { + AnyView( + mainContent.fullScreenCover(isPresented: shouldShowLoginSheet()) { + loginContent + }) + } else { + AnyView( + mainContent.sheet(isPresented: shouldShowLoginSheet()) { + loginContent + }) + } + } + .navigationDestination(for: LibraryDestination.self) { destination in + LibraryDestinationView( + destination: destination, + albumViewModel: albumViewModel, + playerViewModel: playerViewModel, + downloadViewModel: downloadViewModel + ) } } } @@ -203,8 +290,16 @@ struct HomeView: View { struct HomeViewPreviews_Previews: PreviewProvider { @StateObject static var viewModel: AuthViewModel = AuthViewModel() @StateObject static var floooViewModel: FloooViewModel = FloooViewModel() + @StateObject static var albumViewModel: AlbumViewModel = AlbumViewModel() + @StateObject static var playerViewModel: PlayerViewModel = PlayerViewModel() + @StateObject static var downloadViewModel: DownloadViewModel = DownloadViewModel() static var previews: some View { - HomeView(viewModel: viewModel).environmentObject(floooViewModel) + HomeView(viewModel: viewModel) + .environmentObject(floooViewModel) + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .environmentObject(LibraryRouter()) } } diff --git a/flo/Navigation/LibraryView.swift b/flo/Navigation/LibraryView.swift index a20dead..6d309f9 100644 --- a/flo/Navigation/LibraryView.swift +++ b/flo/Navigation/LibraryView.swift @@ -17,6 +17,7 @@ struct LibraryView: View { @EnvironmentObject var playerViewModel: PlayerViewModel @EnvironmentObject var downloadViewModel: DownloadViewModel + @EnvironmentObject var libraryRouter: LibraryRouter @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -49,8 +50,16 @@ struct LibraryView: View { } var body: some View { - NavigationStack { + NavigationStack(path: $libraryRouter.libraryPath) { libraryContent + .navigationDestination(for: LibraryDestination.self) { destination in + LibraryDestinationView( + destination: destination, + albumViewModel: viewModel, + playerViewModel: playerViewModel, + downloadViewModel: downloadViewModel + ) + } } } @@ -279,6 +288,8 @@ struct LibraryView_Previews: PreviewProvider { @StateObject private static var viewModel: AlbumViewModel = .init(albums: albums) static var previews: some View { - LibraryView(viewModel: viewModel).environmentObject(playerViewModel) + LibraryView(viewModel: viewModel) + .environmentObject(playerViewModel) + .environmentObject(LibraryRouter()) } } diff --git a/flo/Navigation/LikedSongsView.swift b/flo/Navigation/LikedSongsView.swift index 1b80cd4..da59b18 100644 --- a/flo/Navigation/LikedSongsView.swift +++ b/flo/Navigation/LikedSongsView.swift @@ -32,11 +32,17 @@ struct LikedSongsView: View { } VStack(alignment: .leading) { - Text(song.title) - .customFont(.headline) - .multilineTextAlignment(.leading) - .lineLimit(2) - .padding(.bottom, 3) + HStack(alignment: .center, spacing: 6) { + Text(song.title) + .customFont(.headline) + .multilineTextAlignment(.leading) + .lineLimit(2) + + if song.isExplicit { + ExplicitBadge(size: .compact) + } + } + .padding(.bottom, 3) Text(song.artist) .customFont(.subheadline) diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 9b2dbd0..6f48cea 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -85,6 +85,9 @@ struct PreferencesView: View { @State private var showLoginSheet = false @State private var showCustomLRCLIBServer = false @State private var showFloPlusSheet = false + @State private var showScrobbleQueueSheet = false + + @ObservedObject private var scrobbleQueue = ScrobbleQueueManager.shared @State private var accentColor = Color(.accent) @State private var playerColor = Color(.player) @@ -199,6 +202,21 @@ struct PreferencesView: View { Text(floooViewModel.streamCacheSize) } + Button(action: { + showScrobbleQueueSheet = true + }) { + HStack { + Text("Offline Scrobbles") + + Spacer() + + Text( + scrobbleQueue.pendingCount == 0 ? "Empty" : "\(scrobbleQueue.pendingCount) waiting" + ) + .foregroundColor(.secondary) + } + } + Picker("Cache limit", selection: $experimentalStreamCacheSize) { Text("Off").tag(Int64(0)) Text("500 MB").tag(Int64(524_288_000)) @@ -414,7 +432,8 @@ struct PreferencesView: View { } } } - )) + ) + ) .disabled(authViewModel.authMode == .iap) Text( @@ -578,6 +597,9 @@ struct PreferencesView: View { FloPlusSheet(showSheet: $showFloPlusSheet) .environmentObject(inAppPurchaseManager) } + .fullScreenCover(isPresented: $showScrobbleQueueSheet) { + ScrobbleQueueView() + } .alert("Unable to Purchase flo+", isPresented: $inAppPurchaseManager.showPurchaseError) { Button("OK", role: .cancel) {} } message: { @@ -625,13 +647,15 @@ struct PreferencesView: View { var body: some View { Group { if UIDevice.current.userInterfaceIdiom == .pad { - AnyView(mainContent.fullScreenCover(isPresented: shouldShowLoginSheet) { - loginContent - }) + AnyView( + mainContent.fullScreenCover(isPresented: shouldShowLoginSheet) { + loginContent + }) } else { - AnyView(mainContent.sheet(isPresented: shouldShowLoginSheet) { - loginContent - }) + AnyView( + mainContent.sheet(isPresented: shouldShowLoginSheet) { + loginContent + }) } } } diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 6b77ac7..2247d57 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -13,6 +13,10 @@ struct PlayerView: View { @Binding var isExpanded: Bool @ObservedObject var viewModel: PlayerViewModel + @ObservedObject var albumViewModel: AlbumViewModel + var onOpenLibraryDestination: ((LibraryDestination) -> Void)? + + @EnvironmentObject var downloadViewModel: DownloadViewModel @State private var offset = CGSize.zero @State private var isDragging = false @@ -115,10 +119,16 @@ struct PlayerView: View { ForEach(viewModel.queue.indices, id: \.self) { idx in HStack(alignment: .top) { VStack(alignment: .leading) { - Text(viewModel.queue[idx].songName ?? "") - .customFont(.callout) - .fontWeight(.medium) - .padding(.bottom, 3) + HStack(alignment: .center, spacing: 6) { + Text(viewModel.queue[idx].songName ?? "") + .customFont(.callout) + .fontWeight(.medium) + + if ExplicitStatus(from: viewModel.queue[idx].explicitStatus).isExplicit { + ExplicitBadge(size: .compact) + } + } + .padding(.bottom, 3) Text(viewModel.queue[idx].artistName ?? "") .customFont(.caption1) @@ -195,6 +205,10 @@ struct PlayerView: View { } } .offset(y: offset.height) + .onAppear { + albumViewModel.getArtists() + albumViewModel.fetchAlbums() + } .gesture( DragGesture() .onChanged { gesture in @@ -214,8 +228,8 @@ struct PlayerView: View { } ) } + .foregroundColor(.white) } - .foregroundColor(.white) } private var windowTopSafeInset: CGFloat { @@ -241,60 +255,14 @@ struct PlayerView: View { .padding(.top, topSafeInset + 8) Spacer() - let coverArtUrl = viewModel.getAlbumCoverArt() - if let image = UIImage(contentsOfFile: coverArtUrl) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } else { - LazyImage(url: URL(string: coverArtUrl)) { state in - if state.isLoading { - Color.gray.opacity(0.3) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } else { - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } else if state.error != nil { - Image("placeholder") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } - } - } - } + albumCoverArt(imageSize: imageSize) Spacer().frame(height: horizontalSizeClass == .regular ? 44 : 36) VStack(alignment: .center, spacing: 10) { - Text(viewModel.nowPlaying.songName ?? "") - .foregroundColor(.white) - .customFont(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .lineLimit(3) + nowPlayingTrackLabel - Text(viewModel.nowPlaying.artistName ?? "") - .foregroundColor(.white.opacity(0.8)) - .customFont(.title3) - .multilineTextAlignment(.center) - .lineLimit(2) + nowPlayingArtistLabel } .padding(.horizontal, 30) @@ -511,6 +479,118 @@ struct PlayerView: View { .ignoresSafeArea() } + private var canNavigateNowPlayingAlbum: Bool { + !viewModel.isLiveRadio && viewModel.nowPlaying.isFromPlaylist == false + } + + @ViewBuilder + private var nowPlayingTrackLabel: some View { + let songName = viewModel.nowPlaying.songName ?? "" + let album = albumViewModel.albumForNavigation( + id: viewModel.nowPlaying.albumId ?? "", + name: viewModel.nowPlaying.albumName ?? "", + artist: viewModel.nowPlaying.artistName ?? "" + ) + + let titleLabel = HStack(alignment: .center, spacing: 8) { + Text(songName) + .foregroundColor(.white) + .customFont(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .lineLimit(3) + + if ExplicitStatus(from: viewModel.nowPlaying.explicitStatus).isExplicit { + ExplicitBadge(tint: .white.opacity(0.85)) + } + } + + if canNavigateNowPlayingAlbum, let album, !songName.isEmpty { + Button { + onOpenLibraryDestination?( + .album(id: album.id, name: album.name, artist: album.albumArtist) + ) + } label: { + titleLabel + } + .buttonStyle(.plain) + } else { + titleLabel + } + } + + @ViewBuilder + private var nowPlayingArtistLabel: some View { + let artistName = viewModel.nowPlaying.artistName ?? "" + let artist = albumViewModel.artistForNavigation(name: artistName) + + if let artist, !artistName.isEmpty, !viewModel.isLiveRadio { + Button { + onOpenLibraryDestination?(.artist(id: artist.id, name: artist.name)) + } label: { + Text(artistName) + .foregroundColor(.white.opacity(0.8)) + .customFont(.title3) + .multilineTextAlignment(.center) + .lineLimit(2) + } + .buttonStyle(.plain) + } else { + Text(artistName) + .foregroundColor(.white.opacity(0.8)) + .customFont(.title3) + .multilineTextAlignment(.center) + .lineLimit(2) + } + } + + @ViewBuilder + private func albumCoverArt(imageSize: CGFloat) -> some View { + albumCoverImage(imageSize: imageSize) + } + + @ViewBuilder + private func albumCoverImage(imageSize: CGFloat) -> some View { + let coverArtUrl = viewModel.getAlbumCoverArt() + if let image = UIImage(contentsOfFile: coverArtUrl) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } else { + LazyImage(url: URL(string: coverArtUrl)) { state in + if state.isLoading { + Color.gray.opacity(0.3) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } else { + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } else if state.error != nil { + Image("placeholder") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } + } + } + } + } + @ViewBuilder private func liveProgressBar() -> some View { GeometryReader { geometry in @@ -530,10 +610,13 @@ struct PlayerView: View { struct PlayerView_previews: PreviewProvider { @StateObject static var viewModel = PlayerViewModel() + @StateObject static var albumViewModel = AlbumViewModel() + @StateObject static var downloadViewModel = DownloadViewModel() @State static var isExpanded: Bool = true static var previews: some View { - PlayerView(isExpanded: $isExpanded, viewModel: viewModel) + PlayerView(isExpanded: $isExpanded, viewModel: viewModel, albumViewModel: albumViewModel) + .environmentObject(downloadViewModel) } } @@ -541,23 +624,23 @@ struct PlayerView_previews: PreviewProvider { /// leaving the bottom edges straight so the background extends /// fully into the bottom safe area. struct TopRoundedRectangle: Shape { - var cornerRadius: CGFloat - - func path(in rect: CGRect) -> Path { - var path = Path() - path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius)) - path.addQuadCurve( - to: CGPoint(x: rect.minX + cornerRadius, y: rect.minY), - control: CGPoint(x: rect.minX, y: rect.minY) - ) - path.addLine(to: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY)) - path.addQuadCurve( - to: CGPoint(x: rect.maxX, y: rect.minY + cornerRadius), - control: CGPoint(x: rect.maxX, y: rect.minY) - ) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.closeSubpath() - return path - } + var cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + cornerRadius, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + cornerRadius), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + return path + } } diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 4b85f37..94b46cc 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -225,7 +225,13 @@ class PlayerViewModel: ObservableObject { self._playFromLocal = audioURL.isFileURL - self.playerItem = AVPlayerItem(url: audioURL) + if !audioURL.isFileURL, AuthService.shared.getAuthMode() == .iap { + let cookies = HTTPCookieStorage.shared.cookies(for: audioURL) ?? [] + let asset = AVURLAsset(url: audioURL, options: [AVURLAssetHTTPCookiesKey: cookies]) + self.playerItem = AVPlayerItem(asset: asset) + } else { + self.playerItem = AVPlayerItem(url: audioURL) + } self.player?.replaceCurrentItem(with: self.playerItem) let duration = CMTime( @@ -303,10 +309,10 @@ class PlayerViewModel: ObservableObject { } if !self.isLocallySaved && self.progress >= 0.5 { - Task { - FloooViewModel.shared.scrobble(submission: true, nowPlaying: self.nowPlaying) + self.isLocallySaved = true - self.isLocallySaved = true + Task { @MainActor in + FloooViewModel.shared.scrobble(submission: true, nowPlaying: self.nowPlaying) } } @@ -344,6 +350,8 @@ class PlayerViewModel: ObservableObject { nowPlayingInfo[MPMediaItemPropertyTitle] = title nowPlayingInfo[MPMediaItemPropertyArtist] = artist nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = playbackDuration + nowPlayingInfo[MPMediaItemPropertyIsExplicit] = + ExplicitStatus(from: self.nowPlaying.explicitStatus).isExplicit if let artwork = artwork { nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork diff --git a/flo/PlaylistDetailView.swift b/flo/PlaylistDetailView.swift index ed6290a..9a460c2 100644 --- a/flo/PlaylistDetailView.swift +++ b/flo/PlaylistDetailView.swift @@ -7,6 +7,39 @@ import SwiftUI +struct PlaylistCoverImageView: View { + let pathOrUrlString: String + + var body: some View { + if pathOrUrlString.hasPrefix("http://") || pathOrUrlString.hasPrefix("https://") { + AsyncImage(url: URL(string: pathOrUrlString)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure, .empty: + placeholderImage + @unknown default: + placeholderImage + } + } + } else if let uiImage = UIImage(contentsOfFile: pathOrUrlString) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + placeholderImage + } + } + + private var placeholderImage: some View { + Image(uiImage: UIImage(named: "placeholder") ?? UIImage()) + .resizable() + .aspectRatio(contentMode: .fit) + } +} + struct PlaylistDetailView: View { @EnvironmentObject private var viewModel: AlbumViewModel @EnvironmentObject private var playerViewModel: PlayerViewModel @@ -18,17 +51,18 @@ struct PlaylistDetailView: View { var body: some View { ScrollView { VStack { - if let image = UIImage(named: "placeholder") { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 300, height: 300) - .clipShape( - RoundedRectangle(cornerRadius: 10, style: .continuous) - ) - .shadow(radius: 5) - .padding(.top, 10) - } + PlaylistCoverImageView( + pathOrUrlString: viewModel.getPlaylistCoverArt( + id: viewModel.playlist.id, + coverArtId: viewModel.playlist.coverArtId + ) + ) + .frame(width: 300, height: 300) + .clipShape( + RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + .shadow(radius: 5) + .padding(.top, 10) Text(viewModel.playlist.name) .customFont(.title) @@ -89,8 +123,15 @@ struct PlaylistDetailView: View { .padding(.trailing, 5) VStack(alignment: .leading) { - Text(song.title) - .fontWeight(.medium) + HStack(alignment: .center, spacing: 6) { + Text(song.title) + .fontWeight(.medium) + .lineLimit(1) + + if song.isExplicit { + ExplicitBadge(size: .compact) + } + } Text(song.artist).customFont(.caption1).offset(y: 5) diff --git a/flo/PlaylistView.swift b/flo/PlaylistView.swift index 1a434c0..a84824d 100644 --- a/flo/PlaylistView.swift +++ b/flo/PlaylistView.swift @@ -12,9 +12,19 @@ struct PlaylistView: View { @EnvironmentObject private var playerViewModel: PlayerViewModel @EnvironmentObject private var downloadViewModel: DownloadViewModel + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var searchPlaylist = "" @State private var showDownloadSheet: Bool = false + private var columns: [GridItem] { + if horizontalSizeClass == .regular { + return Array(repeating: GridItem(.flexible()), count: 4) + } else { + return Array(repeating: GridItem(.flexible()), count: 2) + } + } + var filteredPlaylists: [Playlist] { if searchPlaylist.isEmpty { return viewModel.playlists @@ -27,7 +37,7 @@ struct PlaylistView: View { var body: some View { ScrollView { - LazyVStack { + LazyVGrid(columns: columns) { ForEach(filteredPlaylists) { playlist in NavigationLink { PlaylistDetailView() @@ -38,32 +48,14 @@ struct PlaylistView: View { viewModel.setActivePlaylist(playlist: playlist) } } label: { - VStack { - HStack { - VStack(alignment: .leading) { - Text("\(playlist.name)\(playlist.isPublic ? "" : " 🔒")") - .customFont(.headline) - .multilineTextAlignment(.leading) - - Text(playlist.comment) - .customFont(.caption1) - .multilineTextAlignment(.leading) - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.gray) - .font(.caption) - } - .padding(.horizontal) - .padding(.vertical, 5) - - Divider() - } + PlaylistsView(viewModel: viewModel, playlist: playlist) } } - }.padding(.bottom, 100) + } + .padding(.top, 10) + .padding( + .bottom, playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer ? 100 : 0 + ) } .toolbar { if downloadViewModel.hasDownloadQueue() { diff --git a/flo/PlaylistsView.swift b/flo/PlaylistsView.swift new file mode 100644 index 0000000..f022981 --- /dev/null +++ b/flo/PlaylistsView.swift @@ -0,0 +1,94 @@ +// +// PlaylistsView.swift +// flo +// +// Created by rizaldy on 16/08/26. +// + +import NukeUI +import SwiftUI + +struct PlaylistsView: View { + var viewModel: AlbumViewModel + var playlist: Playlist + + var body: some View { + Group { + VStack(alignment: .leading) { + if let image = UIImage( + contentsOfFile: viewModel.getPlaylistCoverArt( + id: playlist.id, coverArtId: playlist.coverArtId)) + { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity, maxHeight: 300) + .clipShape( + RoundedRectangle(cornerRadius: 5, style: .continuous) + ) + } else { + LazyImage( + url: URL( + string: viewModel.getPlaylistCoverArt( + id: playlist.id, coverArtId: playlist.coverArtId)) + ) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity, maxHeight: 300) + .clipShape( + RoundedRectangle(cornerRadius: 5, style: .continuous) + ) + } else { + if let image = UIImage(named: "placeholder") { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity, maxHeight: 300) + .clipShape( + RoundedRectangle(cornerRadius: 5, style: .continuous) + ) + } + } + } + } + + HStack(alignment: .center, spacing: 4) { + Text(playlist.name) + .customFont(.caption1) + .fontWeight(.bold) + .foregroundColor(.primary) + .truncationMode(.tail) + .lineLimit(1) + .multilineTextAlignment(.leading) + + if !playlist.isPublic { + Text("🔒") + .customFont(.caption2) + } + } + .padding(.trailing, 20) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(playlist.ownerName) + .customFont(.caption2) + .foregroundColor(.gray) + .truncationMode(.tail) + .padding(.trailing, 20) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + }.padding() + } + } +} + +struct PlaylistsView_Preview: PreviewProvider { + @StateObject static private var viewModel: AlbumViewModel = AlbumViewModel() + + static private var playlistData = Playlist(name: "Playlist 1", ownerName: "Owner 1") + + static var previews: some View { + PlaylistsView(viewModel: viewModel, playlist: playlistData) + } +} diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 521b84b..0d944e7 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -448,6 +448,7 @@ }, "Advanced Settings" : { "comment" : "A toggle label for advanced settings in the IAP login view.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -699,6 +700,7 @@ }, "Authentication Token Cookie Name" : { "comment" : "A description of the authentication token cookie name setting.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -711,6 +713,7 @@ }, "Authentication Token Header Name" : { "comment" : "A label displayed above a text field that lets the user specify the name of the HTTP header containing their JWT token.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1198,6 +1201,7 @@ }, "e.g., _oauth2_proxy, KEYCLOAK_IDENTITY" : { "comment" : "A placeholder text for the \"Authentication Token Cookie Name\" field in the IAP login view.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1210,6 +1214,7 @@ }, "e.g., username, user, preferred_username" : { "comment" : "A placeholder text for the \"Username Cookie Name\" field in the advanced settings of the IAP login view.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1221,6 +1226,7 @@ }, "e.g., x-auth-request-access-token" : { "comment" : "A placeholder text for the authentication token header name field in the advanced settings of the IAP login view.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3308,6 +3314,7 @@ }, "The cookie containing your session token (leave empty for auto-detection)" : { "comment" : "A description of the purpose of the \"Authentication Token Cookie Name\" field.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -3320,6 +3327,7 @@ }, "The cookie containing your username (defaults to 'username')" : { "comment" : "A description of the purpose of the \"Username Cookie Name\" setting in the IAP login view.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -3354,6 +3362,7 @@ }, "The HTTP header containing your JWT token (leave empty for auto-detection)" : { "comment" : "A description of what the \"Authentication Token Header Name\" field is for.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -3743,6 +3752,7 @@ }, "Username Cookie Name" : { "comment" : "A label for the username cookie name field in the IAP login view.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { diff --git a/flo/ScrobbleQueueView.swift b/flo/ScrobbleQueueView.swift new file mode 100644 index 0000000..2e4a8ae --- /dev/null +++ b/flo/ScrobbleQueueView.swift @@ -0,0 +1,206 @@ +import SwiftUI + +struct ScrobbleQueueView: View { + @ObservedObject private var queue = ScrobbleQueueManager.shared + @Environment(\.dismiss) private var dismiss + @State private var showClearAllConfirmation = false + + var body: some View { + NavigationStack { + Group { + if queue.scrobbles.isEmpty { + emptyState + } else { + scrobbleList + } + } + .navigationTitle("Offline Scrobbles") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Close") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + if queue.sentCount > 0 { + Button("Clear Sent") { + queue.clearSent() + } + } + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 10) { + Text("No offline scrobbles").font(.headline) + + Text( + "Plays tracked while the server is offline will appear here and be submitted automatically once it is reachable again." + ) + .font(.caption) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var scrobbleList: some View { + List { + if queue.pendingCount > 0 { + Section { + if let nextRetryAt = queue.nextRetryAt { + Label { + (Text("Next retry ") + Text(nextRetryAt, style: .relative)) + .font(.subheadline) + } icon: { + Image(systemName: "clock.arrow.circlepath") + } + .foregroundColor(.secondary) + } else { + Label { + Text("Waiting for the server to be reachable").font(.subheadline) + } icon: { + Image(systemName: "wifi.exclamationmark") + } + .foregroundColor(.secondary) + } + } + } + + ForEach(queue.scrobbles, id: \.objectID) { entry in + ScrobbleQueueRow(entry: entry) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if entry.status == ScrobbleQueueStatus.failed { + Button { + queue.retry(entry) + } label: { + Label("Retry", systemImage: "arrow.clockwise") + } + .tint(.blue) + } + + Button(role: .destructive) { + queue.remove(entry) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + Section { + Button(action: { + queue.flush() + }) { + HStack { + Spacer() + + Text("Retry now") + + if queue.isFlushing { + ProgressView().controlSize(.small) + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + .disabled(queue.pendingCount == 0 || queue.isFlushing) + + Button( + role: .destructive, + action: { + showClearAllConfirmation = true + } + ) { + HStack { + Spacer() + Text("Clear all") + Spacer() + } + .frame(maxWidth: .infinity) + } + } footer: { + Text( + "Waiting and failed scrobbles are retried automatically every few minutes while the server is unreachable. Use Retry now to submit immediately." + ) + } + } + .confirmationDialog( + "Clear all scrobbles?", isPresented: $showClearAllConfirmation, + titleVisibility: .visible + ) { + Button("Clear all", role: .destructive) { + queue.clearAll() + } + + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes every pending, failed, and submitted scrobble from the queue.") + } + } +} + +private struct ScrobbleQueueRow: View { + @ObservedObject var entry: ScrobbleEntity + + private var status: (label: String, color: Color) { + switch entry.status { + case ScrobbleQueueStatus.sent: + return ("Sent", .green) + case ScrobbleQueueStatus.failed: + return ("Failed", .red) + default: + return ("Waiting", .orange) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(entry.trackName ?? entry.songId ?? "Unknown track") + .font(.headline) + .lineLimit(1) + + Spacer() + + Text(status.label) + .font(.caption.weight(.semibold)) + .foregroundColor(status.color) + } + + if let artist = entry.artistName, !artist.isEmpty { + Text(artist) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + } + + HStack { + if let album = entry.albumName, !album.isEmpty { + Text(album) + } + + Spacer() + + if let listenTime = entry.listenTime { + Text(listenTime, format: .dateTime.month(.abbreviated).day().hour().minute()) + } + } + .font(.caption) + .foregroundColor(.gray) + + if entry.status == ScrobbleQueueStatus.failed { + Text(entry.errorReason ?? "Unknown error") + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) + } + } + .padding(.vertical, 2) + } +} diff --git a/flo/Shared/Models/Album.swift b/flo/Shared/Models/Album.swift index 28be295..7197732 100644 --- a/flo/Shared/Models/Album.swift +++ b/flo/Shared/Models/Album.swift @@ -33,21 +33,35 @@ struct Album: Codable, Identifiable, Playable { var name: String = "" var albumArtist: String = "" var artist: String = "" + var artistId: String = "" + var albumArtistId: String = "" var albumCover: String = "" var info: String = "" var songs: [Song] = [] var genre: String = "" var minYear: Int = 0 + var explicitStatus: ExplicitStatus = .unknown + + var isExplicit: Bool { + if explicitStatus.isExplicit { + return true + } + + return songs.contains(where: \.isExplicit) + } enum CodingKeys: String, CodingKey { case id case name case albumArtist case artist + case artistId + case albumArtistId case albumCover case genre case minYear case songs + case explicitStatus } init(from decoder: any Decoder) throws { @@ -56,6 +70,8 @@ struct Album: Codable, Identifiable, Playable { self.id = try container.decode(String.self, forKey: .id) self.name = try container.decode(String.self, forKey: .name) self.albumArtist = try container.decode(String.self, forKey: .albumArtist) + self.artistId = try container.decodeIfPresent(String.self, forKey: .artistId) ?? "" + self.albumArtistId = try container.decodeIfPresent(String.self, forKey: .albumArtistId) ?? "" // pre BFR compatibility // FIXME(@faultables): fix this in 2.x @@ -69,20 +85,34 @@ struct Album: Codable, Identifiable, Playable { 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) ?? [] + self.explicitStatus = ExplicitStatus( + from: try container.decodeIfPresent(String.self, forKey: .explicitStatus)) } init( id: String = "", name: String = "", albumArtist: String = "", artist: String = "", + artistId: String = "", albumArtistId: String = "", songs: [Song] = [], genre: String = "", - minYear: Int = 0 + minYear: Int = 0, explicitStatus: ExplicitStatus = .unknown ) { self.id = id self.name = name self.albumArtist = albumArtist self.artist = artist + self.artistId = artistId + self.albumArtistId = albumArtistId self.songs = songs self.genre = genre self.minYear = minYear + self.explicitStatus = explicitStatus + } + + var resolvedArtistId: String { + if !albumArtistId.isEmpty { + return albumArtistId + } + + return artistId } #if os(iOS) @@ -94,6 +124,7 @@ struct Album: Codable, Identifiable, Playable { self.genre = playlist.genre ?? "Unknown Genre" self.minYear = Int(playlist.minYear) self.albumCover = playlist.albumCover ?? "" + self.explicitStatus = ExplicitStatus(from: playlist.explicitStatus) } #endif diff --git a/flo/Shared/Models/Artist.swift b/flo/Shared/Models/Artist.swift index a5a5dab..d3c662f 100644 --- a/flo/Shared/Models/Artist.swift +++ b/flo/Shared/Models/Artist.swift @@ -11,11 +11,11 @@ struct Artist: Codable, Hashable, Identifiable { static func == (lhs: Artist, rhs: Artist) -> Bool { lhs.id == rhs.id } - + func hash(into hasher: inout Hasher) { hasher.combine(id) } - + let id, name, orderArtistName: String let stats: ArtistStats let size, albumCount, songCount: Int @@ -28,9 +28,10 @@ struct Artist: Codable, Hashable, Identifiable { let externalURL: String? let externalInfoUpdatedAt: String? let fullText: String? - + enum CodingKeys: String, CodingKey { - case id, name, orderArtistName, stats, size, albumCount, songCount, missing, createdAt, updatedAt, sortArtistName, playCount, playDate, fullText + case id, name, orderArtistName, stats, size, albumCount, songCount, missing, createdAt, + updatedAt, sortArtistName, playCount, playDate, fullText case mbzArtistID = "mbzArtistId" case biography case smallImageURL = "smallImageUrl" @@ -39,6 +40,44 @@ struct Artist: Codable, Hashable, Identifiable { case externalURL = "externalUrl" case externalInfoUpdatedAt } + + static func placeholder(id: String, name: String) -> Artist { + Artist( + id: id, + name: name, + orderArtistName: name, + stats: ArtistStats( + producer: nil, + composer: nil, + artist: nil, + maincredit: nil, + albumartist: nil, + arranger: nil, + engineer: nil, + performer: nil, + mixer: nil, + lyricist: nil, + conductor: nil + ), + size: 0, + albumCount: 0, + songCount: 0, + missing: false, + createdAt: "", + updatedAt: "", + sortArtistName: nil, + playCount: nil, + playDate: nil, + mbzArtistID: nil, + biography: nil, + smallImageURL: nil, + mediumImageURL: nil, + largeImageURL: nil, + externalURL: nil, + externalInfoUpdatedAt: nil, + fullText: nil + ) + } } // MARK: - Stats diff --git a/flo/Shared/Models/ArtistRadio.swift b/flo/Shared/Models/ArtistRadio.swift index db3241e..0d997d9 100644 --- a/flo/Shared/Models/ArtistRadio.swift +++ b/flo/Shared/Models/ArtistRadio.swift @@ -14,7 +14,7 @@ struct SimilarSongsList: SubsonicResponseData { private enum SubsonicSongKeys: String, CodingKey { case id, title, artist, albumId, album, track, discNumber, bitRate, samplingRate, suffix, - duration, mediaFileId + duration, mediaFileId, explicitStatus } init(from decoder: any Decoder) throws { @@ -41,7 +41,9 @@ struct SimilarSongsList: SubsonicResponseData { 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) ?? "" + mediaFileId: try s.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "", + explicitStatus: ExplicitStatus( + from: try s.decodeIfPresent(String.self, forKey: .explicitStatus)) )) } self.song = songs @@ -65,24 +67,24 @@ struct SimilarSongsResponse: Codable { 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 + duration, mediaFileId, explicitStatus } - + 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) @@ -99,7 +101,9 @@ struct TopSongsList: SubsonicResponseData { 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) ?? "" + mediaFileId: try s.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "", + explicitStatus: ExplicitStatus( + from: try s.decodeIfPresent(String.self, forKey: .explicitStatus)) )) } self.song = songs @@ -108,11 +112,11 @@ struct TopSongsList: SubsonicResponseData { 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/ExplicitStatus.swift b/flo/Shared/Models/ExplicitStatus.swift new file mode 100644 index 0000000..94561d7 --- /dev/null +++ b/flo/Shared/Models/ExplicitStatus.swift @@ -0,0 +1,31 @@ +// +// ExplicitStatus.swift +// flo +// + +import Foundation + +enum ExplicitStatus: String, Codable, Hashable { + case unknown = "" + case explicit = "explicit" + case clean = "clean" + + var isExplicit: Bool { + self == .explicit + } + + init(from raw: String?) { + switch raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "e", "explicit", "1", "4": + self = .explicit + case "c", "clean", "2": + self = .clean + default: + self = .unknown + } + } + + func annotatedTitle(_ title: String) -> String { + isExplicit ? "\(title) 🅴" : title + } +} diff --git a/flo/Shared/Models/LibraryDestination.swift b/flo/Shared/Models/LibraryDestination.swift new file mode 100644 index 0000000..74d30d3 --- /dev/null +++ b/flo/Shared/Models/LibraryDestination.swift @@ -0,0 +1,11 @@ +// +// LibraryDestination.swift +// flo +// + +import Foundation + +enum LibraryDestination: Hashable { + case artist(id: String, name: String) + case album(id: String, name: String, artist: String) +} diff --git a/flo/Shared/Models/Playlist.swift b/flo/Shared/Models/Playlist.swift index 73d4385..441ff92 100644 --- a/flo/Shared/Models/Playlist.swift +++ b/flo/Shared/Models/Playlist.swift @@ -14,6 +14,7 @@ struct Playlist: Codable, Identifiable, Hashable, Playable { let isPublic: Bool let ownerName: String let artist: String + let coverArtId: String? var songs: [Song] = [] enum CodingKeys: String, CodingKey { @@ -22,18 +23,25 @@ struct Playlist: Codable, Identifiable, Hashable, Playable { case comment case isPublic = "public" case ownerName + case coverArtId case songs } init( - id: String = "", name: String = "", comment: String = "", isPublic: Bool = false, - ownerName: String = "", songs: [Song] = [] + id: String = "", + name: String = "", + comment: String = "", + isPublic: Bool = false, + ownerName: String = "", + coverArtId: String? = nil, + songs: [Song] = [] ) { self.id = id self.name = name self.comment = comment self.isPublic = isPublic self.ownerName = ownerName + self.coverArtId = coverArtId self.songs = songs self.artist = ownerName } @@ -46,6 +54,7 @@ struct Playlist: Codable, Identifiable, Hashable, Playable { self.comment = try container.decode(String.self, forKey: .comment) self.isPublic = try container.decode(Bool.self, forKey: .isPublic) self.ownerName = try container.decode(String.self, forKey: .ownerName) + self.coverArtId = try container.decodeIfPresent(String.self, forKey: .coverArtId) self.songs = try container.decodeIfPresent([Song].self, forKey: .songs) ?? [] self.artist = try container.decode(String.self, forKey: .ownerName) } diff --git a/flo/Shared/Models/Song.swift b/flo/Shared/Models/Song.swift index d47d42b..c63f5f0 100644 --- a/flo/Shared/Models/Song.swift +++ b/flo/Shared/Models/Song.swift @@ -19,11 +19,16 @@ struct Song: Codable, Identifiable, Hashable { let sampleRate: Int let suffix: String let duration: Double + let explicitStatus: ExplicitStatus var mediaFileId: String = "" var fileUrl: String = "" var starred: Bool = false + var isExplicit: Bool { + explicitStatus.isExplicit + } + enum DecodeKeys: String, CodingKey { case id case title @@ -39,6 +44,7 @@ struct Song: Codable, Identifiable, Hashable { case duration case mediaFileId case starred + case explicitStatus } enum EncodeKeys: String, CodingKey { @@ -55,6 +61,7 @@ struct Song: Codable, Identifiable, Hashable { case duration case mediaFileId case starred + case explicitStatus } init(from decoder: any Decoder) throws { @@ -77,6 +84,8 @@ struct Song: Codable, Identifiable, Hashable { self.duration = try container.decode(Double.self, forKey: .duration) self.mediaFileId = try container.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "" self.starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false + self.explicitStatus = ExplicitStatus( + from: try container.decodeIfPresent(String.self, forKey: .explicitStatus)) } func encode(to encoder: any Encoder) throws { @@ -95,6 +104,7 @@ struct Song: Codable, Identifiable, Hashable { try container.encode(duration, forKey: .duration) try container.encode(mediaFileId, forKey: .mediaFileId) try container.encode(starred, forKey: .starred) + try container.encode(explicitStatus.rawValue, forKey: .explicitStatus) } init( @@ -102,7 +112,8 @@ struct Song: Codable, Identifiable, Hashable { trackNumber: Int, discNumber: Int, bitRate: Int, sampleRate: Int, - suffix: String, duration: Double, mediaFileId: String + suffix: String, duration: Double, mediaFileId: String, + explicitStatus: ExplicitStatus = .unknown ) { self.id = id self.title = title @@ -116,23 +127,25 @@ struct Song: Codable, Identifiable, Hashable { self.suffix = suffix self.duration = duration self.mediaFileId = mediaFileId + self.explicitStatus = explicitStatus } #if os(iOS) - init(from cache: CacheEntity) { - self.id = cache.mediaFileId ?? "" - self.title = cache.title ?? "Unknown" - self.artist = cache.artistName ?? "Unknown" - self.albumId = cache.albumId ?? "" - self.albumName = cache.albumName ?? "" - self.trackNumber = 0 - self.discNumber = 0 - self.bitRate = Int(cache.bitRate) - self.sampleRate = Int(cache.sampleRate) - self.suffix = cache.suffix ?? "" - self.duration = cache.duration - self.mediaFileId = cache.mediaFileId ?? "" - } + init(from cache: CacheEntity) { + self.id = cache.mediaFileId ?? "" + self.title = cache.title ?? "Unknown" + self.artist = cache.artistName ?? "Unknown" + self.albumId = cache.albumId ?? "" + self.albumName = cache.albumName ?? "" + self.trackNumber = 0 + self.discNumber = 0 + self.bitRate = Int(cache.bitRate) + self.sampleRate = Int(cache.sampleRate) + self.suffix = cache.suffix ?? "" + self.duration = cache.duration + self.mediaFileId = cache.mediaFileId ?? "" + self.explicitStatus = ExplicitStatus(from: cache.explicitStatus) + } #endif #if os(iOS) @@ -164,6 +177,7 @@ struct Song: Codable, Identifiable, Hashable { self.duration = song.duration self.fileUrl = song.fileURL ?? "" self.mediaFileId = song.mediaFileId ?? "" + self.explicitStatus = ExplicitStatus(from: song.explicitStatus) } #endif } diff --git a/flo/Shared/Models/Stats.swift b/flo/Shared/Models/Stats.swift index 20bba46..3e0ce25 100644 --- a/flo/Shared/Models/Stats.swift +++ b/flo/Shared/Models/Stats.swift @@ -9,4 +9,13 @@ struct Stats { let topArtist: String let topAlbum: String let topAlbumArtist: String + let topAlbumId: String + + var hasNavigableTopArtist: Bool { + !topArtist.isEmpty && topArtist != "N/A" + } + + var hasNavigableTopAlbum: Bool { + (!topAlbumId.isEmpty || !topAlbum.isEmpty) && topAlbum != "N/A" + } } diff --git a/flo/Shared/Models/Subsonic.swift b/flo/Shared/Models/Subsonic.swift index 68d182d..f0e3814 100644 --- a/flo/Shared/Models/Subsonic.swift +++ b/flo/Shared/Models/Subsonic.swift @@ -108,12 +108,14 @@ struct SubsonicSong: Codable { let samplingRate: Int? let suffix: String? let duration: Int? + let explicitStatus: String? func toSong() -> Song { return Song( id: id, title: title, albumId: albumId ?? "", albumName: album ?? "", artist: artist ?? "", trackNumber: track ?? 0, discNumber: discNumber ?? 0, bitRate: bitRate ?? 0, sampleRate: samplingRate ?? 0, suffix: suffix ?? "", - duration: Double(duration ?? 0), mediaFileId: id) + duration: Double(duration ?? 0), mediaFileId: id, + explicitStatus: ExplicitStatus(from: explicitStatus)) } } diff --git a/flo/Shared/Models/UserAuth.swift b/flo/Shared/Models/UserAuth.swift index 692a64c..0d22471 100644 --- a/flo/Shared/Models/UserAuth.swift +++ b/flo/Shared/Models/UserAuth.swift @@ -41,6 +41,6 @@ struct UserAuth: Codable { self.lastFMApiKey = try container.decodeIfPresent(String.self, forKey: .lastFMApiKey) ?? "" self.subsonicSalt = try container.decode(String.self, forKey: .subsonicSalt) self.subsonicToken = try container.decode(String.self, forKey: .subsonicToken) - self.token = try container.decode(String.self, forKey: .token) + self.token = try container.decodeIfPresent(String.self, forKey: .token) ?? "" } } diff --git a/flo/Shared/Services/APIManager.swift b/flo/Shared/Services/APIManager.swift index de5d9fe..9f41c38 100644 --- a/flo/Shared/Services/APIManager.swift +++ b/flo/Shared/Services/APIManager.swift @@ -61,7 +61,7 @@ class APIManager { func NDEndpointRequest( endpoint: String, method: HTTPMethod = .get, parameters: Parameters?, - encoding: ParameterEncoding = URLEncoding.queryString, + encoding: ParameterEncoding = URLEncoding.queryString, timeout: TimeInterval? = nil, completion: @escaping (DataResponse) -> Void ) { let token: String = AuthService.shared.getCreds(key: "NDToken") @@ -70,7 +70,12 @@ class APIManager { let headers: HTTPHeaders = [API.NDAuthHeader: "Bearer \(token)"] session.request( - url, method: method, parameters: parameters, encoding: encoding, headers: headers + url, method: method, parameters: parameters, encoding: encoding, headers: headers, + requestModifier: { request in + if let timeout = timeout { + request.timeoutInterval = timeout + } + } ) .validate(statusCode: 200..<500) .responseDecodable(of: T.self) { response in @@ -80,7 +85,7 @@ class APIManager { func SubsonicEndpointRequest( endpoint: String, method: HTTPMethod = .get, parameters: Parameters?, - encoding: ParameterEncoding = URLEncoding.queryString, + encoding: ParameterEncoding = URLEncoding.queryString, timeout: TimeInterval? = nil, completion: @escaping (DataResponse) -> Void ) { @@ -89,7 +94,12 @@ class APIManager { "\(UserDefaultsManager.serverBaseURL)\(endpoint)\(AuthService.shared.getCreds(key: "subsonicToken"))" session.request( - url, method: method, parameters: parameters, encoding: encoding + url, method: method, parameters: parameters, encoding: encoding, + requestModifier: { request in + if let timeout = timeout { + request.timeoutInterval = timeout + } + } ) .validate(statusCode: 200..<500) .responseDecodable(of: T.self) { response in @@ -172,31 +182,6 @@ extension APIManager { completion(response) } } - - func loginWithIAP( - endpoint: String, parameters: Parameters?, jwtAssertion: String, - completion: @escaping (DataResponse) -> Void - ) { - let headers: HTTPHeaders = [ - "X-Goog-IAP-JWT-Assertion": jwtAssertion - ] - - session.request( - endpoint, - method: .post, - parameters: parameters, - encoding: JSONEncoding.default, - headers: headers, - requestModifier: { request in - request.timeoutInterval = 10 - } - ) - .validate(statusCode: 200..<500) - .responseDecodable(of: T.self) { response in - completion(response) - } - } - func externalRequest( url: String, method: HTTPMethod = .get, diff --git a/flo/Shared/Services/AlbumService.swift b/flo/Shared/Services/AlbumService.swift index 41c347b..d5f33eb 100644 --- a/flo/Shared/Services/AlbumService.swift +++ b/flo/Shared/Services/AlbumService.swift @@ -303,6 +303,29 @@ class AlbumService { } } + func getPlaylistCover(playlistId: String) -> String { + let target = "Media/Various Artists/\(playlistId)/cover.png" + + if LocalFileManager.shared.fileExists(fileName: target) { + return LocalFileManager.shared.fileURL(for: target)?.path ?? "" + } else if let cached = CoverArtCacheManager.shared.cachedFilePath(albumId: playlistId) { + return cached + } else { + let artId = playlistId.hasPrefix("pl-") ? playlistId : "pl-\(playlistId)" + return + "\(UserDefaultsManager.serverBaseURL)\(API.SubsonicEndpoint.coverArt)\(AuthService.shared.getCreds(key: "subsonicToken"))&id=\(artId)&size=300" + } + } + + func getArtistCover(artistId: String, imageURL: String = "") -> String { + if !imageURL.isEmpty { + return imageURL + } + + return + "\(UserDefaultsManager.serverBaseURL)\(API.SubsonicEndpoint.coverArt)\(AuthService.shared.getCreds(key: "subsonicToken"))&id=ar-\(artistId)&size=300" + } + func downloadAlbumCover( artistName: String, albumId: String, albumName: String, completion: @escaping (Result) -> Void @@ -376,6 +399,7 @@ class AlbumService { "Media/\(isFromPlaylist ? "Various Artists" : song.artist)/\(albumName ?? "Unknown Albums")/\(Int16(song.trackNumber)) \(song.title).\(song.suffix)" existingSong.albumName = resolvedAlbumName existingSong.status = status + existingSong.explicitStatus = song.explicitStatus.rawValue } else { let downloadedSong = SongEntity(context: CoreDataManager.shared.viewContext) @@ -393,6 +417,7 @@ class AlbumService { downloadedSong.fileURL = fileURL downloadedSong.status = status downloadedSong.mediaFileId = isFromPlaylist ? song.mediaFileId : song.id + downloadedSong.explicitStatus = song.explicitStatus.rawValue } CoreDataManager.shared.saveRecord() @@ -407,6 +432,7 @@ class AlbumService { album.minYear = Int64(albumToDownload.minYear) album.artistName = albumToDownload.artist album.albumArtist = albumToDownload.albumArtist + album.explicitStatus = albumToDownload.explicitStatus.rawValue CoreDataManager.shared.saveRecord() } diff --git a/flo/Shared/Services/AuthService.swift b/flo/Shared/Services/AuthService.swift index 3bc21cc..a15dd10 100644 --- a/flo/Shared/Services/AuthService.swift +++ b/flo/Shared/Services/AuthService.swift @@ -9,13 +9,18 @@ import Alamofire import Foundation import Pulse +enum IAPSessionCheckResult { + case valid + case invalid(String) + case unreachable +} + class AuthService { static let shared = AuthService() private var NDToken: String? private var subsonicParams: String? private var authMode: AuthMode = .standard - private var iapAuthInfo: IAPAuthInfo? private init() { if let jsonString = try? KeychainManager.getAuthCreds(), @@ -27,14 +32,10 @@ class AuthService { "?u=\(data.username)&t=\(data.subsonicToken)&s=\(data.subsonicSalt)&v=\(AppMeta.subsonicApiVersion)&c=\(AppMeta.name)&f=json" } } - + if let mode = try? KeychainManager.getAuthMode() { authMode = mode } - - if authMode == .iap { - iapAuthInfo = try? KeychainManager.getIAPAuthInfo() - } } func getCreds(key: String = "") -> String { @@ -49,23 +50,13 @@ class AuthService { return token } } - - if key == "IAPJwt" { - if let jwt = iapAuthInfo?.jwtAssertion { - return jwt - } - } return "" } - + func getAuthMode() -> AuthMode { return authMode } - - func getIAPAuthInfo() -> IAPAuthInfo? { - return iapAuthInfo - } func setCreds(_ data: UserAuth) { let subsonicParams = @@ -74,21 +65,11 @@ class AuthService { self.NDToken = data.token self.subsonicParams = subsonicParams } - + func setAuthMode(_ mode: AuthMode) { self.authMode = mode try? KeychainManager.setAuthMode(mode) } - - func setIAPAuthInfo(_ info: IAPAuthInfo) { - self.iapAuthInfo = info - try? KeychainManager.setIAPAuthInfo(info) - } - - func clearIAPAuthInfo() { - self.iapAuthInfo = nil - try? KeychainManager.removeIAPAuthInfo() - } func login( serverUrl: String, username: String, password: String, @@ -128,84 +109,59 @@ class AuthService { } } } - - func loginWithIAP( + + func verifySubsonicAccess( + _ userAuth: UserAuth, serverUrl: String, - jwtAssertion: String, - completion: @escaping (AuthResult) -> Void + completion: @escaping (IAPSessionCheckResult) -> Void ) { - let serverBaseUrl = UserDefaultsManager.serverBaseURL - let isServerBaseURLExist = serverBaseUrl != "" + guard var components = URLComponents(string: "\(serverUrl)/rest/ping") else { + completion(.unreachable) + return + } - let url = "\(isServerBaseURLExist ? serverBaseUrl : serverUrl)\(API.NDEndpoint.loginIAP ?? "/auth/iap")" + components.queryItems = [ + URLQueryItem(name: "u", value: userAuth.username), + URLQueryItem(name: "t", value: userAuth.subsonicToken), + URLQueryItem(name: "s", value: userAuth.subsonicSalt), + URLQueryItem(name: "v", value: AppMeta.subsonicApiVersion), + URLQueryItem(name: "c", value: AppMeta.name), + URLQueryItem(name: "f", value: "json"), + ] + + guard let url = components.url else { + completion(.unreachable) + return + } - let parameters: [String: Any] = ["jwt": jwtAssertion] + URLSession.shared.dataTask(with: URLRequest(url: url)) { data, response, _ in + guard let httpResponse = response as? HTTPURLResponse else { + completion(.unreachable) + return + } - APIManager.shared.loginWithIAP(endpoint: url, parameters: parameters, jwtAssertion: jwtAssertion) { - (response: DataResponse) in - switch response.result { - case .success(let authResponse): - let userEmail = self.extractEmailFromJWT(jwtAssertion) - let userId = self.extractUserIdFromJWT(jwtAssertion) - - let iapInfo = IAPAuthInfo( - jwtAssertion: jwtAssertion, - userEmail: userEmail, - userId: userId - ) - - self.setAuthMode(.iap) - self.setIAPAuthInfo(iapInfo) - - completion(.success(authResponse)) - - case .failure(let afError): - ErrorHandler.handleFailure(afError, response: response) { result in - LoggerStore.shared.storeMessage( - label: "AuthService.loginWithIAP", - level: .debug, - message: response.debugDescription - ) - completion(AuthResult(result: result)) - } + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + completion(.invalid("Authentication rejected by the server.")) + return } - } - } - - private func extractEmailFromJWT(_ jwt: String) -> String? { - guard let payload = decodeJWTPayload(jwt), - let email = payload["email"] as? String else { - return nil - } - return email - } - - private func extractUserIdFromJWT(_ jwt: String) -> String? { - guard let payload = decodeJWTPayload(jwt), - let userId = payload["sub"] as? String else { - return nil - } - return userId - } - - private func decodeJWTPayload(_ jwt: String) -> [String: Any]? { - let segments = jwt.components(separatedBy: ".") - guard segments.count > 1 else { return nil } - - let payloadSegment = segments[1] - - var base64 = payloadSegment - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - - let paddingLength = (4 - base64.count % 4) % 4 - base64 += String(repeating: "=", count: paddingLength) - - guard let data = Data(base64Encoded: base64), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - - return json + + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let subsonicResponse = json["subsonic-response"] as? [String: Any], + let status = subsonicResponse["status"] as? String + else { + completion(.unreachable) + return + } + + if status == "ok" { + completion(.valid) + } else { + let subsonicError = subsonicResponse["error"] as? [String: Any] + let message = + subsonicError?["message"] as? String ?? "Something went wrong with IAP Authentication." + completion(.invalid(message)) + } + }.resume() } } diff --git a/flo/Shared/Services/CoreDataManager.swift b/flo/Shared/Services/CoreDataManager.swift index 0d1149f..21626b1 100644 --- a/flo/Shared/Services/CoreDataManager.swift +++ b/flo/Shared/Services/CoreDataManager.swift @@ -34,7 +34,11 @@ class CoreDataManager: ObservableObject { lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "flo") //FIXME: constants? - container.persistentStoreDescriptions.forEach { $0.shouldAddStoreAsynchronously = false } + container.persistentStoreDescriptions.forEach { description in + description.shouldAddStoreAsynchronously = false + description.shouldMigrateStoreAutomatically = true + description.shouldInferMappingModelAutomatically = true + } var loadError: Error? diff --git a/flo/Shared/Services/FloooService.swift b/flo/Shared/Services/FloooService.swift index f32c15c..6704a0e 100644 --- a/flo/Shared/Services/FloooService.swift +++ b/flo/Shared/Services/FloooService.swift @@ -35,17 +35,21 @@ class FloooService { @MainActor func generateStats(_ listeningActivity: [HistoryEntity]) async -> Stats? { // Extract values on the main thread — NSManagedObjects must not cross thread boundaries - let rawEntries: [(albumName: String, artistName: String)] = listeningActivity.map { - (albumName: $0.albumName ?? "", artistName: $0.artistName ?? "") - } + let rawEntries: [(albumId: String, albumName: String, artistName: String)] = + listeningActivity.map { + ( + albumId: $0.albumId ?? "", + albumName: $0.albumName ?? "", + artistName: $0.artistName ?? "" + ) + } return await Task.detached(priority: .userInitiated) { - let albumCounts = Dictionary(grouping: rawEntries) { entry in + let albumGroups = Dictionary(grouping: rawEntries) { entry in "\(entry.albumName)|\(entry.artistName)" } - .mapValues { $0.count } - let topAlbum = albumCounts.max(by: { $0.value < $1.value }) + let topAlbumGroup = albumGroups.max(by: { $0.value.count < $1.value.count }) let artistCounts = Dictionary(grouping: rawEntries) { entry in entry.artistName @@ -54,11 +58,17 @@ class FloooService { let topArtist = artistCounts.max(by: { $0.value < $1.value }) - let components = topAlbum?.key.split(separator: "|") + let components = topAlbumGroup?.key.split(separator: "|") let album = String(components?[0] ?? "N/A") let artist = String(components?[1] ?? "N/A") - - return Stats(topArtist: topArtist?.key ?? "N/A", topAlbum: album, topAlbumArtist: artist) + let albumId = topAlbumGroup?.value.first(where: { !$0.albumId.isEmpty })?.albumId ?? "" + + return Stats( + topArtist: topArtist?.key ?? "N/A", + topAlbum: album, + topAlbumArtist: artist, + topAlbumId: albumId + ) }.value } @@ -67,7 +77,7 @@ class FloooService { var listenBrainzStatus: Bool? var lastFMStatus: Bool? - var error: Error? + var requestError: Error? group.enter() @@ -75,8 +85,8 @@ class FloooService { switch result { case .success(let status): listenBrainzStatus = status - case .failure: - listenBrainzStatus = false + case .failure(let error): + requestError = error } group.leave() @@ -88,14 +98,19 @@ class FloooService { switch result { case .success(let status): lastFMStatus = status - case .failure: - lastFMStatus = false + case .failure(let error): + requestError = error } group.leave() } group.notify(queue: .main) { + if listenBrainzStatus == nil || lastFMStatus == nil, let requestError = requestError { + completion(.failure(requestError)) + return + } + completion( .success( AccountLinkStatus( @@ -105,8 +120,9 @@ class FloooService { } func checkListenBrainzAccountStatus(completion: @escaping (Result) -> Void) { - APIManager.shared.NDEndpointRequest(endpoint: API.NDEndpoint.listenBrainzLink, parameters: [:]) - { + APIManager.shared.NDEndpointRequest( + endpoint: API.NDEndpoint.listenBrainzLink, parameters: [:], timeout: 8 + ) { (response: DataResponse) in switch response.result { case .success(let status): @@ -118,7 +134,9 @@ class FloooService { } func checkLastFMAccountStatus(completion: @escaping (Result) -> Void) { - APIManager.shared.NDEndpointRequest(endpoint: API.NDEndpoint.lastFMLink, parameters: [:]) { + APIManager.shared.NDEndpointRequest( + endpoint: API.NDEndpoint.lastFMLink, parameters: [:], timeout: 8 + ) { (response: DataResponse) in switch response.result { case .success(let status): @@ -130,17 +148,17 @@ class FloooService { } func scrobbleToBuiltinEndpoint( - submission: Bool, songId: String, + submission: Bool, songId: String, time: Date? = nil, timeout: TimeInterval? = nil, completion: @escaping (Result) -> Void ) { var params: [String: Any] = ["submission": String(submission), "id": songId] if submission { - params["time"] = Int(Date().timeIntervalSince1970 * 1000) + params["time"] = Int((time ?? Date()).timeIntervalSince1970 * 1000) } APIManager.shared.SubsonicEndpointRequest( - endpoint: API.SubsonicEndpoint.scrobble, parameters: params + endpoint: API.SubsonicEndpoint.scrobble, parameters: params, timeout: timeout ) { (response: DataResponse) in switch response.result { @@ -158,3 +176,21 @@ extension FloooService { let status: Bool } } + +extension AFError { + var receivedServerResponse: Bool { + switch self { + case .responseValidationFailed, .responseSerializationFailed: + return true + default: + return false + } + } +} + +extension FloooService { + func shouldQueueOfflineScrobble(_ error: Error) -> Bool { + guard let afError = error as? AFError else { return true } + return !afError.receivedServerResponse + } +} diff --git a/flo/Shared/Services/KeychainManager.swift b/flo/Shared/Services/KeychainManager.swift index d1c48ac..2a21870 100644 --- a/flo/Shared/Services/KeychainManager.swift +++ b/flo/Shared/Services/KeychainManager.swift @@ -29,7 +29,6 @@ class KeychainManager { .accessibility(.afterFirstUnlockThisDeviceOnly) #endif - private static let iapAuthInfoKey = "iapAuthInfo" private static let authModeKey = "authMode" static func getAuthCredsAndPasswords() -> [String: Any] { @@ -116,45 +115,6 @@ class KeychainManager { #endif } - static func getIAPAuthInfo() throws -> IAPAuthInfo? { - #if targetEnvironment(macCatalyst) - guard let jsonString = try store.get(iapAuthInfoKey), - let jsonData = jsonString.data(using: .utf8) - else { - return nil - } - #else - guard let jsonString = try keychain.get(iapAuthInfoKey), - let jsonData = jsonString.data(using: .utf8) - else { - return nil - } - #endif - return try JSONDecoder().decode(IAPAuthInfo.self, from: jsonData) - } - - static func setIAPAuthInfo(_ info: IAPAuthInfo) throws { - let jsonData = try JSONEncoder().encode(info) - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw NSError( - domain: "KeychainManager", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to encode IAP auth info"]) - } - #if targetEnvironment(macCatalyst) - try store.set(jsonString, for: iapAuthInfoKey) - #else - try keychain.set(jsonString, key: iapAuthInfoKey) - #endif - } - - static func removeIAPAuthInfo() throws { - #if targetEnvironment(macCatalyst) - try store.remove(iapAuthInfoKey) - #else - try keychain.remove(iapAuthInfoKey) - #endif - } - static func getAuthMode() throws -> AuthMode? { #if targetEnvironment(macCatalyst) guard let rawValue = try store.get(authModeKey) else { return nil } diff --git a/flo/Shared/Services/NetworkMonitor.swift b/flo/Shared/Services/NetworkMonitor.swift new file mode 100644 index 0000000..9c89611 --- /dev/null +++ b/flo/Shared/Services/NetworkMonitor.swift @@ -0,0 +1,86 @@ +import Foundation +import Network + +final class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + @Published private(set) var isOnline = true + @Published private(set) var isServerReachable = true + + private let monitor = NWPathMonitor() + private let monitorQueue = DispatchQueue(label: "net.faultables.flo.networkmonitor") + private var serverProbe: NWConnection? + + private init() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + guard let self = self else { return } + + let wasOnline = self.isOnline + self.isOnline = path.status == .satisfied + + if !wasOnline && self.isOnline { + self.probeServerReachability() + NotificationCenter.default.post(name: .networkBecameOnline, object: nil) + } + } + } + + monitor.start(queue: monitorQueue) + probeServerReachability() + } + + func probeServerReachability() { + serverProbe?.cancel() + + guard isOnline else { + isServerReachable = false + return + } + + guard + let url = URL(string: UserDefaultsManager.serverBaseURL), + let host = url.host, !host.isEmpty + else { + return + } + + let scheme = url.scheme?.lowercased() ?? "" + let port = + NWEndpoint.Port(rawValue: UInt16(url.port ?? (scheme == "https" ? 443 : 80))) ?? .https + + let connection = NWConnection(host: NWEndpoint.Host(host), port: port, using: .tcp) + serverProbe = connection + + var didResolve = false + + connection.stateUpdateHandler = { [weak self] state in + DispatchQueue.main.async { + guard let self = self else { return } + + switch state { + case .ready: + didResolve = true + self.isServerReachable = true + connection.cancel() + case .failed: + self.isServerReachable = false + default: + break + } + } + } + + connection.start(queue: monitorQueue) + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self, !didResolve else { return } + self.isServerReachable = false + connection.cancel() + } + } +} + +extension Notification.Name { + static let networkBecameOnline = Notification.Name("net.faultables.flo.networkBecameOnline") +} diff --git a/flo/Shared/Services/PlaybackCoordinator.swift b/flo/Shared/Services/PlaybackCoordinator.swift index d147657..5d20bbc 100644 --- a/flo/Shared/Services/PlaybackCoordinator.swift +++ b/flo/Shared/Services/PlaybackCoordinator.swift @@ -217,6 +217,7 @@ final class PlaybackCoordinator { "contextName": nowPlaying.contextName ?? "", "isPlaying": playerViewModel.isPlaying, "coverArt": coverArtUrl, + "isExplicit": ExplicitStatus(from: nowPlaying.explicitStatus).isExplicit, ] } diff --git a/flo/Shared/Services/PlaybackService.swift b/flo/Shared/Services/PlaybackService.swift index 2b4edd9..0dd5608 100644 --- a/flo/Shared/Services/PlaybackService.swift +++ b/flo/Shared/Services/PlaybackService.swift @@ -54,6 +54,7 @@ class PlaybackService { "isFromPlaylist": isFromPlaylist, "isFromLocal": isFromLocal, "duration": song.duration, + "explicitStatus": song.explicitStatus.rawValue, ] as [String: Any] } diff --git a/flo/Shared/Services/ScrobbleQueueManager.swift b/flo/Shared/Services/ScrobbleQueueManager.swift new file mode 100644 index 0000000..0732f25 --- /dev/null +++ b/flo/Shared/Services/ScrobbleQueueManager.swift @@ -0,0 +1,263 @@ +import CoreData +import Foundation +import UIKit + +enum ScrobbleQueueStatus { + static let pending = "pending" + static let failed = "failed" + static let sent = "sent" +} + +final class ScrobbleQueueManager: ObservableObject { + static let shared = ScrobbleQueueManager() + + @Published private(set) var scrobbles: [ScrobbleEntity] = [] + @Published private(set) var isFlushing = false + @Published private(set) var nextRetryAt: Date? + + private var retryTimer: Timer? + private let retryInterval: TimeInterval = 30 + + private init() { + NotificationCenter.default.addObserver( + self, selector: #selector(handleNetworkBecameOnline), name: .networkBecameOnline, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleAppBecameActive), + name: UIApplication.didBecomeActiveNotification, object: nil) + + purgeSent() + reload() + + if UIApplication.shared.applicationState == .active { + flush() + } + } + + var pendingCount: Int { + scrobbles.filter { $0.status != ScrobbleQueueStatus.sent }.count + } + + var sentCount: Int { + scrobbles.filter { $0.status == ScrobbleQueueStatus.sent }.count + } + + func enqueue(nowPlaying: QueueEntity) { + guard let songId = nowPlaying.id, !songId.isEmpty else { return } + + let listenTime = Date() + + let isDuplicate = scrobbles.contains { entry in + entry.songId == songId + && entry.status != ScrobbleQueueStatus.sent + && (entry.listenTime.map { abs($0.timeIntervalSince(listenTime)) < 10 } ?? false) + } + + guard !isDuplicate else { return } + + let entry = ScrobbleEntity(context: CoreDataManager.shared.viewContext) + + entry.songId = songId + entry.trackName = nowPlaying.songName + entry.artistName = nowPlaying.artistName + entry.albumName = nowPlaying.albumName + entry.listenTime = listenTime + entry.queuedAt = Date() + entry.status = ScrobbleQueueStatus.pending + + CoreDataManager.shared.saveRecord() + reload() + scheduleRetry() + } + + func reload() { + scrobbles = CoreDataManager.shared.getRecordsByEntity( + entity: ScrobbleEntity.self, + sortDescriptors: [NSSortDescriptor(key: "queuedAt", ascending: true)]) + } + + func flush() { + guard !isFlushing else { return } + + let pending = scrobbles.filter { $0.status != ScrobbleQueueStatus.sent } + + guard !pending.isEmpty else { + cancelRetry() + return + } + + guard NetworkMonitor.shared.isOnline, NetworkMonitor.shared.isServerReachable else { + scheduleRetry() + return + } + + isFlushing = true + + FloooViewModel.shared.fetchAccountLinkStatus { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(true): + self.submitPending(pending) + + case .success(false): + pending.forEach { CoreDataManager.shared.viewContext.delete($0) } + + CoreDataManager.shared.saveRecord() + + self.isFlushing = false + self.reload() + self.cancelRetry() + + case .failure: + self.isFlushing = false + self.scheduleRetry() + } + } + } + + func remove(_ entry: ScrobbleEntity) { + CoreDataManager.shared.viewContext.delete(entry) + CoreDataManager.shared.saveRecord() + reload() + + if pendingCount == 0 { + cancelRetry() + } + } + + func retry(_ entry: ScrobbleEntity) { + guard entry.status == ScrobbleQueueStatus.failed else { return } + + entry.status = ScrobbleQueueStatus.pending + entry.errorReason = nil + + CoreDataManager.shared.saveRecord() + reload() + + NetworkMonitor.shared.probeServerReachability() + flush() + } + + func clearSent() { + let sent = scrobbles.filter { $0.status == ScrobbleQueueStatus.sent } + + guard !sent.isEmpty else { return } + + sent.forEach { CoreDataManager.shared.viewContext.delete($0) } + + CoreDataManager.shared.saveRecord() + reload() + } + + func clearAll() { + guard !scrobbles.isEmpty else { return } + + scrobbles.forEach { CoreDataManager.shared.viewContext.delete($0) } + + CoreDataManager.shared.saveRecord() + reload() + cancelRetry() + } + + private func submitPending(_ entries: [ScrobbleEntity]) { + guard let entry = entries.first else { + isFlushing = false + reload() + cancelRetry() + return + } + + let remaining = Array(entries.dropFirst()) + + guard let songId = entry.songId, !songId.isEmpty else { + CoreDataManager.shared.viewContext.delete(entry) + CoreDataManager.shared.saveRecord() + submitPending(remaining) + return + } + + FloooService.shared.scrobbleToBuiltinEndpoint( + submission: true, songId: songId, time: entry.listenTime ?? Date() + ) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success: + entry.status = ScrobbleQueueStatus.sent + entry.sentAt = Date() + entry.errorReason = nil + + CoreDataManager.shared.saveRecord() + + self.submitPending(remaining) + + case .failure(let error): + if FloooService.shared.shouldQueueOfflineScrobble(error) { + entry.status = ScrobbleQueueStatus.failed + entry.errorReason = error.localizedDescription + + CoreDataManager.shared.saveRecord() + + self.isFlushing = false + self.reload() + self.scheduleRetry() + } else { + entry.status = ScrobbleQueueStatus.sent + entry.sentAt = Date() + entry.errorReason = nil + + CoreDataManager.shared.saveRecord() + + self.submitPending(remaining) + } + } + } + } + + private func scheduleRetry() { + cancelRetry() + + nextRetryAt = Date().addingTimeInterval(retryInterval) + + retryTimer = Timer.scheduledTimer(withTimeInterval: retryInterval, repeats: false) { + [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + guard NetworkMonitor.shared.isOnline else { + self.nextRetryAt = nil + return + } + + NetworkMonitor.shared.probeServerReachability() + self.flush() + } + } + } + + private func cancelRetry() { + retryTimer?.invalidate() + retryTimer = nil + nextRetryAt = nil + } + + private func purgeSent() { + let sent = CoreDataManager.shared.getRecordsByEntity(entity: ScrobbleEntity.self) + .filter { $0.status == ScrobbleQueueStatus.sent } + + guard !sent.isEmpty else { return } + + sent.forEach { CoreDataManager.shared.viewContext.delete($0) } + + CoreDataManager.shared.saveRecord() + } + + @objc private func handleNetworkBecameOnline() { + NetworkMonitor.shared.probeServerReachability() + flush() + } + + @objc private func handleAppBecameActive() { + NetworkMonitor.shared.probeServerReachability() + flush() + } +} diff --git a/flo/Shared/Services/StreamCacheManager.swift b/flo/Shared/Services/StreamCacheManager.swift index 9675415..aa6dfa7 100644 --- a/flo/Shared/Services/StreamCacheManager.swift +++ b/flo/Shared/Services/StreamCacheManager.swift @@ -56,7 +56,9 @@ class StreamCacheManager { return fileURL } - func cacheSong(mediaFileId: String, originalSuffix: String? = nil, from queueItem: QueueEntity? = nil) { + func cacheSong( + mediaFileId: String, originalSuffix: String? = nil, from queueItem: QueueEntity? = nil + ) { guard UserDefaultsManager.streamCacheMaxSize > 0 else { return } guard !mediaFileId.isEmpty else { return } @@ -104,6 +106,7 @@ class StreamCacheManager { entity.duration = q.duration entity.bitRate = q.bitRate entity.sampleRate = q.sampleRate + entity.explicitStatus = q.explicitStatus } CoreDataManager.shared.saveRecord() @@ -177,7 +180,8 @@ class StreamCacheManager { // Deduplicate by mediaFileId (keep most recent per song) var seen = Set() - return records + return + records .filter { $0.state == "ready" && $0.title != nil } .filter { record in guard let id = record.mediaFileId else { return false } diff --git a/flo/Shared/Utils/Constants.swift b/flo/Shared/Utils/Constants.swift index 0c5968c..dd37655 100644 --- a/flo/Shared/Utils/Constants.swift +++ b/flo/Shared/Utils/Constants.swift @@ -12,7 +12,6 @@ enum API { enum NDEndpoint { static let login = "/auth/login" - static let loginIAP: String? = "/auth/iap" static let getAlbum = "/api/album" static let getArtists = "/api/artist" static let getPlaylists = "/api/playlist" diff --git a/flo/Shared/Utils/ExplicitBadge.swift b/flo/Shared/Utils/ExplicitBadge.swift new file mode 100644 index 0000000..04890bc --- /dev/null +++ b/flo/Shared/Utils/ExplicitBadge.swift @@ -0,0 +1,65 @@ +// +// ExplicitBadge.swift +// flo +// + +import SwiftUI + +struct ExplicitBadge: View { + enum Size { + case compact + case regular + + var fontSize: CGFloat { + switch self { + case .compact: + return 9 + case .regular: + return 11 + } + } + + var horizontalPadding: CGFloat { + switch self { + case .compact: + return 4 + case .regular: + return 5 + } + } + + var verticalPadding: CGFloat { + switch self { + case .compact: + return 1 + case .regular: + return 2 + } + } + + var cornerRadius: CGFloat { + switch self { + case .compact: + return 3 + case .regular: + return 4 + } + } + } + + var tint: Color = .secondary + var size: Size = .regular + + var body: some View { + Text("E") + .font(.system(size: size.fontSize, weight: .bold)) + .foregroundColor(tint) + .padding(.horizontal, size.horizontalPadding) + .padding(.vertical, size.verticalPadding) + .background( + RoundedRectangle(cornerRadius: size.cornerRadius, style: .continuous) + .fill(tint.opacity(0.18)) + ) + .accessibilityLabel("Explicit") + } +} diff --git a/flo/Shared/Utils/IAPLoginView.swift b/flo/Shared/Utils/IAPLoginView.swift index e48ca2e..0a29b68 100644 --- a/flo/Shared/Utils/IAPLoginView.swift +++ b/flo/Shared/Utils/IAPLoginView.swift @@ -15,10 +15,6 @@ struct IAPLoginView: View { @State private var showWebAuth = false @State private var isLoading = false @State private var errorMessage: String? - @State private var showAdvancedSettings = false - @State private var customHeaderName: String = "" - @State private var customCookieName: String = "" - @State private var customUsernameCookie: String = "" var isSubmitButtonDisabled: Bool { serverUrl.isEmpty || isLoading @@ -52,9 +48,6 @@ struct IAPLoginView: View { IAPWebAuthView( serverURL: serverUrl, authViewModel: authViewModel, - customHeaderName: customHeaderName.isEmpty ? nil : customHeaderName, - customCookieName: customCookieName.isEmpty ? nil : customCookieName, - customUsernameCookie: customUsernameCookie.isEmpty ? nil : customUsernameCookie, onSuccess: { dismiss() }, @@ -97,9 +90,7 @@ struct IAPLoginView: View { placeholder: "https://your-iap-server.com", keyboardType: .URL ) - - advancedSettingsSection - + submitButton cancelButton @@ -133,71 +124,6 @@ struct IAPLoginView: View { .padding(.bottom, 10) } - private var advancedSettingsSection: some View { - VStack(alignment: .leading, spacing: 12) { - DisclosureGroup("Advanced Settings", isExpanded: $showAdvancedSettings) { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Authentication Token Header Name") - .font(.subheadline) - .fontWeight(.medium) - Text("The HTTP header containing your JWT token (leave empty for auto-detection)") - .font(.caption) - .foregroundStyle(.secondary) - TextField("e.g., x-auth-request-access-token", text: $customHeaderName) - .padding() - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(.accent, lineWidth: 1) - ) - .autocapitalization(.none) - .disableAutocorrection(true) - .disabled(isLoading) - } - - VStack(alignment: .leading, spacing: 4) { - Text("Authentication Token Cookie Name") - .font(.subheadline) - .fontWeight(.medium) - Text("The cookie containing your session token (leave empty for auto-detection)") - .font(.caption) - .foregroundStyle(.secondary) - TextField("e.g., _oauth2_proxy, KEYCLOAK_IDENTITY", text: $customCookieName) - .padding() - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(.accent, lineWidth: 1) - ) - .autocapitalization(.none) - .disableAutocorrection(true) - .disabled(isLoading) - } - - VStack(alignment: .leading, spacing: 4) { - Text("Username Cookie Name") - .font(.subheadline) - .fontWeight(.medium) - Text("The cookie containing your username (defaults to 'username')") - .font(.caption) - .foregroundStyle(.secondary) - TextField("e.g., username, user, preferred_username", text: $customUsernameCookie) - .padding() - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(.accent, lineWidth: 1) - ) - .autocapitalization(.none) - .disableAutocorrection(true) - .disabled(isLoading) - } - } - .padding(.top, 8) - } - } - .padding(.horizontal, 15) - .padding(.bottom, 10) - } - private var submitButton: some View { VStack(alignment: .leading) { Button(action: authenticateWithIAP) { diff --git a/flo/Shared/Utils/IAPWebAuthView.swift b/flo/Shared/Utils/IAPWebAuthView.swift index 09957d8..2b6c501 100644 --- a/flo/Shared/Utils/IAPWebAuthView.swift +++ b/flo/Shared/Utils/IAPWebAuthView.swift @@ -11,52 +11,46 @@ import WebKit struct IAPWebAuthView: View { let serverURL: String @ObservedObject var authViewModel: AuthViewModel - let customHeaderName: String? - let customCookieName: String? - let customUsernameCookie: String? let onSuccess: () -> Void let onError: (String) -> Void - + @Environment(\.dismiss) private var dismiss @State private var isLoading = false @State private var localError: String? - + var body: some View { NavigationView { ZStack { IAPWebView( url: serverURL, - customHeaderName: customHeaderName, - customCookieName: customCookieName, - customUsernameCookie: customUsernameCookie, - onDataExtracted: { jwt, username, webView in - handleAuthentication(jwt: jwt, username: username, webView: webView) + onAuthExtracted: { userAuth, webView in + handleAuthentication(userAuth: userAuth, webView: webView) }, onError: { error in handleError(error) } ) - + if let error = localError { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 50)) .foregroundStyle(.orange) - + Text("Authentication Failed") .font(.headline) - + Text(error) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) - + Button("Try Again") { localError = nil } .buttonStyle(.borderedProminent) - + Button("Cancel") { dismiss() } @@ -76,73 +70,52 @@ struct IAPWebAuthView: View { } } } - - private func handleAuthentication(jwt: String, username: String, webView: WKWebView) { + + private func handleAuthentication(userAuth: UserAuth, webView: WKWebView) { webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in for cookie in cookies { HTTPCookieStorage.shared.setCookie(cookie) } - - self.completeOAuthLogin(jwt: jwt, username: username) + + self.completeOAuthLogin(userAuth: userAuth) } } - - private func completeOAuthLogin(jwt: String, username: String) { - let iapInfo = IAPAuthInfo(jwtAssertion: jwt, userEmail: username, userId: nil) - AuthService.shared.setIAPAuthInfo(iapInfo) + + private func completeOAuthLogin(userAuth: UserAuth) { AuthService.shared.setAuthMode(AuthMode.iap) - - let userAuth = UserAuth( - id: username, - username: username, - name: username, - isAdmin: false, - lastFMApiKey: "", - subsonicSalt: "", - subsonicToken: "", - token: jwt - ) - - let testURL = "\(serverURL)/api/ping" - - var request = URLRequest(url: URL(string: testURL)!) - request.httpMethod = "GET" - - URLSession.shared.dataTask(with: request) { data, response, error in - if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 200 { - DispatchQueue.main.async { - self.authViewModel.persistAuthData(userAuth) - self.authViewModel.authMode = .iap - self.authViewModel.isLoggedIn = true - self.authViewModel.user = userAuth - - self.dismiss() - self.onSuccess() - } - } else if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - DispatchQueue.main.async { - self.handleError("Something went wrong with IAP Authentication.") - } + + verifySubsonicAccess(userAuth) { success, errorMessage in + DispatchQueue.main.async { + if success { + self.authViewModel.persistAuthData(userAuth) + self.authViewModel.authMode = .iap + self.authViewModel.isLoggedIn = true + self.authViewModel.user = userAuth + + self.dismiss() + self.onSuccess() } else { - DispatchQueue.main.async { - self.authViewModel.persistAuthData(userAuth) - self.authViewModel.authMode = .iap - self.authViewModel.isLoggedIn = true - self.authViewModel.user = userAuth - - self.dismiss() - self.onSuccess() - } - } - } else { - DispatchQueue.main.async { - self.handleError("Could not verify authentication. Please check your network connection.") + self.handleError(errorMessage) } } - }.resume() + } } - + + private func verifySubsonicAccess( + _ userAuth: UserAuth, completion: @escaping (Bool, String) -> Void + ) { + AuthService.shared.verifySubsonicAccess(userAuth, serverUrl: serverURL) { result in + switch result { + case .valid: + completion(true, "") + case .invalid(let message): + completion(false, message) + case .unreachable: + completion(false, "Could not verify authentication. Please check your network connection.") + } + } + } + private func handleError(_ error: String) { localError = error onError(error) diff --git a/flo/Shared/Utils/IAPWebView.swift b/flo/Shared/Utils/IAPWebView.swift index 7a0f759..f9a2805 100644 --- a/flo/Shared/Utils/IAPWebView.swift +++ b/flo/Shared/Utils/IAPWebView.swift @@ -10,237 +10,149 @@ import WebKit struct IAPWebView: UIViewRepresentable { let url: String - let customHeaderName: String? - let customCookieName: String? - let customUsernameCookie: String? - let onDataExtracted: (String, String, WKWebView) -> Void + let onAuthExtracted: (UserAuth, WKWebView) -> Void let onError: (String) -> Void - + func makeCoordinator() -> Coordinator { - Coordinator( - customHeaderName: customHeaderName, - customCookieName: customCookieName, - customUsernameCookie: customUsernameCookie, - onDataExtracted: onDataExtracted, - onError: onError - ) + Coordinator(onAuthExtracted: onAuthExtracted, onError: onError) } - + func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator context.coordinator.webView = webView context.coordinator.originalServerURL = url - + if let url = URL(string: url) { let request = URLRequest(url: url) webView.load(request) + context.coordinator.startTimeout() } else { onError("Invalid server URL") } - + return webView } - + func updateUIView(_ uiView: WKWebView, context: Context) {} - + class Coordinator: NSObject, WKNavigationDelegate { - let customHeaderName: String? - let customCookieName: String? - let customUsernameCookie: String? - let onDataExtracted: (String, String, WKWebView) -> Void + let onAuthExtracted: (UserAuth, WKWebView) -> Void let onError: (String) -> Void private var hasExtractedData = false - private var requestCount = 0 - private let maxRequests = 10 + private var timeoutWorkItem: DispatchWorkItem? weak var webView: WKWebView? var originalServerURL: String = "" - + + private static let timeoutInterval: TimeInterval = 90 + + private static let appConfigScript = """ + (function() { + var c = window.__APP_CONFIG__; + if (!c) { return null; } + return (typeof c === 'string') ? c : JSON.stringify(c); + })() + """ + init( - customHeaderName: String?, - customCookieName: String?, - customUsernameCookie: String?, - onDataExtracted: @escaping (String, String, WKWebView) -> Void, + onAuthExtracted: @escaping (UserAuth, WKWebView) -> Void, onError: @escaping (String) -> Void ) { - self.customHeaderName = customHeaderName - self.customCookieName = customCookieName - self.customUsernameCookie = customUsernameCookie - self.onDataExtracted = onDataExtracted + self.onAuthExtracted = onAuthExtracted self.onError = onError } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void - ) { - requestCount += 1 - - if !hasExtractedData, - let httpResponse = navigationResponse.response as? HTTPURLResponse { - - if httpResponse.statusCode >= 400 { - decisionHandler(.allow) - return - } - - let headers = httpResponse.allHeaderFields - - var possibleTokenHeaders = [ - "x-auth-request-access-token", - "x-auth-token", - "x-forwarded-access-token", - "authorization" - ] - - if let customHeader = customHeaderName, !customHeader.isEmpty { - possibleTokenHeaders.insert(customHeader.lowercased(), at: 0) - } - - var extractedToken: String? - - for (key, value) in headers { - if let headerName = key as? String { - let normalizedHeader = headerName.lowercased() - - if possibleTokenHeaders.contains(normalizedHeader), let token = value as? String { - extractedToken = token.replacingOccurrences(of: "Bearer ", with: "") - break - } - } - } - - if let token = extractedToken { - if let responseURL = httpResponse.url?.absoluteString { - let normalizedResponse = self.normalizeURL(responseURL) - let normalizedOriginal = self.normalizeURL(self.originalServerURL) - - if normalizedResponse.hasPrefix(normalizedOriginal) { - hasExtractedData = true - DispatchQueue.main.async { - if let webView = self.webView { - self.extractUsernameFromCookies(token: token, webView: webView) - } - } - decisionHandler(.cancel) - return - } - } - } - } - - if requestCount > maxRequests && !hasExtractedData { + + func startTimeout() { + let workItem = DispatchWorkItem { [weak self] in + guard let self, !self.hasExtractedData else { return } + self.cancelTimeout() DispatchQueue.main.async { - self.onError("Could not find authentication token after multiple redirects. Make sure your server uses OAuth2-Proxy or IAP.") + self.onError( + "Timed out waiting for the server to authenticate. Make sure Navidrome is behind Authentik/Caddy and reachable." + ) } - decisionHandler(.cancel) - return } - - decisionHandler(.allow) + timeoutWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.timeoutInterval, execute: workItem + ) + } + + private func cancelTimeout() { + timeoutWorkItem?.cancel() + timeoutWorkItem = nil } - - private func extractUsernameFromCookies(token: String, webView: WKWebView) { - webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] cookies in - guard let self = self else { return } - - var username = "OAuth User" - - let usernameCookieName = self.customUsernameCookie ?? "username" - - for cookie in cookies where cookie.name == usernameCookieName { - username = cookie.value - break + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard !hasExtractedData, let currentURL = webView.url?.absoluteString else { return } + + let normalizedCurrent = normalizeURL(currentURL) + let normalizedOriginal = normalizeURL(originalServerURL) + + guard normalizedCurrent.hasPrefix(normalizedOriginal) else { return } + + webView.evaluateJavaScript(Self.appConfigScript) { [weak self] result, _ in + guard let self = self, !self.hasExtractedData else { return } + + guard let jsonString = result as? String, + let jsonData = jsonString.data(using: .utf8), + let appConfig = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let authPayload = appConfig["auth"] as? [String: Any], + let authData = try? JSONSerialization.data(withJSONObject: authPayload), + let userAuth = try? JSONDecoder().decode(UserAuth.self, from: authData) + else { + self.hasExtractedData = true + self.cancelTimeout() + DispatchQueue.main.async { + self.onError( + "Could not read authentication data from the server. Make sure Navidrome is behind Authentik/Caddy and serving the expected login page." + ) + } + return } - + + self.hasExtractedData = true + self.cancelTimeout() DispatchQueue.main.async { - self.onDataExtracted(token, username, webView) + self.onAuthExtracted(userAuth, webView) } } } - + private func normalizeURL(_ urlString: String) -> String { guard let url = URL(string: urlString) else { return urlString } - + var components = URLComponents() components.scheme = url.scheme components.host = url.host components.port = url.port components.path = url.path - + var normalized = components.string ?? urlString if normalized.hasSuffix("/") { normalized = String(normalized.dropLast()) } - + return normalized.lowercased() } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - guard let currentURL = webView.url?.absoluteString else { return } - let normalizedCurrent = normalizeURL(currentURL) - let normalizedOriginal = normalizeURL(originalServerURL) - - if !hasExtractedData && normalizedCurrent.hasPrefix(normalizedOriginal) { - webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in - var extractedToken: String? - - if let customCookie = self.customCookieName, !customCookie.isEmpty { - for cookie in cookies where cookie.name == customCookie { - extractedToken = cookie.value - break - } - } - - if extractedToken == nil { - for cookie in cookies where cookie.name == "KEYCLOAK_IDENTITY" { - extractedToken = cookie.value - break - } - } - - if extractedToken == nil { - for cookie in cookies where cookie.name.hasPrefix("_oauth2_proxy") { - extractedToken = cookie.value - break - } - } - - if let token = extractedToken, !token.isEmpty, let webView = self.webView { - var username = "OAuth User" - - let usernameCookieName = self.customUsernameCookie ?? "username" - - for cookie in cookies where cookie.name == usernameCookieName { - username = cookie.value - break - } - - self.hasExtractedData = true - DispatchQueue.main.async { - self.onDataExtracted(token, username, webView) - } - } - } - } - } - + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { if !hasExtractedData { + cancelTimeout() DispatchQueue.main.async { self.onError("Failed to load server: \(error.localizedDescription)") } } } - + func webView( _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error ) { if !hasExtractedData { + cancelTimeout() DispatchQueue.main.async { self.onError("Failed to connect: \(error.localizedDescription)") } diff --git a/flo/Shared/Utils/LibraryNavigation.swift b/flo/Shared/Utils/LibraryNavigation.swift new file mode 100644 index 0000000..78d8bdf --- /dev/null +++ b/flo/Shared/Utils/LibraryNavigation.swift @@ -0,0 +1,61 @@ +// +// LibraryNavigation.swift +// flo +// + +import SwiftUI + +enum AppTab: Hashable { + case home + case library + case libraryArtists + case likedSongs + case playlists + case songs + case radios + case downloads + case preferences + case debug +} + +final class LibraryRouter: ObservableObject { + @Published var selectedTab: AppTab = .home + @Published var homePath = NavigationPath() + @Published var libraryPath = NavigationPath() + @Published var artistsPath = NavigationPath() +} + +struct LibraryDestinationView: View { + let destination: LibraryDestination + + @ObservedObject var albumViewModel: AlbumViewModel + @ObservedObject var playerViewModel: PlayerViewModel + @ObservedObject var downloadViewModel: DownloadViewModel + + var body: some View { + switch destination { + case .artist(let id, let name): + if let artist = albumViewModel.artistForNavigation(id: id, name: name) { + ArtistDetailView(artist: artist) + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + } else { + Text("Artist unavailable") + .foregroundColor(.secondary) + } + case .album(let id, let name, let artist): + if let album = albumViewModel.albumForNavigation(id: id, name: name, artist: artist) { + AlbumView(viewModel: albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { + albumViewModel.setActiveAlbum(album: album) + } + } else { + Text("Album unavailable") + .foregroundColor(.secondary) + } + } + } +} diff --git a/flo/SongView.swift b/flo/SongView.swift index a4022be..7034957 100644 --- a/flo/SongView.swift +++ b/flo/SongView.swift @@ -26,8 +26,15 @@ struct SongView: View { .padding(.trailing, 5) VStack(alignment: .leading) { - Text(song.title) - .fontWeight(.medium) + HStack(alignment: .center, spacing: 6) { + Text(song.title) + .fontWeight(.medium) + .lineLimit(1) + + if song.isExplicit { + ExplicitBadge(size: .compact) + } + } if song.id.hasPrefix("pl:") { Text(song.artist).customFont(.caption1).offset(y: 5) diff --git a/flo/SongsView.swift b/flo/SongsView.swift index 35759ea..93c17ce 100644 --- a/flo/SongsView.swift +++ b/flo/SongsView.swift @@ -46,11 +46,17 @@ struct SongsView: View { } VStack(alignment: .leading) { - Text(song.title) - .customFont(.headline) - .multilineTextAlignment(.leading) - .lineLimit(2) - .padding(.bottom, 3) + HStack(alignment: .center, spacing: 6) { + Text(song.title) + .customFont(.headline) + .multilineTextAlignment(.leading) + .lineLimit(2) + + if song.isExplicit { + ExplicitBadge(size: .compact) + } + } + .padding(.bottom, 3) Text(song.artist) .customFont(.subheadline) diff --git a/flo/StatCardView.swift b/flo/StatCardView.swift index 18be8c4..2d07062 100644 --- a/flo/StatCardView.swift +++ b/flo/StatCardView.swift @@ -31,7 +31,7 @@ struct StatCard: View { self.icon = icon self.color = color self.isWide = isWide - self.showArrow = false // FIXME: use `showArrow` after implement deeplinks + self.showArrow = showArrow } var body: some View { diff --git a/flo/Watch/WatchAlbumDetailView.swift b/flo/Watch/WatchAlbumDetailView.swift index d1a1bbf..e959d3a 100644 --- a/flo/Watch/WatchAlbumDetailView.swift +++ b/flo/Watch/WatchAlbumDetailView.swift @@ -26,8 +26,15 @@ ) VStack(alignment: .leading, spacing: 2) { - Text(album.name) - .font(.headline) + HStack(alignment: .center, spacing: 4) { + Text(album.name) + .font(.headline) + .lineLimit(1) + + if album.isExplicit { + ExplicitBadge(size: .compact) + } + } Text(album.artist) .font(.caption) .foregroundColor(.secondary) @@ -56,9 +63,15 @@ playerViewModel.playSong(song, inAlbum: album) } label: { VStack(alignment: .leading, spacing: 2) { - Text(song.title) - .font(.body) - .lineLimit(1) + HStack(alignment: .center, spacing: 4) { + Text(song.title) + .font(.body) + .lineLimit(1) + + if song.isExplicit { + ExplicitBadge(size: .compact) + } + } } } } diff --git a/flo/Watch/WatchAlbumsView.swift b/flo/Watch/WatchAlbumsView.swift index 4d0986f..b253343 100644 --- a/flo/Watch/WatchAlbumsView.swift +++ b/flo/Watch/WatchAlbumsView.swift @@ -38,9 +38,15 @@ ) VStack(alignment: .leading, spacing: 2) { - Text(album.name) - .font(.headline) - .lineLimit(1) + HStack(alignment: .center, spacing: 4) { + Text(album.name) + .font(.headline) + .lineLimit(1) + + if album.isExplicit { + ExplicitBadge(size: .compact) + } + } Text(album.artist) .font(.caption) diff --git a/flo/Watch/WatchArtistDetailView.swift b/flo/Watch/WatchArtistDetailView.swift index 8311543..14bc25c 100644 --- a/flo/Watch/WatchArtistDetailView.swift +++ b/flo/Watch/WatchArtistDetailView.swift @@ -42,9 +42,15 @@ ) VStack(alignment: .leading, spacing: 2) { - Text(album.name) - .font(.body) - .lineLimit(1) + HStack(alignment: .center, spacing: 4) { + Text(album.name) + .font(.body) + .lineLimit(1) + + if album.isExplicit { + ExplicitBadge(size: .compact) + } + } Text(album.minYear > 0 ? String(album.minYear) : album.artist) .font(.caption) diff --git a/flo/Watch/WatchHomeView.swift b/flo/Watch/WatchHomeView.swift index c42cc75..8c439c2 100644 --- a/flo/Watch/WatchHomeView.swift +++ b/flo/Watch/WatchHomeView.swift @@ -28,9 +28,15 @@ Text("Nothing Playing") .font(.headline) } else { - Text(playerViewModel.nowPlayingTitle) - .font(.headline) - .lineLimit(1) + HStack(alignment: .center, spacing: 4) { + Text(playerViewModel.nowPlayingTitle) + .font(.headline) + .lineLimit(1) + + if playerViewModel.isNowPlayingExplicit { + ExplicitBadge(size: .compact) + } + } Text(playerViewModel.nowPlayingArtist) .font(.caption) diff --git a/flo/Watch/WatchNowPlayingView.swift b/flo/Watch/WatchNowPlayingView.swift index 7375a89..39541c3 100644 --- a/flo/Watch/WatchNowPlayingView.swift +++ b/flo/Watch/WatchNowPlayingView.swift @@ -20,9 +20,15 @@ } VStack(spacing: 2) { - Text(playerViewModel.nowPlayingTitle) - .font(.headline) - .lineLimit(2) + HStack(alignment: .center, spacing: 4) { + Text(playerViewModel.nowPlayingTitle) + .font(.headline) + .lineLimit(2) + + if playerViewModel.isNowPlayingExplicit { + ExplicitBadge(size: .compact) + } + } Text(playerViewModel.nowPlayingArtist) .font(.caption) diff --git a/flo/Watch/WatchPlayerViewModel.swift b/flo/Watch/WatchPlayerViewModel.swift index 80b585f..9597e8f 100644 --- a/flo/Watch/WatchPlayerViewModel.swift +++ b/flo/Watch/WatchPlayerViewModel.swift @@ -16,6 +16,7 @@ @Published var contextTitle: String = "" @Published var isPlaying: Bool = false @Published var coverArt: String = "" + @Published var isNowPlayingExplicit: Bool = false private let connectivity = WatchConnectivityManager.shared @@ -24,6 +25,7 @@ nowPlayingTitle = songs.first?.title ?? album.name nowPlayingArtist = songs.first?.artist ?? album.artist coverArt = album.albumCover + isNowPlayingExplicit = songs.first?.isExplicit ?? album.isExplicit isPlaying = true @@ -41,6 +43,7 @@ contextTitle = playlist.name nowPlayingTitle = songs.first?.title ?? playlist.name nowPlayingArtist = songs.first?.artist ?? playlist.ownerName + isNowPlayingExplicit = songs.first?.isExplicit ?? false isPlaying = true @@ -59,6 +62,7 @@ nowPlayingTitle = song.title nowPlayingArtist = song.artist coverArt = album.albumCover + isNowPlayingExplicit = song.isExplicit isPlaying = true @@ -77,6 +81,7 @@ contextTitle = playlist.name nowPlayingTitle = song.title nowPlayingArtist = song.artist + isNowPlayingExplicit = song.isExplicit isPlaying = true @@ -96,6 +101,7 @@ contextTitle = "All Songs" nowPlayingTitle = song.title nowPlayingArtist = song.artist + isNowPlayingExplicit = song.isExplicit isPlaying = true @@ -114,6 +120,7 @@ contextTitle = "Radio" nowPlayingTitle = radio.name nowPlayingArtist = radio.streamUrl + isNowPlayingExplicit = false isPlaying = true @@ -179,6 +186,7 @@ contextTitle = "" coverArt = "" isPlaying = false + isNowPlayingExplicit = false return } @@ -187,6 +195,7 @@ contextTitle = payload["contextName"] as? String ?? "" coverArt = payload["coverArt"] as? String ?? "" isPlaying = payload["isPlaying"] as? Bool ?? false + isNowPlayingExplicit = payload["isExplicit"] as? Bool ?? false } } #endif diff --git a/flo/Watch/WatchPlaylistDetailView.swift b/flo/Watch/WatchPlaylistDetailView.swift index 4145899..54e1209 100644 --- a/flo/Watch/WatchPlaylistDetailView.swift +++ b/flo/Watch/WatchPlaylistDetailView.swift @@ -50,9 +50,15 @@ playerViewModel.playSong(song, inPlaylist: playlist) } label: { VStack(alignment: .leading, spacing: 2) { - Text(song.title) - .font(.body) - .lineLimit(1) + HStack(alignment: .center, spacing: 4) { + Text(song.title) + .font(.body) + .lineLimit(1) + + if song.isExplicit { + ExplicitBadge(size: .compact) + } + } Text(song.artist) .font(.caption) diff --git a/flo/Watch/WatchSongsView.swift b/flo/Watch/WatchSongsView.swift index 5b1ff53..2e63cdd 100644 --- a/flo/Watch/WatchSongsView.swift +++ b/flo/Watch/WatchSongsView.swift @@ -6,45 +6,51 @@ // #if os(watchOS) -import SwiftUI + import SwiftUI -struct WatchSongsView: View { - @ObservedObject var libraryViewModel: WatchLibraryViewModel - @ObservedObject var playerViewModel: WatchPlayerViewModel + struct WatchSongsView: View { + @ObservedObject var libraryViewModel: WatchLibraryViewModel + @ObservedObject var playerViewModel: WatchPlayerViewModel - var body: some View { - List { - if libraryViewModel.isLoading { - ProgressView() - } + var body: some View { + List { + if libraryViewModel.isLoading { + ProgressView() + } - if let errorMessage = libraryViewModel.errorMessage { - Text(errorMessage) - .foregroundColor(.red) - } + 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) + ForEach(libraryViewModel.songs) { song in + Button { + playerViewModel.playSongAll(song) + } label: { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center, spacing: 4) { + Text(song.title) + .font(.body) + .lineLimit(1) + + if song.isExplicit { + ExplicitBadge(size: .compact) + } + } + Text(song.artist) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } } } } - } - .navigationTitle("Songs") - .onAppear { - if libraryViewModel.songs.isEmpty { - libraryViewModel.loadAllSongs() + .navigationTitle("Songs") + .onAppear { + if libraryViewModel.songs.isEmpty { + libraryViewModel.loadAllSongs() + } } } } -} #endif diff --git a/flo/flo.xcdatamodeld/flo.xcdatamodel/contents b/flo/flo.xcdatamodeld/flo.xcdatamodel/contents index 8909cc4..83347f4 100644 --- a/flo/flo.xcdatamodeld/flo.xcdatamodel/contents +++ b/flo/flo.xcdatamodeld/flo.xcdatamodel/contents @@ -16,6 +16,7 @@ + @@ -36,6 +37,7 @@ + @@ -53,12 +55,24 @@ + + + + + + + + + + + + @@ -74,6 +88,7 @@ +