diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 89ddfff..80cb354 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02A003E2F3666240024E8EC /* UIScreen+.swift */; }; + B0BAAAA62F31F0A0002A5FBB /* RadiosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */; }; + B0BAAAA92F3213AF002A5FBB /* RadiosViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */; }; + B0BAAAAB2F3214F7002A5FBB /* Radio.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAA2F3214F7002A5FBB /* Radio.swift */; }; + B0BAAAAD2F3216A0002A5FBB /* RadioService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAC2F321697002A5FBB /* RadioService.swift */; }; C401D09A2C5AED9F009F91C7 /* LocalFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */; }; C4051DFF2CD25BBA0039D062 /* ArtistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */; }; C4100A692CE78B25001BC9BE /* PlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4100A682CE78B21001BC9BE /* PlaylistView.swift */; }; @@ -30,6 +35,11 @@ C42E7E182CE7EF5500505B4E /* PlaylistDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42E7E172CE7EF4D00505B4E /* PlaylistDetailView.swift */; }; C440228D2C09BE2E004EE9CD /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C440228C2C09BE2E004EE9CD /* PlayerView.swift */; }; C446A6B72C08DE8800CC9787 /* UserAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C446A6B62C08DE8800CC9787 /* UserAuth.swift */; }; + C456D8F62F2FBD61002AAB8B /* LRCLIB.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8F52F2FBD61002AAB8B /* LRCLIB.swift */; }; + C456D8F82F2FBD64002AAB8B /* LRCLIBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8F72F2FBD64002AAB8B /* LRCLIBService.swift */; }; + C456D8FA2F2FF33E002AAB8B /* LRCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8F92F2FF33B002AAB8B /* LRCParser.swift */; }; + C456D8FC2F2FF39B002AAB8B /* LyricsLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8FB2F2FF397002AAB8B /* LyricsLine.swift */; }; + C456D8FE2F300D3D002AAB8B /* LyricsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8FD2F300D37002AAB8B /* LyricsView.swift */; }; C45F0E2C2CE4CCEA00F75C7A /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = C45F0E2B2CE4CCEA00F75C7A /* Pulse */; }; C45F0E2E2CE4CCEA00F75C7A /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = C45F0E2D2CE4CCEA00F75C7A /* PulseUI */; }; C45F0E312CE5582C00F75C7A /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = C45F0E302CE5582C00F75C7A /* Nuke */; }; @@ -65,6 +75,7 @@ C4E8D9632B763BAB00C2353E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C4E8D9622B763BAB00C2353E /* Preview Assets.xcassets */; }; C4E958982CA033BC00BBF394 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C4E958972CA033BC00BBF394 /* Localizable.xcstrings */; }; C4EAA4862C297E35007EB2E0 /* NowPlaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */; }; + C4F0B0A22F3A111100ABC002 /* AirPlayRoutePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */; }; C4F870CE2CEFCC5E00312F8A /* FloooService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F870CD2CEFCC5B00312F8A /* FloooService.swift */; }; C4F870D02CEFD25900312F8A /* StatCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F870CF2CEFD24D00312F8A /* StatCardView.swift */; }; C4FE524B2C14E1F70053763A /* UserDefaultsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FE524A2C14E1F70053763A /* UserDefaultsManager.swift */; }; @@ -72,6 +83,11 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + B02A003E2F3666240024E8EC /* UIScreen+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+.swift"; sourceTree = ""; }; + B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosView.swift; sourceTree = ""; }; + B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosViewModel.swift; sourceTree = ""; }; + B0BAAAAA2F3214F7002A5FBB /* Radio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radio.swift; sourceTree = ""; }; + B0BAAAAC2F321697002A5FBB /* RadioService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioService.swift; sourceTree = ""; }; C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileManager.swift; sourceTree = ""; }; C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistsView.swift; sourceTree = ""; }; C4100A682CE78B21001BC9BE /* PlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistView.swift; sourceTree = ""; }; @@ -94,6 +110,11 @@ C42E7E172CE7EF4D00505B4E /* PlaylistDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistDetailView.swift; sourceTree = ""; }; C440228C2C09BE2E004EE9CD /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; C446A6B62C08DE8800CC9787 /* UserAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAuth.swift; sourceTree = ""; }; + C456D8F52F2FBD61002AAB8B /* LRCLIB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRCLIB.swift; sourceTree = ""; }; + C456D8F72F2FBD64002AAB8B /* LRCLIBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRCLIBService.swift; sourceTree = ""; }; + C456D8F92F2FF33B002AAB8B /* LRCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRCParser.swift; sourceTree = ""; }; + C456D8FB2F2FF397002AAB8B /* LyricsLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLine.swift; sourceTree = ""; }; + C456D8FD2F300D37002AAB8B /* LyricsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsView.swift; sourceTree = ""; }; C467AD502D3264AE00644E68 /* FloooViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloooViewModel.swift; sourceTree = ""; }; C467AD522D3267CE00644E68 /* Subsonic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subsonic.swift; sourceTree = ""; }; C467AD542D329C8500644E68 /* AccountLinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountLinkStatus.swift; sourceTree = ""; }; @@ -127,6 +148,7 @@ C4E8D9622B763BAB00C2353E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; C4E958972CA033BC00BBF394 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlaying.swift; sourceTree = ""; }; + C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirPlayRoutePicker.swift; sourceTree = ""; }; C4F870CD2CEFCC5B00312F8A /* FloooService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloooService.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 = ""; }; @@ -150,6 +172,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + B0BAAAA72F32139D002A5FBB /* Radios */ = { + isa = PBXGroup; + children = ( + B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */, + B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */, + ); + path = Radios; + sourceTree = ""; + }; C4289F4B2C1253B800C3A4FD /* Shared */ = { isa = PBXGroup; children = ( @@ -173,7 +204,10 @@ C446A6B62C08DE8800CC9787 /* UserAuth.swift */, C4120FDC2C15E1C300E712BE /* Song.swift */, C49495802C1C25E5006B4D1E /* ScanStatus.swift */, + B0BAAAAA2F3214F7002A5FBB /* Radio.swift */, C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */, + C456D8F52F2FBD61002AAB8B /* LRCLIB.swift */, + C456D8FB2F2FF397002AAB8B /* LyricsLine.swift */, ); path = Models; sourceTree = ""; @@ -181,6 +215,9 @@ C4289F4D2C1253EB00C3A4FD /* Utils */ = { isa = PBXGroup; children = ( + B02A003E2F3666240024E8EC /* UIScreen+.swift */, + C456D8F92F2FF33B002AAB8B /* LRCParser.swift */, + C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */, C415F5592C11953000E3E1D2 /* Constants.swift */, C415F5632C11AA8700E3E1D2 /* Fonts.swift */, C49134522C15BE0C00CCF2EB /* Strings.swift */, @@ -221,6 +258,8 @@ C4DE89172C2FFBC900E078CC /* CoreDataManager.swift */, C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */, C4D7F84E2C7F2C5D00165EFD /* PlaybackService.swift */, + B0BAAAAC2F321697002A5FBB /* RadioService.swift */, + C456D8F72F2FBD64002AAB8B /* LRCLIBService.swift */, ); path = Services; sourceTree = ""; @@ -270,6 +309,7 @@ C415F54D2C11908100E3E1D2 /* AuthViewModel.swift */, C4289F472C12391300C3A4FD /* AlbumViewModel.swift */, C4289F502C139B2E00C3A4FD /* AlbumView.swift */, + B0BAAAA72F32139D002A5FBB /* Radios */, C4824D262CE908DA003EAB52 /* SongsView.swift */, C4A4BF3C2C1455A100363290 /* FloatingPlayerView.swift */, C42E7E172CE7EF4D00505B4E /* PlaylistDetailView.swift */, @@ -283,6 +323,7 @@ C429DB312D33C704009F2684 /* DownloadButtonView.swift */, C429DB2F2D33AE81009F2684 /* DownloadQueueView.swift */, C4DFFA202D32E769003B9C4E /* DownloadViewModel.swift */, + C456D8FD2F300D37002AAB8B /* LyricsView.swift */, ); path = flo; sourceTree = ""; @@ -331,7 +372,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1520; - LastUpgradeCheck = 1610; + LastUpgradeCheck = 2600; TargetAttributes = { C4E8D9572B763BA900C2353E = { CreatedOnToolsVersion = 15.2; @@ -390,15 +431,19 @@ C4289F4A2C12392B00C3A4FD /* Album.swift in Sources */, C4824D252CE90872003EAB52 /* ArtistDetailView.swift in Sources */, C4E8D95E2B763BA900C2353E /* ContentView.swift in Sources */, + B0BAAAAD2F3216A0002A5FBB /* RadioService.swift in Sources */, C4FE524D2C14E71B0053763A /* KeychainManager.swift in Sources */, + B0BAAAA62F31F0A0002A5FBB /* RadiosView.swift in Sources */, C4875E022C149DDD00D9BAEB /* AuthService.swift in Sources */, C4EAA4862C297E35007EB2E0 /* NowPlaying.swift in Sources */, + B0BAAAAB2F3214F7002A5FBB /* Radio.swift in Sources */, C467AD552D329C8B00644E68 /* AccountLinkStatus.swift in Sources */, C4120FDD2C15E1C300E712BE /* Song.swift in Sources */, C467AD532D3267D000644E68 /* Subsonic.swift in Sources */, C47876022C2BF15900184A33 /* AlbumsView.swift in Sources */, C4824D272CE908DC003EAB52 /* SongsView.swift in Sources */, C4051DFF2CD25BBA0039D062 /* ArtistsView.swift in Sources */, + C456D8FA2F2FF33E002AAB8B /* LRCParser.swift in Sources */, C4F870CE2CEFCC5E00312F8A /* FloooService.swift in Sources */, C4DFFA212D32E76E003B9C4E /* DownloadViewModel.swift in Sources */, C4120FD92C15D58E00E712BE /* Errors.swift in Sources */, @@ -408,14 +453,19 @@ C4100A6B2CE78B62001BC9BE /* Playlist.swift in Sources */, C4A4BF332C14437700363290 /* LibraryView.swift in Sources */, C46B8DD72CF4B89000B40644 /* Stats.swift in Sources */, + B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */, + C4F0B0A22F3A111100ABC002 /* AirPlayRoutePicker.swift in Sources */, C415F5642C11AA8700E3E1D2 /* Fonts.swift in Sources */, + C456D8FE2F300D3D002AAB8B /* LyricsView.swift in Sources */, C41E15152C0F95AD005BAE63 /* PlayerCustomSlider.swift in Sources */, C467AD512D3264B400644E68 /* FloooViewModel.swift in Sources */, C4FE524B2C14E1F70053763A /* UserDefaultsManager.swift in Sources */, C42E7E182CE7EF5500505B4E /* PlaylistDetailView.swift in Sources */, C429DB302D33AE85009F2684 /* DownloadQueueView.swift in Sources */, C4E8D95C2B763BA900C2353E /* App.swift in Sources */, + C456D8F62F2FBD61002AAB8B /* LRCLIB.swift in Sources */, C4D7F84D2C7F2AE900165EFD /* flo.xcdatamodeld in Sources */, + C456D8F82F2FBD64002AAB8B /* LRCLIBService.swift in Sources */, C4289F512C139B2E00C3A4FD /* AlbumView.swift in Sources */, C4A4BF3D2C1455A100363290 /* FloatingPlayerView.swift in Sources */, C415F54E2C11908100E3E1D2 /* AuthViewModel.swift in Sources */, @@ -428,9 +478,11 @@ C4D7F84F2C7F2C5D00165EFD /* PlaybackService.swift in Sources */, C49134532C15BE0C00CCF2EB /* Strings.swift in Sources */, C4875E002C149D9000D9BAEB /* AlbumService.swift in Sources */, + C456D8FC2F2FF39B002AAB8B /* LyricsLine.swift in Sources */, C429DB322D33C707009F2684 /* DownloadButtonView.swift in Sources */, C4824D232CE8C41F003EAB52 /* Playable.swift in Sources */, C41E15132C0F952A005BAE63 /* PlayerViewModel.swift in Sources */, + B0BAAAA92F3213AF002A5FBB /* RadiosViewModel.swift in Sources */, C4875E042C149F9A00D9BAEB /* APIManager.swift in Sources */, C4A4BF312C14433D00363290 /* HomeView.swift in Sources */, C4A4BF392C14445000363290 /* PreferencesView.swift in Sources */, @@ -499,6 +551,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -556,6 +609,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -568,7 +622,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 170; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; @@ -586,7 +640,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.penerbangwalet.flo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -608,7 +662,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 170; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; @@ -626,11 +680,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.penerbangwalet.flo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.penerbangwalet.flo 1726141004"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.penerbangwalet.flo 1769987359"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/flo/AlbumView.swift b/flo/AlbumView.swift index f6e8b58..c5a9622 100644 --- a/flo/AlbumView.swift +++ b/flo/AlbumView.swift @@ -277,42 +277,43 @@ struct AlbumView: View { struct AlbumViewPreview_Previews: PreviewProvider { static var songs: [Song] = [ Song( - id: "0", title: "Song 1", albumId: "", artist: "", trackNumber: 1, discNumber: 0, bitRate: 0, + id: "0", title: "Song 1", albumId: "", albumName: "Album name", artist: "", + trackNumber: 1, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "0"), Song( - id: "1", title: "Song 2", albumId: "", artist: "Artist Name", trackNumber: 2, discNumber: 0, - bitRate: 0, + id: "1", title: "Song 2", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 2, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "1"), Song( - id: "2", title: "Song 3", albumId: "", artist: "Artist Name", trackNumber: 3, discNumber: 0, - bitRate: 0, + id: "2", title: "Song 3", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 3, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "2"), Song( - id: "3", title: "Song 4", albumId: "", artist: "Artist Name", trackNumber: 4, discNumber: 0, - bitRate: 0, + id: "3", title: "Song 4", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 4, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "3"), Song( - id: "4", title: "Song 6", albumId: "", artist: "Artist Name", trackNumber: 5, discNumber: 0, - bitRate: 0, + id: "4", title: "Song 6", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 5, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "4"), Song( - id: "5", title: "Song 6", albumId: "", artist: "Artist Name", trackNumber: 6, discNumber: 0, - bitRate: 0, + id: "5", title: "Song 6", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 6, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "5"), Song( - id: "6", title: "Song 7", albumId: "", artist: "Artist Name", trackNumber: 7, discNumber: 0, - bitRate: 0, + id: "6", title: "Song 7", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 7, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "6"), Song( - id: "7", title: "Song 8", albumId: "", artist: "Artist Name", trackNumber: 8, discNumber: 0, - bitRate: 0, + id: "7", title: "Song 8", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 8, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "7"), ] diff --git a/flo/ArtistDetailView.swift b/flo/ArtistDetailView.swift index c87d211..b57c918 100644 --- a/flo/ArtistDetailView.swift +++ b/flo/ArtistDetailView.swift @@ -39,7 +39,7 @@ struct ArtistDetailView: View { .padding(.bottom, 3) .frame(maxWidth: .infinity, alignment: .leading) - Text(stripBiography(biography: artist.biography)) + Text(stripBiography(biography: artist.biography ?? "")) .customFont(.subheadline) .lineSpacing(3) .multilineTextAlignment(.leading) diff --git a/flo/ArtistsView.swift b/flo/ArtistsView.swift index c9da2eb..3287176 100644 --- a/flo/ArtistsView.swift +++ b/flo/ArtistsView.swift @@ -11,17 +11,15 @@ struct ArtistsView: View { @EnvironmentObject private var viewModel: AlbumViewModel @State private var searchArtist = "" + @State private var filterAlbumArtistOnly: Bool = true let artists: [Artist] var filteredArtists: [Artist] { - if searchArtist.isEmpty { - return artists - } else { - return artists.filter { artist in - artist.name.localizedCaseInsensitiveContains(searchArtist) - || artist.fullText.localizedCaseInsensitiveContains(searchArtist) - } + artists.filter { artist in + let matchesAlbumArtist = !filterAlbumArtistOnly || artist.stats.albumartist != nil + let matchesSearch = searchArtist.isEmpty || artist.name.localizedCaseInsensitiveContains(searchArtist) + return matchesAlbumArtist && matchesSearch } } @@ -39,16 +37,16 @@ struct ArtistsView: View { Text(artist.name) .customFont(.headline) .multilineTextAlignment(.leading) - + Spacer() - + Image(systemName: "chevron.right") .foregroundColor(.gray) .font(.caption) } .padding(.horizontal) .padding(.vertical, 5) - + Divider() } } @@ -59,6 +57,17 @@ struct ArtistsView: View { .searchable( text: $searchArtist, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search" ) + .toolbar { + Menu { + Button { + self.filterAlbumArtistOnly.toggle() + } label: { + Label("Album Artist Only", systemImage: self.filterAlbumArtistOnly ? "checkmark.circle" : "circle") + } + } label: { + Label("", systemImage: "ellipsis.circle") + } + } } } } diff --git a/flo/ContentView.swift b/flo/ContentView.swift index 1816c10..c434947 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -74,6 +74,7 @@ struct ContentView: View { Spacer() if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { + let bottomPaddingForSmallerScreens: CGFloat = UIScreen.screenWidth <= 375 ? 32 : 0 FloatingPlayerView(viewModel: playerViewModel) .padding(.bottom, 50) .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) @@ -106,6 +107,7 @@ struct ContentView: View { self.isSwipping = false } ) + .padding(.bottom, bottomPaddingForSmallerScreens) } } } diff --git a/flo/FloatingPlayerView.swift b/flo/FloatingPlayerView.swift index 44426af..2e35895 100644 --- a/flo/FloatingPlayerView.swift +++ b/flo/FloatingPlayerView.swift @@ -8,110 +8,121 @@ import NukeUI import SwiftUI +extension View { + @ViewBuilder + func glassedEffect(in shape: some Shape, interactive: Bool = false) -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(interactive ? .regular.interactive() : .regular, in: shape) + .contentShape(shape) + } else { + self.background { + shape.glassed() + } + } + } +} + +extension Shape { + func glassed() -> some View { + ZStack { + Color.clear + .background(.ultraThinMaterial) + + LinearGradient( + gradient: Gradient(colors: [ + Color.primary.opacity(0.08), + Color.primary.opacity(0.05), + Color.primary.opacity(0.01), + Color.clear, + Color.clear, + Color.clear, + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + .mask(self) + .overlay( + self.stroke(Color.primary.opacity(0.2), lineWidth: 0.7) + ) + } +} + struct FloatingPlayerView: View { @ObservedObject var viewModel: PlayerViewModel - var range: ClosedRange = 0...1 - var body: some View { ZStack { - HStack { - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 50, height: 50) - .clipShape( - RoundedRectangle(cornerRadius: 10, style: .continuous) - ) - } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 50, height: 50) - .clipShape( - RoundedRectangle(cornerRadius: 5, style: .continuous) - ) - } else { - Color.gray.opacity(0.3).frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + HStack(spacing: 10) { + Group { + if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in + if state.isLoading { + Color.gray.opacity(0.3) + } else { + if let image = state.image { + image.resizable().aspectRatio(contentMode: .fit) + } else { + Image("placeholder") + .resizable() + .scaledToFit() + .padding(8) + } + } } } } + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(radius: 2) - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 1) { Text(viewModel.nowPlaying.songName ?? "") - .foregroundColor(.white) - .customFont(.headline) + .foregroundColor(.accent) + .customFont(.callout) + .fontWeight(.bold) .lineLimit(1) + Text(viewModel.nowPlaying.artistName ?? "") - .foregroundColor(.white) - .customFont(.subheadline) + .customFont(.caption1) .lineLimit(1) - - GeometryReader { geometry in - ZStack(alignment: .leading) { - Rectangle() - .foregroundColor(Color.gray.opacity(0.3)) - .frame(height: 3) - .cornerRadius(10) - - Rectangle() - .foregroundColor(Color.white) - .frame( - width: CGFloat( - (viewModel.progress - range.lowerBound) / (range.upperBound - range.lowerBound)) - * geometry.size.width, height: 3 - ) - .cornerRadius(10).opacity(viewModel.isMediaLoading ? 0 : 1) - }.frame(height: 3) - }.frame(height: 3) } - HStack(spacing: 20) { + Spacer() + + HStack(spacing: 16) { if viewModel.isMediaLoading { - ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)) + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.7) } else { Button { viewModel.isPlaying ? viewModel.pause() : viewModel.play() } label: { Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 20)) - .disabled(viewModel.isMediaLoading) - }.opacity(viewModel.isMediaFailed ? 0 : 1) - } - }.padding() - }.padding(8).foregroundColor(.white) - }.background { - if UserDefaultsManager.playerBackground == PlayerBackground.translucent { - ZStack { - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { - Image(uiImage: image) - .resizable() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .blur(radius: 50, opaque: true) - .edgesIgnoringSafeArea(.all) - } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .blur(radius: 50, opaque: true) - .edgesIgnoringSafeArea(.all) - } + .font(.system(size: 20, weight: .semibold)) + .symbolRenderingMode(.hierarchical) } + .buttonStyle(.plain) + .opacity(viewModel.isMediaFailed ? 0.3 : 1) } - - Rectangle().fill(.thinMaterial).edgesIgnoringSafeArea(.all) - }.environment(\.colorScheme, .dark) - } else { - Rectangle().fill(Color("PlayerColor")) + } + .padding(.trailing, 8) } + .padding(.horizontal, 12) + .padding(.vertical, 8) } - .cornerRadius(10).padding(8) + .contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .glassedEffect(in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 16, x: 0, y: 6) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + .padding(.horizontal, 16) + .padding(.bottom, 8) } } diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift new file mode 100644 index 0000000..d4a513f --- /dev/null +++ b/flo/LyricsView.swift @@ -0,0 +1,229 @@ +// +// LyricsView.swift +// flo +// +// Created by rizaldy on 02/02/26. +// + +import NukeUI +import SwiftUI + +struct LyricsView: View { + @ObservedObject var viewModel: PlayerViewModel + @Binding var showQueue: Bool + + let imageSize: CGFloat + + private var isPlainLyrics: Bool { + return viewModel.lyrics.count == 1 + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 16) { + Group { + if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Color.gray.opacity(0.3) + } + } + } + } + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.nowPlaying.songName ?? "") + .foregroundColor(.white) + .customFont(.body) + .fontWeight(.bold) + .lineLimit(1) + + Text(viewModel.nowPlaying.artistName ?? "") + .foregroundColor(.white.opacity(0.7)) + .customFont(.subheadline) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + viewModel.toggleLyricsMode() + } label: { + Image(systemName: "chevron.down") + .font(.title3.weight(.semibold)) + .foregroundColor(.white) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(.white.opacity(0.15)) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3) + } + } + .padding(.horizontal, 30) + .padding(.top, 16) + .padding(.bottom, 16) + .onTapGesture { + viewModel.toggleLyricsMode() + } + + if viewModel.isLoadingLyrics { + Spacer() + ProgressView() + .scaleEffect(1.5) + .foregroundColor(.white) + Spacer() + } else if let error = viewModel.lyricsError { + Spacer() + VStack(spacing: 16) { + Text(error) + .foregroundColor(.white.opacity(0.7)) + .multilineTextAlignment(.center) + } + Spacer() + } else if viewModel.lyrics.isEmpty { + Spacer() + VStack(spacing: 16) { + Text("No lyrics available").foregroundColor(.white.opacity(0.7)) + } + Spacer() + } else { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 20) { + ForEach(Array(viewModel.lyrics.enumerated()), id: \.element.id) { index, line in + LyricLineView( + text: line.text, + isCurrentLine: index == viewModel.currentLyricsLineIndex, + isPastLine: index < viewModel.currentLyricsLineIndex, + isPlainLyrics: isPlainLyrics + ) + .id(index) + .onTapGesture { + guard !isPlainLyrics else { return } + + let progress = line.timestamp / viewModel.nowPlaying.duration + + viewModel.seek(to: progress) + viewModel.play() + } + } + + Spacer().frame(height: 250) + } + .padding(.horizontal, 30) + } + .onAppear { + guard !isPlainLyrics else { return } + guard viewModel.currentLyricsLineIndex >= 0 else { return } + + proxy.scrollTo(viewModel.currentLyricsLineIndex, anchor: .center) + } + .onChange(of: viewModel.currentLyricsLineIndex) { newIndex in + guard !isPlainLyrics else { return } + guard newIndex >= 0 else { return } + + withAnimation(.easeInOut(duration: 0.5)) { + proxy.scrollTo(newIndex, anchor: .center) + } + } + } + } + + Spacer() + + VStack(spacing: 0) { + HStack(spacing: 0) { + Button { + viewModel.toggleLyricsMode() + } label: { + Image(systemName: "quote.bubble.fill") + .font(.title2) + .foregroundColor(.white) + } + .frame(width: 56, alignment: .leading) + + AirPlayRoutePicker(tintColor: UIColor.white, activeTintColor: UIColor.white) + .frame(width: 36, height: 36, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) + .overlay(alignment: .bottom) { + if let outputName = viewModel.externalOutputName { + Text(outputName) + .foregroundColor(.white) + .customFont(.caption2) + .fontWeight(.bold) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: 260) + .fixedSize(horizontal: false, vertical: true) + .offset(y: 13) + } + } + + Button { + showQueue.toggle() + } label: { + Image(systemName: "list.bullet") + .font(.title2) + .foregroundColor(.white) + .overlay( + Group { + Image(systemName: "repeat") + .font(.caption) + .overlay( + Group { + Text("1") + .font(.system(size: 8)) + } + .offset(x: 7, y: -4) + .opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) + ) + .opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 1) + } + .padding(5) + .background( + .black.opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 0.2) + ) + .clipShape(Circle()) + .offset(x: 10, y: -10) + ) + } + .frame(width: 56, alignment: .trailing) + } + .padding(.horizontal, 30) + .padding(.top, 10) + } + } + } +} + +struct LyricLineView: View { + let text: String + + let isCurrentLine: Bool + let isPastLine: Bool + let isPlainLyrics: Bool + + var body: some View { + Text(text) + .foregroundColor( + isCurrentLine ? .white : (isPastLine ? .white.opacity(0.3) : .white.opacity(0.5)) + ) + .customFont(.title) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .lineSpacing(6) + .scaleEffect(isCurrentLine && !isPlainLyrics ? 1.03 : 1.0) + .animation(.easeInOut(duration: 0.3), value: isCurrentLine) + .opacity(isPlainLyrics ? 0.9 : 1.0) + } +} diff --git a/flo/Navigation/HomeView.swift b/flo/Navigation/HomeView.swift index e274012..84d757b 100644 --- a/flo/Navigation/HomeView.swift +++ b/flo/Navigation/HomeView.swift @@ -13,6 +13,37 @@ struct HomeView: View { @EnvironmentObject var floooViewModel: FloooViewModel + private enum ConnectionState { + case online + case expired + case freshInstall + } + + private var connectionState: ConnectionState { + if viewModel.isLoggedIn { + return .online + } else if hasConfiguredServer() { + return .expired + } else { + return .freshInstall + } + } + + private var statusColor: Color { + switch connectionState { + case .online: + return .green + case .expired: + return .orange + case .freshInstall: + return .red + } + } + + private func hasConfiguredServer() -> Bool { + UserDefaults.standard.string(forKey: "serverURL") != nil + } + private func shouldShowLoginSheet() -> Binding { Binding( get: { @@ -48,8 +79,16 @@ struct HomeView: View { } } } label: { - Image(systemName: "person.crop.circle.fill") - .font(.largeTitle) + ZStack { + Image(systemName: "person.crop.circle.fill") + .font(.largeTitle) + .foregroundColor(.accentColor) + + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + .offset(x: 12, y: -12) + } } }.padding(.top) .sheet(isPresented: shouldShowLoginSheet()) { @@ -64,39 +103,30 @@ struct HomeView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { - if !viewModel.isLoggedIn { - VStack { - Text("Login to start streaming your music by tapping the icon above") - .customFont(.body) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } - .padding() - .overlay( - RoundedRectangle(cornerRadius: 8).stroke(Color("PlayerColor"), lineWidth: 0.8) - ) - .padding(.top, 10) - .padding(.bottom) - } - Text("Listening Activity (all time)").customFont(.title2).fontWeight(.bold) .multilineTextAlignment(.leading) + + let statCardSpacing: CGFloat = UIScreen.screenWidth <= 375 ? 8 : 16 + + EqualHeightHStack(alignment: .top, spacing: statCardSpacing) { + EqualHeightItem { + StatCard( + title: "Total Listens", + value: floooViewModel.totalPlay.description, + icon: "headphones", + color: .purple + ) + } - HStack(alignment: .top, spacing: 16) { - StatCard( - title: "Total Listens", - value: floooViewModel.totalPlay.description, - icon: "headphones", - color: .purple - ) - - StatCard( - title: "Top Artist", - value: floooViewModel.stats?.topArtist ?? "N/A", - icon: "music.mic", - color: .blue, - showArrow: true - ) + EqualHeightItem { + StatCard( + title: "Top Artist", + value: floooViewModel.stats?.topArtist ?? "N/A", + icon: "music.mic", + color: .blue, + showArrow: true + ) + } } HStack(alignment: .top, spacing: 16) { diff --git a/flo/Navigation/LibraryView.swift b/flo/Navigation/LibraryView.swift index e1ec4f2..80b6ace 100644 --- a/flo/Navigation/LibraryView.swift +++ b/flo/Navigation/LibraryView.swift @@ -125,6 +125,26 @@ struct LibraryView: View { } Divider() + + NavigationLink { + RadiosView() + .environmentObject(playerViewModel) + } label: { + HStack { + Image(systemName: "radio") + .frame(width: 20, height: 10) + .foregroundColor(.accent) + Text("Radios") + .customFont(.headline) + .padding(.leading, 8) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) + }.padding(.horizontal).padding(.vertical, 5) + } + + Divider() } LazyVGrid(columns: columns) { @@ -171,8 +191,8 @@ struct LibraryView: View { struct LibraryView_Previews: PreviewProvider { static private var songs: [Song] = [ Song( - id: "0", title: "Song name", albumId: "", artist: "", trackNumber: 1, discNumber: 0, - bitRate: 0, + id: "0", title: "Song name", albumId: "", albumName: "Album 1", artist: "", + trackNumber: 1, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "m4a", duration: 100, mediaFileId: "0") ] diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 069b65b..5646c54 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -12,6 +12,7 @@ struct PreferencesView: View { @State private var storeCredsInKeychain = false @State private var optimizeLocalStorageAlert = false @State private var showLoginSheet = false + @State private var showCustomLRCLIBServer = false @State private var accentColor = Color(.accent) @State private var playerColor = Color(.player) @@ -21,9 +22,15 @@ struct PreferencesView: View { @EnvironmentObject var playerViewModel: PlayerViewModel let themeColors = ["Blue", "Green", "Red", "Ohio"] + let presetExperimentalLRCLIBServer: [(label: String, url: String)] = [ + ("lrclib.net", "https://lrclib.net"), + ("lrclib.flooo.club", "https://lrclib.flooo.club"), + ] @State private var experimentalMaxBitrate = UserDefaultsManager.maxBitRate @State private var experimentalPlayerBackground = UserDefaultsManager.playerBackground + @State private var experimentalLRCLIBIntegration = UserDefaultsManager.LRCLIBServerURL + @State private var customLRCLIBServer = "" var shouldShowLoginSheet: Binding { Binding( @@ -36,6 +43,21 @@ struct PreferencesView: View { ) } + var lrclibOptions: [(label: String, url: String)] { + let current = UserDefaultsManager.LRCLIBServerURL + + let isCustom = + !current.isEmpty && !presetExperimentalLRCLIBServer.contains(where: { $0.url == current }) + + var options = presetExperimentalLRCLIBServer + + if isCustom { + options.append(("Custom (\(current))", current)) + } + + return options + } + func getAppVersion() -> String { if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { return appVersion @@ -152,7 +174,7 @@ struct PreferencesView: View { // TODO: finish this later Section(header: Text("Experimental")) { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { Toggle( "Enable Debug", isOn: Binding( @@ -169,6 +191,29 @@ struct PreferencesView: View { } VStack(alignment: .leading) { + Picker(selection: $experimentalLRCLIBIntegration, label: Text("LRCLIB")) { + Text("Disabled").tag("") + + ForEach(lrclibOptions, id: \.url) { option in + Text(option.label).tag(option.url) + } + + Text("Add/Change Custom").tag("custom") + } + .onChange(of: experimentalLRCLIBIntegration) { value in + if value != "custom" { + UserDefaultsManager.LRCLIBServerURL = value + floooViewModel.getUserDefaults() + } else { + showCustomLRCLIBServer.toggle() + } + } + + Text("LRCLIB server is required. Learn more at dub.sh/flo-lrclib").font(.caption) + .foregroundColor(.gray) + } + + VStack(alignment: .leading, spacing: 4) { Picker(selection: $experimentalMaxBitrate, label: Text("Max Bitrate")) { ForEach(TranscodingSettings.availableBitRate, id: \.self) { bitrate in Text(bitrate == "0" ? "Source" : bitrate).tag(bitrate) @@ -183,17 +228,7 @@ struct PreferencesView: View { ).font(.caption).foregroundColor(.gray) } - Toggle( - "Use translucent backgrounds", - isOn: Binding( - get: { UserDefaultsManager.playerBackground == PlayerBackground.translucent }, - set: { - UserDefaultsManager.playerBackground = - $0 ? PlayerBackground.translucent : PlayerBackground.solid - } - )) - - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 8) { Toggle( "Save login info", isOn: Binding( @@ -235,7 +270,7 @@ struct PreferencesView: View { } if authViewModel.isLoggedIn { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 6) { Toggle(isOn: $floooViewModel.isLastFmLinked) { Text("Scrobble to Last.fm") }.disabled(true) @@ -244,12 +279,14 @@ struct PreferencesView: View { .foregroundColor(.gray) } - Toggle(isOn: $floooViewModel.isListenBrainzLinked) { - Text("Scrobble to ListenBrainz") - }.disabled(true) + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: $floooViewModel.isListenBrainzLinked) { + Text("Scrobble to ListenBrainz") + }.disabled(true) - Text("To change this, please do so via the Navidrome Web UI").font(.caption) - .foregroundColor(.gray) + Text("To change this, please do so via the Navidrome Web UI").font(.caption) + .foregroundColor(.gray) + } } } @@ -353,6 +390,25 @@ struct PreferencesView: View { floooViewModel.getUserDefaults() } } + .alert("LRCLIB Server URL", isPresented: $showCustomLRCLIBServer) { + Button("Cancel", role: .cancel) { + self.showCustomLRCLIBServer.toggle() + self.experimentalLRCLIBIntegration = "" + } + + Button("Save") { + UserDefaultsManager.LRCLIBServerURL = customLRCLIBServer + self.experimentalLRCLIBIntegration = customLRCLIBServer + floooViewModel.getUserDefaults() + } + + TextField("https://lrclib.your-server.net", text: $customLRCLIBServer).keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .textContentType(.none) + } message: { + Text("Learn more at https://dub.sh/flo-lrclib") + } } } diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 36345aa..224a82b 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -51,7 +51,9 @@ struct PlayerView: View { if viewModel.queue.isEmpty { Text("").customFont(.subheadline) } else { - Text("From \(viewModel.nowPlaying.albumName ?? "")").customFont(.subheadline) + Text( + "From \(viewModel.nowPlaying.contextName ?? viewModel.nowPlaying.albumName ?? "")" + ).customFont(.subheadline) } Spacer() @@ -97,7 +99,7 @@ struct PlayerView: View { .padding(.bottom, 5) ScrollView { - VStack(alignment: .leading) { + LazyVStack(alignment: .leading) { ForEach(viewModel.queue.indices, id: \.self) { idx in HStack(alignment: .top) { VStack(alignment: .leading) { @@ -154,180 +156,24 @@ struct PlayerView: View { .animation(.spring(duration: 0.2), value: showQueue) ZStack { - VStack { - - Rectangle() - .foregroundColor(Color.gray.opacity(0.8)) - .frame(width: 50, height: 5) - .cornerRadius(30) - .padding(.top, 20) - - Spacer() - - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } else { - Color.gray.opacity(0.3) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } - } - } - - Spacer() - - VStack(alignment: .center, spacing: 10) { - Text(viewModel.nowPlaying.songName ?? "") - .foregroundColor(.white) - .customFont(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .lineLimit(3) - - Text(viewModel.nowPlaying.artistName ?? "") - .foregroundColor(.white.opacity(0.8)) - .customFont(.title3) - .multilineTextAlignment(.center) - .lineLimit(2) - } - - Spacer() - - HStack(spacing: size.width * 0.15) { - Button { - viewModel.prevSong() - } label: { - Image(systemName: "backward.fill").font(.title) - } - - Button { - viewModel.isPlaying ? viewModel.pause() : viewModel.play() - } label: { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 50)) - } - .foregroundColor(viewModel.isMediaLoading ? .gray : .white) - .disabled(viewModel.isMediaLoading) - - Button { - viewModel.nextSong() - } label: { - Image(systemName: "forward.fill").font(.title) - } - } - - Spacer() - - VStack { - - PlayerCustomSlider( - isMediaLoading: viewModel.isMediaLoading, - isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 - ) { newValue in - viewModel.seek(to: newValue) - } - - HStack { - Text(viewModel.currentTimeString) - .foregroundColor(.white) - .customFont(.caption2) - .frame(width: 60, alignment: .leading) - - Spacer() - - Text( - viewModel.isPlayFromSource - ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" - : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)" - ) - .foregroundColor(.white) - .customFont(.caption2) - .fontWeight(.bold) - .textCase(.uppercase) - .frame(maxWidth: .infinity, alignment: .center) - - Spacer() - - Text(viewModel.totalTimeString) - .foregroundColor(.white) - .customFont(.caption2) - .frame(width: 60, alignment: .trailing) - } - } - - Spacer() - - HStack { - Button { - - } label: { - Image(systemName: "quote.bubble") - .font(.title2) - .foregroundColor(.gray) - }.disabled(true) - - Spacer() - - Button { - - } label: { - Image(systemName: "airplayaudio") - .font(.title2) - .foregroundColor(.gray) - }.disabled(true) - - Spacer() + if viewModel.isLyricsMode { + LyricsView( + viewModel: viewModel, + showQueue: $showQueue, + imageSize: imageSize + ).transition(.opacity.combined(with: .move(edge: .bottom))) + } - Button { - self.showQueue.toggle() - } label: { - Image(systemName: "list.bullet") - .font(.title2) - .overlay( - Group { - Image(systemName: "repeat") - .font(.caption) - .overlay( - Group { - Text("1") - .font(.system(size: 8)) - } - .offset(x: 7, y: -4) - .opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) - ) - .opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 1) - } - .padding(5) - .background( - .black.opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 0.2) - ) - .clipShape(Circle()) - .offset(x: 10, y: -10) - ) - } - } + if !viewModel.isLyricsMode { + mainPlayerView(size: size, imageSize: imageSize).transition(.opacity) } - .padding(.horizontal, 30) } .frame(maxHeight: .infinity) + .onChange(of: viewModel.isLiveRadio) { isLive in + if isLive { + showQueue = false + } + } .background { ZStack { if UserDefaultsManager.playerBackground == PlayerBackground.translucent { @@ -363,9 +209,11 @@ struct PlayerView: View { .gesture( DragGesture() .onChanged { gesture in - if gesture.translation.height > 0 { - offset = gesture.translation - isDragging = true + if !viewModel.isLyricsMode { + if gesture.translation.height > 0 { + offset = gesture.translation + isDragging = true + } } } .onEnded { _ in @@ -380,6 +228,251 @@ struct PlayerView: View { } .foregroundColor(.white) } + + @ViewBuilder + private func mainPlayerView(size: CGSize, imageSize: CGFloat) -> some View { + VStack { + Rectangle() + .foregroundColor(Color.gray.opacity(0.8)) + .frame(width: 50, height: 5) + .cornerRadius(30) + .padding(.top, 20) + + 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) + ) + } + } + } + } + + Spacer() + + VStack(alignment: .center, spacing: 10) { + Text(viewModel.nowPlaying.songName ?? "") + .foregroundColor(.white) + .customFont(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .lineLimit(3) + + Text(viewModel.nowPlaying.artistName ?? "") + .foregroundColor(.white.opacity(0.8)) + .customFont(.title3) + .multilineTextAlignment(.center) + .lineLimit(2) + } + + Spacer() + + if viewModel.isLiveRadio { + HStack { + Spacer() + + Button { + viewModel.isPlaying ? viewModel.pause() : viewModel.play() + } label: { + Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 50)) + } + .foregroundColor(viewModel.isMediaLoading ? .gray : .white) + .disabled(viewModel.isMediaLoading) + + Spacer() + } + } else { + HStack(spacing: size.width * 0.15) { + Button { + viewModel.prevSong() + } label: { + Image(systemName: "backward.fill").font(.title) + } + + Button { + viewModel.isPlaying ? viewModel.pause() : viewModel.play() + } label: { + Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 50)) + } + .foregroundColor(viewModel.isMediaLoading ? .gray : .white) + .disabled(viewModel.isMediaLoading) + + Button { + viewModel.nextSong() + } label: { + Image(systemName: "forward.fill").font(.title) + } + } + } + + Spacer() + + VStack { + if viewModel.isLiveRadio { + liveProgressBar() + } else { + PlayerCustomSlider( + isMediaLoading: viewModel.isMediaLoading, + isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 + ) { newValue in + viewModel.seek(to: newValue) + } + } + + HStack { + Text(viewModel.isLiveRadio ? "" : viewModel.currentTimeString) + .foregroundColor(.white) + .customFont(.caption2) + .frame(width: 60, alignment: .leading) + + Spacer() + + Text( + viewModel.isLiveRadio + ? "LIVE" + : (viewModel.isPlayFromSource + ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" + : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)") + ) + .foregroundColor(.white) + .customFont(.caption2) + .fontWeight(.bold) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + + Text(viewModel.isLiveRadio ? "" : viewModel.totalTimeString) + .foregroundColor(.white) + .customFont(.caption2) + .frame(width: 60, alignment: .trailing) + } + } + + Spacer() + + bottomControlBar(showQueue: $showQueue) + } + .padding(.horizontal, 30) + } + + @ViewBuilder + private func bottomControlBar(showQueue: Binding) -> some View { + let isLyricsDisabled = + viewModel.isLiveRadio || (viewModel.lyrics.isEmpty && (viewModel.lyricsError != nil)) + + let isQueueDisabled = viewModel.isLiveRadio + + HStack(spacing: 0) { + Button { + viewModel.toggleLyricsMode() + } label: { + Image(systemName: "quote.bubble") + .font(.title2) + .foregroundColor(isLyricsDisabled ? .white.opacity(0.4) : .white) + } + .disabled(isLyricsDisabled) + .frame(width: 56, alignment: .leading) + + AirPlayRoutePicker(tintColor: UIColor.white, activeTintColor: UIColor.white) + .frame(width: 36, height: 36, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) + .overlay(alignment: .bottom) { + if let outputName = viewModel.externalOutputName { + Text(outputName) + .foregroundColor(.white) + .customFont(.caption2) + .fontWeight(.bold) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: 260) + .fixedSize(horizontal: false, vertical: true) + .offset(y: 13) + } + } + + Button { + if !isQueueDisabled { + showQueue.wrappedValue.toggle() + } + } label: { + Image(systemName: "list.bullet") + .font(.title2) + .overlay( + Group { + Image(systemName: "repeat") + .font(.caption) + .overlay( + Group { + Text("1") + .font(.system(size: 8)) + } + .offset(x: 7, y: -4) + .opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) + ) + .opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 1) + } + .padding(5) + .background( + .black.opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 0.2) + ) + .clipShape(Circle()) + .offset(x: 10, y: -10) + ) + } + .disabled(isQueueDisabled) + .opacity(isQueueDisabled ? 0.4 : 1) + .frame(width: 56, alignment: .trailing) + } + } + + @ViewBuilder + private func liveProgressBar() -> some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.gray.opacity(0.8)) + .frame(height: 5) + + Capsule() + .fill(Color.white) + .frame(width: geometry.size.width, height: 4) + } + } + .frame(height: 20) + } } struct PlayerView_previews: PreviewProvider { diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 7fd2df2..baf1738 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -27,11 +27,17 @@ class PlayerViewModel: ObservableObject { @Published var isSeeking: Bool = false @Published var isLyricsMode: Bool = false + @Published var lyrics: [LyricsLine] = [] + @Published var currentLyricsLineIndex: Int = 0 + @Published var isLoadingLyrics: Bool = false + @Published var lyricsError: String? + @Published var progress: Double = 0.0 @Published var currentTimeString: String = "00:00" @Published var totalTimeString: String = "00:00" @Published var shouldHidePlayer: Bool = false + @Published var externalOutputName: String? // FIXME: this make confusion with `isDownloaded` and/or `isPlayingFromLocal` @Published var _playFromLocal: Bool = false @@ -41,6 +47,7 @@ class PlayerViewModel: ObservableObject { private var totalDuration: Double = 0.0 private var playerItemObservation: AnyCancellable? private var interruptionObservation = Set() + private var routeChangeObservation = Set() private var scrobbleThreshold = 0.5 @@ -53,9 +60,21 @@ class PlayerViewModel: ObservableObject { || UserDefaultsManager.maxBitRate == TranscodingSettings.sourceBitRate } + var isLRCLIBEnabled: Bool { + return UserDefaultsManager.LRCLIBServerURL != "" + } + + var isLiveRadio: Bool { + guard hasNowPlaying() else { return false } + + return nowPlaying.duration.isInfinite || nowPlaying.duration.isNaN + } + init() { self.player = AVPlayer() self.observeInterruptionNotifications() + self.observeRouteChangeNotifications() + self.updateAudioRoute() let lastPlayData = PlaybackService.shared.getQueue() let queueActiveIdx = UserDefaultsManager.queueActiveIdx @@ -88,6 +107,34 @@ class PlayerViewModel: ObservableObject { .store(in: &interruptionObservation) } + func observeRouteChangeNotifications() { + NotificationCenter.default + .publisher(for: AVAudioSession.routeChangeNotification) + .sink { _ in + self.updateAudioRoute() + } + .store(in: &routeChangeObservation) + } + + func updateAudioRoute() { + let outputs = AVAudioSession.sharedInstance().currentRoute.outputs + + if let externalOutput = outputs.first(where: { !Self.isInternalAudioOutput($0) }) { + self.externalOutputName = externalOutput.portName + } else { + self.externalOutputName = nil + } + } + + private static func isInternalAudioOutput(_ output: AVAudioSessionPortDescription) -> Bool { + switch output.portType { + case .builtInReceiver, .builtInSpeaker, .builtInMic: + return true + default: + return false + } + } + func handleInterruptionNotification(_ notification: Notification) { guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? Int, @@ -124,8 +171,12 @@ class PlayerViewModel: ObservableObject { func getAlbumCoverArt() -> String { return AlbumService.shared.getAlbumCover( - artistName: self.nowPlaying.artistName ?? "", albumName: self.nowPlaying.albumName ?? "", - albumId: self.nowPlaying.albumId ?? "", trackId: self.nowPlaying.id ?? "") + artistName: self.nowPlaying.artistName ?? "", + albumName: self.nowPlaying.albumName ?? "", + albumId: self.nowPlaying.albumId ?? "", + trackId: self.nowPlaying.id ?? "", + contextName: self.nowPlaying.contextName + ) } func hasNowPlaying() -> Bool { @@ -136,6 +187,8 @@ class PlayerViewModel: ObservableObject { self.shouldHidePlayer = false self.isLocallySaved = false + self.resetLyrics() + if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) } @@ -192,6 +245,10 @@ class PlayerViewModel: ObservableObject { playbackDuration: self.totalDuration) FloooViewModel.shared.setNowPlayingToScrobbleServer(nowPlaying: self.nowPlaying) + + if isLRCLIBEnabled && !isLiveRadio { + self.fetchLyrics() + } } private func addPeriodicTimeObserver() { @@ -204,11 +261,20 @@ class PlayerViewModel: ObservableObject { let currentTime = CMTimeGetSeconds(time) let roundedTotalDuration = floor(self.totalDuration) - self.progress = currentTime / self.totalDuration + if self.totalDuration.isFinite, self.totalDuration > 0 { + self.progress = currentTime / self.totalDuration + } else { + self.progress = 0.0 + } + self.currentTimeString = timeString(for: currentTime) UserDefaultsManager.nowPlayingProgress = self.progress + if self.isLRCLIBEnabled { + self.updateCurrentLyricsLine(currentTime: currentTime) + } + if !self.isLocallySaved && self.progress >= 0.5 { Task { FloooViewModel.shared.scrobble(submission: true, nowPlaying: self.nowPlaying) @@ -217,7 +283,10 @@ class PlayerViewModel: ObservableObject { } } - if round(currentTime) >= roundedTotalDuration { + if self.totalDuration.isFinite, + self.totalDuration > 0, + round(currentTime) >= roundedTotalDuration + { self.nextSong() UserDefaultsManager.removeObject(key: UserDefaultsKeys.nowPlayingProgress) @@ -228,39 +297,56 @@ class PlayerViewModel: ObservableObject { private func initNowPlayingInfo( title: String, artist: String, playbackDuration: Double ) { - var nowPlayingInfo = [String: Any]() - DispatchQueue.global().async { - let url: URL - let albumCoverArt = self.getAlbumCoverArt() + let artwork = self.makeNowPlayingArtwork() - if albumCoverArt.hasPrefix("/") { - url = URL(fileURLWithPath: albumCoverArt) - } else { - guard let remoteURL = URL(string: albumCoverArt) else { - return + DispatchQueue.main.async { + var nowPlayingInfo = [String: Any]() + + nowPlayingInfo[MPMediaItemPropertyTitle] = title + nowPlayingInfo[MPMediaItemPropertyArtist] = artist + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = playbackDuration + + if let artwork = artwork { + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork } - url = remoteURL + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } + } + } - if let data = try? Data(contentsOf: url), - let image = UIImage(data: data) - { - let artwork = MPMediaItemArtwork(boundsSize: image.size) { size in + private func makeNowPlayingArtwork() -> MPMediaItemArtwork? { + if isLiveRadio { + if let image = UIImage(named: "placeholder") { + return MPMediaItemArtwork(boundsSize: image.size) { _ in return image } - - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork } - DispatchQueue.main.async { - nowPlayingInfo[MPMediaItemPropertyTitle] = title - nowPlayingInfo[MPMediaItemPropertyArtist] = artist - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = playbackDuration + return nil + } - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - } + let albumCoverArt = self.getAlbumCoverArt() + + let image: UIImage? + + if albumCoverArt.hasPrefix("/") { + image = UIImage(contentsOfFile: albumCoverArt) + } else if let remoteURL = URL(string: albumCoverArt), + let data = try? Data(contentsOf: remoteURL) + { + image = UIImage(data: data) + } else { + image = nil + } + + guard let resolvedImage = image else { + return nil + } + + return MPMediaItemArtwork(boundsSize: resolvedImage.size) { _ in + return resolvedImage } } @@ -307,6 +393,10 @@ class PlayerViewModel: ObservableObject { commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.addTarget { event in + if self.isLiveRadio { + return .commandFailed + } + if let event = event as? MPChangePlaybackPositionCommandEvent { let progress = event.positionTime / self.totalDuration @@ -348,12 +438,20 @@ class PlayerViewModel: ObservableObject { } func seek(to progress: Double) { + if isLiveRadio { + return + } + let newTime = CMTime( seconds: progress * totalDuration, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) player?.seek(to: newTime) self.updateNowPlayingInfo(progress: progress, rate: 1.0) + + if isLRCLIBEnabled { + self.updateCurrentLyricsLine(currentTime: progress * totalDuration) + } } func setPlaybackMode() { @@ -380,6 +478,68 @@ class PlayerViewModel: ObservableObject { self.addToQueue(idx: 0, item: queue) } + func playRadioItem(radio: Radio) { + guard let radioUrl = Self.normalizedRadioURL(from: radio.streamUrl) else { + return + } + + let item = radio.toPlayable() + let queue = PlaybackService.shared.addToQueue(item: item, isFromLocal: false) + + self.activeQueueIdx = 0 + self.queue = queue + self.shouldHidePlayer = false + self.isLocallySaved = false + self._playFromLocal = false + + self.resetLyrics() + + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + + self.timeObserverToken = nil + } + + self.playerItem = AVPlayerItem(url: radioUrl) + self.player?.replaceCurrentItem(with: self.playerItem) + + self.playerItemObservation = self.playerItem?.publisher(for: \.status) + .sink { [weak self] status in + guard let self = self else { return } + switch status { + case .readyToPlay: + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.isMediaLoading = false + self.isMediaFailed = false + } + case .failed: + self.isMediaLoading = false + self.isMediaFailed = true + case .unknown: + self.isMediaLoading = false + @unknown default: + self.isMediaLoading = true + } + } + + self.isMediaLoading = true + self.isMediaFailed = false + self.totalDuration = self.nowPlaying.duration + self.progress = 0.0 + self.currentTimeString = "00:00" + self.totalTimeString = "00:00" + + self.addPeriodicTimeObserver() + self.play() + + self.initNowPlayingInfo( + title: item.name, + artist: item.artist, + playbackDuration: 0) + PlaybackService.shared.clearQueue() + UserDefaultsManager.removeObject(key: UserDefaultsKeys.nowPlayingProgress) + } + func shuffleItem(item: T, isFromLocal: Bool) { var shuffledItem = item shuffledItem.songs.shuffle() @@ -469,6 +629,8 @@ class PlayerViewModel: ObservableObject { self.stop() self.progress = 0.0 + self.resetLyrics() + self.isLocallySaved = false self.shouldHidePlayer = true @@ -478,6 +640,131 @@ class PlayerViewModel: ObservableObject { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil } + func resetLyrics() { + self.lyrics = [] + self.currentLyricsLineIndex = -1 + self.lyricsError = nil + self.isLyricsMode = false + } + + func fetchLyrics() { + // just in case + guard !(self.nowPlaying.songName?.isEmpty ?? true), + !(self.nowPlaying.artistName?.isEmpty ?? true) + else { + self.lyricsError = "Missing track information" + + return + } + + self.isLoadingLyrics = true + self.lyricsError = nil + + let albumName = self.nowPlaying.albumName?.trimmingCharacters(in: .whitespacesAndNewlines) + let contextName = self.nowPlaying.contextName?.trimmingCharacters(in: .whitespacesAndNewlines) + let isFromPlaylist = self.nowPlaying.isFromPlaylist + + let albumNameForLyrics: String? + + if isFromPlaylist { + if let albumName, !albumName.isEmpty, albumName != contextName { + albumNameForLyrics = albumName + } else { + albumNameForLyrics = nil + } + } else { + albumNameForLyrics = (albumName?.isEmpty == false) ? albumName : nil + } + + LRCLIBService.shared.fetchLyrics( + trackName: self.nowPlaying.songName ?? "", + artistName: self.nowPlaying.artistName ?? "", + albumName: albumNameForLyrics, + duration: self.nowPlaying.duration + ) { [weak self] result in + DispatchQueue.main.async { + self?.isLoadingLyrics = false + + switch result { + case .success(let response): + if let syncedLyrics = response.syncedLyrics, !syncedLyrics.isEmpty { + self?.lyrics = LRCParser.parse(syncedLyrics) + } else if let plainLyrics = response.plainLyrics, !plainLyrics.isEmpty { + self?.lyrics = [LyricsLine(timestamp: 1, text: plainLyrics)] + } else { + self?.lyricsError = "No lyrics available" + } + + case .failure: + self?.lyricsError = "Failed to load lyrics" + } + } + } + } + + func updateCurrentLyricsLine(currentTime: TimeInterval) { + guard !lyrics.isEmpty else { return } + + let lookahead: TimeInterval = 0.5 + let adjustedTime = currentTime + lookahead + + var newIndex = -1 + + for (index, line) in lyrics.enumerated() { + if adjustedTime >= line.timestamp { + newIndex = index + } else { + break + } + } + + if newIndex != currentLyricsLineIndex { + currentLyricsLineIndex = newIndex + } + } + + func toggleLyricsMode() { + if isLiveRadio { + return + } + + withAnimation(.spring(duration: 0.3)) { + isLyricsMode.toggle() + } + } + + private static func normalizedRadioURL(from streamUrl: String) -> URL? { + let trimmedUrl = streamUrl.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedUrl.isEmpty else { return nil } + + if let url = URL(string: trimmedUrl), url.scheme != nil { + return url + } + + if let encoded = trimmedUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed), + let url = URL(string: encoded), + url.scheme != nil + { + return url + } + + let withScheme = "https://\(trimmedUrl)" + + if let url = URL(string: withScheme), url.host != nil { + return url + } + + if let encoded = withScheme.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed), + let url = URL(string: encoded), + url.host != nil + { + return url + } + + return nil + } + deinit { if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) diff --git a/flo/Radios/RadiosView.swift b/flo/Radios/RadiosView.swift new file mode 100644 index 0000000..c72789c --- /dev/null +++ b/flo/Radios/RadiosView.swift @@ -0,0 +1,104 @@ +// +// SongsView.swift +// flo +// +// + +import NukeUI +import SwiftUI + +struct RadiosView: View { + @EnvironmentObject private var playerViewModel: PlayerViewModel + + @StateObject var viewModel = RadiosViewModel() + @State private var searchRadio = "" + + var filteredRadios: [Radio] { + if searchRadio.isEmpty { + return viewModel.radios + } else { + return viewModel.radios.filter { radio in + radio.name.localizedCaseInsensitiveContains(searchRadio) + } + } + } + + var body: some View { + ScrollView { + if filteredRadios.isEmpty { + emptyStateView + } else { + LazyVStack { + ForEach(filteredRadios, id: \.id) { radio in + Group { + HStack { + Color("PlayerColor").frame(width: 40, height: 40) + .cornerRadius(5) + .overlay { + Image(systemName: "dot.radiowaves.up.forward") + .resizable() + .scaledToFit() + .foregroundStyle(.white) + .padding(8) + } + + VStack(alignment: .leading) { + Text(radio.name) + .customFont(.headline) + .multilineTextAlignment(.leading) + .lineLimit(2) + .padding(.bottom, 3) + } + .padding(.horizontal, 10) + + Spacer() + } + .padding(.horizontal) + .background(Color(UIColor.systemBackground)) + + Divider() + } + .onTapGesture { + playerViewModel.playRadioItem(radio: radio) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 10) + } + } + .navigationTitle("Radios") + .navigationBarTitleDisplayMode(.large) + .searchable( + text: $searchRadio, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search" + ) + .onAppear { + viewModel.fetchAllRadios() + } + } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Image(systemName: "dot.radiowaves.up.forward") + .font(.system(size: 36, weight: .semibold)) + .foregroundStyle(Color.gray.opacity(0.7)) + + Text(searchRadio.isEmpty ? "No radios available" : "No radios match your search") + .customFont(.headline) + .multilineTextAlignment(.center) + + Text( + searchRadio.isEmpty + ? "Add radios in your Navidrome server." + : "Try a different keyword." + ) + .customFont(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, minHeight: 300) + .padding(.horizontal, 24) + } +} diff --git a/flo/Radios/RadiosViewModel.swift b/flo/Radios/RadiosViewModel.swift new file mode 100644 index 0000000..451da23 --- /dev/null +++ b/flo/Radios/RadiosViewModel.swift @@ -0,0 +1,29 @@ +// flo + +import Foundation +import Combine + +class RadiosViewModel: ObservableObject { + @Published var radios: [Radio] = [] + + @Published var isLoading = false + @Published var error: Error? + + func fetchAllRadios() { + RadioService.shared.getAllRadios { result in + self.isLoading = true + + DispatchQueue.main.async { + self.isLoading = false + + switch result { + case .success(let radios): + self.radios = radios + + case .failure(let error): + self.error = error + } + } + } + } +} diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 46e5278..2da47ed 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -191,6 +191,36 @@ } } }, + "Add radios in your Navidrome server." : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tambahkan Radio melalui server Navidrome" + } + } + } + }, + "Add/Change Custom" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tambah/Ubah Custom" + } + } + } + }, + "Album Artist Only" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hanya Album Artist" + } + } + } + }, "Album Info" : { "localizations" : { "en" : { @@ -393,6 +423,16 @@ } } }, + "Disabled" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disabled" + } + } + } + }, "Download" : { "localizations" : { "en" : { @@ -603,6 +643,16 @@ } } }, + "https://lrclib.your-server.net" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://lrclib.your-server.net" + } + } + } + }, "Keychain.%@" : { "localizations" : { "id" : { @@ -613,6 +663,16 @@ } } }, + "Learn more at https://dub.sh/flo-lrclib" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selengkapnya di https://dub.sh/flo-lrclib" + } + } + } + }, "Library" : { "localizations" : { "en" : { @@ -661,6 +721,16 @@ } } }, + "LIVE" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + } + } + }, "Local Storage" : { "localizations" : { "en" : { @@ -725,50 +795,64 @@ } } }, - "Login to start streaming your music by tapping the icon above" : { + "Login to your Navidrome server to continue" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Login to start streaming your music by tapping the icon above" + "value" : "Login to your Navidrome server to continue" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Login untuk mulai streaming musik kamu dengan mengetuk ikon di atas" + "value" : "Login ke server Navidrome kamu untuk melanjutkan" } } } }, - "Login to your Navidrome server to continue" : { + "Logout" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Login to your Navidrome server to continue" + "value" : "Logout" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Login ke server Navidrome kamu untuk melanjutkan" + "value" : "Logout" } } } }, - "Logout" : { + "LRCLIB" : { "localizations" : { - "en" : { + "id" : { "stringUnit" : { "state" : "translated", - "value" : "Logout" + "value" : "LRCLIB" } - }, + } + } + }, + "LRCLIB server is required. Learn more at dub.sh/flo-lrclib" : { + "localizations" : { "id" : { "stringUnit" : { "state" : "translated", - "value" : "Logout" + "value" : "Server LRCLIB dibutuhkan. Selengkapnya di dub.sh/flo-lrclib" + } + } + } + }, + "LRCLIB Server URL" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB Server URL" } } } @@ -805,6 +889,36 @@ } } }, + "No lyrics available" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak ada lirik ditemukan" + } + } + } + }, + "No radios available" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak ada radio ditemukan" + } + } + } + }, + "No radios match your search" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak ada radio ditemukan" + } + } + } + }, "OK" : { "localizations" : { "en" : { @@ -933,6 +1047,16 @@ } } }, + "Radios" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radios" + } + } + } + }, "Redownload Album" : { "localizations" : { "id" : { @@ -1365,18 +1489,12 @@ } } }, - "Use translucent backgrounds" : { + "Try a different keyword." : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Use translucent backgrounds" - } - }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Gunakan translucent background" + "value" : "Coba kata kunci yang lain." } } } diff --git a/flo/Shared/Models/Artist.swift b/flo/Shared/Models/Artist.swift index ffc8b2e..a5a5dab 100644 --- a/flo/Shared/Models/Artist.swift +++ b/flo/Shared/Models/Artist.swift @@ -7,18 +7,48 @@ import Foundation -struct Artist: Codable, Identifiable, Hashable { - let id: String - let name: String - let fullText: String - let biography: String +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 + let missing: Bool + let createdAt, updatedAt: String + let sortArtistName: String? + let playCount: Int? + let playDate, mbzArtistID, biography: String? + let smallImageURL, mediumImageURL, largeImageURL: String? + 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 mbzArtistID = "mbzArtistId" + case biography + case smallImageURL = "smallImageUrl" + case mediumImageURL = "mediumImageUrl" + case largeImageURL = "largeImageUrl" + case externalURL = "externalUrl" + case externalInfoUpdatedAt + } +} - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) +// MARK: - Stats +struct ArtistStats: Codable { + let producer, composer, artist, maincredit: Albumartist? + let albumartist, arranger, engineer, performer: Albumartist? + let mixer, lyricist, conductor: Albumartist? +} - self.id = try container.decode(String.self, forKey: .id) - self.name = try container.decode(String.self, forKey: .name) - self.fullText = try container.decodeIfPresent(String.self, forKey: .fullText) ?? "" - self.biography = try container.decodeIfPresent(String.self, forKey: .biography) ?? "" - } +// MARK: - Albumartist +struct Albumartist: Codable { + let songCount, albumCount, size: Int } diff --git a/flo/Shared/Models/LRCLIB.swift b/flo/Shared/Models/LRCLIB.swift new file mode 100644 index 0000000..349f178 --- /dev/null +++ b/flo/Shared/Models/LRCLIB.swift @@ -0,0 +1,39 @@ +// +// LRCLIB.swift +// flo +// +// Created by rizaldy on 01/02/26. +// + +import Foundation + +struct LRCLIBLyrics: Codable { + let id: Int? + let name: String? + let trackName: String? + let artistName: String? + let albumName: String? + let instrumental: Bool? + let plainLyrics: String? + let syncedLyrics: String? + + private let _duration: Double? + + var duration: Int? { + guard let dur = _duration else { return nil } + + return Int(dur.rounded()) + } + + enum CodingKeys: String, CodingKey { + case id + case name + case trackName + case artistName + case albumName + case _duration = "duration" + case instrumental + case plainLyrics + case syncedLyrics + } +} diff --git a/flo/Shared/Models/LyricsLine.swift b/flo/Shared/Models/LyricsLine.swift new file mode 100644 index 0000000..bb8b483 --- /dev/null +++ b/flo/Shared/Models/LyricsLine.swift @@ -0,0 +1,18 @@ +// +// LyricsLine.swift +// flo +// +// Created by rizaldy on 02/02/26. +// + +import Foundation + +struct LyricsLine: Identifiable { + let id = UUID() + let timestamp: TimeInterval + let text: String + + func isCurrentLine(currentTime: TimeInterval, threshold: TimeInterval = 0.5) -> Bool { + return abs(currentTime - timestamp) < threshold + } +} diff --git a/flo/Shared/Models/Radio.swift b/flo/Shared/Models/Radio.swift new file mode 100644 index 0000000..7949727 --- /dev/null +++ b/flo/Shared/Models/Radio.swift @@ -0,0 +1,101 @@ +// +// Radio.swift +// flo +// +// Created by Francesco (f-longobardi) +// + +import Foundation + +struct RadioList: SubsonicResponseData { + static var key: String { + return "internetRadioStations" + } + let internetRadioStation: [Radio] +} + +struct RadioListResponse: Codable { + let subsonicResponse: SubsonicResponse + + private enum CodingKeys: String, CodingKey { + case subsonicResponse = "subsonic-response" + } + var radioStations: [Radio] { + return subsonicResponse.data?.internetRadioStation ?? [] + } +} + +struct Radio: Codable, Identifiable, Hashable { + let id: String + let name: String + let streamUrl: String + + enum CodingKeys: CodingKey { + case id + case name + case streamUrl + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(String.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + self.streamUrl = try container.decode(String.self, forKey: .streamUrl) + } + + init( + id: String, + name: String, + streamUrl: String + ) { + self.id = id + self.name = name + self.streamUrl = streamUrl + } + + // This function will create a mock 'Playable' entity for the radio station + func toPlayable() -> RadioEntity { + let displayHost = Radio.displayHost(from: streamUrl) + return RadioEntity( + id: id, + name: name, + songs: [ + Song( + id: id, + title: name, + albumId: "", + albumName: "", + artist: displayHost, + trackNumber: 1, + discNumber: 1, + bitRate: .zero, + sampleRate: 1, + suffix: "", + duration: .infinity, + mediaFileId: id + ) + ], + artist: displayHost + ) + } + + private static func displayHost(from urlString: String) -> String { + guard let url = URL(string: urlString), + let host = url.host, + !host.isEmpty + else { + return urlString + } + + return host + } + +} + +struct RadioEntity: Playable { + var id: String + var name: String + var songs: [Song] + var artist: String +} diff --git a/flo/Shared/Models/Song.swift b/flo/Shared/Models/Song.swift index ea5f5b8..ee9fa27 100644 --- a/flo/Shared/Models/Song.swift +++ b/flo/Shared/Models/Song.swift @@ -12,6 +12,7 @@ struct Song: Codable, Identifiable, Hashable { let title: String let artist: String let albumId: String + let albumName: String let trackNumber: Int let discNumber: Int let bitRate: Int @@ -22,11 +23,28 @@ struct Song: Codable, Identifiable, Hashable { var mediaFileId: String = "" var fileUrl: String = "" - enum CodingKeys: CodingKey { + enum DecodeKeys: String, CodingKey { case id case title case artist case albumId + case album + case albumName + case trackNumber + case discNumber + case bitRate + case sampleRate + case suffix + case duration + case mediaFileId + } + + enum EncodeKeys: String, CodingKey { + case id + case title + case artist + case albumId + case albumName = "album" case trackNumber case discNumber case bitRate @@ -37,12 +55,16 @@ struct Song: Codable, Identifiable, Hashable { } init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let container = try decoder.container(keyedBy: DecodeKeys.self) self.id = try container.decode(String.self, forKey: .id) self.title = try container.decode(String.self, forKey: .title) self.artist = try container.decode(String.self, forKey: .artist) self.albumId = try container.decode(String.self, forKey: .albumId) + self.albumName = try container.decodeIfPresent(String.self, forKey: .album) + ?? container.decodeIfPresent(String.self, forKey: .albumName) + ?? "" + self.trackNumber = try container.decode(Int.self, forKey: .trackNumber) self.discNumber = try container.decode(Int.self, forKey: .discNumber) self.bitRate = try container.decode(Int.self, forKey: .bitRate) @@ -52,8 +74,26 @@ struct Song: Codable, Identifiable, Hashable { self.mediaFileId = try container.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "" } + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: EncodeKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(title, forKey: .title) + try container.encode(artist, forKey: .artist) + try container.encode(albumId, forKey: .albumId) + try container.encode(albumName, forKey: .albumName) + try container.encode(trackNumber, forKey: .trackNumber) + try container.encode(discNumber, forKey: .discNumber) + try container.encode(bitRate, forKey: .bitRate) + try container.encode(sampleRate, forKey: .sampleRate) + try container.encode(suffix, forKey: .suffix) + try container.encode(duration, forKey: .duration) + try container.encode(mediaFileId, forKey: .mediaFileId) + } + init( - id: String, title: String, albumId: String, artist: String, trackNumber: Int, discNumber: Int, + id: String, title: String, albumId: String, albumName: String, artist: String, + trackNumber: Int, discNumber: Int, bitRate: Int, sampleRate: Int, suffix: String, duration: Double, mediaFileId: String @@ -62,6 +102,7 @@ struct Song: Codable, Identifiable, Hashable { self.title = title self.artist = artist self.albumId = albumId + self.albumName = albumName self.trackNumber = Int(trackNumber) self.discNumber = Int(discNumber) self.bitRate = Int(bitRate) @@ -76,6 +117,21 @@ struct Song: Codable, Identifiable, Hashable { self.title = song.title ?? "N/A" self.artist = song.artistName ?? "N/A" self.albumId = song.albumId ?? "" + + if let storedAlbumName = song.albumName, !storedAlbumName.isEmpty { + self.albumName = storedAlbumName + } else if let fileURL = song.fileURL { + let parts = fileURL.split(separator: "/") + + if parts.count >= 3 { + self.albumName = String(parts[2]) + } else { + self.albumName = "" + } + } else { + self.albumName = "" + } + self.trackNumber = Int(song.trackNumber) self.discNumber = Int(song.discNumber) self.bitRate = Int(song.bitRate) diff --git a/flo/Shared/Services/APIManager.swift b/flo/Shared/Services/APIManager.swift index 0511d0f..5ec2f4a 100644 --- a/flo/Shared/Services/APIManager.swift +++ b/flo/Shared/Services/APIManager.swift @@ -172,4 +172,21 @@ extension APIManager { completion(response) } } + + func externalRequest( + url: String, + method: HTTPMethod = .get, + parameters: Parameters? = nil, + encoding: ParameterEncoding = URLEncoding.queryString, + headers: HTTPHeaders? = nil, + completion: @escaping (DataResponse) -> Void + ) { + session.request( + url, method: method, parameters: parameters, encoding: encoding, headers: headers + ) + .validate(statusCode: 200..<300) + .responseDecodable(of: T.self) { response in + completion(response) + } + } } diff --git a/flo/Shared/Services/AlbumService.swift b/flo/Shared/Services/AlbumService.swift index 8407114..b1a71a5 100644 --- a/flo/Shared/Services/AlbumService.swift +++ b/flo/Shared/Services/AlbumService.swift @@ -203,13 +203,21 @@ class AlbumService { } func getAlbumCover( - artistName: String, albumName: String, albumId: String = "", trackId: String = "" + artistName: String, + albumName: String, + albumId: String = "", + trackId: String = "", + contextName: String? = nil ) -> String { let target = "Media/\(artistName)/\(albumName)/cover.png" let anotherTarget = "Media/Various Artists/\(albumName)/cover/\(trackId).png" + let contextTarget = + contextName.map { "Media/Various Artists/\($0)/cover/\(trackId).png" } if LocalFileManager.shared.fileExists(fileName: target) { return LocalFileManager.shared.fileURL(for: target)?.path ?? "" + } else if let contextTarget, LocalFileManager.shared.fileExists(fileName: contextTarget) { + return LocalFileManager.shared.fileURL(for: contextTarget)?.path ?? "" } else if LocalFileManager.shared.fileExists(fileName: anotherTarget) { return LocalFileManager.shared.fileURL(for: anotherTarget)?.path ?? "" } else { @@ -284,14 +292,18 @@ class AlbumService { let fileURL = "Media/\(isFromPlaylist ? "Various Artists" : song.artist)/\(albumName ?? "Unknown Albums")/\(Int16(song.trackNumber)) \(song.title).\(song.suffix)" + let resolvedAlbumName = !song.albumName.isEmpty ? song.albumName : (albumName ?? "") + if let existingSong = checkExistingSong.first { existingSong.fileURL = "Media/\(isFromPlaylist ? "Various Artists" : song.artist)/\(albumName ?? "Unknown Albums")/\(Int16(song.trackNumber)) \(song.title).\(song.suffix)" + existingSong.albumName = resolvedAlbumName existingSong.status = status } else { let downloadedSong = SongEntity(context: CoreDataManager.shared.viewContext) downloadedSong.albumId = albumId + downloadedSong.albumName = resolvedAlbumName downloadedSong.id = songId downloadedSong.title = song.title downloadedSong.artistName = song.artist diff --git a/flo/Shared/Services/CoreDataManager.swift b/flo/Shared/Services/CoreDataManager.swift index b06cd0d..8e89551 100644 --- a/flo/Shared/Services/CoreDataManager.swift +++ b/flo/Shared/Services/CoreDataManager.swift @@ -5,7 +5,7 @@ // Created by rizaldy on 29/06/24. // -import CoreData +@preconcurrency import CoreData import Foundation class CoreDataManager: ObservableObject { @@ -52,10 +52,12 @@ class CoreDataManager: ObservableObject { request.sortDescriptors = sortDescriptors request.fetchBatchSize = batchSize + let context = self.viewContext + return await withCheckedContinuation { continuation in - viewContext.perform { + context.perform { do { - let results = try self.viewContext.fetch(request) + let results = try context.fetch(request) continuation.resume(returning: results) } catch { diff --git a/flo/Shared/Services/LRCLIBService.swift b/flo/Shared/Services/LRCLIBService.swift new file mode 100644 index 0000000..26e63ff --- /dev/null +++ b/flo/Shared/Services/LRCLIBService.swift @@ -0,0 +1,48 @@ +// +// LRCLIBService.swift +// flo +// +// Created by rizaldy on 01/02/26. +// + +import Alamofire +import Foundation + +class LRCLIBService { + static let shared = LRCLIBService() + + func fetchLyrics( + trackName: String, + artistName: String, + albumName: String? = nil, + duration: Double? = nil, + completion: @escaping (Result) -> Void + ) { + var parameters: [String: String] = [ + "track_name": trackName, + "artist_name": artistName, + ] + + if let albumName = albumName { + parameters["album_name"] = albumName + } + + if let duration = duration, + duration.isFinite, + duration > 0, + duration < Double(Int.max) + { + parameters["duration"] = String(Int(duration.rounded())) + } + + let request: (DataResponse) -> Void = { response in + completion(response.result.mapError { $0 as Error }) + } + + APIManager.shared.externalRequest( + url: "\(UserDefaultsManager.LRCLIBServerURL)/api/get", + parameters: parameters, + completion: request + ) + } +} diff --git a/flo/Shared/Services/PlaybackService.swift b/flo/Shared/Services/PlaybackService.swift index 2d5ca52..2b4edd9 100644 --- a/flo/Shared/Services/PlaybackService.swift +++ b/flo/Shared/Services/PlaybackService.swift @@ -31,23 +31,35 @@ class PlaybackService { func addToQueue(item: T, isFromLocal: Bool = false) -> [QueueEntity] { self.clearQueue() - for song in item.songs { - let queue = QueueEntity(context: CoreDataManager.shared.viewContext) - - queue.id = song.mediaFileId == "" ? song.id : song.mediaFileId - queue.albumId = song.albumId - queue.albumName = item.name - queue.artistName = song.artist - queue.bitRate = Int16(song.bitRate) - queue.sampleRate = Int32(song.sampleRate) - queue.songName = song.title - queue.suffix = song.suffix - queue.isFromLocal = isFromLocal - queue.duration = song.duration - - CoreDataManager.shared.saveRecord() + let isPlaylist = item is Playlist + let isPlaylistAlbum = + (item as? Album).map { album in + album.artist == "Various Artists" && album.albumArtist == "Various Artists" + && album.genre.contains(" by ") + } ?? false + + let isFromPlaylist = isPlaylist || isPlaylistAlbum + + let objects = item.songs.map { song in + return [ + "id": song.mediaFileId == "" ? song.id : song.mediaFileId, + "albumId": song.albumId, + "albumName": song.albumName.isEmpty ? item.name : song.albumName, + "contextName": item.name, + "artistName": song.artist, + "bitRate": song.bitRate, + "sampleRate": song.sampleRate, + "songName": song.title, + "suffix": song.suffix, + "isFromPlaylist": isFromPlaylist, + "isFromLocal": isFromLocal, + "duration": song.duration, + ] as [String: Any] } + let request = NSBatchInsertRequest(entity: QueueEntity.entity(), objects: objects) + _ = try? CoreDataManager.shared.viewContext.execute(request) + return self.getQueue() } } diff --git a/flo/Shared/Services/RadioService.swift b/flo/Shared/Services/RadioService.swift new file mode 100644 index 0000000..2f4dbd6 --- /dev/null +++ b/flo/Shared/Services/RadioService.swift @@ -0,0 +1,25 @@ +// flo + +import Foundation +import Alamofire + +class RadioService { + static let shared = RadioService() + + func getStreamUrl(radio: Radio) -> String { + radio.streamUrl + } + + func getAllRadios(completion: @escaping (Result<[Radio], Error>) -> Void) { + + APIManager.shared.SubsonicEndpointRequest(endpoint: API.SubsonicEndpoint.radios, parameters: nil) { + (response: DataResponse) in + switch response.result { + case .success(let radios): + completion(.success(radios.radioStations)) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/flo/Shared/Services/UserDefaultsManager.swift b/flo/Shared/Services/UserDefaultsManager.swift index 6de4a5b..507412a 100644 --- a/flo/Shared/Services/UserDefaultsManager.swift +++ b/flo/Shared/Services/UserDefaultsManager.swift @@ -90,11 +90,11 @@ class UserDefaultsManager { static var playerBackground: String { get { - return UserDefaults.standard.string(forKey: UserDefaultsKeys.playerBackground) - ?? PlayerBackground.translucent + return PlayerBackground.translucent } set { - UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.playerBackground) + UserDefaults.standard.set( + PlayerBackground.translucent, forKey: UserDefaultsKeys.playerBackground) } } @@ -107,4 +107,14 @@ class UserDefaultsManager { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.saveLoginInfo) } } + + static var LRCLIBServerURL: String { + get { + return UserDefaults.standard.string(forKey: UserDefaultsKeys.LRCLIBServerURL) ?? "" + } + + set { + UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.LRCLIBServerURL) + } + } } diff --git a/flo/Shared/Utils/AirPlayRoutePicker.swift b/flo/Shared/Utils/AirPlayRoutePicker.swift new file mode 100644 index 0000000..078dfbe --- /dev/null +++ b/flo/Shared/Utils/AirPlayRoutePicker.swift @@ -0,0 +1,30 @@ +// +// AirPlayRoutePicker.swift +// flo +// +// Created by rizaldy on 03/02/26. +// + +import AVKit +import SwiftUI + +struct AirPlayRoutePicker: UIViewRepresentable { + var tintColor: UIColor = .white + var activeTintColor: UIColor = .white + + func makeUIView(context: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + + view.backgroundColor = .clear + view.prioritizesVideoDevices = false + view.tintColor = tintColor + view.activeTintColor = activeTintColor + + return view + } + + func updateUIView(_ uiView: AVRoutePickerView, context: Context) { + uiView.tintColor = tintColor + uiView.activeTintColor = activeTintColor + } +} diff --git a/flo/Shared/Utils/Constants.swift b/flo/Shared/Utils/Constants.swift index e0feeef..e004b9f 100644 --- a/flo/Shared/Utils/Constants.swift +++ b/flo/Shared/Utils/Constants.swift @@ -28,6 +28,7 @@ struct API { static let scanStatus = "/rest/getScanStatus" static let download = "/rest/download" static let scrobble = "/rest/scrobble" + static let radios = "/rest/getInternetRadioStations" } } @@ -52,6 +53,7 @@ enum UserDefaultsKeys { static let enableMaxBitRate = "enableMaxBitRate" static let playerBackground = "playerBackground" static let saveLoginInfo = "saveLoginInfo" + static let LRCLIBServerURL = "LRCLIBServerURL" } enum KeychainKeys { diff --git a/flo/Shared/Utils/LRCParser.swift b/flo/Shared/Utils/LRCParser.swift new file mode 100644 index 0000000..e50ca46 --- /dev/null +++ b/flo/Shared/Utils/LRCParser.swift @@ -0,0 +1,49 @@ +// +// LRCParser.swift +// flo +// +// Created by rizaldy on 02/02/26. +// + +import Foundation + +class LRCParser { + static func parse(_ lrcContent: String) -> [LyricsLine] { + let pattern = #"\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)"# + + var lines: [LyricsLine] = [] + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return [] + } + + let nsRange = NSRange(lrcContent.startIndex..., in: lrcContent) + let matches = regex.matches(in: lrcContent, options: [], range: nsRange) + + for match in matches { + guard let minutesRange = Range(match.range(at: 1), in: lrcContent), + let secondsRange = Range(match.range(at: 2), in: lrcContent), + let millisecondsRange = Range(match.range(at: 3), in: lrcContent), + let textRange = Range(match.range(at: 4), in: lrcContent) + else { + continue + } + + let minutes = Double(lrcContent[minutesRange]) ?? 0 + let seconds = Double(lrcContent[secondsRange]) ?? 0 + let millisString = String(lrcContent[millisecondsRange]) + + let millisValue = Double(millisString) ?? 0.0 + let milliseconds = millisValue / (millisString.count == 2 ? 100.0 : 1000.0) + + let timestamp = minutes * 60 + seconds + milliseconds + let text = String(lrcContent[textRange]).trimmingCharacters(in: .whitespacesAndNewlines) + + if !text.isEmpty { + lines.append(LyricsLine(timestamp: timestamp, text: text)) + } + } + + return lines.sorted { $0.timestamp < $1.timestamp } + } +} diff --git a/flo/Shared/Utils/UIScreen+.swift b/flo/Shared/Utils/UIScreen+.swift new file mode 100644 index 0000000..dfb0949 --- /dev/null +++ b/flo/Shared/Utils/UIScreen+.swift @@ -0,0 +1,14 @@ +// +// UIScreen+.swift +// flo +// +// Created by Francesco on 06/02/26. +// + +import SwiftUI + +extension UIScreen { + static let screenWidth = UIScreen.main.bounds.size.width + static let screenHeight = UIScreen.main.bounds.size.height + static let screenSize = UIScreen.main.bounds.size +} diff --git a/flo/SongView.swift b/flo/SongView.swift index 82a93a7..a4022be 100644 --- a/flo/SongView.swift +++ b/flo/SongView.swift @@ -97,43 +97,43 @@ struct SongView: View { struct SongView_Previews: PreviewProvider { static let songs: [Song] = [ Song( - id: "0", title: "Song 1", albumId: "", artist: "Artist Name", trackNumber: 1, discNumber: 0, - bitRate: 0, + id: "0", title: "Song 1", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 1, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "0"), Song( - id: "1", title: "Song 2", albumId: "", artist: "Artist Name", trackNumber: 2, discNumber: 0, - bitRate: 0, + id: "1", title: "Song 2", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 2, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "1"), Song( - id: "2", title: "Song 3", albumId: "", artist: "Artist Name", trackNumber: 3, discNumber: 0, - bitRate: 0, + id: "2", title: "Song 3", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 3, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "2"), Song( - id: "3", title: "Song 4", albumId: "", artist: "Artist Name", trackNumber: 4, discNumber: 0, - bitRate: 0, + id: "3", title: "Song 4", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 4, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "3"), Song( - id: "4", title: "Song 5", albumId: "", artist: "Artist Name", trackNumber: 5, discNumber: 0, - bitRate: 0, + id: "4", title: "Song 5", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 5, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "4"), Song( - id: "5", title: "Song 6", albumId: "", artist: "Artist Name", trackNumber: 6, discNumber: 0, - bitRate: 0, + id: "5", title: "Song 6", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 6, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "5"), Song( - id: "6", title: "Song 7", albumId: "", artist: "Artist Name", trackNumber: 7, discNumber: 0, - bitRate: 0, + id: "6", title: "Song 7", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 7, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "6"), Song( - id: "7", title: "Song 8", albumId: "", artist: "Artist Name", trackNumber: 8, discNumber: 0, - bitRate: 0, + id: "7", title: "Song 8", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 8, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "7"), ] diff --git a/flo/StatCardView.swift b/flo/StatCardView.swift index 82b395c..18be8c4 100644 --- a/flo/StatCardView.swift +++ b/flo/StatCardView.swift @@ -35,46 +35,169 @@ struct StatCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: icon) - .foregroundColor(color) - Text(title) - .foregroundColor(.secondary) - .customFont(.body) - - Spacer() - - if showArrow { - Image(systemName: "chevron.right") + if #available(iOS 26.0, *) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Text(title) .foregroundColor(.secondary) - .font(.system(size: 14)) + .customFont(.body) + .minimumScaleFactor(0.7) + + Spacer() + + if showArrow { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.system(size: 14)) + } } - } - .customFont(.subheadline) + .customFont(.subheadline) - VStack(alignment: .leading, spacing: 4) { - Text(value) - .customFont(.title2) - .lineSpacing(2) - .fontWeight(.bold) - .lineLimit(2) + VStack(alignment: .leading, spacing: 4) { + Text(value) + .customFont(.title2) + .lineSpacing(2) + .fontWeight(.bold) + .lineLimit(2) + .minimumScaleFactor(0.7) - if let subtitle = subtitle { - Text(subtitle) + if let subtitle = subtitle { + Text(subtitle) + .foregroundColor(.secondary) + .customFont(.subheadline) + .lineSpacing(2) + .lineLimit(2) + .minimumScaleFactor(0.7) + } + } + } + .padding() + .glassEffect(in: .rect(cornerRadius: 16)) + .frame(maxWidth: isWide ? .infinity : nil) + } else { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Text(title) .foregroundColor(.secondary) - .customFont(.subheadline) + .customFont(.body) + .minimumScaleFactor(0.7) + + Spacer() + + if showArrow { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.system(size: 14)) + } + } + .customFont(.subheadline) + + VStack(alignment: .leading, spacing: 4) { + Text(value) + .customFont(.title2) .lineSpacing(2) + .fontWeight(.bold) .lineLimit(2) + .minimumScaleFactor(0.7) + + if let subtitle = subtitle { + Text(subtitle) + .foregroundColor(.secondary) + .customFont(.subheadline) + .lineSpacing(2) + .lineLimit(2) + .minimumScaleFactor(0.7) + } + } + } + .padding() + .frame(maxWidth: isWide ? .infinity : nil) + .background(Color(UIColor.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(UIColor.separator), lineWidth: 0.8) + ) + } + } +} + +private struct MaxHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct EqualHeightValueKey: EnvironmentKey { + static let defaultValue: CGFloat = 0 +} + +extension EnvironmentValues { + fileprivate var equalHeightValue: CGFloat { + get { self[EqualHeightValueKey.self] } + set { self[EqualHeightValueKey.self] = newValue } + } +} + +struct EqualHeightItem: View { + @State private var ownHeight: CGFloat = 0 + @Environment(\.equalHeightValue) private var equalHeightValue + @ViewBuilder let content: () -> Content + + var body: some View { + content() + .frame(minHeight: shouldExpand ? equalHeightValue : nil, alignment: .top) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: MaxHeightPreferenceKey.self, value: proxy.size.height) + .onAppear { + ownHeight = proxy.size.height + } + .onChange(of: proxy.size.height) { newValue in + ownHeight = newValue + } } + ) + } + + private var shouldExpand: Bool { + equalHeightValue > 0 && ownHeight > 0 && ownHeight < equalHeightValue + } +} + +struct EqualHeightHStack: View { + let alignment: VerticalAlignment + let spacing: CGFloat? + + @ViewBuilder let content: () -> Content + @State private var maxHeight: CGFloat = 0 + + init( + alignment: VerticalAlignment = .center, + spacing: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.alignment = alignment + self.spacing = spacing + self.content = content + } + + var body: some View { + HStack(alignment: alignment, spacing: spacing) { + content() + } + .frame(height: maxHeight == 0 ? nil : maxHeight, alignment: alignment == .top ? .top : .center) + .environment(\.equalHeightValue, maxHeight) + .onPreferenceChange(MaxHeightPreferenceKey.self) { newValue in + if maxHeight != newValue { + maxHeight = newValue } } - .padding() - .frame(maxWidth: isWide ? .infinity : nil) - .background(Color(UIColor.systemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(Color(UIColor.separator), lineWidth: 0.8) - ) } } diff --git a/flo/flo.xcdatamodeld/flo.xcdatamodel/contents b/flo/flo.xcdatamodeld/flo.xcdatamodel/contents index 2e4e5cc..40af39c 100644 --- a/flo/flo.xcdatamodeld/flo.xcdatamodel/contents +++ b/flo/flo.xcdatamodeld/flo.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -28,15 +28,18 @@ + + +