diff --git a/Gemfile b/Gemfile index 7a118b4..1203fda 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,3 @@ source "https://rubygems.org" -gem "fastlane" +gem "fastlane", ">= 2.233.0" diff --git a/Gemfile.lock b/Gemfile.lock index f28c8b4..fbca8f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,45 +1,49 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.1005.0) - aws-sdk-core (3.212.0) + aws-eventstream (1.4.0) + aws-partitions (1.1241.0) + aws-sdk-core (3.246.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.95.0) - aws-sdk-core (~> 3, >= 3.210.0) + logger + aws-sdk-kms (1.123.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.170.1) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.220.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.1) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) - digest-crc (0.6.5) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.10.4) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -51,32 +55,36 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.3.1) - fastlane (2.225.0) + fastimage (2.4.1) + fastlane (2.233.0) CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2.0) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -84,20 +92,24 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-env (>= 1.6.0, <= 2.1.1) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) naturally (~> 2.2) + nkf (~> 0.2.0) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.5) @@ -108,86 +120,90 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) + xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) + google-apis-androidpublisher_v3 (0.99.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.31.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.1) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.62.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.4.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.59.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) jwt (>= 1.4, < 3.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.7) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.8.1) - jwt (2.9.3) + json (2.19.4) + jwt (2.10.2) base64 + logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + multi_json (1.20.1) multipart-post (2.4.1) + mutex_m (0.3.0) nanaimo (0.4.0) - naturally (2.2.1) + naturally (2.3.0) nkf (0.2.0) - optparse (0.6.0) + optparse (0.8.1) os (1.1.4) - plist (3.7.1) - public_suffix (6.0.1) - rake (13.2.1) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) - rexml (3.3.9) - rouge (2.0.7) + retriable (3.4.1) + rexml (3.4.4) + rouge (3.28.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) + rubyzip (2.4.1) security (0.1.5) - signet (0.19.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -206,8 +222,8 @@ GEM colored2 (~> 3.1) nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) - xcpretty (0.3.0) - rouge (~> 2.0.7) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) @@ -216,7 +232,7 @@ PLATFORMS ruby DEPENDENCIES - fastlane + fastlane (>= 2.233.0) BUNDLED WITH 2.5.23 diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 930bac5..99dda2b 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,4 +1,4 @@ -fastlane_version "2.225.0" +fastlane_version "2.233.0" default_platform(:ios) groups = ["Kepelet"] @@ -20,6 +20,7 @@ platform :ios do lane :sync_certs do api_key = lane_context[SharedValues::APP_STORE_CONNECT_API_KEY] + match({readonly: true, type: "appstore", platform: "catalyst", app_identifier: "com.penerbangwalet.flo", api_key: api_key}) match({readonly: true, type: "appstore", api_key: api_key}) end @@ -42,6 +43,7 @@ platform :ios do load_asc_api_key sync_certs fetch_and_increment_build_number + build_app(configuration: configuration) end diff --git a/fastlane/Gymfile b/fastlane/Gymfile index 06be506..62d309d 100644 --- a/fastlane/Gymfile +++ b/fastlane/Gymfile @@ -4,5 +4,4 @@ export_method("app-store") output_directory("./fastlane/builds") -include_bitcode(false) include_symbols(false) diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index c71bbb3..dec34de 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 02E566BC379A4C25B4AB907C /* CachedSongsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8455A32A2A31C3AA7C4DC99 /* CachedSongsView.swift */; }; + 0C1A2B3C4D5E6F7A8B9C0D1E /* LibraryCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2A3B4C5D6E7F8A9B0C1D2E /* LibraryCacheManager.swift */; }; + 0D1A2B3C4D5E6F7A8B9C0D1E /* CoverArtCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D2A3B4C5D6E7F8A9B0C1D2E /* CoverArtCacheManager.swift */; }; + 0E1A2B3C4D5E6F7A8B9C0D1E /* LikedSongsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2A3B4C5D6E7F8A9B0C1D2E /* LikedSongsView.swift */; }; 50C912892F5DD9280087EE61 /* AuthMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C912872F5DD9280087EE61 /* AuthMode.swift */; }; 50C912912F5DD9990087EE61 /* IAPLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C9128A2F5DD9990087EE61 /* IAPLoginView.swift */; }; 50C912A42F648A440087EE61 /* IAPWebAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C912A32F648A440087EE61 /* IAPWebAuthView.swift */; }; @@ -14,7 +18,7 @@ 55CFFAB52342C01E366F87B0 /* CarPlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2645E70081EEE2C0396E04D8 /* CarPlayCoordinator.swift */; }; 587A2E31AB9ADC1C724BE658 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFA9D48444F961601BF12316 /* AppDelegate.swift */; }; 5E64C3D71B02BE8FEDF5FF3C /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B2E217C6248B16912297F /* CarPlaySceneDelegate.swift */; }; - 6BDAFB58A4C77A82BCCFD0F4 /* CarPlay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE85DCBEE7ADB56E8BFD6338 /* CarPlay.framework */; }; + 6BDAFB58A4C77A82BCCFD0F4 /* CarPlay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE85DCBEE7ADB56E8BFD6338 /* CarPlay.framework */; platformFilter = ios; }; 7C09E28C1BFFDB5D5F124EC9 /* CarPlayNowPlayingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EEF17030A154EB98122A089 /* CarPlayNowPlayingManager.swift */; }; B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02A003E2F3666240024E8EC /* UIScreen+.swift */; }; B0AD2E712F4B037400577062 /* ArtistDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E6E2F4B037400577062 /* ArtistDetailView.swift */; }; @@ -49,7 +53,7 @@ C42B25842F44551B00E62008 /* PlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42B25832F44551B00E62008 /* PlaybackCoordinator.swift */; }; C42B25862F44551B00E62008 /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42B25852F44551B00E62008 /* WatchConnectivityManager.swift */; }; C42B25882F4456BF00E62008 /* WatchLibraryResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42B25872F4456BF00E62008 /* WatchLibraryResponder.swift */; }; - C42B25972F4458EF00E62008 /* flo Watch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = C42B258D2F4458ED00E62008 /* flo Watch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + C42B25972F4458EF00E62008 /* flo Watch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = C42B258D2F4458ED00E62008 /* flo Watch Watch App.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C42B259C2F445B7000E62008 /* WatchArtistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42B25792F4453B600E62008 /* WatchArtistsView.swift */; }; C42B259D2F445B7000E62008 /* WatchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42B256D2F44536D00E62008 /* WatchHomeView.swift */; }; C42B259E2F445B7000E62008 /* WatchPlaylistDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42B257F2F4453CE00E62008 /* WatchPlaylistDetailView.swift */; }; @@ -114,6 +118,7 @@ C4A4BF3D2C1455A100363290 /* FloatingPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A4BF3C2C1455A100363290 /* FloatingPlayerView.swift */; }; C4B232EF2F44809F006A5044 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C4E8D95F2B763BAB00C2353E /* Assets.xcassets */; }; C4B7E6A42F3C1001001D9A01 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = C4B7E6A32F3C0FFF001D9A01 /* PrivacyInfo.xcprivacy */; }; + C4CACHE012D7B0000003B9C4F /* StreamCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CACHE002D7B0000003B9C4F /* StreamCacheManager.swift */; }; C4D7F84D2C7F2AE900165EFD /* flo.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C4D7F84B2C7F2AE900165EFD /* flo.xcdatamodeld */; }; C4D7F84F2C7F2C5D00165EFD /* PlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D7F84E2C7F2C5D00165EFD /* PlaybackService.swift */; }; C4DE89182C2FFBC900E078CC /* CoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4DE89172C2FFBC900E078CC /* CoreDataManager.swift */; }; @@ -158,6 +163,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0C2A3B4C5D6E7F8A9B0C1D2E /* LibraryCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryCacheManager.swift; sourceTree = ""; }; + 0D2A3B4C5D6E7F8A9B0C1D2E /* CoverArtCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverArtCacheManager.swift; sourceTree = ""; }; + 0E2A3B4C5D6E7F8A9B0C1D2E /* LikedSongsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikedSongsView.swift; sourceTree = ""; }; 0EEF17030A154EB98122A089 /* CarPlayNowPlayingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarPlayNowPlayingManager.swift; sourceTree = ""; }; 2645E70081EEE2C0396E04D8 /* CarPlayCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarPlayCoordinator.swift; sourceTree = ""; }; 50C912872F5DD9280087EE61 /* AuthMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthMode.swift; sourceTree = ""; }; @@ -243,6 +251,7 @@ C4A4BF382C14445000363290 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; C4A4BF3C2C1455A100363290 /* FloatingPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPlayerView.swift; sourceTree = ""; }; C4B7E6A32F3C0FFF001D9A01 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + C4CACHE002D7B0000003B9C4F /* StreamCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCacheManager.swift; sourceTree = ""; }; C4D7F84C2C7F2AE900165EFD /* flo.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = flo.xcdatamodel; sourceTree = ""; }; C4D7F84E2C7F2C5D00165EFD /* PlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackService.swift; sourceTree = ""; }; C4DE89172C2FFBC900E078CC /* CoreDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManager.swift; sourceTree = ""; }; @@ -262,6 +271,7 @@ C4FE524C2C14E71B0053763A /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; CE85DCBEE7ADB56E8BFD6338 /* CarPlay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = CarPlay.framework; sourceTree = SDKROOT; }; CFA9D48444F961601BF12316 /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + E8455A32A2A31C3AA7C4DC99 /* CachedSongsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedSongsView.swift; sourceTree = ""; }; F6CC2B5AA866663E09C3E38A /* SceneDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -424,6 +434,9 @@ children = ( C4F870CD2CEFCC5B00312F8A /* FloooService.swift */, C4875DFF2C149D9000D9BAEB /* AlbumService.swift */, + C4CACHE002D7B0000003B9C4F /* StreamCacheManager.swift */, + 0C2A3B4C5D6E7F8A9B0C1D2E /* LibraryCacheManager.swift */, + 0D2A3B4C5D6E7F8A9B0C1D2E /* CoverArtCacheManager.swift */, C4875E012C149DDD00D9BAEB /* AuthService.swift */, C4875E032C149F9A00D9BAEB /* APIManager.swift */, C4FE524A2C14E1F70053763A /* UserDefaultsManager.swift */, @@ -447,8 +460,10 @@ children = ( C4A4BF302C14433D00363290 /* HomeView.swift */, C4A4BF322C14437700363290 /* LibraryView.swift */, + E8455A32A2A31C3AA7C4DC99 /* CachedSongsView.swift */, C4A4BF362C14442F00363290 /* DownloadsView.swift */, C4A4BF382C14445000363290 /* PreferencesView.swift */, + 0E2A3B4C5D6E7F8A9B0C1D2E /* LikedSongsView.swift */, ); path = Navigation; sourceTree = ""; @@ -701,6 +716,7 @@ C47876022C2BF15900184A33 /* AlbumsView.swift in Sources */, 50C912A42F648A440087EE61 /* IAPWebAuthView.swift in Sources */, C4824D272CE908DC003EAB52 /* SongsView.swift in Sources */, + 0E1A2B3C4D5E6F7A8B9C0D1E /* LikedSongsView.swift in Sources */, C456D8FA2F2FF33E002AAB8B /* LRCParser.swift in Sources */, 50C912892F5DD9280087EE61 /* AuthMode.swift in Sources */, C4F870CE2CEFCC5E00312F8A /* FloooService.swift in Sources */, @@ -731,6 +747,7 @@ C415F54E2C11908100E3E1D2 /* AuthViewModel.swift in Sources */, C42B25842F44551B00E62008 /* PlaybackCoordinator.swift in Sources */, 50C912912F5DD9990087EE61 /* IAPLoginView.swift in Sources */, + 02E566BC379A4C25B4AB907C /* CachedSongsView.swift in Sources */, C4A4BF372C14442F00363290 /* DownloadsView.swift in Sources */, C4100A692CE78B25001BC9BE /* PlaylistView.swift in Sources */, C440228D2C09BE2E004EE9CD /* PlayerView.swift in Sources */, @@ -741,6 +758,9 @@ C4D7F84F2C7F2C5D00165EFD /* PlaybackService.swift in Sources */, C49134532C15BE0C00CCF2EB /* Strings.swift in Sources */, C4875E002C149D9000D9BAEB /* AlbumService.swift in Sources */, + C4CACHE012D7B0000003B9C4F /* StreamCacheManager.swift in Sources */, + 0C1A2B3C4D5E6F7A8B9C0D1E /* LibraryCacheManager.swift in Sources */, + 0D1A2B3C4D5E6F7A8B9C0D1E /* CoverArtCacheManager.swift in Sources */, C456D8FC2F2FF39B002AAB8B /* LyricsLine.swift in Sources */, C429DB322D33C707009F2684 /* DownloadButtonView.swift in Sources */, C4824D232CE8C41F003EAB52 /* Playable.swift in Sources */, @@ -765,6 +785,7 @@ /* Begin PBXTargetDependency section */ C42B25962F4458EF00E62008 /* PBXTargetDependency */ = { isa = PBXTargetDependency; + platformFilter = ios; target = C42B258C2F4458ED00E62008 /* flo Watch Watch App */; targetProxy = C42B25952F4458EF00E62008 /* PBXContainerItemProxy */; }; @@ -774,7 +795,7 @@ C42B25992F4458EF00E62008 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIconWatch; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = flo/flo.entitlements; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; @@ -812,7 +833,7 @@ C42B259A2F4458EF00E62008 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIconWatch; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = flo/flo.entitlements; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution"; @@ -977,40 +998,55 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - CODE_SIGN_ENTITLEMENTS = flo/flo.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = flo/flo.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 211; + CURRENT_PROJECT_VERSION = 212; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 8BJ4LW5J8P; + ENABLE_APP_SANDBOX = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = flo/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.2; + MARKETING_VERSION = 2.3; NEW_SETTING = ""; PRODUCT_BUNDLE_IDENTIFIER = com.penerbangwalet.flo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.penerbangwalet.flo"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match Development com.penerbangwalet.flo catalyst"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -1021,41 +1057,56 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - CODE_SIGN_ENTITLEMENTS = flo/flo.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = flo/flo.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 211; + CURRENT_PROJECT_VERSION = 212; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 8BJ4LW5J8P; + ENABLE_APP_SANDBOX = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = flo/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.2; + MARKETING_VERSION = 2.3; "MTL_ENABLE_DEBUG_INFO[arch=*]" = NO; NEW_SETTING = ""; PRODUCT_BUNDLE_IDENTIFIER = com.penerbangwalet.flo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.penerbangwalet.flo 1773593177"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match AppStore com.penerbangwalet.flo catalyst"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/flo/AlbumView.swift b/flo/AlbumView.swift index c5a9622..d2ec421 100644 --- a/flo/AlbumView.swift +++ b/flo/AlbumView.swift @@ -10,6 +10,7 @@ import SwiftUI struct AlbumView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject var playerViewModel: PlayerViewModel @EnvironmentObject var downloadViewModel: DownloadViewModel diff --git a/flo/AlbumViewModel.swift b/flo/AlbumViewModel.swift index 1197f0b..eb174f3 100644 --- a/flo/AlbumViewModel.swift +++ b/flo/AlbumViewModel.swift @@ -15,6 +15,7 @@ class AlbumViewModel: ObservableObject { @Published var artistAlbums: [Album] = [] @Published var albums: [Album] = [] @Published var album: Album = Album() + @Published var starredSongs: [Song] = [] @Published var downloadedAlbums: [Album] = [] @Published var isDownloadingAlbumId: String = "" @@ -97,17 +98,77 @@ class AlbumViewModel: ObservableObject { } } - func fetchAllSongs() { - AlbumService.shared.getAllSongs { result in - self.isLoading = true + // MARK: - Generic cache helpers + + private enum CacheKey: String { + case albums, artists, playlists, songs + } + private func fetchCached( + current: [T], + cacheKey: CacheKey, + showsLoading: Bool = false, + assign: @escaping ([T]) -> Void, + request: @escaping (@escaping (Result<[T], Error>) -> Void) -> Void + ) { + if current.isEmpty, + let cached = LibraryCacheManager.shared.load([T].self, forKey: cacheKey.rawValue) + { + assign(cached) + } + if showsLoading { isLoading = true } + request { result in DispatchQueue.main.async { - self.isLoading = false + if showsLoading { self.isLoading = false } + switch result { + case .success(let items): + assign(items) + if !items.isEmpty { + DispatchQueue.global(qos: .utility).async { + LibraryCacheManager.shared.save(items, forKey: cacheKey.rawValue) + } + } + case .failure(let error): + self.error = error + } + } + } + } + + @MainActor + private func refreshCached( + cacheKey: CacheKey, + assign: @escaping ([T]) -> Void, + request: @escaping (@escaping (Result<[T], Error>) -> Void) -> Void + ) async { + isLoading = true + defer { isLoading = false } + await withCheckedContinuation { continuation in + request { result in + DispatchQueue.main.async { + switch result { + case .success(let items): + assign(items) + if !items.isEmpty { + DispatchQueue.global(qos: .utility).async { + LibraryCacheManager.shared.save(items, forKey: cacheKey.rawValue) + } + } + case .failure(let error): + self.error = error + } + continuation.resume() + } + } + } + } + func fetchStarredSongs() { + AlbumService.shared.getStarredSongs { result in + DispatchQueue.main.async { switch result { case .success(let songs): - self.songs = songs - + self.starredSongs = songs case .failure(let error): self.error = error } @@ -115,6 +176,13 @@ class AlbumViewModel: ObservableObject { } } + // MARK: - Fetch methods + + func fetchAllSongs() { + fetchCached(current: songs, cacheKey: .songs, + assign: { self.songs = $0 }, request: AlbumService.shared.getAllSongs) + } + func getAlbumInfo() { AlbumService.shared.getAlbumInfo(id: self.album.id) { result in DispatchQueue.main.async { @@ -275,19 +343,8 @@ class AlbumViewModel: ObservableObject { } func fetchAlbums() { - isLoading = true - AlbumService.shared.getAlbum { result in - DispatchQueue.main.async { - self.isLoading = false - switch result { - case .success(let albums): - self.albums = albums - case .failure(let error): - print("error>>>>", error) - self.error = error - } - } - } + fetchCached(current: albums, cacheKey: .albums, showsLoading: true, + assign: { self.albums = $0 }, request: AlbumService.shared.getAlbum) } func fetchAlbumsByArtist(id: String) { @@ -326,29 +383,35 @@ class AlbumViewModel: ObservableObject { } func getPlaylists() { - AlbumService.shared.getPlaylists { result in - DispatchQueue.main.async { - switch result { - case .success(let playlists): - self.playlists = playlists - case .failure(let error): - self.error = error - } - } - } + fetchCached(current: playlists, cacheKey: .playlists, + assign: { self.playlists = $0 }, request: AlbumService.shared.getPlaylists) } func getArtists() { - AlbumService.shared.getArtists { result in - DispatchQueue.main.async { - switch result { - case .success(let artists): - self.artists = artists - case .failure(let error): - self.error = error - } - } - } + fetchCached(current: artists, cacheKey: .artists, + assign: { self.artists = $0 }, request: AlbumService.shared.getArtists) + } + + // MARK: - Async refresh variants + + @MainActor func refreshAlbums() async { + await refreshCached(cacheKey: .albums, assign: { self.albums = $0 }, + request: AlbumService.shared.getAlbum) + } + + @MainActor func refreshArtists() async { + await refreshCached(cacheKey: .artists, assign: { self.artists = $0 }, + request: AlbumService.shared.getArtists) + } + + @MainActor func refreshPlaylists() async { + await refreshCached(cacheKey: .playlists, assign: { self.playlists = $0 }, + request: AlbumService.shared.getPlaylists) + } + + @MainActor func refreshAllSongs() async { + await refreshCached(cacheKey: .songs, assign: { self.songs = $0 }, + request: AlbumService.shared.getAllSongs) } func fetchDownloadedAlbums() { diff --git a/flo/App.swift b/flo/App.swift index 11cc3a0..9c2cbfc 100644 --- a/flo/App.swift +++ b/flo/App.swift @@ -13,6 +13,10 @@ import UIKit struct FloApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + init() { + StreamCacheManager.shared.reconcile() + } + var body: some Scene { WindowGroup { ContentView() diff --git a/flo/Artists/ArtistDetailView.swift b/flo/Artists/ArtistDetailView.swift index 63293a4..1b8753b 100644 --- a/flo/Artists/ArtistDetailView.swift +++ b/flo/Artists/ArtistDetailView.swift @@ -10,6 +10,7 @@ import SwiftUI struct ArtistDetailView: View { @EnvironmentObject var viewModel: AlbumViewModel @EnvironmentObject var playerViewModel: PlayerViewModel + @EnvironmentObject var downloadViewModel: DownloadViewModel @StateObject var artistDetailViewModel = ArtistDetailViewModel() @@ -18,10 +19,15 @@ struct ArtistDetailView: View { let artist: Artist - let columns = [ - GridItem(.flexible()), - GridItem(.flexible()), - ] + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + private var columns: [GridItem] { + if horizontalSizeClass == .regular { + return Array(repeating: GridItem(.flexible()), count: 4) + } else { + return Array(repeating: GridItem(.flexible()), count: 2) + } + } func stripBiography(biography: String) -> String { guard let regex = try? NSRegularExpression(pattern: "]*>.*?") else { @@ -31,7 +37,8 @@ struct ArtistDetailView: View { let range = NSRange(location: 0, length: biography.utf16.count) let stripped = regex.stringByReplacingMatches( - in: biography, range: range, withTemplate: "") + in: biography, range: range, withTemplate: "" + ) return stripped == "" ? "No biography available" : stripped } @@ -113,6 +120,8 @@ struct ArtistDetailView: View { ForEach(viewModel.artistAlbums) { album in NavigationLink { AlbumView(viewModel: viewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) .onAppear { viewModel.setActiveAlbum(album: album) } diff --git a/flo/Artists/ArtistsView.swift b/flo/Artists/ArtistsView.swift index 3287176..becbc3e 100644 --- a/flo/Artists/ArtistsView.swift +++ b/flo/Artists/ArtistsView.swift @@ -9,6 +9,8 @@ import SwiftUI struct ArtistsView: View { @EnvironmentObject private var viewModel: AlbumViewModel + @EnvironmentObject private var playerViewModel: PlayerViewModel + @EnvironmentObject private var downloadViewModel: DownloadViewModel @State private var searchArtist = "" @State private var filterAlbumArtistOnly: Bool = true @@ -18,55 +20,61 @@ struct ArtistsView: View { var filteredArtists: [Artist] { artists.filter { artist in let matchesAlbumArtist = !filterAlbumArtistOnly || artist.stats.albumartist != nil - let matchesSearch = searchArtist.isEmpty || artist.name.localizedCaseInsensitiveContains(searchArtist) + let matchesSearch = + searchArtist.isEmpty || artist.name.localizedCaseInsensitiveContains(searchArtist) return matchesAlbumArtist && matchesSearch } } var body: some View { - NavigationStack { - ScrollView { - LazyVStack { - ForEach(filteredArtists) { artist in - NavigationLink { - ArtistDetailView(artist: artist) - .environmentObject(viewModel) - } label: { - VStack { - HStack { - Text(artist.name) - .customFont(.headline) - .multilineTextAlignment(.leading) - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.gray) - .font(.caption) - } - .padding(.horizontal) - .padding(.vertical, 5) - - Divider() + ScrollView { + LazyVStack { + ForEach(filteredArtists) { artist in + NavigationLink { + ArtistDetailView(artist: artist) + .environmentObject(viewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + } label: { + VStack { + HStack { + Text(artist.name) + .customFont(.headline) + .multilineTextAlignment(.leading) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) } + .padding(.horizontal) + .padding(.vertical, 5) + + Divider() } } - }.padding(.bottom, 100) - } - .navigationTitle("Artists") - .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") - } + } + }.padding(.bottom, 100) + } + .navigationTitle("Artists") + .refreshable { + await viewModel.refreshArtists() + } + .searchable( + text: $searchArtist, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search" + ) + .toolbar { + Menu { + Button { + self.filterAlbumArtistOnly.toggle() } label: { - Label("", systemImage: "ellipsis.circle") + Label( + "Album Artist Only", + systemImage: self.filterAlbumArtistOnly ? "checkmark.circle" : "circle") } + } label: { + Label("", systemImage: "ellipsis.circle") } } } diff --git a/flo/AuthViewModel.swift b/flo/AuthViewModel.swift index 048c548..0916692 100644 --- a/flo/AuthViewModel.swift +++ b/flo/AuthViewModel.swift @@ -27,7 +27,7 @@ class AuthViewModel: ObservableObject { @Published var isSubmitting: Bool = false @Published var isLoggedIn: Bool = false - + @Published var authMode: AuthMode = .standard @Published var iapJwtAssertion: String = "" @Published var useIAPAuth: Bool = false @@ -51,29 +51,29 @@ class AuthViewModel: ObservableObject { { let data: UserAuth = try JSONDecoder().decode(UserAuth.self, from: jsonData) - self.serverUrl = UserDefaultsManager.serverBaseURL - self.username = data.username - - self.authMode = AuthService.shared.getAuthMode() + serverUrl = UserDefaultsManager.serverBaseURL + username = data.username + + authMode = AuthService.shared.getAuthMode() if UserDefaultsManager.saveLoginInfo { do { - self.password = try KeychainManager.getAuthPassword() ?? "" + password = try KeychainManager.getAuthPassword() ?? "" } catch { print("Error loading password from Keychain: \(error)") } if authMode == .iap, let iapInfo = AuthService.shared.getIAPAuthInfo() { - self.loginWithIAP(jwtAssertion: iapInfo.jwtAssertion) + loginWithIAP(jwtAssertion: iapInfo.jwtAssertion) } else { - self.login() + login() } } else { - - self.user = UserAuth( + user = UserAuth( id: data.id, username: data.username, name: data.name, isAdmin: data.isAdmin, - lastFMApiKey: data.lastFMApiKey) - self.isLoggedIn = true + lastFMApiKey: data.lastFMApiKey + ) + isLoggedIn = true } } } catch { @@ -132,8 +132,8 @@ class AuthViewModel: ObservableObject { do { try KeychainManager.removeAuthCreds() - self.destroySavedPassword() - + destroySavedPassword() + if authMode == .iap { try? KeychainManager.removeIAPAuthInfo() try? KeychainManager.removeAuthMode() @@ -142,10 +142,10 @@ class AuthViewModel: ObservableObject { UserDefaultsManager.removeObject(key: UserDefaultsKeys.serverURL) - self.user = nil - self.isLoggedIn = false - self.authMode = .standard - } catch let error { + user = nil + isLoggedIn = false + authMode = .standard + } catch { print("error>>>>> \(error)") } } @@ -156,7 +156,7 @@ class AuthViewModel: ObservableObject { UserDefaultsManager.saveLoginInfo = false UserDefaultsManager.removeObject(key: UserDefaultsKeys.saveLoginInfo) - } catch let error { + } catch { print("error>>>>> \(error)") } } @@ -166,24 +166,29 @@ class AuthViewModel: ObservableObject { let jsonData = try JSONEncoder().encode(data) let jsonString = String(data: jsonData, encoding: .utf8)! - try KeychainManager.setAuthCreds(newValue: jsonString) + do { + try KeychainManager.setAuthCreds(newValue: jsonString) + } catch { + print("Error saving auth creds to Keychain: \(error)") + } AuthService.shared.setCreds(data) - UserDefaultsManager.serverBaseURL = self.serverUrl + UserDefaultsManager.serverBaseURL = serverUrl - self.user = UserAuth( + user = UserAuth( id: data.id, username: data.username, name: data.name, isAdmin: data.isAdmin, - lastFMApiKey: data.lastFMApiKey) + lastFMApiKey: data.lastFMApiKey + ) } catch { - print("Error saving data to Keychain: \(error)") + print("Error encoding auth data: \(error)") } } - + func loginWithIAP(jwtAssertion: String? = nil) { isSubmitting = true - - let jwt = jwtAssertion ?? self.iapJwtAssertion - + + let jwt = jwtAssertion ?? iapJwtAssertion + guard !jwt.isEmpty else { DispatchQueue.main.async { self.isSubmitting = false @@ -197,7 +202,7 @@ class AuthViewModel: ObservableObject { switch result { case .success(let data): self.persistAuthData(data) - + self.authMode = .iap if UserDefaultsManager.saveLoginInfo { @@ -228,13 +233,12 @@ class AuthViewModel: ObservableObject { } } } - + func toggleAuthMode() { useIAPAuth.toggle() } - + func isUsingIAPAuth() -> Bool { return authMode == .iap } } - diff --git a/flo/CarPlay/CarPlayCoordinator.swift b/flo/CarPlay/CarPlayCoordinator.swift index 1c2716c..319b7c2 100644 --- a/flo/CarPlay/CarPlayCoordinator.swift +++ b/flo/CarPlay/CarPlayCoordinator.swift @@ -3,643 +3,845 @@ // flo // -import CarPlay import Combine -class CarPlayCoordinator { - private let interfaceController: CPInterfaceController - private let playerVM = PlayerViewModel.shared - private var nowPlayingManager: CarPlayNowPlayingManager? +#if canImport(CarPlay) + import CarPlay - init(interfaceController: CPInterfaceController) { - self.interfaceController = interfaceController - } - - func start() { - nowPlayingManager = CarPlayNowPlayingManager( - playerVM: playerVM, interfaceController: interfaceController) - nowPlayingManager?.configure() + class CarPlayCoordinator { + private let interfaceController: CPInterfaceController + private let playerVM = PlayerViewModel.shared + private var nowPlayingManager: CarPlayNowPlayingManager? - let tabBar = CPTabBarTemplate(templates: [ - makeLibraryTab(), - makePlaylistsTab(), - makeRadioTab(), - makeDownloadsTab(), - ]) + init(interfaceController: CPInterfaceController) { + self.interfaceController = interfaceController + } - interfaceController.setRootTemplate(tabBar, animated: true, completion: nil) - } + func start() { + nowPlayingManager = CarPlayNowPlayingManager( + playerVM: playerVM, interfaceController: interfaceController + ) + nowPlayingManager?.configure() - func stop() { - nowPlayingManager?.teardown() - nowPlayingManager = nil - } + let tabBar = CPTabBarTemplate(templates: [ + makeLibraryTab(), + makePlaylistsTab(), + makeRadioTab(), + makeDownloadsTab(), + ]) - // MARK: - Now Playing Navigation + interfaceController.setRootTemplate(tabBar, animated: true, completion: nil) + } - private func showNowPlaying() { - if !(interfaceController.topTemplate is CPNowPlayingTemplate) { - interfaceController.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil) + func stop() { + nowPlayingManager?.teardown() + nowPlayingManager = nil } - } - // MARK: - Library Tab + // MARK: - Now Playing Navigation - private func makeLibraryTab() -> CPListTemplate { - let albumsItem = CPListItem( - text: "Albums", detailText: nil, - image: UIImage(systemName: "square.stack")?.withRenderingMode(.alwaysTemplate)) - albumsItem.handler = { [weak self] _, completion in - self?.showAlbumsList() - completion() + private func showNowPlaying() { + if !(interfaceController.topTemplate is CPNowPlayingTemplate) { + interfaceController.pushTemplate( + CPNowPlayingTemplate.shared, animated: true, completion: nil) + } } - let artistsItem = CPListItem( - text: "Artists", detailText: nil, - image: UIImage(systemName: "music.mic")?.withRenderingMode(.alwaysTemplate)) - artistsItem.handler = { [weak self] _, completion in - self?.showArtistsList() - completion() - } + // MARK: - Library Tab - let songsItem = CPListItem( - text: "Songs", detailText: nil, - image: UIImage(systemName: "music.note")?.withRenderingMode(.alwaysTemplate)) - songsItem.handler = { [weak self] _, completion in - self?.showSongsList() - completion() - } + private func makeLibraryTab() -> CPListTemplate { + let albumsItem = CPListItem( + text: String(localized: "Albums"), detailText: nil, + image: UIImage(systemName: "square.stack")?.withRenderingMode(.alwaysTemplate) + ) + albumsItem.handler = { [weak self] _, completion in + self?.showAlbumsList() + completion() + } - let section = CPListSection(items: [albumsItem, artistsItem, songsItem]) - let template = CPListTemplate(title: "Library", sections: [section]) - template.tabImage = UIImage(systemName: "square.grid.2x2") + let artistsItem = CPListItem( + text: String(localized: "Artists"), detailText: nil, + image: UIImage(systemName: "music.mic")?.withRenderingMode(.alwaysTemplate) + ) + artistsItem.handler = { [weak self] _, completion in + self?.showArtistsList() + completion() + } - return template - } + let songsItem = CPListItem( + text: String(localized: "Songs"), detailText: nil, + image: UIImage(systemName: "music.note")?.withRenderingMode(.alwaysTemplate) + ) + songsItem.handler = { [weak self] _, completion in + self?.showSongsList() + completion() + } - // MARK: - Albums - - private func showAlbumsList() { - let loadingTemplate = CPListTemplate(title: "Albums", sections: []) - interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) - - AlbumService.shared.getAlbum { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let albums): - let items = albums.map { album -> CPListItem in - let item = CPListItem( - text: album.name, - detailText: album.albumArtist.isEmpty ? album.artist : album.albumArtist - ) - item.handler = { [weak self] _, completion in - self?.showAlbumDetail(album: album, isDownloaded: false) - completion() + let likedSongsItem = CPListItem( + text: String(localized: "Liked Songs"), detailText: nil, + image: UIImage(systemName: "heart.fill")?.withRenderingMode(.alwaysTemplate) + ) + likedSongsItem.handler = { [weak self] _, completion in + self?.showLikedSongs() + completion() + } + + let section = CPListSection(items: [albumsItem, artistsItem, songsItem, likedSongsItem]) + let template = CPListTemplate(title: String(localized: "Library"), sections: [section]) + template.tabImage = UIImage(systemName: "square.grid.2x2") + + return template + } + + // MARK: - Albums + + private func showAlbumsList() { + let loadingTemplate = CPListTemplate(title: String(localized: "Albums"), sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getAlbum { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let albums): + let items = albums.map { album -> CPListItem in + let item = CPListItem( + text: album.name, + detailText: album.albumArtist.isEmpty ? album.artist : album.albumArtist + ) + item.handler = { [weak self] _, completion in + self?.showAlbumDetail(album: album, isDownloaded: false) + completion() + } + let coverURL = AlbumService.shared.getAlbumCover( + artistName: album.albumArtist.isEmpty ? album.artist : album.albumArtist, + albumName: album.name, + albumId: album.id, + albumCover: album.albumCover + ) + CarPlayImageLoader.loadImage(from: coverURL) { image in + item.setImage(image) + } + return item } - let coverURL = AlbumService.shared.getAlbumCover( - artistName: album.albumArtist.isEmpty ? album.artist : album.albumArtist, - albumName: album.name, - albumId: album.id, - albumCover: album.albumCover - ) - CarPlayImageLoader.loadImage(from: coverURL) { image in - item.setImage(image) + loadingTemplate.updateSections([CPListSection(items: items)]) + + case .failure: + let errorItem = CPListItem( + text: String(localized: "Failed to load albums"), + detailText: String(localized: "Tap to retry")) + errorItem.handler = { [weak self] _, completion in + self?.interfaceController.popTemplate(animated: false, completion: nil) + self?.showAlbumsList() + completion() } - return item - } - loadingTemplate.updateSections([CPListSection(items: items)]) - - case .failure: - let errorItem = CPListItem(text: "Failed to load albums", detailText: "Tap to retry") - errorItem.handler = { [weak self] _, completion in - self?.interfaceController.popTemplate(animated: false, completion: nil) - self?.showAlbumsList() - completion() + loadingTemplate.updateSections([CPListSection(items: [errorItem])]) } - loadingTemplate.updateSections([CPListSection(items: [errorItem])]) } } } - } - // MARK: - Album Detail + // MARK: - Album Detail - private func showAlbumDetail(album: Album, isDownloaded: Bool) { - let detailTemplate = CPListTemplate(title: album.name, sections: []) - interfaceController.pushTemplate(detailTemplate, animated: true, completion: nil) + private func showAlbumDetail(album: Album, isDownloaded: Bool) { + let detailTemplate = CPListTemplate(title: album.name, sections: []) + interfaceController.pushTemplate(detailTemplate, animated: true, completion: nil) - AlbumService.shared.getSongFromAlbum(id: album.id) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } + AlbumService.shared.getSongFromAlbum(id: album.id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } - var albumWithSongs = album + var albumWithSongs = album - switch result { - case .success(let songs): - let localSongs = AlbumService.shared.getSongsByAlbumId(albumId: album.id) - let remoteSongs = songs.filter { song in - !localSongs.contains(where: { $0.id == song.id }) - } - albumWithSongs.songs = - (localSongs + remoteSongs).sorted { - if $0.discNumber == $1.discNumber { - return $0.trackNumber < $1.trackNumber - } - return $0.discNumber < $1.discNumber + switch result { + case .success(let songs): + let localSongs = AlbumService.shared.getSongsByAlbumId(albumId: album.id) + let remoteSongs = songs.filter { song in + !localSongs.contains(where: { $0.id == song.id }) } - case .failure: - albumWithSongs.songs = AlbumService.shared.getSongsByAlbumId(albumId: album.id) - } + albumWithSongs.songs = + (localSongs + remoteSongs).sorted { + if $0.discNumber == $1.discNumber { + return $0.trackNumber < $1.trackNumber + } + return $0.discNumber < $1.discNumber + } + case .failure: + albumWithSongs.songs = AlbumService.shared.getSongsByAlbumId(albumId: album.id) + } - self.buildAlbumDetailSections( - template: detailTemplate, - album: albumWithSongs, - isDownloaded: isDownloaded - ) + self.buildAlbumDetailSections( + template: detailTemplate, + album: albumWithSongs, + isDownloaded: isDownloaded + ) + } } } - } - - private func buildAlbumDetailSections( - template: CPListTemplate, album: Album, isDownloaded: Bool - ) { - let playAllItem = CPListItem( - text: "Play All", - detailText: "\(album.songs.count) tracks", - image: UIImage(systemName: "play.fill")?.withRenderingMode(.alwaysTemplate) - ) - playAllItem.handler = { [weak self] _, completion in - self?.playerVM.playItem(item: album, isFromLocal: isDownloaded) - self?.showNowPlaying() - completion() - } - - let shuffleItem = CPListItem( - text: "Shuffle", - detailText: nil, - image: UIImage(systemName: "shuffle")?.withRenderingMode(.alwaysTemplate) - ) - shuffleItem.handler = { [weak self] _, completion in - self?.playerVM.shuffleItem(item: album, isFromLocal: isDownloaded) - self?.showNowPlaying() - completion() - } - let actionSection = CPListSection(items: [playAllItem, shuffleItem]) + private func buildAlbumDetailSections( + template: CPListTemplate, album: Album, isDownloaded: Bool + ) { + let playAllItem = CPListItem( + text: String(localized: "Play All"), + detailText: String(localized: "\(album.songs.count) tracks"), + image: UIImage(systemName: "play.fill")?.withRenderingMode(.alwaysTemplate) + ) + playAllItem.handler = { [weak self] _, completion in + self?.playerVM.playItem(item: album, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } - let trackItems = album.songs.enumerated().map { (idx, song) -> CPListItem in - let item = CPListItem( - text: song.title, - detailText: song.artist + let shuffleItem = CPListItem( + text: String(localized: "Shuffle"), + detailText: nil, + image: UIImage(systemName: "shuffle")?.withRenderingMode(.alwaysTemplate) ) - item.handler = { [weak self] _, completion in - self?.playerVM.playBySong(idx: idx, item: album, isFromLocal: isDownloaded) + shuffleItem.handler = { [weak self] _, completion in + self?.playerVM.shuffleItem(item: album, isFromLocal: isDownloaded) self?.showNowPlaying() completion() } - return item - } - let trackSection = CPListSection( - items: trackItems, - header: "Tracks", - sectionIndexTitle: nil - ) - template.updateSections([actionSection, trackSection]) - } + let actionSection = CPListSection(items: [playAllItem, shuffleItem]) - // MARK: - Artists + let trackItems = album.songs.enumerated().map { idx, song -> CPListItem in + let item = CPListItem( + text: song.title, + detailText: song.artist + ) + item.handler = { [weak self] _, completion in + self?.playerVM.playBySong(idx: idx, item: album, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } + return item + } + let trackSection = CPListSection( + items: trackItems, + header: String(localized: "Tracks"), + sectionIndexTitle: nil + ) - private var filterAlbumArtistOnly = true + template.updateSections([actionSection, trackSection]) + } - private func showArtistsList() { - let loadingTemplate = CPListTemplate(title: "Artists", sections: []) - interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + // MARK: - Artists - AlbumService.shared.getArtists { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let artists): - let filteredArtists = artists.filter { artist in - !self.filterAlbumArtistOnly || artist.stats.albumartist != nil - } - let items = filteredArtists.map { artist -> CPListItem in - let item = CPListItem( - text: artist.name, - detailText: "\(artist.albumCount) albums" - ) - item.handler = { [weak self] _, completion in - self?.showArtistAlbums(artist: artist) + private var filterAlbumArtistOnly = true + + private func showArtistsList() { + let loadingTemplate = CPListTemplate(title: String(localized: "Artists"), sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getArtists { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let artists): + let filteredArtists = artists.filter { artist in + !self.filterAlbumArtistOnly || artist.stats.albumartist != nil + } + let items = filteredArtists.map { artist -> CPListItem in + let item = CPListItem( + text: artist.name, + detailText: String(localized: "\(artist.albumCount) albums") + ) + item.handler = { [weak self] _, completion in + self?.showArtistAlbums(artist: artist) + completion() + } + return item + } + loadingTemplate.updateSections([CPListSection(items: items)]) + + case .failure: + let errorItem = CPListItem( + text: String(localized: "Failed to load artists"), + detailText: String(localized: "Tap to retry")) + errorItem.handler = { [weak self] _, completion in + self?.interfaceController.popTemplate(animated: false, completion: nil) + self?.showArtistsList() completion() } - return item - } - loadingTemplate.updateSections([CPListSection(items: items)]) - - case .failure: - let errorItem = CPListItem(text: "Failed to load artists", detailText: "Tap to retry") - errorItem.handler = { [weak self] _, completion in - self?.interfaceController.popTemplate(animated: false, completion: nil) - self?.showArtistsList() - completion() + loadingTemplate.updateSections([CPListSection(items: [errorItem])]) } - loadingTemplate.updateSections([CPListSection(items: [errorItem])]) } } } - } - private func showArtistAlbums(artist: Artist) { - let loadingTemplate = CPListTemplate(title: artist.name, sections: []) - interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) - - AlbumService.shared.getAlbumsByArtist(id: artist.id) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let albums): - let radioItem = CPListItem( - text: "Play Artist Radio", - detailText: nil, - image: UIImage(systemName: "dot.radiowaves.up.forward") - ) - radioItem.handler = { [weak self] _, completion in - self?.playArtistRadio(artist: artist) - completion() - } - - let topSongsItem = CPListItem( - text: "Play Top Songs", - detailText: nil, - image: UIImage(systemName: "star.fill") - ) - topSongsItem.handler = { [weak self] _, completion in - self?.playArtistTopSongs(artist: artist) - completion() - } - - let actionSection = CPListSection(items: [radioItem, topSongsItem]) - - let albumItems = albums.map { album -> CPListItem in - let item = CPListItem( - text: album.name, - detailText: album.minYear > 0 ? "\(album.minYear)" : nil + private func showArtistAlbums(artist: Artist) { + let loadingTemplate = CPListTemplate(title: artist.name, sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getAlbumsByArtist(id: artist.id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let albums): + let radioItem = CPListItem( + text: String(localized: "Play Artist Radio"), + detailText: nil, + image: UIImage(systemName: "dot.radiowaves.up.forward") ) - item.handler = { [weak self] _, completion in - self?.showAlbumDetail(album: album, isDownloaded: false) + radioItem.handler = { [weak self] _, completion in + self?.playArtistRadio(artist: artist) completion() } - let coverURL = AlbumService.shared.getAlbumCover( - artistName: album.albumArtist, - albumName: album.name, - albumId: album.id, - albumCover: album.albumCover + + let topSongsItem = CPListItem( + text: String(localized: "Play Top Songs"), + detailText: nil, + image: UIImage(systemName: "star.fill") ) - CarPlayImageLoader.loadImage(from: coverURL) { image in - item.setImage(image) + topSongsItem.handler = { [weak self] _, completion in + self?.playArtistTopSongs(artist: artist) + completion() } - return item - } - let albumSection = CPListSection( - items: albumItems, - header: "Albums", - sectionIndexTitle: nil - ) - loadingTemplate.updateSections([actionSection, albumSection]) + let actionSection = CPListSection(items: [radioItem, topSongsItem]) - case .failure: - loadingTemplate.updateSections([ - CPListSection(items: [ - CPListItem(text: "Failed to load albums", detailText: nil) + let albumItems = albums.map { album -> CPListItem in + let item = CPListItem( + text: album.name, + detailText: album.minYear > 0 ? "\(album.minYear)" : nil + ) + item.handler = { [weak self] _, completion in + self?.showAlbumDetail(album: album, isDownloaded: false) + completion() + } + let coverURL = AlbumService.shared.getAlbumCover( + artistName: album.albumArtist, + albumName: album.name, + albumId: album.id, + albumCover: album.albumCover + ) + CarPlayImageLoader.loadImage(from: coverURL) { image in + item.setImage(image) + } + return item + } + let albumSection = CPListSection( + items: albumItems, + header: String(localized: "Albums"), + sectionIndexTitle: nil + ) + + loadingTemplate.updateSections([actionSection, albumSection]) + + case .failure: + loadingTemplate.updateSections([ + CPListSection(items: [ + CPListItem(text: String(localized: "Failed to load albums"), detailText: nil) + ]) ]) - ]) + } } } } - } - // MARK: - Artist Radio & Top Songs - - private func playArtistRadio(artist: Artist) { - RadioService.shared.getSimilarSongs(id: artist.id) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let songs) where !songs.isEmpty: - let playable = RadioEntity( - id: artist.id, - name: "\(artist.name) Radio", - songs: songs, - artist: artist.name - ) - self.playerVM.playItem(item: playable, isFromLocal: false) - self.showNowPlaying() - case .success: - self.showErrorTemplate(title: "No Radio Available", message: "No similar songs found for \(artist.name).") - case .failure: - self.showErrorTemplate(title: "Radio Unavailable", message: "Could not load radio for \(artist.name).") + // MARK: - Artist Radio & Top Songs + + private func playArtistRadio(artist: Artist) { + RadioService.shared.getSimilarSongs(id: artist.id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let songs) where !songs.isEmpty: + let playable = RadioEntity( + id: artist.id, + name: String(localized: "\(artist.name) Radio"), + songs: songs, + artist: artist.name + ) + self.playerVM.playItem(item: playable, isFromLocal: false) + self.showNowPlaying() + case .success: + self.showErrorTemplate( + title: String(localized: "No Radio Available"), + message: String(localized: "No similar songs found for \(artist.name).")) + case .failure: + self.showErrorTemplate( + title: String(localized: "Radio Unavailable"), + message: String(localized: "Could not load radio for \(artist.name).")) + } } } } - } - private func playArtistTopSongs(artist: Artist) { - RadioService.shared.getTopSongs(artistName: artist.name, count: 20) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let songs) where !songs.isEmpty: - let playable = RadioEntity( - id: artist.id, - name: "\(artist.name) Top Songs", - songs: songs, - artist: artist.name - ) - self.playerVM.playItem(item: playable, isFromLocal: false) - self.showNowPlaying() - case .success: - self.showErrorTemplate(title: "No Top Songs", message: "No top songs found for \(artist.name).") - case .failure: - self.showErrorTemplate(title: "Unavailable", message: "Could not load top songs for \(artist.name).") + private func playArtistTopSongs(artist: Artist) { + RadioService.shared.getTopSongs(artistName: artist.name, count: 20) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let songs) where !songs.isEmpty: + let playable = RadioEntity( + id: artist.id, + name: String(localized: "\(artist.name) Top Songs"), + songs: songs, + artist: artist.name + ) + self.playerVM.playItem(item: playable, isFromLocal: false) + self.showNowPlaying() + case .success: + self.showErrorTemplate( + title: String(localized: "No Top Songs"), + message: String(localized: "No top songs found for \(artist.name).")) + case .failure: + self.showErrorTemplate( + title: String(localized: "Unavailable"), + message: String(localized: "Could not load top songs for \(artist.name).")) + } } } } - } - private func showErrorTemplate(title: String, message: String) { - let item = CPListItem(text: title, detailText: message) - let template = CPListTemplate(title: title, sections: [CPListSection(items: [item])]) - interfaceController.pushTemplate(template, animated: true, completion: nil) - } + private func showErrorTemplate(title: String, message: String) { + let item = CPListItem(text: title, detailText: message) + let template = CPListTemplate(title: title, sections: [CPListSection(items: [item])]) + interfaceController.pushTemplate(template, animated: true, completion: nil) + } - // MARK: - Songs - - private func showSongsList() { - let loadingTemplate = CPListTemplate(title: "Songs", sections: []) - interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) - - AlbumService.shared.getAllSongs { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let songs): - let items = songs.enumerated().map { (idx, song) -> CPListItem in - let item = CPListItem( - text: song.title, - detailText: song.artist - ) - item.handler = { [weak self] _, completion in - guard let self = self else { return } - let allTracks = Playlist(name: "All Tracks", songs: songs) - self.playerVM.playBySong(idx: idx, item: allTracks, isFromLocal: false) - self.showNowPlaying() + // MARK: - Songs + + private func showSongsList() { + let loadingTemplate = CPListTemplate(title: String(localized: "Songs"), sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getAllSongs { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let songs): + let items = songs.enumerated().map { idx, song -> CPListItem in + let item = CPListItem( + text: song.title, + detailText: song.artist + ) + item.handler = { [weak self] _, completion in + guard let self = self else { return } + let allTracks = Playlist(name: String(localized: "All Tracks"), songs: songs) + self.playerVM.playBySong(idx: idx, item: allTracks, isFromLocal: false) + self.showNowPlaying() + completion() + } + return item + } + loadingTemplate.updateSections([CPListSection(items: items)]) + + case .failure: + let errorItem = CPListItem( + text: String(localized: "Failed to load songs"), + detailText: String(localized: "Tap to retry")) + errorItem.handler = { [weak self] _, completion in + self?.interfaceController.popTemplate(animated: false, completion: nil) + self?.showSongsList() completion() } - return item - } - loadingTemplate.updateSections([CPListSection(items: items)]) - - case .failure: - let errorItem = CPListItem(text: "Failed to load songs", detailText: "Tap to retry") - errorItem.handler = { [weak self] _, completion in - self?.interfaceController.popTemplate(animated: false, completion: nil) - self?.showSongsList() - completion() + loadingTemplate.updateSections([CPListSection(items: [errorItem])]) } - loadingTemplate.updateSections([CPListSection(items: [errorItem])]) } } } - } - // MARK: - Playlists Tab - - private func makePlaylistsTab() -> CPListTemplate { - let loadingItem = CPListItem(text: "Loading playlists…", detailText: nil) - loadingItem.isEnabled = false - let template = CPListTemplate( - title: "Playlists", sections: [CPListSection(items: [loadingItem])]) - template.tabImage = UIImage(systemName: "music.note.list") - - AlbumService.shared.getPlaylists { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let playlists): - let items = playlists.map { playlist -> CPListItem in - let item = CPListItem( - text: playlist.name, - detailText: playlist.comment.isEmpty ? playlist.ownerName : playlist.comment + // MARK: - Liked Songs + + private func showLikedSongs() { + let loadingTemplate = CPListTemplate(title: String(localized: "Liked Songs"), sections: []) + interfaceController.pushTemplate(loadingTemplate, animated: true, completion: nil) + + AlbumService.shared.getStarredSongs { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let songs): + if songs.isEmpty { + loadingTemplate.updateSections([ + CPListSection(items: [ + CPListItem(text: String(localized: "No liked songs yet"), detailText: nil) + ]) + ]) + return + } + + let playAllItem = CPListItem( + text: String(localized: "Play All"), + detailText: String(localized: "\(songs.count) songs"), + image: UIImage(systemName: "play.fill") + ) + playAllItem.handler = { [weak self] _, completion in + let collection = SongCollection(id: "liked-songs", name: "Liked Songs", songs: songs) + self?.playerVM.playItem(item: collection, isFromLocal: false) + self?.showNowPlaying() + completion() + } + + let shuffleItem = CPListItem( + text: String(localized: "Shuffle"), + detailText: nil, + image: UIImage(systemName: "shuffle") + ) + shuffleItem.handler = { [weak self] _, completion in + let collection = SongCollection(id: "liked-songs", name: "Liked Songs", songs: songs) + self?.playerVM.shuffleItem(item: collection, isFromLocal: false) + self?.showNowPlaying() + completion() + } + + let actionSection = CPListSection(items: [playAllItem, shuffleItem]) + + let trackItems = songs.enumerated().map { idx, song -> CPListItem in + let item = CPListItem( + text: song.title, + detailText: song.artist + ) + item.handler = { [weak self] _, completion in + let collection = SongCollection( + id: "liked-songs", name: "Liked Songs", songs: songs) + self?.playerVM.playBySong(idx: idx, item: collection, isFromLocal: false) + self?.showNowPlaying() + completion() + } + return item + } + let trackSection = CPListSection( + items: trackItems, + header: String(localized: "Songs"), + sectionIndexTitle: nil ) - item.handler = { [weak self] _, completion in - self?.showPlaylistDetail(playlist: playlist) + + loadingTemplate.updateSections([actionSection, trackSection]) + + case .failure: + let errorItem = CPListItem( + text: String(localized: "Failed to load liked songs"), + detailText: String(localized: "Tap to retry")) + errorItem.handler = { [weak self] _, completion in + self?.interfaceController.popTemplate(animated: false, completion: nil) + self?.showLikedSongs() completion() } - return item + loadingTemplate.updateSections([CPListSection(items: [errorItem])]) } - template.updateSections([CPListSection(items: items)]) + } + } + } + + // MARK: - Playlists Tab + + private func makePlaylistsTab() -> CPListTemplate { + let loadingItem = CPListItem(text: String(localized: "Loading playlists…"), detailText: nil) + loadingItem.isEnabled = false + let template = CPListTemplate( + title: String(localized: "Playlists"), sections: [CPListSection(items: [loadingItem])] + ) + template.tabImage = UIImage(systemName: "music.note.list") + + AlbumService.shared.getPlaylists { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let playlists): + let items = playlists.map { playlist -> CPListItem in + let item = CPListItem( + text: playlist.name, + detailText: playlist.comment.isEmpty ? playlist.ownerName : playlist.comment + ) + item.handler = { [weak self] _, completion in + self?.showPlaylistDetail(playlist: playlist) + completion() + } + return item + } + template.updateSections([CPListSection(items: items)]) - case .failure: - template.updateSections([ - CPListSection(items: [ - CPListItem(text: "Failed to load playlists", detailText: nil) + case .failure: + template.updateSections([ + CPListSection(items: [ + CPListItem(text: String(localized: "Failed to load playlists"), detailText: nil) + ]) ]) - ]) + } } } + + return template } - return template - } + private func showPlaylistDetail(playlist: Playlist) { + let detailTemplate = CPListTemplate(title: playlist.name, sections: []) + interfaceController.pushTemplate(detailTemplate, animated: true, completion: nil) - private func showPlaylistDetail(playlist: Playlist) { - let detailTemplate = CPListTemplate(title: playlist.name, sections: []) - interfaceController.pushTemplate(detailTemplate, animated: true, completion: nil) + AlbumService.shared.getSongsByPlaylist(id: playlist.id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } - AlbumService.shared.getSongsByPlaylist(id: playlist.id) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } + var playlistWithSongs = playlist - var playlistWithSongs = playlist + switch result { + case .success(let songs): + playlistWithSongs.songs = songs + case .failure: + playlistWithSongs.songs = [] + } - switch result { - case .success(let songs): - playlistWithSongs.songs = songs - case .failure: - playlistWithSongs.songs = [] + let isDownloaded = AlbumService.shared.checkIfAlbumDownloaded(albumID: playlist.id) + self.buildPlaylistDetailSections( + template: detailTemplate, + playlist: playlistWithSongs, + isDownloaded: isDownloaded + ) } - - let isDownloaded = AlbumService.shared.checkIfAlbumDownloaded(albumID: playlist.id) - self.buildPlaylistDetailSections( - template: detailTemplate, - playlist: playlistWithSongs, - isDownloaded: isDownloaded - ) } } - } - - private func buildPlaylistDetailSections( - template: CPListTemplate, playlist: Playlist, isDownloaded: Bool - ) { - let playAllItem = CPListItem( - text: "Play All", - detailText: "\(playlist.songs.count) tracks", - image: UIImage(systemName: "play.fill") - ) - playAllItem.handler = { [weak self] _, completion in - self?.playerVM.playItem(item: playlist, isFromLocal: isDownloaded) - self?.showNowPlaying() - completion() - } - - let shuffleItem = CPListItem( - text: "Shuffle", - detailText: nil, - image: UIImage(systemName: "shuffle") - ) - shuffleItem.handler = { [weak self] _, completion in - self?.playerVM.shuffleItem(item: playlist, isFromLocal: isDownloaded) - self?.showNowPlaying() - completion() - } - let actionSection = CPListSection(items: [playAllItem, shuffleItem]) + private func buildPlaylistDetailSections( + template: CPListTemplate, playlist: Playlist, isDownloaded: Bool + ) { + let playAllItem = CPListItem( + text: String(localized: "Play All"), + detailText: String(localized: "\(playlist.songs.count) tracks"), + image: UIImage(systemName: "play.fill") + ) + playAllItem.handler = { [weak self] _, completion in + self?.playerVM.playItem(item: playlist, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } - let trackItems = playlist.songs.enumerated().map { (idx, song) -> CPListItem in - let item = CPListItem( - text: song.title, - detailText: song.artist + let shuffleItem = CPListItem( + text: String(localized: "Shuffle"), + detailText: nil, + image: UIImage(systemName: "shuffle") ) - item.handler = { [weak self] _, completion in - self?.playerVM.playBySong(idx: idx, item: playlist, isFromLocal: isDownloaded) + shuffleItem.handler = { [weak self] _, completion in + self?.playerVM.shuffleItem(item: playlist, isFromLocal: isDownloaded) self?.showNowPlaying() completion() } - return item + + let actionSection = CPListSection(items: [playAllItem, shuffleItem]) + + let trackItems = playlist.songs.enumerated().map { idx, song -> CPListItem in + let item = CPListItem( + text: song.title, + detailText: song.artist + ) + item.handler = { [weak self] _, completion in + self?.playerVM.playBySong(idx: idx, item: playlist, isFromLocal: isDownloaded) + self?.showNowPlaying() + completion() + } + return item + } + let trackSection = CPListSection( + items: trackItems, + header: String(localized: "Tracks"), + sectionIndexTitle: nil + ) + + template.updateSections([actionSection, trackSection]) } - let trackSection = CPListSection( - items: trackItems, - header: "Tracks", - sectionIndexTitle: nil - ) - template.updateSections([actionSection, trackSection]) - } + // MARK: - Radio Tab + + private func makeRadioTab() -> CPListTemplate { + let loadingItem = CPListItem(text: String(localized: "Loading stations…"), detailText: nil) + loadingItem.isEnabled = false + let template = CPListTemplate( + title: String(localized: "Radio"), sections: [CPListSection(items: [loadingItem])] + ) + template.tabImage = UIImage(systemName: "radio") + + RadioService.shared.getAllRadios { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let radios): + if radios.isEmpty { + template.updateSections([ + CPListSection(items: [ + CPListItem(text: String(localized: "No radio stations"), detailText: nil) + ]) + ]) + return + } - // MARK: - Radio Tab - - private func makeRadioTab() -> CPListTemplate { - let loadingItem = CPListItem(text: "Loading stations…", detailText: nil) - loadingItem.isEnabled = false - let template = CPListTemplate( - title: "Radio", sections: [CPListSection(items: [loadingItem])]) - template.tabImage = UIImage(systemName: "radio") - - RadioService.shared.getAllRadios { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let radios): - if radios.isEmpty { + let items = radios.map { radio -> CPListItem in + let item = CPListItem( + text: radio.name, + detailText: nil, + image: UIImage(systemName: "dot.radiowaves.up.forward") + ) + item.handler = { [weak self] _, completion in + self?.playerVM.playRadioItem(radio: radio) + self?.showNowPlaying() + completion() + } + return item + } + template.updateSections([CPListSection(items: items)]) + + case .failure: template.updateSections([ CPListSection(items: [ - CPListItem(text: "No radio stations", detailText: nil) + CPListItem(text: String(localized: "Failed to load radios"), detailText: nil) ]) ]) - return } + } + } - let items = radios.map { radio -> CPListItem in - let item = CPListItem( - text: radio.name, - detailText: nil, - image: UIImage(systemName: "dot.radiowaves.up.forward") + return template + } + + // MARK: - Downloads Tab + + private func makeDownloadsTab() -> CPListTemplate { + let loadingItem = CPListItem(text: String(localized: "Loading downloads…"), detailText: nil) + loadingItem.isEnabled = false + let template = CPListTemplate( + title: String(localized: "Downloads"), sections: [CPListSection(items: [loadingItem])] + ) + template.tabImage = UIImage(systemName: "arrow.down.circle") + + AlbumService.shared.getDownloadedAlbum { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + + let cachedSongs = StreamCacheManager.shared.getCachedSongs() + var sections: [CPListSection] = [] + + // Cached songs section + if !cachedSongs.isEmpty { + let cachedItem = CPListItem( + text: String(localized: "Cached"), + detailText: String(localized: "\(cachedSongs.count) songs"), + image: UIImage(systemName: "music.note.list")?.withRenderingMode(.alwaysTemplate) ) - item.handler = { [weak self] _, completion in - self?.playerVM.playRadioItem(radio: radio) - self?.showNowPlaying() + cachedItem.handler = { [weak self] _, completion in + self?.showCachedSongs(songs: cachedSongs) completion() } - return item + sections.append(CPListSection(items: [cachedItem])) } - template.updateSections([CPListSection(items: items)]) - case .failure: - template.updateSections([ - CPListSection(items: [ - CPListItem(text: "Failed to load radios", detailText: nil) - ]) - ]) + switch result { + case .success(let albums): + let filtered = albums.filter { album in + !AlbumService.shared.getSongsByAlbumId(albumId: album.id).isEmpty + } + + if filtered.isEmpty && cachedSongs.isEmpty { + template.updateSections([ + CPListSection(items: [ + CPListItem( + text: String(localized: "No downloads"), + detailText: String(localized: "Download music from the app")) + ]) + ]) + return + } + + let items = filtered.map { album -> CPListItem in + let item = CPListItem( + text: album.name, + detailText: album.artist + ) + item.handler = { [weak self] _, completion in + self?.showAlbumDetail(album: album, isDownloaded: true) + completion() + } + let coverURL = AlbumService.shared.getAlbumCover( + artistName: album.albumArtist, + albumName: album.name, + albumId: album.id, + albumCover: album.albumCover + ) + CarPlayImageLoader.loadImage(from: coverURL) { image in + item.setImage(image) + } + return item + } + if !items.isEmpty { + sections.append( + CPListSection( + items: items, + header: String(localized: "Albums"), + sectionIndexTitle: nil + )) + } + template.updateSections(sections) + + case .failure: + if cachedSongs.isEmpty { + template.updateSections([ + CPListSection(items: [ + CPListItem(text: String(localized: "No downloads available"), detailText: nil) + ]) + ]) + } else { + template.updateSections(sections) + } + } } } + + return template } - return template - } + // MARK: - Cached Songs - // MARK: - Downloads Tab - - private func makeDownloadsTab() -> CPListTemplate { - let loadingItem = CPListItem(text: "Loading downloads…", detailText: nil) - loadingItem.isEnabled = false - let template = CPListTemplate( - title: "Downloads", sections: [CPListSection(items: [loadingItem])]) - template.tabImage = UIImage(systemName: "arrow.down.circle") - - AlbumService.shared.getDownloadedAlbum { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let albums): - let filtered = albums.filter { album in - !AlbumService.shared.getSongsByAlbumId(albumId: album.id).isEmpty - } + private func showCachedSongs(songs: [Song]) { + let playAllItem = CPListItem( + text: String(localized: "Play All"), + detailText: String(localized: "\(songs.count) songs"), + image: UIImage(systemName: "play.fill") + ) + playAllItem.handler = { [weak self] _, completion in + let collection = SongCollection(id: "cached-songs", name: "Cached", songs: songs) + self?.playerVM.playItem(item: collection, isFromLocal: true) + self?.showNowPlaying() + completion() + } - if filtered.isEmpty { - template.updateSections([ - CPListSection(items: [ - CPListItem(text: "No downloads", detailText: "Download music from the app") - ]) - ]) - return - } + let shuffleItem = CPListItem( + text: String(localized: "Shuffle"), + detailText: nil, + image: UIImage(systemName: "shuffle") + ) + shuffleItem.handler = { [weak self] _, completion in + let collection = SongCollection(id: "cached-songs", name: "Cached", songs: songs) + self?.playerVM.shuffleItem(item: collection, isFromLocal: true) + self?.showNowPlaying() + completion() + } - let items = filtered.map { album -> CPListItem in - let item = CPListItem( - text: album.name, - detailText: album.artist - ) - item.handler = { [weak self] _, completion in - self?.showAlbumDetail(album: album, isDownloaded: true) - completion() - } - let coverURL = AlbumService.shared.getAlbumCover( - artistName: album.albumArtist, - albumName: album.name, - albumId: album.id, - albumCover: album.albumCover - ) - CarPlayImageLoader.loadImage(from: coverURL) { image in - item.setImage(image) - } - return item - } - template.updateSections([CPListSection(items: items)]) + let actionSection = CPListSection(items: [playAllItem, shuffleItem]) - case .failure: - template.updateSections([ - CPListSection(items: [ - CPListItem(text: "No downloads available", detailText: nil) - ]) - ]) + let trackItems = songs.enumerated().map { idx, song -> CPListItem in + let item = CPListItem( + text: song.title, + detailText: song.artist + ) + item.handler = { [weak self] _, completion in + let collection = SongCollection(id: "cached-songs", name: "Cached", songs: songs) + self?.playerVM.playBySong(idx: idx, item: collection, isFromLocal: true) + self?.showNowPlaying() + completion() } + return item } - } + let trackSection = CPListSection( + items: trackItems, + header: String(localized: "Tracks"), + sectionIndexTitle: nil + ) - return template + let template = CPListTemplate( + title: String(localized: "Cached"), sections: [actionSection, trackSection]) + interfaceController.pushTemplate(template, animated: true, completion: nil) + } } -} +#endif diff --git a/flo/CarPlay/CarPlayNowPlayingManager.swift b/flo/CarPlay/CarPlayNowPlayingManager.swift index 2a99767..b689943 100644 --- a/flo/CarPlay/CarPlayNowPlayingManager.swift +++ b/flo/CarPlay/CarPlayNowPlayingManager.swift @@ -3,102 +3,124 @@ // flo // -import CarPlay import Combine -class CarPlayNowPlayingManager: NSObject { - private let playerVM: PlayerViewModel - private var cancellables = Set() - private weak var interfaceController: CPInterfaceController? +#if canImport(CarPlay) + import CarPlay - init(playerVM: PlayerViewModel, interfaceController: CPInterfaceController) { - self.playerVM = playerVM - self.interfaceController = interfaceController - super.init() - } + class CarPlayNowPlayingManager: NSObject { + private let playerVM: PlayerViewModel + private var cancellables = Set() + private weak var interfaceController: CPInterfaceController? - func configure() { - let nowPlaying = CPNowPlayingTemplate.shared - nowPlaying.add(self) + init(playerVM: PlayerViewModel, interfaceController: CPInterfaceController) { + self.playerVM = playerVM + self.interfaceController = interfaceController + super.init() + } - nowPlaying.isUpNextButtonEnabled = true - nowPlaying.upNextTitle = "Up Next" + func configure() { + let nowPlaying = CPNowPlayingTemplate.shared + nowPlaying.add(self) + + nowPlaying.isUpNextButtonEnabled = true + nowPlaying.upNextTitle = String(localized: "Up Next") + + updateButtons(on: nowPlaying) + + playerVM.$isShuffling + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateButtons(on: nowPlaying) + } + .store(in: &cancellables) + + playerVM.$playbackMode + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateButtons(on: nowPlaying) + } + .store(in: &cancellables) + + playerVM.$isStarred + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateButtons(on: nowPlaying) + } + .store(in: &cancellables) + } - updateButtons(on: nowPlaying) + func teardown() { + cancellables.removeAll() + CPNowPlayingTemplate.shared.remove(self) + } - playerVM.$isShuffling - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateButtons(on: nowPlaying) + private func updateButtons(on template: CPNowPlayingTemplate) { + let shuffleButton = CPNowPlayingShuffleButton { [weak self] _ in + self?.playerVM.shuffleCurrentQueue() } - .store(in: &cancellables) - playerVM.$playbackMode - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateButtons(on: nowPlaying) + let repeatButton = CPNowPlayingRepeatButton { [weak self] _ in + self?.playerVM.setPlaybackMode() } - .store(in: &cancellables) - } - func teardown() { - cancellables.removeAll() - CPNowPlayingTemplate.shared.remove(self) - } + if playerVM.isLiveRadio { + template.updateNowPlayingButtons([shuffleButton, repeatButton]) + } else { + let heartImage = UIImage( + systemName: playerVM.isStarred ? "heart.fill" : "heart" + )?.withRenderingMode(.alwaysTemplate) - private func updateButtons(on template: CPNowPlayingTemplate) { - let shuffleButton = CPNowPlayingShuffleButton { [weak self] _ in - self?.playerVM.shuffleCurrentQueue() - } + let heartButton = CPNowPlayingImageButton(image: heartImage ?? UIImage()) { [weak self] _ in + self?.playerVM.toggleStar() + } - let repeatButton = CPNowPlayingRepeatButton { [weak self] _ in - self?.playerVM.setPlaybackMode() + template.updateNowPlayingButtons([shuffleButton, heartButton, repeatButton]) + } } - - template.updateNowPlayingButtons([shuffleButton, repeatButton]) } -} -// MARK: - CPNowPlayingTemplateObserver + // MARK: - CPNowPlayingTemplateObserver -extension CarPlayNowPlayingManager: CPNowPlayingTemplateObserver { - func nowPlayingTemplateUpNextButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) { - guard let interfaceController = interfaceController else { return } + extension CarPlayNowPlayingManager: CPNowPlayingTemplateObserver { + func nowPlayingTemplateUpNextButtonTapped(_: CPNowPlayingTemplate) { + guard let interfaceController = interfaceController else { return } - let queue = playerVM.queue - let activeIdx = playerVM.activeQueueIdx + let queue = playerVM.queue + let activeIdx = playerVM.activeQueueIdx - let upcomingItems = queue.enumerated().compactMap { (idx, entity) -> CPListItem? in - guard idx > activeIdx else { return nil } + let upcomingItems = queue.enumerated().compactMap { idx, entity -> CPListItem? in + guard idx > activeIdx else { return nil } - let item = CPListItem( - text: entity.songName ?? "Unknown", - detailText: entity.artistName ?? "" - ) - item.handler = { [weak self] _, completion in - self?.playerVM.playFromQueue(idx: idx) - completion() + let item = CPListItem( + text: entity.songName ?? String(localized: "Unknown"), + detailText: entity.artistName ?? "" + ) + item.handler = { [weak self] _, completion in + self?.playerVM.playFromQueue(idx: idx) + completion() + } + return item } - return item - } - if upcomingItems.isEmpty { - let emptyItem = CPListItem(text: "No upcoming tracks", detailText: nil) - let template = CPListTemplate( - title: "Up Next", - sections: [CPListSection(items: [emptyItem])] - ) - interfaceController.pushTemplate(template, animated: true, completion: nil) - } else { - let template = CPListTemplate( - title: "Up Next", - sections: [CPListSection(items: upcomingItems)] - ) - interfaceController.pushTemplate(template, animated: true, completion: nil) + if upcomingItems.isEmpty { + let emptyItem = CPListItem(text: String(localized: "No upcoming tracks"), detailText: nil) + let template = CPListTemplate( + title: String(localized: "Up Next"), + sections: [CPListSection(items: [emptyItem])] + ) + interfaceController.pushTemplate(template, animated: true, completion: nil) + } else { + let template = CPListTemplate( + title: String(localized: "Up Next"), + sections: [CPListSection(items: upcomingItems)] + ) + interfaceController.pushTemplate(template, animated: true, completion: nil) + } } - } - func nowPlayingTemplateAlbumArtistButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) { - // Not used + func nowPlayingTemplateAlbumArtistButtonTapped(_: CPNowPlayingTemplate) { + // Not used + } } -} +#endif diff --git a/flo/CarPlay/CarPlaySceneDelegate.swift b/flo/CarPlay/CarPlaySceneDelegate.swift index 476188d..1411ab4 100644 --- a/flo/CarPlay/CarPlaySceneDelegate.swift +++ b/flo/CarPlay/CarPlaySceneDelegate.swift @@ -3,25 +3,28 @@ // flo // -import CarPlay import UIKit -class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { - private var coordinator: CarPlayCoordinator? +#if canImport(CarPlay) + import CarPlay - func templateApplicationScene( - _ templateApplicationScene: CPTemplateApplicationScene, - didConnect interfaceController: CPInterfaceController - ) { - coordinator = CarPlayCoordinator(interfaceController: interfaceController) - coordinator?.start() - } + class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { + private var coordinator: CarPlayCoordinator? + + func templateApplicationScene( + _: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { + coordinator = CarPlayCoordinator(interfaceController: interfaceController) + coordinator?.start() + } - func templateApplicationScene( - _ templateApplicationScene: CPTemplateApplicationScene, - didDisconnectInterfaceController interfaceController: CPInterfaceController - ) { - coordinator?.stop() - coordinator = nil + func templateApplicationScene( + _: CPTemplateApplicationScene, + didDisconnectInterfaceController _: CPInterfaceController + ) { + coordinator?.stop() + coordinator = nil + } } -} +#endif diff --git a/flo/ContentView.swift b/flo/ContentView.swift index 07353f5..ac12301 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -10,6 +10,7 @@ import SwiftUI struct ContentView: View { @AppStorage(UserDefaultsKeys.enableDebug) private var enableDebug = false + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var isPlayerExpanded: Bool = false @State private var tabViewID = UUID() @@ -26,82 +27,117 @@ struct ContentView: View { private var swipeThreshold: CGFloat = 150.0 - var body: some View { - ZStack { - TabView { - HomeView(viewModel: authViewModel).tabItem { - Label("Home", systemImage: "house") - }.environmentObject(floooViewModel) - - if authViewModel.isLoggedIn { - LibraryView(viewModel: albumViewModel).tabItem { - Label("Library", systemImage: "square.grid.2x2") - }.environmentObject(playerViewModel).environmentObject(downloadViewModel) - .onAppear { - albumViewModel.fetchAlbums() - } - } + private var isPadSidebar: Bool { + guard UIDevice.current.userInterfaceIdiom == .pad else { return false } + if #available(iOS 18.0, *) { + return true + } + return false + } - DownloadsView(viewModel: albumViewModel).tabItem { - Label("Downloads", systemImage: "arrow.down.circle") - }.environmentObject(playerViewModel).environmentObject(downloadViewModel).onAppear { - albumViewModel.fetchDownloadedAlbums() - }.badge(downloadViewModel.getRemainingDownloadItems()) + private func estimatedSidebarWidth(for totalWidth: CGFloat) -> CGFloat { + #if targetEnvironment(macCatalyst) + return min(max(totalWidth * 0.22, 220), 320) + #else + return 0 + #endif + } - PreferencesView(authViewModel: authViewModel).tabItem { - Label("Preferences", systemImage: "gear") - }.environmentObject(playerViewModel).environmentObject(floooViewModel).environmentObject(inAppPurchaseManager) + private func floatingPlayerContentCenterOffsetX(totalWidth: CGFloat) -> CGFloat { + #if targetEnvironment(macCatalyst) + return estimatedSidebarWidth(for: totalWidth) / 2 + #else + return 0 + #endif + } - if UserDefaultsManager.enableDebug { - ConsoleView().tabItem { - Label("Debug", systemImage: "terminal") - } - } + @ViewBuilder + private var baseBackgroundView: some View { + #if targetEnvironment(macCatalyst) + Color(.systemBackground) + .ignoresSafeArea() + #else + EmptyView() + #endif + } + + @ViewBuilder + private var rootTabView: some View { + if UIDevice.current.userInterfaceIdiom == .pad { + if #available(iOS 18.0, *) { + sidebarTabView + .tabViewStyle(.sidebarAdaptable) + } else { + baseTabView } - .id(tabViewID) - .onChange(of: enableDebug) { _ in - tabViewID = UUID() + } else { + baseTabView + } + } + + private var baseTabView: some View { + TabView { + HomeView(viewModel: authViewModel).tabItem { + Label("Home", systemImage: "house") + }.environmentObject(floooViewModel) + + if authViewModel.isLoggedIn { + LibraryView(viewModel: albumViewModel).tabItem { + Label("Library", systemImage: "square.grid.2x2") + }.environmentObject(playerViewModel).environmentObject(downloadViewModel) + .onAppear { + albumViewModel.fetchAlbums() + } } - ZStack { - if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { - PlayerView(isExpanded: $isPlayerExpanded, viewModel: playerViewModel) - .offset(y: isPlayerExpanded ? 0 : UIScreen.main.bounds.height) - .animation(.spring(duration: 0.2), value: isPlayerExpanded) + DownloadsView(viewModel: albumViewModel).tabItem { + Label("Downloads", systemImage: "arrow.down.circle") + }.environmentObject(playerViewModel).environmentObject(downloadViewModel).onAppear { + albumViewModel.fetchDownloadedAlbums() + }.badge(downloadViewModel.getRemainingDownloadItems()) + + PreferencesView(authViewModel: authViewModel).tabItem { + Label("Preferences", systemImage: "gear") + }.environmentObject(playerViewModel).environmentObject(floooViewModel).environmentObject( + inAppPurchaseManager) + + if UserDefaultsManager.enableDebug { + ConsoleView().tabItem { + Label("Debug", systemImage: "terminal") } } + } + .id(tabViewID) + .onChange(of: enableDebug) { _ in + tabViewID = UUID() + } + } - VStack { - Spacer() - + @available(iOS 18.0, *) + private func sidebarTabContent(_ content: Content) -> some View { + content + .overlay(alignment: .bottom) { if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { - let bottomPaddingForSmallerScreens: CGFloat = UIScreen.screenWidth <= 390 ? 32 : 0 - FloatingPlayerView(viewModel: playerViewModel) - .padding(.bottom, 40 + bottomPaddingForSmallerScreens) + .frame(maxWidth: 720) .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) - .offset( - x: self.floatingPlayerOffsetX, y: isPlayerExpanded ? UIScreen.main.bounds.height : 0 - ) - .animation(.spring(duration: 0.2), value: isPlayerExpanded) + .offset(x: floatingPlayerOffsetX) .onTapGesture { self.isPlayerExpanded = true } .gesture( DragGesture() .onChanged { value in - // only care with swipe left :)) if value.translation.width < .zero { floatingPlayerOffsetX = value.translation.width } - // debounce thing - if abs(floatingPlayerOffsetX) > swipeThreshold && !isSwipping { + if abs(floatingPlayerOffsetX) > swipeThreshold, !isSwipping { isSwipping = true } } - .onEnded { value in - if abs(floatingPlayerOffsetX) > swipeThreshold && isSwipping { + .onEnded { _ in + if abs(floatingPlayerOffsetX) > swipeThreshold, isSwipping { playerViewModel.destroyPlayerAndQueue() } @@ -111,6 +147,209 @@ struct ContentView: View { ) } } + } + + @available(iOS 18.0, *) + private var sidebarTabView: some View { + TabView { + Tab("Home", systemImage: "house") { + sidebarTabContent( + HomeView(viewModel: authViewModel) + .environmentObject(floooViewModel) + ) + } + + if authViewModel.isLoggedIn { + TabSection("Library") { + Tab("Albums", systemImage: "square.grid.2x2") { + sidebarTabContent( + LibraryView(viewModel: albumViewModel, showQuickNavigation: false) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { + albumViewModel.fetchAlbums() + } + ) + } + + Tab("Artists", systemImage: "music.mic") { + sidebarTabContent( + NavigationStack { + ArtistsView(artists: albumViewModel.artists) + .onAppear { + albumViewModel.getArtists() + } + } + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + ) + } + + Tab("Liked Songs", systemImage: "heart.fill") { + sidebarTabContent( + NavigationStack { + LikedSongsView() + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + } + ) + } + + Tab("Playlists", systemImage: "music.note.list") { + sidebarTabContent( + NavigationStack { + PlaylistView() + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { + albumViewModel.getPlaylists() + } + } + ) + } + + Tab("Songs", systemImage: "music.note") { + sidebarTabContent( + NavigationStack { + SongsView() + .environmentObject(albumViewModel) + .environmentObject(playerViewModel) + .onAppear { + albumViewModel.fetchAllSongs() + } + } + ) + } + + Tab("Radios", systemImage: "radio") { + sidebarTabContent( + NavigationStack { + RadiosView() + .environmentObject(playerViewModel) + } + ) + } + } + } + + Tab("Downloads", systemImage: "arrow.down.circle") { + sidebarTabContent( + DownloadsView(viewModel: albumViewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { + albumViewModel.fetchDownloadedAlbums() + } + ) + } + .badge(downloadViewModel.getRemainingDownloadItems()) + + Tab("Preferences", systemImage: "gear") { + sidebarTabContent( + PreferencesView(authViewModel: authViewModel) + .environmentObject(playerViewModel) + .environmentObject(floooViewModel) + .environmentObject(inAppPurchaseManager) + ) + } + + if UserDefaultsManager.enableDebug { + Tab("Debug", systemImage: "terminal") { + sidebarTabContent( + ConsoleView() + ) + } + } + } + .id(tabViewID) + .onChange(of: enableDebug) { _ in + tabViewID = UUID() + } + } + + var body: some View { + GeometryReader { geometry in + let offScreenY: CGFloat = { + #if targetEnvironment(macCatalyst) + geometry.size.height + #else + UIScreen.main.bounds.height + #endif + }() + + ZStack { + baseBackgroundView + + rootTabView + + if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { + PlayerView(isExpanded: $isPlayerExpanded, viewModel: playerViewModel) + .ignoresSafeArea() + .offset(y: isPlayerExpanded ? 0 : offScreenY) + .animation(.spring(duration: 0.2), value: isPlayerExpanded) + } + + if !isPadSidebar { + VStack { + Spacer() + + if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { + let isSmallScreen = UIScreen.main.bounds.width <= 390 + let isPad = UIDevice.current.userInterfaceIdiom == .pad + let bottomPadding: CGFloat = isSmallScreen ? 32 : 0 + let playerWidth: CGFloat? = + isPad + ? 720 + : (horizontalSizeClass == .regular ? 500 : nil) + let playerCenterOffsetX = floatingPlayerContentCenterOffsetX( + totalWidth: geometry.size.width + ) + let playerBottomPadding: CGFloat = { + #if targetEnvironment(macCatalyst) + 10 + #else + isPad ? 0 : (40 + bottomPadding) + #endif + }() + + FloatingPlayerView(viewModel: playerViewModel) + .frame(maxWidth: playerWidth ?? .infinity) + .padding(.bottom, playerBottomPadding) + .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) + .offset( + x: playerCenterOffsetX + self.floatingPlayerOffsetX, + y: isPlayerExpanded ? offScreenY : 0 + ) + .animation(.spring(duration: 0.2), value: isPlayerExpanded) + .onTapGesture { + self.isPlayerExpanded = true + } + .gesture( + DragGesture() + .onChanged { value in + if value.translation.width < .zero { + floatingPlayerOffsetX = value.translation.width + } + + if abs(floatingPlayerOffsetX) > swipeThreshold, !isSwipping { + isSwipping = true + } + } + .onEnded { _ in + if abs(floatingPlayerOffsetX) > swipeThreshold, isSwipping { + playerViewModel.destroyPlayerAndQueue() + } + + self.floatingPlayerOffsetX = .zero + self.isSwipping = false + } + ) + } + } + } + } } .onAppear { PlaybackCoordinator.shared.attach(playerViewModel: playerViewModel) diff --git a/flo/FloatingPlayerView.swift b/flo/FloatingPlayerView.swift index 8ded213..94c75cc 100644 --- a/flo/FloatingPlayerView.swift +++ b/flo/FloatingPlayerView.swift @@ -122,7 +122,6 @@ struct FloatingPlayerView: View { .shadow(color: .black.opacity(0.15), radius: 16, x: 0, y: 6) .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) .padding(.horizontal, 16) - .padding(.bottom, UIScreen.screenWidth <= 390 ? 8 : 0) } } diff --git a/flo/FloooViewModel.swift b/flo/FloooViewModel.swift index c7bbe4f..a168bc1 100644 --- a/flo/FloooViewModel.swift +++ b/flo/FloooViewModel.swift @@ -13,6 +13,7 @@ class FloooViewModel: ObservableObject { @Published var downloadedSongs: Int = 0 @Published var localDirectorySize: String = "0 MB" + @Published var streamCacheSize: String = "0 MB" @Published var stats: Stats? @Published var totalPlay: Int = 0 @@ -60,9 +61,11 @@ class FloooViewModel: ObservableObject { Task { do { let calculateDirectorySize = try await LocalFileManager.shared.calculateDirectorySize() + let cacheSize = await StreamCacheManager.shared.calculateCacheSize() await MainActor.run { self.localDirectorySize = calculateDirectorySize + self.streamCacheSize = bytesToMBOrGB(cacheSize) } } catch { print("Error: \(error)") diff --git a/flo/Info.plist b/flo/Info.plist index d0693b9..a25feb1 100644 --- a/flo/Info.plist +++ b/flo/Info.plist @@ -2,43 +2,41 @@ - ITSAppUsesNonExemptEncryption - UIAppFonts PlusJakartaSans-VariableFont_wght.ttf - UIBackgroundModes - - audio - UIApplicationSceneManifest UIApplicationSupportsMultipleScenes UISceneConfigurations - UIWindowSceneSessionRoleApplication + CPTemplateApplicationSceneSessionRoleApplication - UISceneConfigurationName - Default Configuration UISceneClassName - UIWindowScene + CPTemplateApplicationScene + UISceneConfigurationName + CarPlay Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate - CPTemplateApplicationSceneSessionRoleApplication + UIWindowSceneSessionRoleApplication - UISceneConfigurationName - CarPlay Configuration UISceneClassName - CPTemplateApplicationScene - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + UIWindowScene + UISceneConfigurationName + Default Configuration + UIBackgroundModes + + audio + diff --git a/flo/LoginView.swift b/flo/LoginView.swift index ed34a0e..821b269 100644 --- a/flo/LoginView.swift +++ b/flo/LoginView.swift @@ -26,6 +26,18 @@ struct Login: View { headerSection formSection } + .overlay(alignment: .topTrailing) { + if UIDevice.current.userInterfaceIdiom == .pad { + Button(action: { showLoginSheet = false }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .frame(width: 34, height: 34) + .glassedEffect(in: Circle(), interactive: true) + } + .padding() + } + } .alert(isPresented: $viewModel.showAlert) { Alert( title: Text("Login Failed"), @@ -44,6 +56,8 @@ struct Login: View { } .background(Color(.systemBackground)) .foregroundColor(.accent) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) } private var extraMessage: some View { diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift index d4a513f..7f2cccd 100644 --- a/flo/LyricsView.swift +++ b/flo/LyricsView.swift @@ -13,6 +13,8 @@ struct LyricsView: View { @Binding var showQueue: Bool let imageSize: CGFloat + let topSafeInset: CGFloat + let bottomSafeInset: CGFloat private var isPlainLyrics: Bool { return viewModel.lyrics.count == 1 @@ -69,7 +71,7 @@ struct LyricsView: View { } } .padding(.horizontal, 30) - .padding(.top, 16) + .padding(.top, topSafeInset + 8) .padding(.bottom, 16) .onTapGesture { viewModel.toggleLyricsMode() @@ -149,11 +151,26 @@ struct LyricsView: View { .font(.title2) .foregroundColor(.white) } - .frame(width: 56, alignment: .leading) + .frame(width: 44, height: 44) + + Spacer(minLength: 0) + + Button { + viewModel.toggleStar() + } label: { + Image(systemName: viewModel.isStarred ? "heart.fill" : "heart") + .font(.title2) + .foregroundColor(.white) + } + .disabled(viewModel.isLiveRadio) + .opacity(viewModel.isLiveRadio ? 0.4 : 1) + .frame(width: 44, height: 44) + + Spacer(minLength: 0) AirPlayRoutePicker(tintColor: UIColor.white, activeTintColor: UIColor.white) - .frame(width: 36, height: 36, alignment: .center) - .frame(maxWidth: .infinity, alignment: .center) + .frame(width: 36, height: 36) + .frame(width: 44, height: 44) .overlay(alignment: .bottom) { if let outputName = viewModel.externalOutputName { Text(outputName) @@ -168,6 +185,8 @@ struct LyricsView: View { } } + Spacer(minLength: 0) + Button { showQueue.toggle() } label: { @@ -196,10 +215,11 @@ struct LyricsView: View { .offset(x: 10, y: -10) ) } - .frame(width: 56, alignment: .trailing) + .frame(width: 44, height: 44) } - .padding(.horizontal, 30) + .padding(.horizontal, 18) .padding(.top, 10) + .padding(.bottom, max(bottomSafeInset, 12)) } } } diff --git a/flo/Navigation/CachedSongsView.swift b/flo/Navigation/CachedSongsView.swift new file mode 100644 index 0000000..2693b66 --- /dev/null +++ b/flo/Navigation/CachedSongsView.swift @@ -0,0 +1,73 @@ +// +// CachedSongsView.swift +// flo +// + +import NukeUI +import SwiftUI + +struct CachedSongsView: View { + @ObservedObject var viewModel: AlbumViewModel + @EnvironmentObject private var playerViewModel: PlayerViewModel + + let songs: [Song] + + var body: some View { + ScrollView { + LazyVStack { + ForEach(Array(songs.enumerated()), id: \.element.id) { idx, song in + VStack { + HStack { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt(id: song.albumId))) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60) + .clipShape( + RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + } else { + Color("PlayerColor").frame(width: 60, height: 60) + .cornerRadius(5) + } + } + + VStack(alignment: .leading) { + Text(song.title) + .customFont(.headline) + .multilineTextAlignment(.leading) + .lineLimit(2) + .padding(.bottom, 3) + + Text(song.artist) + .customFont(.subheadline) + .foregroundColor(.gray) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 10) + + Spacer() + + Text(timeString(for: song.duration)).customFont(.caption1) + } + .padding(.horizontal) + .background(Color(UIColor.systemBackground)) + + Divider() + } + .onTapGesture { + let cached = SongCollection(id: "cached-songs", name: "Cached", songs: songs) + playerViewModel.playBySong(idx: idx, item: cached, isFromLocal: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 10) + .padding( + .bottom, playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer ? 100 : 0) + } + .navigationTitle("Cached") + } +} diff --git a/flo/Navigation/DownloadsView.swift b/flo/Navigation/DownloadsView.swift index fe5d606..d2c4c7c 100644 --- a/flo/Navigation/DownloadsView.swift +++ b/flo/Navigation/DownloadsView.swift @@ -9,15 +9,21 @@ import SwiftUI struct DownloadsView: View { @State private var searchAlbum = "" + @State private var cachedSongs: [Song] = [] @ObservedObject var viewModel: AlbumViewModel @EnvironmentObject var playerViewModel: PlayerViewModel - let columns = [ - GridItem(.flexible()), - GridItem(.flexible()), - ] + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + private var columns: [GridItem] { + if horizontalSizeClass == .regular { + return Array(repeating: GridItem(.flexible()), count: 4) + } else { + return Array(repeating: GridItem(.flexible()), count: 2) + } + } var filteredAlbums: [Album] { if searchAlbum.isEmpty { @@ -32,8 +38,8 @@ struct DownloadsView: View { var body: some View { NavigationStack { ScrollView { - if viewModel.downloadedAlbums.isEmpty { - VStack(alignment: .leading) { + if viewModel.downloadedAlbums.isEmpty && cachedSongs.isEmpty { + VStack(alignment: .center) { Image("Downloads").resizable().aspectRatio(contentMode: .fit).frame(width: 300) .padding() .padding(.bottom, 10) @@ -41,15 +47,46 @@ struct DownloadsView: View { Text("Going off the grid?") .customFont(.title1) .fontWeight(.bold) - .multilineTextAlignment(.leading) + .multilineTextAlignment(.center) .padding(.bottom, 10) Text( "Bring your music anywhere, even when you're offline. Your downloaded music will be here." ) .customFont(.subheadline) + .multilineTextAlignment(.center) }.padding(.horizontal, 20).foregroundColor(.accent) } + .frame(maxWidth: .infinity) + } + + // Cached songs section + if !cachedSongs.isEmpty { + NavigationLink { + CachedSongsView(viewModel: viewModel, songs: cachedSongs) + } label: { + HStack { + Image(systemName: "music.note.list") + .font(.title3) + .foregroundColor(.accentColor) + .frame(width: 40) + VStack(alignment: .leading) { + Text("Cached") + .customFont(.headline) + Text("\(cachedSongs.count) songs") + .customFont(.caption1) + .foregroundColor(.gray) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + + Divider().padding(.horizontal) } LazyVGrid(columns: columns, spacing: 20) { @@ -72,6 +109,9 @@ struct DownloadsView: View { prompt: "Search" ) } + .onAppear { + cachedSongs = StreamCacheManager.shared.getCachedSongs() + } } } } diff --git a/flo/Navigation/HomeView.swift b/flo/Navigation/HomeView.swift index 242d66f..c52cb15 100644 --- a/flo/Navigation/HomeView.swift +++ b/flo/Navigation/HomeView.swift @@ -55,114 +55,149 @@ struct HomeView: View { ) } - var body: some View { - VStack { - HStack { - Text("Home").font(.system(size: 32)).foregroundColor(.primary).fontWeight(.bold).padding( - .vertical) - Spacer() - Menu { - Button(action: { - showLoginSheet = true - }) { - if !viewModel.isLoggedIn { - Text("Login") - } else { - Text("Logged in as \(viewModel.user?.name ?? "")") - } - }.disabled(viewModel.isLoggedIn) - if viewModel.isLoggedIn { - Button(action: { - viewModel.logout() - }) { - Text("Logout") - } - } - } label: { - 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()) { - Login(viewModel: viewModel, showLoginSheet: $showLoginSheet) - .onDisappear { - if viewModel.isLoggedIn { - self.floooViewModel.checkScanStatus() + private func homeContentWidth(for availableWidth: CGFloat) -> CGFloat { + let horizontalPadding: CGFloat = 32 + let baseWidth = max(availableWidth - horizontalPadding, 0) + + if UIDevice.current.userInterfaceIdiom == .pad { + return min(baseWidth, 700) + } + + return baseWidth + } + + private var mainContent: some View { + GeometryReader { rootGeometry in + let contentWidth = homeContentWidth(for: rootGeometry.size.width) + let horizontalInset = max((rootGeometry.size.width - contentWidth) / 2, 16) + + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("") + .font(.system(size: 32)) + .foregroundColor(.primary) + .fontWeight(.bold) + .padding(.vertical) + Spacer() + Menu { + Button(action: { + showLoginSheet = true + }) { + if !viewModel.isLoggedIn { + Text("Login") + } else { + Text("Logged in as \(viewModel.user?.name ?? "")") + } + }.disabled(viewModel.isLoggedIn) + if viewModel.isLoggedIn { + Button(action: { + viewModel.logout() + }) { + Text("Logout") + } + } + } label: { + 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() - - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text("Listening Activity (all time)").customFont(.title2).fontWeight(.bold) - .multilineTextAlignment(.leading) - - let statCardSpacing: CGFloat = UIScreen.screenWidth <= 390 ? 8 : 16 - - HStack(alignment: .top, spacing: statCardSpacing) { - 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 - ) - } + Text("Listening Activity (all time)").customFont(.title2).fontWeight(.bold) + .multilineTextAlignment(.leading) + + let statCardSpacing: CGFloat = rootGeometry.size.width <= 390 ? 8 : 16 + + HStack(alignment: .top, spacing: statCardSpacing) { + 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 + ) + } - HStack(alignment: .top, spacing: 16) { - StatCard( - title: "Top Album", - value: floooViewModel.stats?.topAlbum ?? "N/A", - subtitle: floooViewModel.stats?.topAlbumArtist ?? "N/A", - icon: "record.circle", - color: .pink, - isWide: true, - showArrow: true - ) - } + HStack(alignment: .top, spacing: 16) { + StatCard( + title: "Top Album", + value: floooViewModel.stats?.topAlbum ?? "N/A", + subtitle: floooViewModel.stats?.topAlbumArtist ?? "N/A", + icon: "record.circle", + color: .pink, + isWide: true, + showArrow: true + ) + } - HStack(spacing: 16) { - StatCard( - title: "Experimental", - value: "More data is cooking soon", - icon: "chart.pie", - color: .indigo, - isWide: false, - showArrow: false + HStack(spacing: 16) { + StatCard( + title: "Experimental", + value: "More data is cooking soon", + icon: "chart.pie", + color: .indigo, + isWide: false, + showArrow: false + ) + } + Text( + "This stat is generated on-device (once every session) and no data is stored or shared with a third party — #selfhosting, baby!" ) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .customFont(.caption1) + .lineSpacing(2) } - Text( - "This stat is generated on-device (once every session) and no data is stored or shared with a third party — #selfhosting, baby!" - ) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - .customFont(.caption1) - .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, horizontalInset) + .padding(.bottom, 100) } - .padding(.bottom, 100) - .padding(.horizontal) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } .onAppear { self.floooViewModel.getListeningHistory() } } + + private var loginContent: some View { + Login(viewModel: viewModel, showLoginSheet: $showLoginSheet) + .onDisappear { + if viewModel.isLoggedIn { + self.floooViewModel.checkScanStatus() + } + } + } + + var body: some View { + Group { + if UIDevice.current.userInterfaceIdiom == .pad { + AnyView(mainContent.fullScreenCover(isPresented: shouldShowLoginSheet()) { + loginContent + }) + } else { + AnyView(mainContent.sheet(isPresented: shouldShowLoginSheet()) { + loginContent + }) + } + } + } } struct HomeViewPreviews_Previews: PreviewProvider { diff --git a/flo/Navigation/LibraryView.swift b/flo/Navigation/LibraryView.swift index 80b6ace..a20dead 100644 --- a/flo/Navigation/LibraryView.swift +++ b/flo/Navigation/LibraryView.swift @@ -8,18 +8,31 @@ import SwiftUI struct LibraryView: View { + let showQuickNavigation: Bool @State private var searchAlbum = "" @State private var showDownloadSheet: Bool = false + @State private var forceShowQuickNavigation: Bool = false @ObservedObject var viewModel: AlbumViewModel @EnvironmentObject var playerViewModel: PlayerViewModel @EnvironmentObject var downloadViewModel: DownloadViewModel - let columns = [ - GridItem(.flexible()), - GridItem(.flexible()), - ] + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + private var columns: [GridItem] { + if horizontalSizeClass == .regular { + return Array(repeating: GridItem(.flexible()), count: 4) + } else { + return Array(repeating: GridItem(.flexible()), count: 2) + } + } + + init(viewModel: AlbumViewModel, showQuickNavigation: Bool = true) { + self.viewModel = viewModel + self.showQuickNavigation = showQuickNavigation + _forceShowQuickNavigation = State(initialValue: !showQuickNavigation) + } var filteredAlbums: [Album] { if searchAlbum.isEmpty { @@ -31,181 +44,239 @@ struct LibraryView: View { } } + private var shouldShowQuickNavigation: Bool { + showQuickNavigation || forceShowQuickNavigation + } + var body: some View { NavigationStack { - ScrollView { - if viewModel.albums.isEmpty && viewModel.error != nil { - VStack(alignment: .leading) { - Image("Home").resizable().aspectRatio(contentMode: .fit).frame( - maxWidth: .infinity, maxHeight: 300 - ).padding() - Group { - Text("Your Navidrome session may have expired") - .customFont(.title1) - .fontWeight(.bold) - .multilineTextAlignment(.leading) - .padding(.bottom, 10) - Text( - "The quickest action you can take is to log back in — for now." - ) - .customFont(.subheadline) - - }.padding(.horizontal, 20).foregroundColor(.accent) - } - } else { - if searchAlbum.isEmpty { - NavigationLink { - ArtistsView(artists: viewModel.artists) - .environmentObject(viewModel) - .onAppear { - viewModel.getArtists() - } - } label: { - HStack { - Image(systemName: "music.mic") - .frame(width: 20, height: 10) - .foregroundColor(.accent) - Text("Artists") - .customFont(.headline) - .padding(.leading, 8) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.gray) - .font(.caption) - }.padding(.horizontal).padding(.vertical, 5) - } + libraryContent + } + } - Divider() + var libraryContent: some View { + ScrollView { + if viewModel.albums.isEmpty && viewModel.error != nil { + VStack(alignment: .center) { + Image("Home").resizable().aspectRatio(contentMode: .fit).frame( + maxWidth: .infinity, maxHeight: 300 + ).padding() + Group { + Text("Your Navidrome session may have expired") + .customFont(.title1) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .padding(.bottom, 10) + Text( + "The quickest action you can take is to log back in — for now." + ) + .customFont(.subheadline) + .multilineTextAlignment(.center) - NavigationLink { - PlaylistView() - .environmentObject(viewModel) - .environmentObject(playerViewModel) - .environmentObject(downloadViewModel) - .onAppear { - viewModel.getPlaylists() - } - } label: { - HStack { - Image(systemName: "music.note.list") - .frame(width: 20, height: 10) - .foregroundColor(.accent) - Text("Playlists") - .customFont(.headline) - .padding(.leading, 8) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.gray) - .font(.caption) - }.padding(.horizontal).padding(.vertical, 5) + }.padding(.horizontal, 20).foregroundColor(.accent) + } + .frame(maxWidth: .infinity) + } else { + if !showQuickNavigation && searchAlbum.isEmpty { + Button(action: { + forceShowQuickNavigation.toggle() + }) { + HStack { + Image(systemName: forceShowQuickNavigation ? "eye.slash" : "list.bullet") + Text(forceShowQuickNavigation ? "Hide quick links" : "Show quick links") + .customFont(.headline) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) } + .padding(.horizontal) + .padding(.vertical, 5) + } + Divider() + } - Divider() + if shouldShowQuickNavigation && searchAlbum.isEmpty { + NavigationLink { + ArtistsView(artists: viewModel.artists) + .environmentObject(viewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { + viewModel.getArtists() + } + } label: { + HStack { + Image(systemName: "music.mic") + .frame(width: 20, height: 10) + .foregroundColor(.accent) + Text("Artists") + .customFont(.headline) + .padding(.leading, 8) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) + }.padding(.horizontal).padding(.vertical, 5) + } - NavigationLink { - SongsView() - .environmentObject(viewModel) - .environmentObject(playerViewModel) - .onAppear { - viewModel.fetchAllSongs() - } - } label: { - HStack { - Image(systemName: "music.note") - .frame(width: 20, height: 10) - .foregroundColor(.accent) - Text("Songs") - .customFont(.headline) - .padding(.leading, 8) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.gray) - .font(.caption) - }.padding(.horizontal).padding(.vertical, 5) - } + Divider() - 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() + NavigationLink { + LikedSongsView() + .environmentObject(viewModel) + .environmentObject(playerViewModel) + } label: { + HStack { + Image(systemName: "heart.fill") + .frame(width: 20, height: 10) + .foregroundColor(.accent) + Text("Liked Songs") + .customFont(.headline) + .padding(.leading, 8) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) + }.padding(.horizontal).padding(.vertical, 5) + } + + Divider() + + NavigationLink { + PlaylistView() + .environmentObject(viewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { + viewModel.getPlaylists() + } + } label: { + HStack { + Image(systemName: "music.note.list") + .frame(width: 20, height: 10) + .foregroundColor(.accent) + Text("Playlists") + .customFont(.headline) + .padding(.leading, 8) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) + }.padding(.horizontal).padding(.vertical, 5) } - LazyVGrid(columns: columns) { - ForEach(filteredAlbums) { album in - NavigationLink { - AlbumView(viewModel: viewModel) - .environmentObject(downloadViewModel) - .onAppear { - viewModel.setActiveAlbum(album: album) - } - } label: { - AlbumsView(viewModel: viewModel, album: album) + Divider() + + NavigationLink { + SongsView() + .environmentObject(viewModel) + .environmentObject(playerViewModel) + .onAppear { + viewModel.fetchAllSongs() } + } label: { + HStack { + Image(systemName: "music.note") + .frame(width: 20, height: 10) + .foregroundColor(.accent) + Text("Songs") + .customFont(.headline) + .padding(.leading, 8) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) + }.padding(.horizontal).padding(.vertical, 5) + } + + 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) { + ForEach(filteredAlbums) { album in + NavigationLink { + AlbumView(viewModel: viewModel) + .environmentObject(downloadViewModel) + .onAppear { + viewModel.setActiveAlbum(album: album) + } + } label: { + AlbumsView(viewModel: viewModel, album: album) } } - .padding(.top, 10) - .padding( - .bottom, playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer ? 100 : 0 - ) - .searchable( - text: $searchAlbum, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search" - ) } + .padding(.top, 10) + .padding( + .bottom, playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer ? 100 : 0 + ) + .searchable( + text: $searchAlbum, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search" + ) } - .sheet(isPresented: $showDownloadSheet) { - DownloadQueueView().environmentObject(downloadViewModel) - } - .toolbar { - if downloadViewModel.hasDownloadQueue() { - Button(action: { - showDownloadSheet.toggle() - }) { - Label("", systemImage: "icloud.and.arrow.down") - } + } + .sheet(isPresented: $showDownloadSheet) { + DownloadQueueView().environmentObject(downloadViewModel) + } + .toolbar { + if downloadViewModel.hasDownloadQueue() { + Button(action: { + showDownloadSheet.toggle() + }) { + Label("", systemImage: "icloud.and.arrow.down") } } - .navigationTitle("Library") + } + .navigationTitle("Library") + .refreshable { + await viewModel.refreshAlbums() + await viewModel.refreshArtists() + await viewModel.refreshPlaylists() } } } struct LibraryView_Previews: PreviewProvider { - static private var songs: [Song] = [ + private static var songs: [Song] = [ Song( id: "0", title: "Song name", albumId: "", albumName: "Album 1", artist: "", trackNumber: 1, discNumber: 0, bitRate: 0, sampleRate: 44100, - suffix: "m4a", duration: 100, mediaFileId: "0") + suffix: "m4a", duration: 100, mediaFileId: "0" + ) ] - static private var albums: [Album] = [ + private static var albums: [Album] = [ Album( name: "Album 1", artist: "Artist 1", songs: songs ) ] - @StateObject static private var playerViewModel: PlayerViewModel = PlayerViewModel() - @StateObject static private var viewModel: AlbumViewModel = AlbumViewModel(albums: albums) + @StateObject private static var playerViewModel: PlayerViewModel = .init() + @StateObject private static var viewModel: AlbumViewModel = .init(albums: albums) static var previews: some View { LibraryView(viewModel: viewModel).environmentObject(playerViewModel) diff --git a/flo/Navigation/LikedSongsView.swift b/flo/Navigation/LikedSongsView.swift new file mode 100644 index 0000000..1b80cd4 --- /dev/null +++ b/flo/Navigation/LikedSongsView.swift @@ -0,0 +1,75 @@ +// +// LikedSongsView.swift +// flo +// + +import NukeUI +import SwiftUI + +struct LikedSongsView: View { + @EnvironmentObject private var viewModel: AlbumViewModel + @EnvironmentObject private var playerViewModel: PlayerViewModel + + var body: some View { + ScrollView { + LazyVStack { + ForEach(Array(viewModel.starredSongs.enumerated()), id: \.element.id) { idx, song in + VStack { + HStack { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt(id: song.albumId))) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60) + .clipShape( + RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + } else { + Color("PlayerColor").frame(width: 60, height: 60) + .cornerRadius(5) + } + } + + VStack(alignment: .leading) { + Text(song.title) + .customFont(.headline) + .multilineTextAlignment(.leading) + .lineLimit(2) + .padding(.bottom, 3) + + Text(song.artist) + .customFont(.subheadline) + .foregroundColor(.gray) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 10) + + Spacer() + } + .padding(.horizontal) + .background(Color(UIColor.systemBackground)) + + Divider() + } + .onTapGesture { + let liked = SongCollection( + id: "starred-songs", name: "Liked Songs", songs: viewModel.starredSongs) + playerViewModel.playBySong(idx: idx, item: liked, isFromLocal: false) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 10) + .padding(.bottom, 100) + .navigationTitle("Liked Songs") + } + .onAppear { + viewModel.fetchStarredSongs() + } + .onChange(of: playerViewModel.isStarred) { _ in + viewModel.fetchStarredSongs() + } + } +} diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 27438d1..9b2dbd0 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -116,6 +116,8 @@ struct PreferencesView: View { @State private var experimentalMaxBitrate = UserDefaultsManager.maxBitRate @State private var experimentalPlayerBackground = UserDefaultsManager.playerBackground + @State private var experimentalStreamCacheSize = UserDefaultsManager.streamCacheMaxSize + @State private var clearStreamCacheAlert = false @State private var experimentalLRCLIBIntegration = UserDefaultsManager.LRCLIBServerURL @State private var customLRCLIBServer = "" @@ -169,7 +171,7 @@ struct PreferencesView: View { return "000000" } - var body: some View { + private var mainContent: some View { NavigationStack { Form { Section(header: Text("Local Storage")) { @@ -191,6 +193,41 @@ struct PreferencesView: View { Text(floooViewModel.localDirectorySize) } + HStack { + Text("Streaming cache") + Spacer() + Text(floooViewModel.streamCacheSize) + } + + Picker("Cache limit", selection: $experimentalStreamCacheSize) { + Text("Off").tag(Int64(0)) + Text("500 MB").tag(Int64(524_288_000)) + Text("1 GB").tag(Int64(1_073_741_824)) + Text("2 GB").tag(Int64(2_147_483_648)) + Text("5 GB").tag(Int64(5_368_709_120)) + } + .onChange(of: experimentalStreamCacheSize) { value in + UserDefaultsManager.streamCacheMaxSize = value + } + + Button(action: { + self.clearStreamCacheAlert.toggle() + }) { + Text("Clear streaming cache") + }.alert( + "Clear Streaming Cache", isPresented: $clearStreamCacheAlert, + actions: { + Button( + "Clear", role: .destructive, + action: { + StreamCacheManager.shared.clearCache() + floooViewModel.getLocalStorageInformation() + }) + }, + message: { + Text("This will delete all cached streamed songs. Downloads are not affected.") + }) + Button( role: .destructive, action: { @@ -386,23 +423,6 @@ struct PreferencesView: View { : "flo will store your server URL, username, and password in the Keychain with no biometric protection. If you enable this, flo will try to 'refresh' the auth token—by logging you in automatically—every time you open flo so you'll never log out unless you do it explicitly (it will also reset this option). Logging in via OAuth will reset this option." ).font(.caption).foregroundColor(.gray) } - .sheet(isPresented: shouldShowLoginSheet) { - Login(viewModel: authViewModel, showLoginSheet: $showLoginSheet) - .onDisappear { - if authViewModel.isLoggedIn { - self.floooViewModel.checkScanStatus() - self.floooViewModel.checkAccountLinkStatus() - } - - if UserDefaultsManager.enableDebug { - floooViewModel.getUserDefaults() - } - - if !showLoginSheet && authViewModel.experimentalSaveLoginInfo { - authViewModel.experimentalSaveLoginInfo = false - } - } - } if authViewModel.isLoggedIn { VStack(alignment: .leading, spacing: 6) { @@ -583,6 +603,38 @@ struct PreferencesView: View { Text("Learn more at https://dub.sh/flo-lrclib") } } + + private var loginContent: some View { + Login(viewModel: authViewModel, showLoginSheet: $showLoginSheet) + .onDisappear { + if authViewModel.isLoggedIn { + self.floooViewModel.checkScanStatus() + self.floooViewModel.checkAccountLinkStatus() + } + + if UserDefaultsManager.enableDebug { + floooViewModel.getUserDefaults() + } + + if !showLoginSheet && authViewModel.experimentalSaveLoginInfo { + authViewModel.experimentalSaveLoginInfo = false + } + } + } + + var body: some View { + Group { + if UIDevice.current.userInterfaceIdiom == .pad { + AnyView(mainContent.fullScreenCover(isPresented: shouldShowLoginSheet) { + loginContent + }) + } else { + AnyView(mainContent.sheet(isPresented: shouldShowLoginSheet) { + loginContent + }) + } + } + } } struct FloPlusSheet: View { diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 224a82b..6b77ac7 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -7,6 +7,7 @@ import NukeUI import SwiftUI +import UIKit struct PlayerView: View { @Binding var isExpanded: Bool @@ -20,223 +21,224 @@ struct PlayerView: View { @GestureState private var queueDragOffset: CGSize = .zero + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + var body: some View { - GeometryReader { - let size = $0.size - let imageSize: CGFloat = 300 - - // FIXME: Refactor this? - ZStack(alignment: .topLeading) { - Color(.systemBackground) - .ignoresSafeArea() - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - VStack(alignment: .leading) { - HStack { - Spacer() + GeometryReader { proxy in + let size = proxy.size + let topSafeInset = max(proxy.safeAreaInsets.top, windowTopSafeInset) + let bottomSafeInset = proxy.safeAreaInsets.bottom + let imageSize: CGFloat = horizontalSizeClass == .regular ? min(400, size.width * 0.4) : 300 + let isIPadPortrait = UIDevice.current.userInterfaceIdiom == .pad && size.height > size.width + let queueSheetHeight = isIPadPortrait ? min(700, max(500, size.height * 0.62)) : 500 - Rectangle() - .foregroundColor(Color.gray.opacity(0.3)) - .frame(width: 50, height: 5) - .cornerRadius(30) - .padding(.top) + ZStack { + playerBackground() + .offset(y: offset.height) - Spacer() - } - VStack(alignment: .leading, spacing: 3) { - Text("Playing Next").customFont(.headline) - - HStack(alignment: .bottom, spacing: 10) { - if viewModel.queue.isEmpty { - Text("").customFont(.subheadline) - } else { - Text( - "From \(viewModel.nowPlaying.contextName ?? viewModel.nowPlaying.albumName ?? "")" - ).customFont(.subheadline) - } + ZStack { + // Keep interactive content draggable while preserving full-bleed background. + ZStack(alignment: .topLeading) { + Color(.systemBackground) + .ignoresSafeArea() + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + VStack(alignment: .leading) { + HStack { + Spacer() - Spacer() - - Button { - viewModel.shuffleCurrentQueue() - } label: { - Image(systemName: "shuffle") - .foregroundColor(Color.accentColor) - .fontWeight(.bold) - .padding(5) - .background( - viewModel.isShuffling ? Color.gray.opacity(0.2) : Color(.systemBackground) - ) - .cornerRadius(5) - } + Rectangle() + .foregroundColor(Color.gray.opacity(0.3)) + .frame(width: 50, height: 5) + .cornerRadius(30) + .padding(.top) - Button { - viewModel.setPlaybackMode() - } label: { - Image(systemName: "repeat") - .foregroundColor(Color.accentColor) - .fontWeight(.bold) - .overlay( - Group { - Text("1") - .font(.caption) - .clipShape(Circle()) - .offset(x: 10, y: -5) - .fontWeight(.bold) - }.opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) - ) - .padding(5) - .background( - viewModel.playbackMode == PlaybackMode.defaultPlayback - ? Color(.systemBackground) : Color.gray.opacity(0.2) - ) - .cornerRadius(5) + Spacer() } - } - } - .padding(.horizontal) - .padding(.bottom, 5) - - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(viewModel.queue.indices, id: \.self) { idx in - HStack(alignment: .top) { - VStack(alignment: .leading) { - Text(viewModel.queue[idx].songName ?? "") - .customFont(.callout) - .fontWeight(.medium) - .padding(.bottom, 3) - - Text(viewModel.queue[idx].artistName ?? "") - .customFont(.caption1) + VStack(alignment: .leading, spacing: 3) { + Text("Playing Next").customFont(.headline) + + HStack(alignment: .bottom, spacing: 10) { + if viewModel.queue.isEmpty { + Text("").customFont(.subheadline) + } else { + Text( + "From \(viewModel.nowPlaying.contextName ?? viewModel.nowPlaying.albumName ?? "")" + ).customFont(.subheadline) } - .frame(maxWidth: .infinity, alignment: .leading) Spacer() - Text(timeString(for: viewModel.queue[idx].duration)).customFont(.caption1) - .padding(.top, 4) + Button { + viewModel.shuffleCurrentQueue() + } label: { + Image(systemName: "shuffle") + .foregroundColor(Color.accentColor) + .fontWeight(.bold) + .padding(5) + .background( + viewModel.isShuffling ? Color.gray.opacity(0.2) : Color(.systemBackground) + ) + .cornerRadius(5) + } + + Button { + viewModel.setPlaybackMode() + } label: { + Image(systemName: "repeat") + .foregroundColor(Color.accentColor) + .fontWeight(.bold) + .overlay( + Group { + Text("1") + .font(.caption) + .clipShape(Circle()) + .offset(x: 10, y: -5) + .fontWeight(.bold) + }.opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) + ) + .padding(5) + .background( + viewModel.playbackMode == PlaybackMode.defaultPlayback + ? Color(.systemBackground) : Color.gray.opacity(0.2) + ) + .cornerRadius(5) + } } - .padding(.vertical, 5) - .padding(.horizontal) - .background( - viewModel.activeQueueIdx == idx - ? Color.gray.opacity(0.1) : Color(.systemBackground) - ) - .onTapGesture { - viewModel.playFromQueue(idx: idx) + } + .padding(.horizontal) + .padding(.bottom, 5) + + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(viewModel.queue.indices, id: \.self) { idx in + HStack(alignment: .top) { + VStack(alignment: .leading) { + Text(viewModel.queue[idx].songName ?? "") + .customFont(.callout) + .fontWeight(.medium) + .padding(.bottom, 3) + + Text(viewModel.queue[idx].artistName ?? "") + .customFont(.caption1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + Text(timeString(for: viewModel.queue[idx].duration)).customFont(.caption1) + .padding(.top, 4) + } + .padding(.vertical, 5) + .padding(.horizontal) + .background( + viewModel.activeQueueIdx == idx + ? Color.gray.opacity(0.1) : Color(.systemBackground) + ) + .onTapGesture { + viewModel.playFromQueue(idx: idx) + } + } + } + }.padding(.bottom, 60) + } + } + .gesture( + DragGesture() + .updating($queueDragOffset) { value, state, _ in + if value.translation.height > 0 { + state = value.translation + } + } + .onEnded { value in + if value.translation.height > 100 { + self.showQueue = false } } + ) + .animation(.spring(duration: 0.4), value: queueDragOffset.height) + .foregroundColor(.primary) + .zIndex(1) + .offset( + y: showQueue + ? size.height - queueSheetHeight + queueDragOffset.height : size.height + ) + .frame(height: queueSheetHeight) + .animation(.spring(duration: 0.2), value: showQueue) + + ZStack { + if viewModel.isLyricsMode { + LyricsView( + viewModel: viewModel, + showQueue: $showQueue, + imageSize: imageSize, + topSafeInset: topSafeInset, + bottomSafeInset: bottomSafeInset + ).transition(.opacity.combined(with: .move(edge: .bottom))) } - }.padding(.bottom, 60) - } - } - .gesture( - DragGesture() - .updating($queueDragOffset) { value, state, _ in - if value.translation.height > 0 { - state = value.translation + + if !viewModel.isLyricsMode { + mainPlayerView( + size: size, + imageSize: imageSize, + topSafeInset: topSafeInset, + bottomSafeInset: bottomSafeInset + ).transition(.opacity) } } - .onEnded { value in - if value.translation.height > 100 { - self.showQueue = false + .frame(maxHeight: .infinity) + .onChange(of: viewModel.isLiveRadio) { isLive in + if isLive { + showQueue = false } } - ) - .animation(.spring(duration: 0.4), value: queueDragOffset.height) - .foregroundColor(.primary) - .zIndex(1) - .offset( - y: showQueue - ? UIScreen.main.bounds.height - 500 + queueDragOffset.height : UIScreen.main.bounds.height - ) - .frame(height: 500) - .animation(.spring(duration: 0.2), value: showQueue) - - ZStack { - if viewModel.isLyricsMode { - LyricsView( - viewModel: viewModel, - showQueue: $showQueue, - imageSize: imageSize - ).transition(.opacity.combined(with: .move(edge: .bottom))) } - - if !viewModel.isLyricsMode { - mainPlayerView(size: size, imageSize: imageSize).transition(.opacity) - } - } - .frame(maxHeight: .infinity) - .onChange(of: viewModel.isLiveRadio) { isLive in - if isLive { - showQueue = false - } - } - .background { - ZStack { - if UserDefaultsManager.playerBackground == PlayerBackground.translucent { - 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) + .offset(y: offset.height) + .gesture( + DragGesture() + .onChanged { gesture in + if !viewModel.isLyricsMode { + if gesture.translation.height > 0 { + offset = gesture.translation + isDragging = true } } } - - Rectangle().fill(.thinMaterial).edgesIgnoringSafeArea(.all) - } else { - Rectangle().fill(Color("PlayerColor")).edgesIgnoringSafeArea(.all) - } - } - .environment(\.colorScheme, .dark) - .clipShape( - RoundedRectangle(cornerRadius: 25, style: .continuous) - ).edgesIgnoringSafeArea(.all) - } - .offset(y: offset.height) - .gesture( - DragGesture() - .onChanged { gesture in - if !viewModel.isLyricsMode { - if gesture.translation.height > 0 { - offset = gesture.translation - isDragging = true + .onEnded { _ in + if offset.height > size.height / 3 { + isExpanded = false } + offset = .zero + isDragging = false } - } - .onEnded { _ in - if offset.height > size.height / 3 { - isExpanded = false - } - offset = .zero - isDragging = false - } - ) - + ) + } } .foregroundColor(.white) } + private var windowTopSafeInset: CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .safeAreaInsets.top ?? 0 + } + @ViewBuilder - private func mainPlayerView(size: CGSize, imageSize: CGFloat) -> some View { + private func mainPlayerView( + size: CGSize, + imageSize: CGFloat, + topSafeInset: CGFloat, + bottomSafeInset: CGFloat + ) -> some View { VStack { Rectangle() .foregroundColor(Color.gray.opacity(0.8)) .frame(width: 50, height: 5) .cornerRadius(30) - .padding(.top, 20) + .padding(.top, topSafeInset + 8) Spacer() let coverArtUrl = viewModel.getAlbumCoverArt() @@ -278,7 +280,7 @@ struct PlayerView: View { } } - Spacer() + Spacer().frame(height: horizontalSizeClass == .regular ? 44 : 36) VStack(alignment: .center, spacing: 10) { Text(viewModel.nowPlaying.songName ?? "") @@ -294,6 +296,7 @@ struct PlayerView: View { .multilineTextAlignment(.center) .lineLimit(2) } + .padding(.horizontal, 30) Spacer() @@ -380,12 +383,15 @@ struct PlayerView: View { .frame(width: 60, alignment: .trailing) } } - - Spacer() + .padding(.horizontal, 30) bottomControlBar(showQueue: $showQueue) + .padding(.top, 16) + .padding(.horizontal, 18) + .padding(.bottom, max(bottomSafeInset, 12)) } - .padding(.horizontal, 30) + .frame(maxWidth: horizontalSizeClass == .regular ? 500 : .infinity) + .frame(maxWidth: .infinity) } @ViewBuilder @@ -404,11 +410,26 @@ struct PlayerView: View { .foregroundColor(isLyricsDisabled ? .white.opacity(0.4) : .white) } .disabled(isLyricsDisabled) - .frame(width: 56, alignment: .leading) + .frame(width: 44, height: 44) + + Spacer(minLength: 0) + + Button { + viewModel.toggleStar() + } label: { + Image(systemName: viewModel.isStarred ? "heart.fill" : "heart") + .font(.title2) + .foregroundColor(.white) + } + .disabled(viewModel.isLiveRadio) + .opacity(viewModel.isLiveRadio ? 0.4 : 1) + .frame(width: 44, height: 44) + + Spacer(minLength: 0) AirPlayRoutePicker(tintColor: UIColor.white, activeTintColor: UIColor.white) - .frame(width: 36, height: 36, alignment: .center) - .frame(maxWidth: .infinity, alignment: .center) + .frame(width: 36, height: 36) + .frame(width: 44, height: 44) .overlay(alignment: .bottom) { if let outputName = viewModel.externalOutputName { Text(outputName) @@ -423,6 +444,8 @@ struct PlayerView: View { } } + Spacer(minLength: 0) + Button { if !isQueueDisabled { showQueue.wrappedValue.toggle() @@ -454,8 +477,38 @@ struct PlayerView: View { } .disabled(isQueueDisabled) .opacity(isQueueDisabled ? 0.4 : 1) - .frame(width: 56, alignment: .trailing) + .frame(width: 44, height: 44) } + .frame(height: 44) + } + + @ViewBuilder + private func playerBackground() -> some View { + ZStack { + if UserDefaultsManager.playerBackground == PlayerBackground.translucent { + if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + Image(uiImage: image) + .resizable() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .blur(radius: 50, opaque: true) + } 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) + } + } + } + + Rectangle().fill(.thinMaterial) + } else { + Rectangle().fill(Color("PlayerColor")) + } + } + .environment(\.colorScheme, .dark) + .ignoresSafeArea() } @ViewBuilder @@ -483,3 +536,28 @@ struct PlayerView_previews: PreviewProvider { PlayerView(isExpanded: $isExpanded, viewModel: viewModel) } } + +/// A shape that rounds only the top-left and top-right corners, +/// leaving the bottom edges straight so the background extends +/// fully into the bottom safe area. +struct TopRoundedRectangle: Shape { + var cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + cornerRadius, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + cornerRadius), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + return path + } +} diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 58cf99b..4b85f37 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -40,6 +40,7 @@ class PlayerViewModel: ObservableObject { @Published var totalTimeString: String = "00:00" @Published var shouldHidePlayer: Bool = false @Published var externalOutputName: String? + @Published var isStarred: Bool = false // FIXME: this make confusion with `isDownloaded` and/or `isPlayingFromLocal` @Published var _playFromLocal: Bool = false @@ -52,6 +53,7 @@ class PlayerViewModel: ObservableObject { private var routeChangeObservation = Set() private var scrobbleThreshold = 0.5 + private var hasTriggeredCache: Bool = false var nowPlaying: QueueEntity { return self.queue[self.activeQueueIdx] @@ -196,16 +198,23 @@ class PlayerViewModel: ObservableObject { self.shouldHidePlayer = false self.isLocallySaved = false + self.hasTriggeredCache = false + + StreamCacheManager.shared.cancelAllInFlight() try? AVAudioSession.sharedInstance().setActive(true) self.resetLyrics() + self.checkStarredStatus() if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) } - let streamUrl = AlbumService.shared.getStreamUrl(id: self.nowPlaying.id ?? "") + let songId = self.nowPlaying.id ?? "" + StreamCacheManager.shared.setCurrentlyPlaying(mediaFileId: songId) + + let streamUrl = AlbumService.shared.getStreamUrl(id: songId) guard let audioURL = URL(string: streamUrl), !streamUrl.isEmpty else { self.isMediaLoading = false @@ -301,6 +310,17 @@ class PlayerViewModel: ObservableObject { } } + if !self.hasTriggeredCache && currentTime >= 10.0 && !self.isLiveRadio { + self.hasTriggeredCache = true + if let nextIdx = self.nextQueueIdxForPreCache(), + let nextId = self.queue[nextIdx].id, !nextId.isEmpty + { + StreamCacheManager.shared.cacheSong( + mediaFileId: nextId, originalSuffix: self.queue[nextIdx].suffix, + from: self.queue[nextIdx]) + } + } + if self.totalDuration.isFinite, self.totalDuration > 0, round(currentTime) >= roundedTotalDuration @@ -647,6 +667,22 @@ class PlayerViewModel: ObservableObject { UserDefaultsManager.queueActiveIdx = self.activeQueueIdx } + private func nextQueueIdxForPreCache() -> Int? { + if queue.count <= 1 { return nil } + + if playbackMode == PlaybackMode.repeatOnce { + return nil + } + + if playbackMode == PlaybackMode.repeatAlbum { + return activeQueueIdx + 1 >= queue.count ? 0 : activeQueueIdx + 1 + } + + let nextIdx = activeQueueIdx + 1 + guard nextIdx < queue.count else { return nil } + return nextIdx + } + func destroyPlayerAndQueue() { self.stop() self.progress = 0.0 @@ -789,6 +825,35 @@ class PlayerViewModel: ObservableObject { return nil } + private func checkStarredStatus() { + self.isStarred = false + if let songId = self.nowPlaying.id, !songId.isEmpty { + AlbumService.shared.isStarred(songId: songId) { [weak self] starred in + DispatchQueue.main.async { + guard self?.nowPlaying.id == songId else { return } + self?.isStarred = starred + } + } + } + } + + func toggleStar() { + guard let songId = self.nowPlaying.id, !songId.isEmpty else { return } + + let shouldStar = !self.isStarred + self.isStarred = shouldStar + + let action = shouldStar ? AlbumService.shared.starSong : AlbumService.shared.unstarSong + action(songId) { [weak self] success in + if !success { + DispatchQueue.main.async { + guard self?.nowPlaying.id == songId else { return } + self?.isStarred = !shouldStar + } + } + } + } + deinit { if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) diff --git a/flo/PlaylistView.swift b/flo/PlaylistView.swift index d8e8fc4..1a434c0 100644 --- a/flo/PlaylistView.swift +++ b/flo/PlaylistView.swift @@ -26,62 +26,63 @@ struct PlaylistView: View { } var body: some View { - NavigationStack { - ScrollView { - LazyVStack { - ForEach(filteredPlaylists) { playlist in - NavigationLink { - PlaylistDetailView() - .environmentObject(viewModel) - .environmentObject(playerViewModel) - .environmentObject(downloadViewModel) - .onAppear { - viewModel.setActivePlaylist(playlist: playlist) - } - } label: { - VStack { - HStack { - VStack(alignment: .leading) { - Text("\(playlist.name)\(playlist.isPublic ? "" : " 🔒")") - .customFont(.headline) - .multilineTextAlignment(.leading) - - Text(playlist.comment) - .customFont(.caption1) - .multilineTextAlignment(.leading) - } - - Spacer() + ScrollView { + LazyVStack { + ForEach(filteredPlaylists) { playlist in + NavigationLink { + PlaylistDetailView() + .environmentObject(viewModel) + .environmentObject(playerViewModel) + .environmentObject(downloadViewModel) + .onAppear { + viewModel.setActivePlaylist(playlist: playlist) + } + } label: { + VStack { + HStack { + VStack(alignment: .leading) { + Text("\(playlist.name)\(playlist.isPublic ? "" : " 🔒")") + .customFont(.headline) + .multilineTextAlignment(.leading) - Image(systemName: "chevron.right") - .foregroundColor(.gray) - .font(.caption) + Text(playlist.comment) + .customFont(.caption1) + .multilineTextAlignment(.leading) } - .padding(.horizontal) - .padding(.vertical, 5) - Divider() + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) } + .padding(.horizontal) + .padding(.vertical, 5) + + Divider() } } - }.padding(.bottom, 100) - } - .toolbar { - if downloadViewModel.hasDownloadQueue() { - Button(action: { - showDownloadSheet.toggle() - }) { - Label("", systemImage: "icloud.and.arrow.down") - } + } + }.padding(.bottom, 100) + } + .toolbar { + if downloadViewModel.hasDownloadQueue() { + Button(action: { + showDownloadSheet.toggle() + }) { + Label("", systemImage: "icloud.and.arrow.down") } } - .sheet(isPresented: $showDownloadSheet) { - DownloadQueueView().environmentObject(downloadViewModel) - } - .navigationTitle("Playlists") - .searchable( - text: $searchPlaylist, placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search") } + .sheet(isPresented: $showDownloadSheet) { + DownloadQueueView().environmentObject(downloadViewModel) + } + .navigationTitle("Playlists") + .refreshable { + await viewModel.refreshPlaylists() + } + .searchable( + text: $searchPlaylist, placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search") } } diff --git a/flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 8810922..aec58f9 100644 --- a/flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -7,9 +7,39 @@ "size" : "1024x1024" }, { - "filename" : "flo 1.png", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "flo-dark.png", "idiom" : "universal", - "platform" : "watchos", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "clear" + } + ], + "filename" : "flo-clear-light.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "flo-tinted-light.png", + "idiom" : "universal", + "platform" : "ios", "size" : "1024x1024" } ], diff --git a/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo 1.png b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo 1.png deleted file mode 100644 index d055849..0000000 Binary files a/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo 1.png and /dev/null differ diff --git a/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-clear-light.png b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-clear-light.png new file mode 100644 index 0000000..6a60b53 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-clear-light.png differ diff --git a/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-dark.png b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-dark.png new file mode 100644 index 0000000..8197b07 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-dark.png differ diff --git a/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-tinted-light.png b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-tinted-light.png new file mode 100644 index 0000000..27de3ca Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo-tinted-light.png differ diff --git a/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo.png b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo.png index d055849..702044f 100644 Binary files a/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo.png and b/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/flo_alt_1.png b/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/flo_alt_1.png index 183b6a5..9e3d26f 100644 Binary files a/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/flo_alt_1.png and b/flo/Resources/Assets.xcassets/AppIconAlt1.appiconset/flo_alt_1.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/flo_alt_2.png b/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/flo_alt_2.png index 8cbf0bc..9ef01a6 100644 Binary files a/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/flo_alt_2.png and b/flo/Resources/Assets.xcassets/AppIconAlt2.appiconset/flo_alt_2.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/flo_alt_3.png b/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/flo_alt_3.png index 77f8e31..78b33cd 100644 Binary files a/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/flo_alt_3.png and b/flo/Resources/Assets.xcassets/AppIconAlt3.appiconset/flo_alt_3.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/Contents.json b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/Contents.json new file mode 100644 index 0000000..ae745ec --- /dev/null +++ b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/Contents.json @@ -0,0 +1,108 @@ +{ + "images" : [ + { + "filename" : "watch-marketing-1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "filename" : "watch-notification-38mm@2x.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "watch-notification-42mm@2x.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "watch-companion@2x.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "watch-companion@3x.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "watch-home-38mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "watch-home-40mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "watch-home-44mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "filename" : "watch-longlook-42mm@2x.png", + "idiom" : "watch", + "role" : "longLook", + "scale" : "2x", + "size" : "44x44", + "subtype" : "42mm" + }, + { + "filename" : "watch-longlook-44mm@2x.png", + "idiom" : "watch", + "role" : "longLook", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "filename" : "watch-shortlook-38mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "watch-shortlook-42mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "watch-shortlook-44mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-companion@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-companion@2x.png new file mode 100644 index 0000000..2822c73 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-companion@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-companion@3x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-companion@3x.png new file mode 100644 index 0000000..7f328d6 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-companion@3x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-38mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-38mm@2x.png new file mode 100644 index 0000000..144c3ab Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-38mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-40mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-40mm@2x.png new file mode 100644 index 0000000..7a8e7dd Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-40mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-44mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-44mm@2x.png new file mode 100644 index 0000000..b9a904d Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-home-44mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-longlook-42mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-longlook-42mm@2x.png new file mode 100644 index 0000000..7a8e7dd Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-longlook-42mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-longlook-44mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-longlook-44mm@2x.png new file mode 100644 index 0000000..b9a904d Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-longlook-44mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-marketing-1024.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-marketing-1024.png new file mode 100644 index 0000000..bcb4fbb Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-marketing-1024.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-notification-38mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-notification-38mm@2x.png new file mode 100644 index 0000000..13c528c Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-notification-38mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-notification-42mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-notification-42mm@2x.png new file mode 100644 index 0000000..7dbadea Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-notification-42mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-38mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-38mm@2x.png new file mode 100644 index 0000000..2a8d717 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-38mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-42mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-42mm@2x.png new file mode 100644 index 0000000..30e08de Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-42mm@2x.png differ diff --git a/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-44mm@2x.png b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-44mm@2x.png new file mode 100644 index 0000000..505d846 Binary files /dev/null and b/flo/Resources/Assets.xcassets/AppIconWatch.appiconset/watch-shortlook-44mm@2x.png differ diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 676056c..521b84b 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -3,6 +3,12 @@ "strings" : { "" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : " " + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -14,21 +20,45 @@ "state" : "translated", "value" : " " } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : " " + } } } }, "'%@' has been downloaded" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' wurde heruntergeladen" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "'%@' sudah terunduh" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' har blitt nedlastet" + } } } }, "%@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -40,11 +70,23 @@ "state" : "translated", "value" : "%@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } } } }, "%@ %@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, "en" : { "stringUnit" : { "state" : "new", @@ -56,11 +98,23 @@ "state" : "translated", "value" : "%@ %@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %@" + } } } }, "%@ (%@)" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$@)" + } + }, "en" : { "stringUnit" : { "state" : "new", @@ -72,11 +126,23 @@ "state" : "translated", "value" : "%@ (%@)" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (%@)" + } } } }, "%@ • %@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ • %2$@" + } + }, "en" : { "stringUnit" : { "state" : "new", @@ -88,11 +154,43 @@ "state" : "translated", "value" : "%@ • %@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ • %@" + } + } + } + }, + "%@ Radio" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-Radio" + } + } + } + }, + "%@ Top Songs" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Top-Songs" + } } } }, "%@%@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } + }, "en" : { "stringUnit" : { "state" : "new", @@ -104,31 +202,90 @@ "state" : "translated", "value" : "%1$@%2$@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } } } }, "%@%%" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%@%%" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } } } }, "%lld" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%lld" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + } + } + }, + "%lld albums" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Alben" + } + } + } + }, + "%lld songs" : { + + }, + "%lld tracks" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Titel" + } } } }, "•" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "•" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -140,11 +297,23 @@ "state" : "translated", "value" : "•" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "•" + } } } }, "1" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -156,11 +325,35 @@ "state" : "translated", "value" : "1" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "1" + } } } + }, + "1 GB" : { + + }, + "2 GB" : { + + }, + "5 GB" : { + + }, + "500 MB" : { + }, "About flo" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über flo" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -172,11 +365,23 @@ "state" : "translated", "value" : "Tentang flo" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Om flo" + } } } }, "Accent color" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akzentfarbe" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -188,45 +393,101 @@ "state" : "translated", "value" : "Warna aksen" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aksentfarge" + } } } }, "Add radios in your Navidrome server." : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Füge Radios auf deinem Navidrome-Server hinzu." + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Tambahkan Radio melalui server Navidrome" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til radioer på din Navidrome server" + } } } }, "Add/Change Custom" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eigene hinzufügen/ändern" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Tambah/Ubah Custom" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til/Endre Custom" + } } } }, "Advanced Settings" : { "comment" : "A toggle label for advanced settings in the IAP login view.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erweiterte Einstellungen" + } + } + } }, "Album Artist Only" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur Album-Künstler" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Hanya Album Artist" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun Album Artist" + } } } }, "Album Info" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Albuminfo" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -238,31 +499,87 @@ "state" : "translated", "value" : "Info album" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Album Informasjon" + } + } + } + }, + "Albums" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alben" + } + } + } + }, + "All Tracks" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Titel" + } } } }, "Alternate app icons are not supported on this device." : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alternative App-Icons werden auf diesem Gerät nicht unterstützt." + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Ikon alternatif aplikasi tidak didukung di perangkat ini." } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alternative appikoner er ikke støttet på denne enheten." + } } } }, "App Icon" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-Icon" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Ikon Aplikasi" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Ikon" + } } } }, "App Version" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-Version" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -274,21 +591,45 @@ "state" : "translated", "value" : "Versi aplikasi" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Versjon" + } } } }, "Artist Radio" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Künstler-Radio" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Artist Radio" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artist Radio" + } } } }, "Artists" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Künstler" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -300,35 +641,94 @@ "state" : "translated", "value" : "Artis" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artister" + } } } }, "Authenticate" : { - "comment" : "The text of the button that allows a user to authenticate with their in-app purchases.", - "isCommentAutoGenerated" : true - }, + "comment" : "The text of the button that allows a user to authenticate with their IAP", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentifizieren" + } + } + } + }, "Authenticate using OAuth2-Proxy or Identity-Aware Proxy" : { "comment" : "A description below the IAP login form, explaining how to authenticate using an IAP server.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentifizierung über OAuth2-Proxy oder Identity-Aware Proxy" + } + } + } }, "Authenticating..." : { "comment" : "A label displayed while waiting for the IAP authentication to complete.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentifizierung läuft..." + } + } + } }, "Authentication Failed" : { "comment" : "A title that appears when IAP authentication fails.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentifizierung fehlgeschlagen" + } + } + } }, "Authentication Token Cookie Name" : { "comment" : "A description of the authentication token cookie name setting.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name des Authentifizierungstoken-Cookies" + } + } + } }, "Authentication Token Header Name" : { "comment" : "A label displayed above a text field that lets the user specify the name of the HTTP header containing their JWT token.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name des Authentifizierungstoken-Headers" + } + } + } }, "Bring your music anywhere, even when you're offline. Your downloaded music will be here." : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nimm deine Musik überall hin mit, auch wenn du offline bist. Deine heruntergeladene Musik findest du hier." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -340,11 +740,23 @@ "state" : "translated", "value" : "Bawa musik kamu ke mana saja, bahkan saat kamu sedang offline. Musik yang kamu unduh akan ada di sini." } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta med musikken din hvor som helst, selv når du er frakoblet. Din nedlastede musikk vil være her." + } } } }, "by %@ (%@)" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "von %1$@ (%2$@)" + } + }, "en" : { "stringUnit" : { "state" : "new", @@ -356,11 +768,29 @@ "state" : "translated", "value" : "oleh %1$@ (%2$@)" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "av %1$@ (%2$@)" + } } } + }, + "Cache limit" : { + + }, + "Cached" : { + }, "Cancel" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -372,21 +802,48 @@ "state" : "translated", "value" : "Batal" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } } } + }, + "Clear" : { + }, "Clear Downloaded/Canceled Queue" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heruntergeladene/Abgebrochene Warteschlange leeren" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Hapus antrian unduhan yang sudah selesai/dibatalkan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm Nedlastet/Kansellert Kø" + } } } }, "Clear listening history (no alert and irreversible)" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hörverlauf löschen (ohne Warnung und unwiderruflich)" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -398,11 +855,29 @@ "state" : "translated", "value" : "Hapus riwayat mendengarkan (tanpa peringatan dan tidak dapat dibatalkan)" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm lyttehistorikk (ingen varsel og irreversibelt)" + } } } + }, + "Clear streaming cache" : { + + }, + "Clear Streaming Cache" : { + }, "Continue" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsetzen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -414,11 +889,43 @@ "state" : "translated", "value" : "Lanjutkan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsett" + } + } + } + }, + "Could not load radio for %@." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radio für %@ konnte nicht geladen werden." + } + } + } + }, + "Could not load top songs for %@." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Top-Songs für %@ konnten nicht geladen werden." + } } } }, "Currently the output format is MP3 due to compatibility issues; however, MP3 is less efficient in streaming at lower bitrates compared to modern codecs like Opus." : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Derzeit ist das Ausgabeformat MP3 aufgrund von Kompatibilitätsproblemen; MP3 ist jedoch beim Streaming mit niedrigen Bitraten weniger effizient als moderne Codecs wie Opus." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -430,11 +937,23 @@ "state" : "translated", "value" : "Saat ini format keluarannya adalah MP3 karena masalah kompatibilitas; namun, MP3 kurang efisien dalam streaming pada bitrate yang lebih rendah dibandingkan dengan codec modern seperti Opus." } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "For øyeblikket er utgangsformatet MP3 på grunn av kompatibilitetsproblemer; men, MP3 er mindre effektivt ved strømming på lavere bithastigheter sammenlignet med moderne kodeker som Opus." + } } } }, "Debug" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debug" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -446,11 +965,23 @@ "state" : "translated", "value" : "Debug" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feilsøk" + } } } }, "Description (i.e: for my wife)" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschreibung (z.B.: für meine Frau)" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -462,11 +993,23 @@ "state" : "translated", "value" : "Deskripsi (cth: untuk istri)" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskrivelse (i.e: for min kone)" + } } } }, "Development" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entwicklung" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -478,21 +1021,45 @@ "state" : "translated", "value" : "Pengembangan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utvikling" + } } } }, "Disabled" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiviert" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Disabled" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivert" + } } } }, "Download" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Herunterladen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -504,21 +1071,55 @@ "state" : "translated", "value" : "Unduh" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last ned" + } + } + } + }, + "Download music from the app" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lade Musik aus der App herunter" + } } } }, "Download Queue" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download-Warteschlange" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Antrian Unduhan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nedlastningskø" + } } } }, "Downloaded Albums" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heruntergeladene Alben" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -530,11 +1131,23 @@ "state" : "translated", "value" : "Album terunduh" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nedlastede Album" + } } } }, "Downloaded Songs" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heruntergeladene Songs" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -546,11 +1159,23 @@ "state" : "translated", "value" : "Lagu terunduh" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nedlastede Sanger" + } } } }, "Downloads" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -562,43 +1187,101 @@ "state" : "translated", "value" : "Unduhan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nedlastninger" + } } } }, "e.g., _oauth2_proxy, KEYCLOAK_IDENTITY" : { "comment" : "A placeholder text for the \"Authentication Token Cookie Name\" field in the IAP login view.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z.B. _oauth2_proxy, KEYCLOAK_IDENTITY" + } + } + } }, "e.g., username, user, preferred_username" : { - "comment" : "A placeholder text for the \"Username Cookie Name\" field in the advanced settings of the in-app purchase login view.", - "isCommentAutoGenerated" : true + "comment" : "A placeholder text for the \"Username Cookie Name\" field in the advanced settings of the IAP login view.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z.B. username, user, preferred_username" + } + } + } }, "e.g., x-auth-request-access-token" : { - "comment" : "A placeholder text for the authentication token header name field in the advanced settings of the in-app purchase login view.", - "isCommentAutoGenerated" : true + "comment" : "A placeholder text for the authentication token header name field in the advanced settings of the IAP login view.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z.B. x-auth-request-access-token" + } + } + } }, "Enable Debug" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debug-Modus aktivieren" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Aktifkan Debug" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver Feilsøking" + } } } }, "Enabling this option may affect the experience." : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Aktivieren dieser Option kann die Nutzung beeinträchtigen." + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Mengaktifkan opsi ini dapat memengaruhi pengalaman." } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivering av dette alternative kan påvirke opplevelsen." + } } } }, "Experimental" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Experimentell" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -610,48 +1293,149 @@ "state" : "translated", "value" : "Eksperimental" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksperimentel" + } } } }, - "flo will store your server URL, username, and password in the Keychain with no biometric protection. If you enable this, flo will try to 'refresh' the auth token—by logging you in automatically—every time you open flo so you'll never log out unless you do it explicitly (it will also reset this option). Logging in via OAuth will reset this option." : { + "Failed to load albums" : { "localizations" : { - "id" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "flo akan menyimpan URL server, nama pengguna, dan kata sandi kamu di Keychain tanpa perlindungan biometrik. Jika kamu mengaktifkannya, flo akan mencoba untuk ‘menyegarkan’ token autentikasi—dengan melakukan login secara otomatis—setiap kali kamu membuka flo sehingga kamu tidak akan pernah logout kecuali kamu melakukannya secara eksplisit (dan juga akan me-reset opsi ini)." + "value" : "Alben konnten nicht geladen werden" } } } }, - "flo+ purchased" : { - "extractionState" : "stale", + "Failed to load artists" : { "localizations" : { - "id" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "flo+ telah terbeli" + "value" : "Künstler konnten nicht geladen werden" } } } }, - "Font Family" : { + "Failed to load liked songs" : { + + }, + "Failed to load playlists" : { "localizations" : { - "en" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Font Family" + "value" : "Playlists konnten nicht geladen werden" } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keluarga font" + } + } + }, + "Failed to load radios" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radios konnten nicht geladen werden" + } + } + } + }, + "Failed to load songs" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Songs konnten nicht geladen werden" + } + } + } + }, + "flo will store your server URL, username, and password in the Keychain with no biometric protection. If you enable this, flo will try to 'refresh' the auth token—by logging you in automatically—every time you open flo so you'll never log out unless you do it explicitly (it will also reset this option). Logging in via OAuth will reset this option." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo speichert deine Server-URL, deinen Benutzernamen und dein Passwort ohne biometrischen Schutz im Schlüsselbund. Wenn du dies aktivierst, versucht flo bei jedem Öffnen das Authentifizierungstoken zu erneuern — indem du automatisch angemeldet wirst — sodass du nie abgemeldet wirst, es sei denn, du tust es ausdrücklich (dies setzt auch diese Option zurück). Die Anmeldung über OAuth setzt diese Option zurück." + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo akan menyimpan URL server, nama pengguna, dan kata sandi kamu di Keychain tanpa perlindungan biometrik. Jika kamu mengaktifkannya, flo akan mencoba untuk 'menyegarkan' token autentikasi—dengan melakukan login secara otomatis—setiap kali kamu membuka flo sehingga kamu tidak akan pernah logout kecuali kamu melakukannya secara eksplisit (dan juga akan me-reset opsi ini)." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo vil lagre din server URL, brukernavn, og passord i Nøkkelringen uten biometrisk beskyttelse. Hvis du aktiverer dette, vil flo prøve å 'oppdatere' auth tokenet-ved å logge deg in automatisk-hver gang du åpner flo så du aldri vil logges ut med mindre du selv gjør det eksplisitt (dette vil også tilbakestille dette alternativet)" + } + } + } + }, + "flo+ purchased" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo+ gekauft" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo+ telah terbeli" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo+ kjøpt" + } + } + } + }, + "Font Family" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schrift-Familie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Font Family" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keluarga font" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fontfamilie" } } } }, "For now this action means 'Delete all downloaded albums and songs' including its content. Continue?" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Aktion bedeutet derzeit „Alle heruntergeladenen Alben und Songs löschen“ einschließlich deren Inhalt. Fortfahren?" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -663,21 +1447,45 @@ "state" : "translated", "value" : "Untuk saat ini tindakan ini berarti 'Hapus semua album dan lagu yang diunduh' termasuk kontennya. Lanjutkan?" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foreløpig betyr denne handlingen 'Slett alle nedlastede album og sanger' inkludert deres innhold. Fortsett?" + } } } }, "Force Logout" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abmelden erzwingen" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Logout paksa" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tving utlogging" + } } } }, "From %@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Von %@" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -689,21 +1497,45 @@ "state" : "translated", "value" : "Dari %@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fra %@" + } } } }, "Get a dedicated channel on flo Campfire" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erhalte einen eigenen Kanal auf flo Campfire" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Dapatkan channel khusus di flo Campfire" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Få en dedikert kanal på flo Campfire" + } } } }, "Going off the grid?" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offline unterwegs?" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -715,21 +1547,48 @@ "state" : "translated", "value" : "Keluar dari jaringan?" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skal du være koblet fra nettet?" + } } } }, "Help fund flo development" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterstütze die Entwicklung von flo" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Bantu danai pengembangan flo" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hjelp å finansier flo sin utvikling" + } } } + }, + "Hide quick links" : { + }, "Home" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startseite" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -741,41 +1600,89 @@ "state" : "translated", "value" : "Beranda" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hjem" + } } } }, "https://lrclib.your-server.net" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://lrclib.dein-server.net" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "https://lrclib.your-server.net" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://lrclib.your-server.net" + } } } }, "Keychain.%@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keychain.%@" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Keychain.%@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nøkkelring.%@" + } } } }, "Learn more at https://dub.sh/flo-lrclib" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr erfahren unter https://dub.sh/flo-lrclib" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Selengkapnya di https://dub.sh/flo-lrclib" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lær mer på https://dub.sh/flo-lrclib" + } } } }, "Library" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bibliothek" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -787,11 +1694,26 @@ "state" : "translated", "value" : "Koleksi" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bibliotek" + } } } + }, + "Liked Songs" : { + }, "Link copied to clipboard! (%@)" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Link in die Zwischenablage kopiert! (%@)" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -803,11 +1725,23 @@ "state" : "translated", "value" : "Tautan disalin ke papan klip! (%@)" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lenke kopiert til utklippstavle" + } } } }, "Listening Activity (all time)" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Höraktivität (Gesamthistorie)" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -819,21 +1753,75 @@ "state" : "translated", "value" : "Riwayat mendengarkan (sepanjang waktu)" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lytteaktivitet (hele tiden)" + } } } }, "LIVE" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "LIVE" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + } + } + }, + "Loading downloads…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads werden geladen…" + } + } + } + }, + "Loading playlists…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlists werden geladen…" + } + } + } + }, + "Loading stations…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender werden geladen…" + } } } }, "Local Storage" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lokaler Speicher" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -845,11 +1833,23 @@ "state" : "translated", "value" : "Penyimpanan lokal" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lokal lagringsplass" + } } } }, "Logged in as %@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angemeldet als %@" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -861,11 +1861,23 @@ "state" : "translated", "value" : "Login sebagai %@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logget inn som %@" + } } } }, "Login" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmelden" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -877,11 +1889,23 @@ "state" : "translated", "value" : "Login" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn" + } } } }, "Login Failed" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmeldung fehlgeschlagen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -893,11 +1917,23 @@ "state" : "translated", "value" : "Login gagal" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pålogging mislyktes" + } } } }, "Login to your Navidrome server to continue" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melde dich bei deinem Navidrome-Server an, um fortzufahren" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -909,15 +1945,34 @@ "state" : "translated", "value" : "Login ke server Navidrome kamu untuk melanjutkan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg på din Navidrome server for å fortsette" + } } } }, "Login with IAP" : { - "comment" : "A button label that allows users to log in using in-app purchases.", - "isCommentAutoGenerated" : true + "comment" : "A button label that allows users to log in using IAP.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit IAP anmelden" + } + } + } }, "Logout" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abmelden" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -929,41 +1984,89 @@ "state" : "translated", "value" : "Logout" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg ut" + } } } }, "LRCLIB" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "LRCLIB" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB" + } } } }, "LRCLIB server is required. Learn more at dub.sh/flo-lrclib" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB-Server erforderlich. Mehr erfahren unter dub.sh/flo-lrclib" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Server LRCLIB dibutuhkan. Selengkapnya di dub.sh/flo-lrclib" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB server er påkrevd. Lær mer på dub.sh/flo-lrclib" + } } } }, "LRCLIB Server URL" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB-Server-URL" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "LRCLIB Server URL" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB Server URL" + } } } }, "Max Bitrate" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Max. Bitrate" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -975,11 +2078,23 @@ "state" : "translated", "value" : "Max Bitrate" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maks birate" + } } } }, "Navidrome Version" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navidrome-Version" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -991,41 +2106,175 @@ "state" : "translated", "value" : "Versi Navidrome" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navidrome Versjon" + } + } + } + }, + "No downloads" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Downloads" + } + } + } + }, + "No downloads available" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Downloads verfügbar" + } } } + }, + "No liked songs yet" : { + }, "No lyrics available" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Songtexte verfügbar" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Tidak ada lirik ditemukan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen tekster tilgjengelig" + } + } + } + }, + "No Radio Available" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Radio verfügbar" + } + } + } + }, + "No radio stations" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Radiosender" + } } } }, "No radios available" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Radios verfügbar" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Tidak ada radio ditemukan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen radioer tilgjengelig" + } } } }, "No radios match your search" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Radios zu deiner Suche gefunden" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Tidak ada radio ditemukan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen radioer matcher søket ditt" + } + } + } + }, + "No similar songs found for %@." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine ähnlichen Songs für %@ gefunden." + } } } + }, + "No Top Songs" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Top-Songs" + } + } + } + }, + "No top songs found for %@." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Top-Songs für %@ gefunden." + } + } + } + }, + "No upcoming tracks" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine weiteren Titel" + } + } + } + }, + "Off" : { + }, "OK" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1037,76 +2286,161 @@ "state" : "translated", "value" : "OK" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } } } }, "Optimize local storage" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lokalen Speicher optimieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimize local storage" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimalkan penyimpanan lokal" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimaliser lokal lagringsplass" + } + } + } + }, + "Optimize Local Storage" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lokalen Speicher optimieren" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Optimize local storage" + "value" : "Optimize Local storage" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimalkan penyimpanan lokal" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimaliser Lokal Lagringsplass" + } + } + } + }, + "OR" : { + "comment" : "A label used to separate different options in a list.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ODER" + } + } + } + }, + "Play" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiedergeben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mainkan" } }, - "id" : { + "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Optimalkan penyimpanan lokal" + "value" : "Spill" } } } }, - "Optimize Local Storage" : { + "Play All" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Optimize Local storage" - } - }, - "id" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Optimalkan penyimpanan lokal" + "value" : "Alle abspielen" } } } }, - "OR" : { - "comment" : "A label used to separate different options in a list.", - "isCommentAutoGenerated" : true - }, - "Play" : { + "Play Artist Radio" : { "localizations" : { - "en" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Play" + "value" : "Künstler-Radio abspielen" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Mainkan" + "value" : "Mainkan Artist Radio" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spill Artist Radio" } } } }, - "Play Artist Radio" : { + "Play Top Songs" : { "localizations" : { - "id" : { + "de" : { "stringUnit" : { "state" : "translated", - "value" : "Mainkan Artist Radio" + "value" : "Top-Songs abspielen" } } } - }, - "Play Top Songs" : { - }, "Player color" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Player-Farbe" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1118,11 +2452,23 @@ "state" : "translated", "value" : "Warna player" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spiller farge" + } } } }, "Playing Next" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nächster Titel" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1134,11 +2480,23 @@ "state" : "translated", "value" : "Selanjutnya" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spiller Neste" + } } } }, "Playlists" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlists" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1150,11 +2508,23 @@ "state" : "translated", "value" : "Daftar Putar" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spilleliste" + } } } }, "Preferences" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1166,91 +2536,219 @@ "state" : "translated", "value" : "Preferensi" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferanser" + } } } }, "Purchase flo+" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo+ kaufen" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Beli flo+" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kjøp flo+" + } } } }, "Purchase flo+ for %@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo+ kaufen für %@" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Beli flo+ seharga %@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kjøp flo+ for %@" + } + } + } + }, + "Radio" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radio" + } + } + } + }, + "Radio Unavailable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radio nicht verfügbar" + } } } }, "Radios" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radios" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Radios" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radioer" + } } } }, "Redownload Album" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Album erneut herunterladen" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Unduh ulang album" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nedlast album på nytt" + } } } }, "Redownload Album (force)" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Album erneut herunterladen (erzwingen)" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Paksa unduh album" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tvangsnedlast album på nytt" + } } } }, "Redownload Playlist" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlist erneut herunterladen" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Unduh ulang playlist" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nedlast spilleliste på nytt" + } } } }, "Redownload Playlist (force)" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlist erneut herunterladen (erzwingen)" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Paksa unduh playlist" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tvangsnedlast spilleliste på nytt" + } } } }, "Refetch UserDefaults & Keychains" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "UserDefaults & Keychains neu laden" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Refetch UserDefaults & Keychains" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent Brukerstandardinnstillinger & Nøkkelring" + } } } }, "Remove Download" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download entfernen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1262,31 +2760,67 @@ "state" : "translated", "value" : "Hapus unduhan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern nedlastning" + } } } }, "Restore purchases" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Käufe wiederherstellen" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Pulihkan pembelian" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gjenopprett kjøp" + } } } }, "Retry all Failed Queue" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle fehlgeschlagenen Downloads wiederholen" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Coba lagi semua antrian download yang gagal" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv alle mislykkede køer på nytt" + } } } }, "Save" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1298,11 +2832,23 @@ "state" : "translated", "value" : "Simpan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre" + } } } }, "Save login info" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmeldedaten speichern" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1314,11 +2860,23 @@ "state" : "translated", "value" : "Simpan info login" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre innloggingsinformasjon" + } } } }, "Scrobble to Last.fm" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu Last.fm „scrobbeln“" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1330,11 +2888,23 @@ "state" : "translated", "value" : "Scrobble ke Last.fm" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrobble til Last.fm" + } } } }, "Scrobble to ListenBrainz" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu ListenBrainz „scrobbeln“" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1346,11 +2916,23 @@ "state" : "translated", "value" : "Scrobble ke ListenBrainz" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrobble til ListenBrainz" + } } } }, "Search" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1362,11 +2944,23 @@ "state" : "translated", "value" : "Cari" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Søk" + } } } }, "Server Information" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serverinformationen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1378,11 +2972,23 @@ "state" : "translated", "value" : "Informasi server" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server Informasjon" + } } } }, "Server URL" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server-URL" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1394,11 +3000,23 @@ "state" : "translated", "value" : "URL server" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server URL" + } } } }, "Share" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1410,11 +3028,23 @@ "state" : "translated", "value" : "Bagikan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del" + } } } }, "Share album '%@'" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Album „%@“ teilen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1424,13 +3054,25 @@ "id" : { "stringUnit" : { "state" : "translated", - "value" : "Bagikan album ‘%@'" + "value" : "Bagikan album '%@'" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del album '%@'" } } } }, "Share features with Download option is disabled, please update directly in Navidrome UI if needed" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilen-Funktionen mit Download-Option sind deaktiviert, bitte direkt in der Navidrome-Oberfläche aktualisieren" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1442,11 +3084,26 @@ "state" : "translated", "value" : "Fitur berbagi dengan opsi Unduh dinonaktifkan, harap perbarui langsung di UI Navidrome jika diperlukan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del egenskaper med Nedlastningsmulighet er deaktivert, vennligst oppdater direkte i Navidrome UI hvis nødvending" + } } } + }, + "Show quick links" : { + }, "Shuffle" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shuffle" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1458,25 +3115,57 @@ "state" : "translated", "value" : "Acak" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilfeldig rekkefølge" + } } } }, "Sign In" : { "comment" : "The title of the navigation bar at the top of the view.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmelden" + } + } + } }, "Some other things yet to come" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weitere Funktionen folgen" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Beberapa hal lain yang akan datang" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noen andre ting som ennå skal komme" + } } } }, "Songs" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Songs" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1488,11 +3177,23 @@ "state" : "translated", "value" : "Lagu" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sanger" + } } } }, "Source Code" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quellcode" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1504,11 +3205,26 @@ "state" : "translated", "value" : "Sumber kode" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kildekode" + } } } + }, + "Streaming cache" : { + }, "Subsonic Version" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subsonic-Version" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1520,22 +3236,56 @@ "state" : "translated", "value" : "Versi Subsonic" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subsonic Versjon" + } + } + } + }, + "Tap to retry" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zum Wiederholen tippen" + } } } }, "Thank you for supporting flo!" : { "extractionState" : "stale", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Danke für deine Unterstützung für flo!" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Terima kasih telah mendukung flo!" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takk for at du støtter flo!" + } } } }, "Thanks for choosing flo!" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Danke, dass du dich für flo entschieden hast!" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1547,33 +3297,81 @@ "state" : "translated", "value" : "Terima kasih telah memilih flo!" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takk for at du valgte flo!" + } } } }, "The cookie containing your session token (leave empty for auto-detection)" : { "comment" : "A description of the purpose of the \"Authentication Token Cookie Name\" field.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Cookie mit deinem Sitzungstoken (leer lassen für automatische Erkennung)" + } + } + } }, "The cookie containing your username (defaults to 'username')" : { "comment" : "A description of the purpose of the \"Username Cookie Name\" setting in the IAP login view.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Cookie mit deinem Benutzernamen (Standard: 'username')" + } + } + } }, "The full version of flo is always Free and OSS" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Vollversion von flo ist kostenlos und Open Source" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Versi lengkap flo selalu gratis dan OSS" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den fullstendige versjonen av flo er alltid Fri og ÅKK" + } } } }, "The HTTP header containing your JWT token (leave empty for auto-detection)" : { "comment" : "A description of what the \"Authentication Token Header Name\" field is for.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der HTTP-Header mit deinem JWT-Token (leer lassen für automatische Erkennung)" + } + } + } }, "The password is stored securely in Keychain" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Passwort wird sicher im Schlüsselbund gespeichert" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1585,11 +3383,23 @@ "state" : "translated", "value" : "Kata sandi disimpan dengan aman di Keychain" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passordet er lagret sikkert i Nøkkelringen" + } } } }, "The quickest action you can take is to log back in — for now." : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die schnellste Lösung ist, dich erneut anzumelden — vorerst." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1601,14 +3411,33 @@ "state" : "translated", "value" : "Tindakan tercepat yang dapat kamu lakukan adalah Login kembali — untuk saat ini." } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det raskeste valget du kan ta er å logge inn igjen - for nå." + } } } }, "This option is not available when using OAuth." : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Option ist bei Verwendung von OAuth nicht verfügbar." + } + } + } }, "This stat is generated on-device (once every session) and no data is stored or shared with a third party — #selfhosting, baby!" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Statistik wird auf dem Gerät erstellt (einmal pro Sitzung) und es werden keine Daten gespeichert oder an Dritte weitergegeben — #selfhosting!" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1620,21 +3449,48 @@ "state" : "translated", "value" : "Statistik ini dibuat di perangkat (sekali setiap sesi) dan tidak ada data yang disimpan atau dibagikan ke pihak ketiga — #selfhosting, bos!" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne statistikken er generert på enheten (en gang hver økt) og ingen data er lagret eller delt med en tredjepart - #selfhosting, baby!" + } } } + }, + "This will delete all cached streamed songs. Downloads are not affected." : { + }, "To change this, please do so via the Navidrome Web UI" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Um dies zu ändern, verwende bitte die Navidrome-Weboberfläche" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Gunakan Navidrome Web UI untuk mengubah ini" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "For å endre dette, vennligst gjør det via Navidrome Web UI'et" + } } } }, "Total Files Scanned" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dateien insgesamt gescannt" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1646,11 +3502,23 @@ "state" : "translated", "value" : "Total File yang Dipindai" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt Antall Filer Skannet" + } } } }, "Total Folders Scanned" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordner insgesamt gescannt" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1662,11 +3530,23 @@ "state" : "translated", "value" : "Total Folder yang Dipindai" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt Antall Mapper Skannet" + } } } }, "Total usage" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamtnutzung" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1678,73 +3558,209 @@ "state" : "translated", "value" : "Jumlah penggunaan" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalt brukt" + } + } + } + }, + "Tracks" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel" + } } } }, "Troubleshoot" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehlerbehebung" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Troubleshoot" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feilsøk" + } } } }, "Try a different keyword." : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versuche ein anderes Stichwort." + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Coba kata kunci yang lain." } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv et annet nøkkelord" + } } } }, "Try Again" : { "comment" : "A button that allows the user to try authenticating again if the previous attempt failed.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut versuchen" + } + } + } }, "Unable to Change App Icon" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-Symbol konnte nicht geändert werden" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Gagal mengubah ikon aplikasi" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klarte ikke å endre appikon" + } } } }, "Unable to Purchase flo+" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "flo+ konnte nicht gekauft werden" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Gagal membeli flo+" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klarte ikke å kjøpe flo+" + } + } + } + }, + "Unavailable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht verfügbar" + } + } + } + }, + "Unknown" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbekannt" + } + } + } + }, + "Up Next" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warteschlange" + } } } }, "Use this if your server is behind OAuth2-Proxy or Identity-Aware Proxy" : { "comment" : "A piece of text explaining the purpose of the IAP login button.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwende dies, wenn dein Server hinter einem OAuth2-Proxy oder Identity-Aware Proxy steht" + } + } + } }, "UserDefaults.%@" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "UserDefaults.%@" + } + }, "id" : { "stringUnit" : { "state" : "translated", "value" : "UserDefaults.%@" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "UserDefaults.%@" + } } } }, "Username Cookie Name" : { "comment" : "A label for the username cookie name field in the IAP login view.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cookie der Benutzernamen enthält" + } + } + } }, "Your Navidrome session may have expired" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Navidrome-Sitzung ist möglicherweise abgelaufen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -1756,9 +3772,15 @@ "state" : "translated", "value" : "Sesi Navidrome kamu mungkin telah kedaluwarsa" } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din Navidrome økt kan ha utløpt" + } } } } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/flo/SceneDelegate.swift b/flo/SceneDelegate.swift index e774518..7595181 100644 --- a/flo/SceneDelegate.swift +++ b/flo/SceneDelegate.swift @@ -3,35 +3,43 @@ // flo // -import UIKit import SwiftUI +import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo _: UISceneSession, + options _: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions - ) { - guard let windowScene = scene as? UIWindowScene else { return } + #if targetEnvironment(macCatalyst) + if let titlebar = windowScene.titlebar { + titlebar.titleVisibility = .hidden + titlebar.toolbar = nil + titlebar.toolbarStyle = .unifiedCompact + } + #endif - let window = UIWindow(windowScene: windowScene) - let contentView = ContentView() - .environmentObject(InAppPurchaseManager()) + let window = UIWindow(windowScene: windowScene) + let contentView = ContentView() + .environmentObject(InAppPurchaseManager()) - window.rootViewController = UIHostingController(rootView: contentView) - self.window = window - window.makeKeyAndVisible() - } + window.rootViewController = UIHostingController(rootView: contentView) + self.window = window + window.makeKeyAndVisible() + } - func sceneDidDisconnect(_ scene: UIScene) {} + func sceneDidDisconnect(_: UIScene) {} - func sceneDidBecomeActive(_ scene: UIScene) {} + func sceneDidBecomeActive(_: UIScene) {} - func sceneWillResignActive(_ scene: UIScene) {} + func sceneWillResignActive(_: UIScene) {} - func sceneWillEnterForeground(_ scene: UIScene) {} + func sceneWillEnterForeground(_: UIScene) {} - func sceneDidEnterBackground(_ scene: UIScene) {} + func sceneDidEnterBackground(_: UIScene) {} } diff --git a/flo/Shared/Models/Playable.swift b/flo/Shared/Models/Playable.swift index 7ebc2b0..d47cab3 100644 --- a/flo/Shared/Models/Playable.swift +++ b/flo/Shared/Models/Playable.swift @@ -11,3 +11,10 @@ protocol Playable { var songs: [Song] { get set } var artist: String { get } } + +struct SongCollection: Playable { + let id: String + let name: String + var songs: [Song] + let artist: String = "" +} diff --git a/flo/Shared/Models/Song.swift b/flo/Shared/Models/Song.swift index 71cdaaa..d47d42b 100644 --- a/flo/Shared/Models/Song.swift +++ b/flo/Shared/Models/Song.swift @@ -22,6 +22,7 @@ struct Song: Codable, Identifiable, Hashable { var mediaFileId: String = "" var fileUrl: String = "" + var starred: Bool = false enum DecodeKeys: String, CodingKey { case id @@ -37,6 +38,7 @@ struct Song: Codable, Identifiable, Hashable { case suffix case duration case mediaFileId + case starred } enum EncodeKeys: String, CodingKey { @@ -52,6 +54,7 @@ struct Song: Codable, Identifiable, Hashable { case suffix case duration case mediaFileId + case starred } init(from decoder: any Decoder) throws { @@ -73,6 +76,7 @@ struct Song: Codable, Identifiable, Hashable { self.suffix = try container.decode(String.self, forKey: .suffix) self.duration = try container.decode(Double.self, forKey: .duration) self.mediaFileId = try container.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "" + self.starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false } func encode(to encoder: any Encoder) throws { @@ -90,6 +94,7 @@ struct Song: Codable, Identifiable, Hashable { try container.encode(suffix, forKey: .suffix) try container.encode(duration, forKey: .duration) try container.encode(mediaFileId, forKey: .mediaFileId) + try container.encode(starred, forKey: .starred) } init( @@ -113,6 +118,23 @@ struct Song: Codable, Identifiable, Hashable { self.mediaFileId = mediaFileId } + #if os(iOS) + init(from cache: CacheEntity) { + self.id = cache.mediaFileId ?? "" + self.title = cache.title ?? "Unknown" + self.artist = cache.artistName ?? "Unknown" + self.albumId = cache.albumId ?? "" + self.albumName = cache.albumName ?? "" + self.trackNumber = 0 + self.discNumber = 0 + self.bitRate = Int(cache.bitRate) + self.sampleRate = Int(cache.sampleRate) + self.suffix = cache.suffix ?? "" + self.duration = cache.duration + self.mediaFileId = cache.mediaFileId ?? "" + } + #endif + #if os(iOS) init(from song: SongEntity) { self.id = song.id ?? "" diff --git a/flo/Shared/Models/Subsonic.swift b/flo/Shared/Models/Subsonic.swift index ba3c3ae..68d182d 100644 --- a/flo/Shared/Models/Subsonic.swift +++ b/flo/Shared/Models/Subsonic.swift @@ -75,3 +75,45 @@ extension SubsonicResponse { } typealias BasicSubsonicResponse = SubsonicResponse + +struct Starred2Response: Codable { + struct SubsonicResponseBody: Codable { + struct Starred2: Codable { + let song: [SubsonicSong]? + } + + let starred2: Starred2? + } + + let subsonicResponse: SubsonicResponseBody + + enum CodingKeys: String, CodingKey { + case subsonicResponse = "subsonic-response" + } + + var songs: [Song] { + return (subsonicResponse.starred2?.song ?? []).map { $0.toSong() } + } +} + +struct SubsonicSong: Codable { + let id: String + let title: String + let artist: String? + let albumId: String? + let album: String? + let track: Int? + let discNumber: Int? + let bitRate: Int? + let samplingRate: Int? + let suffix: String? + let duration: Int? + + func toSong() -> Song { + return Song( + id: id, title: title, albumId: albumId ?? "", albumName: album ?? "", + artist: artist ?? "", trackNumber: track ?? 0, discNumber: discNumber ?? 0, + bitRate: bitRate ?? 0, sampleRate: samplingRate ?? 0, suffix: suffix ?? "", + duration: Double(duration ?? 0), mediaFileId: id) + } +} diff --git a/flo/Shared/Services/AlbumService.swift b/flo/Shared/Services/AlbumService.swift index 2b91051..41c347b 100644 --- a/flo/Shared/Services/AlbumService.swift +++ b/flo/Shared/Services/AlbumService.swift @@ -11,7 +11,7 @@ import Foundation class AlbumService { static let shared = AlbumService() - private func buildRemoteStreamUrl(id: String) -> String { + func buildRemoteStreamUrl(id: String) -> String { let maxBitrate = UserDefaultsManager.maxBitRate let format = @@ -34,9 +34,65 @@ class AlbumService { return fileUrl.absoluteString } + if let cachedUrl = StreamCacheManager.shared.cachedFileURL(mediaFileId: id) { + return cachedUrl.absoluteString + } + return buildRemoteStreamUrl(id: id) } + func isStarred(songId: String, completion: @escaping (Bool) -> Void) { + let params: [String: Any] = [ + "_start": 0, "_end": 1, "id": songId, + ] + + APIManager.shared.NDEndpointRequest(endpoint: API.NDEndpoint.getSong, parameters: params) { + (response: DataResponse<[Song], AFError>) in + switch response.result { + case .success(let songs): + completion(songs.first?.starred ?? false) + case .failure: + completion(false) + } + } + } + + func starSong(id: String, completion: @escaping (Bool) -> Void) { + let url = + "\(UserDefaultsManager.serverBaseURL)\(API.SubsonicEndpoint.star)\(AuthService.shared.getCreds(key: "subsonicToken"))&id=\(id)" + + APIManager.shared.session.request(url) + .validate(statusCode: 200..<300) + .response { response in + completion(response.error == nil) + } + } + + func unstarSong(id: String, completion: @escaping (Bool) -> Void) { + let url = + "\(UserDefaultsManager.serverBaseURL)\(API.SubsonicEndpoint.unstar)\(AuthService.shared.getCreds(key: "subsonicToken"))&id=\(id)" + + APIManager.shared.session.request(url) + .validate(statusCode: 200..<300) + .response { response in + completion(response.error == nil) + } + } + + func getStarredSongs(completion: @escaping (Result<[Song], Error>) -> Void) { + APIManager.shared.SubsonicEndpointRequest( + endpoint: API.SubsonicEndpoint.getStarred2, parameters: nil + ) { + (response: DataResponse) in + switch response.result { + case .success(let starred): + completion(.success(starred.songs)) + case .failure(let error): + completion(.failure(error)) + } + } + } + func getSongFromAlbum(id: String, completion: @escaping (Result<[Song], Error>) -> Void) { // FIXME: get all songs for now let params: [String: Any] = [ @@ -239,6 +295,8 @@ class AlbumService { return LocalFileManager.shared.fileURL(for: contextTarget)?.path ?? "" } else if LocalFileManager.shared.fileExists(fileName: anotherTarget) { return LocalFileManager.shared.fileURL(for: anotherTarget)?.path ?? "" + } else if let cached = CoverArtCacheManager.shared.cachedFilePath(albumId: albumId) { + return cached } else { return "\(UserDefaultsManager.serverBaseURL)\(API.SubsonicEndpoint.coverArt)\(AuthService.shared.getCreds(key: "subsonicToken"))&id=al-\(albumId)&size=300" diff --git a/flo/Shared/Services/CoreDataManager.swift b/flo/Shared/Services/CoreDataManager.swift index e0ff941..0d1149f 100644 --- a/flo/Shared/Services/CoreDataManager.swift +++ b/flo/Shared/Services/CoreDataManager.swift @@ -202,7 +202,7 @@ class CoreDataManager: ObservableObject { } func clearEverything() { - let entities = ["QueueEntity", "SongEntity", "PlaylistEntity"] + let entities = ["QueueEntity", "SongEntity", "PlaylistEntity", "CacheEntity"] for entity in entities { let fetchRequest = NSFetchRequest(entityName: entity) diff --git a/flo/Shared/Services/CoverArtCacheManager.swift b/flo/Shared/Services/CoverArtCacheManager.swift new file mode 100644 index 0000000..039cf52 --- /dev/null +++ b/flo/Shared/Services/CoverArtCacheManager.swift @@ -0,0 +1,71 @@ +// +// CoverArtCacheManager.swift +// flo +// + +import Foundation + +class CoverArtCacheManager { + static let shared = CoverArtCacheManager() + + private let fileManager = FileManager.default + private let cacheDirectory: URL? + private var inFlightIds: Set = [] + private let syncQueue = DispatchQueue(label: "net.faultables.flo.coverartcache") + + private init() { + self.cacheDirectory = + fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent("CoverArtCache") + + if let dir = cacheDirectory, !fileManager.fileExists(atPath: dir.path) { + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + } + } + + func cachedFilePath(albumId: String) -> String? { + guard let dir = cacheDirectory else { return nil } + let file = dir.appendingPathComponent("\(albumId).img") + guard fileManager.fileExists(atPath: file.path) else { return nil } + return file.path + } + + func cacheIfNeeded(albumId: String) { + guard !albumId.isEmpty else { return } + + var shouldDownload = false + syncQueue.sync { + if cachedFilePath(albumId: albumId) == nil && !inFlightIds.contains(albumId) { + inFlightIds.insert(albumId) + shouldDownload = true + } + } + guard shouldDownload else { return } + + let params: [String: Any] = ["id": "al-\(albumId)", "size": 300] + APIManager.shared.SubsonicEndpointDownload( + endpoint: API.SubsonicEndpoint.coverArt, parameters: params + ) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let tempFile): + if let dir = self.cacheDirectory { + let target = dir.appendingPathComponent("\(albumId).img") + try? self.fileManager.removeItem(at: target) + try? self.fileManager.moveItem(at: tempFile, to: target) + } + case .failure: + break + } + self.syncQueue.sync { + self.inFlightIds.remove(albumId) + } + } + } + + func clearCache() { + guard let dir = cacheDirectory, fileManager.fileExists(atPath: dir.path) else { return } + try? fileManager.removeItem(at: dir) + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + } +} diff --git a/flo/Shared/Services/FloooService.swift b/flo/Shared/Services/FloooService.swift index 6f068d9..f32c15c 100644 --- a/flo/Shared/Services/FloooService.swift +++ b/flo/Shared/Services/FloooService.swift @@ -75,8 +75,8 @@ class FloooService { switch result { case .success(let status): listenBrainzStatus = status - case .failure(let err): - error = err + case .failure: + listenBrainzStatus = false } group.leave() @@ -88,27 +88,19 @@ class FloooService { switch result { case .success(let status): lastFMStatus = status - case .failure(let err): - error = err + case .failure: + lastFMStatus = false } group.leave() } group.notify(queue: .main) { - if let error = error { - completion(.failure(error)) - - return - } - - guard let listenBrainz = listenBrainzStatus, let lastFm = lastFMStatus else { - completion(.failure(NSError(domain: "", code: -1))) - - return - } - - completion(.success(AccountLinkStatus(listenBrainz: listenBrainz, lastFM: lastFm))) + completion( + .success( + AccountLinkStatus( + listenBrainz: listenBrainzStatus ?? false, + lastFM: lastFMStatus ?? false))) } } diff --git a/flo/Shared/Services/KeychainManager.swift b/flo/Shared/Services/KeychainManager.swift index dd612cb..d1c48ac 100644 --- a/flo/Shared/Services/KeychainManager.swift +++ b/flo/Shared/Services/KeychainManager.swift @@ -8,9 +8,26 @@ import Foundation import KeychainAccess +/// Storage for sensitive credentials. +/// +/// On iOS we use `KeychainAccess` (Apple Keychain). On Mac Catalyst the +/// system Keychain rejects every read/write with `errSecMissingEntitlement` +/// (-34018) unless the app is either sandboxed or signed with a real +/// development certificate plus `keychain-access-groups` entitlement. +/// Neither is acceptable for this project today (sandboxing would relocate +/// the app's data container and drop existing downloads, and ad-hoc local +/// builds don't have a dev cert), so on Catalyst we fall back to a tiny +/// file-backed store inside the app's Application Support directory with +/// `0600` permissions. macOS file system permissions already restrict it +/// to the current user account, which matches the threat model the prior +/// Keychain usage actually relied on. class KeychainManager { - private static let keychain = Keychain(service: KeychainKeys.service) - .accessibility(.afterFirstUnlockThisDeviceOnly) + #if targetEnvironment(macCatalyst) + private static let store = FileBackedCredentialStore() + #else + private static let keychain = Keychain(service: KeychainKeys.service) + .accessibility(.afterFirstUnlockThisDeviceOnly) + #endif private static let iapAuthInfoKey = "iapAuthInfo" private static let authModeKey = "authMode" @@ -37,7 +54,7 @@ class KeychainManager { } catch { keychainData["authPassword"] = "Error: \(error.localizedDescription)" } - + do { if let authMode = try getAuthMode() { keychainData["authMode"] = authMode.rawValue @@ -52,62 +69,196 @@ class KeychainManager { } static func getAuthCreds() throws -> String? { - return try keychain.get(KeychainKeys.dataKey) + #if targetEnvironment(macCatalyst) + return try store.get(KeychainKeys.dataKey) + #else + return try keychain.get(KeychainKeys.dataKey) + #endif } static func getAuthPassword() throws -> String? { - return try keychain.get(KeychainKeys.serverPassword) + #if targetEnvironment(macCatalyst) + return try store.get(KeychainKeys.serverPassword) + #else + return try keychain.get(KeychainKeys.serverPassword) + #endif } static func removeAuthCreds() throws { - try keychain.remove(KeychainKeys.dataKey) + #if targetEnvironment(macCatalyst) + try store.remove(KeychainKeys.dataKey) + #else + try keychain.remove(KeychainKeys.dataKey) + #endif } static func removeAuthPassword() throws { - try keychain.remove(KeychainKeys.serverPassword) + #if targetEnvironment(macCatalyst) + try store.remove(KeychainKeys.serverPassword) + #else + try keychain.remove(KeychainKeys.serverPassword) + #endif } static func setAuthCreds(newValue: String) throws { - try keychain.set(newValue, key: KeychainKeys.dataKey) + #if targetEnvironment(macCatalyst) + try store.set(newValue, for: KeychainKeys.dataKey) + #else + try keychain.set(newValue, key: KeychainKeys.dataKey) + #endif } static func setAuthPassword(newValue: String) throws { - try keychain.set(newValue, key: KeychainKeys.serverPassword) + #if targetEnvironment(macCatalyst) + try store.set(newValue, for: KeychainKeys.serverPassword) + #else + try keychain.set(newValue, key: KeychainKeys.serverPassword) + #endif } - + static func getIAPAuthInfo() throws -> IAPAuthInfo? { - guard let jsonString = try keychain.get(iapAuthInfoKey), - let jsonData = jsonString.data(using: .utf8) else { - return nil - } + #if targetEnvironment(macCatalyst) + guard let jsonString = try store.get(iapAuthInfoKey), + let jsonData = jsonString.data(using: .utf8) + else { + return nil + } + #else + guard let jsonString = try keychain.get(iapAuthInfoKey), + let jsonData = jsonString.data(using: .utf8) + else { + return nil + } + #endif return try JSONDecoder().decode(IAPAuthInfo.self, from: jsonData) } - + static func setIAPAuthInfo(_ info: IAPAuthInfo) throws { let jsonData = try JSONEncoder().encode(info) guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw NSError(domain: "KeychainManager", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to encode IAP auth info"]) + throw NSError( + domain: "KeychainManager", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode IAP auth info"]) } - try keychain.set(jsonString, key: iapAuthInfoKey) + #if targetEnvironment(macCatalyst) + try store.set(jsonString, for: iapAuthInfoKey) + #else + try keychain.set(jsonString, key: iapAuthInfoKey) + #endif } - + static func removeIAPAuthInfo() throws { - try keychain.remove(iapAuthInfoKey) + #if targetEnvironment(macCatalyst) + try store.remove(iapAuthInfoKey) + #else + try keychain.remove(iapAuthInfoKey) + #endif } - + static func getAuthMode() throws -> AuthMode? { - guard let rawValue = try keychain.get(authModeKey) else { - return nil - } + #if targetEnvironment(macCatalyst) + guard let rawValue = try store.get(authModeKey) else { return nil } + #else + guard let rawValue = try keychain.get(authModeKey) else { return nil } + #endif return AuthMode(rawValue: rawValue) } - + static func setAuthMode(_ mode: AuthMode) throws { - try keychain.set(mode.rawValue, key: authModeKey) + #if targetEnvironment(macCatalyst) + try store.set(mode.rawValue, for: authModeKey) + #else + try keychain.set(mode.rawValue, key: authModeKey) + #endif } - + static func removeAuthMode() throws { - try keychain.remove(authModeKey) + #if targetEnvironment(macCatalyst) + try store.remove(authModeKey) + #else + try keychain.remove(authModeKey) + #endif } } + +#if targetEnvironment(macCatalyst) + /// Mac Catalyst credential store backed by per-key files under + /// `~/Library/Application Support//Credentials/`. + /// + /// File permissions are set to `0600` so only the current user account can + /// read them, matching what the system Keychain provided in practice for + /// this app. This is intentionally simple — secure-by-isolation, not by + /// encryption — and exists because the system Keychain refuses to work on + /// non-sandboxed, ad-hoc-signed Catalyst builds. + final class FileBackedCredentialStore { + enum StoreError: Error, LocalizedError { + case directoryUnavailable + + var errorDescription: String? { + switch self { + case .directoryUnavailable: + return "Could not resolve Application Support directory for credential storage." + } + } + } + + private let directoryURL: URL + private let fileManager = FileManager.default + + init() { + let baseURL: URL + if let supportURL = try? FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true + ) { + let bundleID = Bundle.main.bundleIdentifier ?? AppMeta.identifier + baseURL = supportURL.appendingPathComponent(bundleID, isDirectory: true) + } else { + baseURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + } + directoryURL = baseURL.appendingPathComponent("Credentials", isDirectory: true) + } + + private func ensureDirectory() throws { + if !fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.createDirectory( + at: directoryURL, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } + } + + private func fileURL(for key: String) -> URL { + // Sanitize key to keep it filesystem-safe. + let safe = key.replacingOccurrences(of: "/", with: "_") + return directoryURL.appendingPathComponent(safe, isDirectory: false) + } + + func get(_ key: String) throws -> String? { + let url = fileURL(for: key) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let data = try Data(contentsOf: url) + return String(data: data, encoding: .utf8) + } + + func set(_ value: String, for key: String) throws { + try ensureDirectory() + let url = fileURL(for: key) + guard let data = value.data(using: .utf8) else { + throw NSError( + domain: "FileBackedCredentialStore", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode value as UTF-8"] + ) + } + try data.write(to: url, options: [.atomic, .completeFileProtection]) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + func remove(_ key: String) throws { + let url = fileURL(for: key) + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + } + } +#endif diff --git a/flo/Shared/Services/LibraryCacheManager.swift b/flo/Shared/Services/LibraryCacheManager.swift new file mode 100644 index 0000000..f3f6ac2 --- /dev/null +++ b/flo/Shared/Services/LibraryCacheManager.swift @@ -0,0 +1,43 @@ +// +// LibraryCacheManager.swift +// flo +// + +import Foundation + +class LibraryCacheManager { + static let shared = LibraryCacheManager() + + private let fileManager = FileManager.default + private let cacheDirectory: URL? + + private init() { + self.cacheDirectory = + fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent("LibraryCache") + + if let dir = cacheDirectory, !fileManager.fileExists(atPath: dir.path) { + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + } + } + + func save(_ items: T, forKey key: String) { + guard let dir = cacheDirectory else { return } + let file = dir.appendingPathComponent("\(key).json") + guard let data = try? JSONEncoder().encode(items) else { return } + try? data.write(to: file, options: .atomic) + } + + func load(_ type: T.Type, forKey key: String) -> T? { + guard let dir = cacheDirectory else { return nil } + let file = dir.appendingPathComponent("\(key).json") + guard let data = try? Data(contentsOf: file) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + func clearCache() { + guard let dir = cacheDirectory, fileManager.fileExists(atPath: dir.path) else { return } + try? fileManager.removeItem(at: dir) + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + } +} diff --git a/flo/Shared/Services/StreamCacheManager.swift b/flo/Shared/Services/StreamCacheManager.swift new file mode 100644 index 0000000..9675415 --- /dev/null +++ b/flo/Shared/Services/StreamCacheManager.swift @@ -0,0 +1,331 @@ +// +// StreamCacheManager.swift +// flo +// + +import Alamofire +import CoreData +import Foundation + +class StreamCacheManager { + static let shared = StreamCacheManager() + + private let fileManager = FileManager.default + private let cacheDirectory: URL? + private let syncQueue = DispatchQueue(label: "net.faultables.flo.streamcache") + private var inFlightDownloads: [String: DownloadRequest] = [:] + private var inFlightProgress: [String: Double] = [:] + private var inFlightKeys: Set = [] + private var currentlyPlayingSongId: String? + + private init() { + self.cacheDirectory = + fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent("StreamCache") + + if let dir = cacheDirectory, !fileManager.fileExists(atPath: dir.path) { + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + } + } + + // MARK: - Public API + + func cachedFileURL(mediaFileId: String) -> URL? { + let bitrate = UserDefaultsManager.maxBitRate + let key = cacheKey(mediaFileId: mediaFileId, bitrate: bitrate) + + guard + let record = CoreDataManager.shared.getRecordByKey( + entity: CacheEntity.self, key: \CacheEntity.cacheKey, value: key, limit: 1 + ).first, + record.state == "ready", + let filePath = record.filePath, + let dir = cacheDirectory + else { return nil } + + let fileURL = dir.appendingPathComponent(filePath) + guard fileManager.fileExists(atPath: fileURL.path) else { + CoreDataManager.shared.deleteRecordByKey( + entity: CacheEntity.self, key: \CacheEntity.cacheKey, value: key) + return nil + } + + record.lastAccessedAt = Date() + CoreDataManager.shared.saveRecord() + + return fileURL + } + + func cacheSong(mediaFileId: String, originalSuffix: String? = nil, from queueItem: QueueEntity? = nil) { + guard UserDefaultsManager.streamCacheMaxSize > 0 else { return } + guard !mediaFileId.isEmpty else { return } + + let bitrate = UserDefaultsManager.maxBitRate + let key = cacheKey(mediaFileId: mediaFileId, bitrate: bitrate) + + // Register in-flight atomically — prevents duplicate downloads + let didRegister: Bool = syncQueue.sync { + guard !inFlightKeys.contains(key) else { return false } + inFlightKeys.insert(key) + return true + } + guard didRegister else { return } + + // Already cached? + let existing = CoreDataManager.shared.getRecordByKey( + entity: CacheEntity.self, key: \CacheEntity.cacheKey, value: key, limit: 1) + if !existing.isEmpty { + syncQueue.async { self.inFlightKeys.remove(key) } + return + } + + let format = + bitrate == TranscodingSettings.sourceBitRate + ? TranscodingSettings.sourceFormat : TranscodingSettings.targetFormat + let suffix = format == "raw" ? (originalSuffix ?? "raw") : format + + // Create CacheEntity with downloading state + let entity = CacheEntity(context: CoreDataManager.shared.viewContext) + entity.cacheKey = key + entity.mediaFileId = mediaFileId + entity.filePath = "\(key).\(suffix)" + entity.suffix = suffix + entity.state = "downloading" + entity.cachedAt = Date() + entity.lastAccessedAt = Date() + entity.fileSize = 0 + + // Store song metadata for offline browsing + if let q = queueItem { + entity.title = q.songName + entity.artistName = q.artistName + entity.albumId = q.albumId + entity.albumName = q.albumName + entity.duration = q.duration + entity.bitRate = q.bitRate + entity.sampleRate = q.sampleRate + } + + CoreDataManager.shared.saveRecord() + + let params: [String: Any] = [ + "id": mediaFileId, + "maxBitRate": bitrate, + "format": format, + ] + + let progressUpdate: (Double) -> Void = { [weak self] progress in + self?.syncQueue.async { self?.inFlightProgress[key] = progress / 100.0 } + } + + let request = APIManager.shared.SubsonicEndpointDownloadNew( + endpoint: API.SubsonicEndpoint.stream, parameters: params, progressUpdate: progressUpdate + ) { [weak self] result in + guard let self = self else { return } + + self.syncQueue.async { + self.inFlightDownloads.removeValue(forKey: key) + self.inFlightProgress.removeValue(forKey: key) + self.inFlightKeys.remove(key) + } + + switch result { + case .success(let tempFile): + guard let dir = self.cacheDirectory else { + self.removeCacheRecord(key: key) + return + } + + let target = dir.appendingPathComponent("\(key).\(suffix)") + + LocalFileManager.shared.moveFile(source: tempFile, target: target) { moveResult in + switch moveResult { + case .success: + let fileSize = + (try? self.fileManager.attributesOfItem(atPath: target.path)[.size] as? Int64) ?? 0 + + let records = CoreDataManager.shared.getRecordByKey( + entity: CacheEntity.self, key: \CacheEntity.cacheKey, value: key, limit: 1) + if let record = records.first { + record.state = "ready" + record.fileSize = fileSize + CoreDataManager.shared.saveRecord() + } + + self.evictIfNeeded() + + case .failure: + self.removeCacheRecord(key: key) + } + } + + case .failure(let error): + if let afError = error.asAFError, case .explicitlyCancelled = afError { + // Cancelled — clean up + } + self.removeCacheRecord(key: key) + } + } + + syncQueue.sync { self.inFlightDownloads[key] = request } + } + + func getCachedSongs() -> [Song] { + let sortDescriptor = NSSortDescriptor(key: "lastAccessedAt", ascending: false) + let records = CoreDataManager.shared.getRecordsByEntity( + entity: CacheEntity.self, sortDescriptors: [sortDescriptor]) + + // Deduplicate by mediaFileId (keep most recent per song) + var seen = Set() + return records + .filter { $0.state == "ready" && $0.title != nil } + .filter { record in + guard let id = record.mediaFileId else { return false } + return seen.insert(id).inserted + } + .map { Song(from: $0) } + } + + func setCurrentlyPlaying(mediaFileId: String) { + syncQueue.async { self.currentlyPlayingSongId = mediaFileId } + } + + func cancelAllInFlight() { + let keysToClean: [String] = syncQueue.sync { + let keys = Array(inFlightDownloads.keys) + for (_, request) in inFlightDownloads { + request.cancel() + } + inFlightDownloads.removeAll() + inFlightProgress.removeAll() + inFlightKeys.removeAll() + return keys + } + + for key in keysToClean { + removeCacheRecord(key: key) + } + } + + func clearCache() { + // Cancel all downloads + syncQueue.sync { + for (_, request) in inFlightDownloads { request.cancel() } + inFlightDownloads.removeAll() + inFlightProgress.removeAll() + inFlightKeys.removeAll() + } + + // Delete all files + if let dir = cacheDirectory, fileManager.fileExists(atPath: dir.path) { + try? fileManager.removeItem(at: dir) + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + } + + // Delete all CacheEntity records + let fetchRequest = NSFetchRequest(entityName: "CacheEntity") + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + try? CoreDataManager.shared.viewContext.execute(deleteRequest) + try? CoreDataManager.shared.viewContext.save() + } + + func calculateCacheSize() async -> Int64 { + return await MainActor.run { + let records = CoreDataManager.shared.getRecordsByEntity(entity: CacheEntity.self) + return records.filter { $0.state == "ready" }.reduce(0) { $0 + $1.fileSize } + } + } + + func reconcile() { + // Cancel any active downloads first to prevent races + syncQueue.sync { + for (_, request) in inFlightDownloads { request.cancel() } + inFlightDownloads.removeAll() + inFlightProgress.removeAll() + inFlightKeys.removeAll() + } + + let records = CoreDataManager.shared.getRecordsByEntity(entity: CacheEntity.self) + + for record in records { + if record.state == "downloading" { + if let filePath = record.filePath, let dir = cacheDirectory { + let fileURL = dir.appendingPathComponent(filePath) + try? fileManager.removeItem(at: fileURL) + } + CoreDataManager.shared.viewContext.delete(record) + continue + } + + if let filePath = record.filePath, let dir = cacheDirectory { + let fileURL = dir.appendingPathComponent(filePath) + if !fileManager.fileExists(atPath: fileURL.path) { + CoreDataManager.shared.viewContext.delete(record) + } + } else { + CoreDataManager.shared.viewContext.delete(record) + } + } + + CoreDataManager.shared.saveRecord() + + // Re-fetch surviving records for orphan file cleanup + let survivingRecords = CoreDataManager.shared.getRecordsByEntity(entity: CacheEntity.self) + let knownFiles = Set(survivingRecords.compactMap { $0.filePath }) + + if let dir = cacheDirectory, + let files = try? fileManager.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil) + { + for file in files { + if !knownFiles.contains(file.lastPathComponent) { + try? fileManager.removeItem(at: file) + } + } + } + } + + // MARK: - Private + + private func cacheKey(mediaFileId: String, bitrate: String) -> String { + return "\(mediaFileId)_\(bitrate)" + } + + private func removeCacheRecord(key: String) { + CoreDataManager.shared.deleteRecordByKey( + entity: CacheEntity.self, key: \CacheEntity.cacheKey, value: key) + } + + private func evictIfNeeded() { + let maxSize = UserDefaultsManager.streamCacheMaxSize + guard maxSize > 0 else { return } + + let sortDescriptor = NSSortDescriptor(key: "lastAccessedAt", ascending: true) + let records = CoreDataManager.shared.getRecordsByEntity( + entity: CacheEntity.self, sortDescriptors: [sortDescriptor]) + + let totalSize = records.filter { $0.state == "ready" }.reduce(Int64(0)) { $0 + $1.fileSize } + guard totalSize > maxSize else { return } + + var currentSize = totalSize + let currentlyPlaying: String? = syncQueue.sync { currentlyPlayingSongId } + + for record in records where record.state == "ready" { + guard currentSize > maxSize else { break } + + if let playing = currentlyPlaying, record.mediaFileId == playing { continue } + + let size = record.fileSize + + if let filePath = record.filePath, let dir = cacheDirectory { + let fileURL = dir.appendingPathComponent(filePath) + try? fileManager.removeItem(at: fileURL) + } + + CoreDataManager.shared.viewContext.delete(record) + currentSize -= size + } + + CoreDataManager.shared.saveRecord() + } +} diff --git a/flo/Shared/Services/UserDefaultsManager.swift b/flo/Shared/Services/UserDefaultsManager.swift index a50e3e1..c91290b 100644 --- a/flo/Shared/Services/UserDefaultsManager.swift +++ b/flo/Shared/Services/UserDefaultsManager.swift @@ -119,6 +119,16 @@ class UserDefaultsManager { } } + static var streamCacheMaxSize: Int64 { + get { + let stored = UserDefaults.standard.object(forKey: UserDefaultsKeys.streamCacheMaxSize) + return (stored as? Int64) ?? 0 // default off + } + set { + UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.streamCacheMaxSize) + } + } + static var floPlus: Bool { get { return UserDefaults.standard.bool(forKey: UserDefaultsKeys.floPlus) diff --git a/flo/Shared/Utils/Constants.swift b/flo/Shared/Utils/Constants.swift index 93f9343..0c5968c 100644 --- a/flo/Shared/Utils/Constants.swift +++ b/flo/Shared/Utils/Constants.swift @@ -7,10 +7,10 @@ import Foundation -struct API { +enum API { static let NDAuthHeader = "X-ND-Authorization" - struct NDEndpoint { + enum NDEndpoint { static let login = "/auth/login" static let loginIAP: String? = "/auth/iap" static let getAlbum = "/api/album" @@ -22,7 +22,7 @@ struct API { static let lastFMLink = "/api/lastfm/link" } - struct SubsonicEndpoint { + enum SubsonicEndpoint { static let stream = "/rest/stream" static let coverArt = "/rest/getCoverArt" static let albuminfo = "/rest/getAlbumInfo" @@ -32,6 +32,9 @@ struct API { static let radios = "/rest/getInternetRadioStations" static let similarSongs = "/rest/getSimilarSongs2" static let topSongs = "/rest/getTopSongs" + static let star = "/rest/star" + static let unstar = "/rest/unstar" + static let getStarred2 = "/rest/getStarred2" } } @@ -58,10 +61,18 @@ enum UserDefaultsKeys { static let saveLoginInfo = "saveLoginInfo" static let LRCLIBServerURL = "LRCLIBServerURL" static let floPlus = "floPlus" + static let streamCacheMaxSize = "streamCacheMaxSize" } enum KeychainKeys { - static let service = AppMeta.identifier + // On iOS the app has historically shipped with this fixed service name; keep it + // to avoid logging existing users out on upgrade. On Mac Catalyst the keychain + // is namespaced by app bundle id, so use that to ensure writes succeed. + #if targetEnvironment(macCatalyst) + static let service = Bundle.main.bundleIdentifier ?? AppMeta.identifier + #else + static let service = AppMeta.identifier + #endif static let dataKey = "authCreds" static let serverPassword = "serverPassword" } diff --git a/flo/Shared/Utils/IAPLoginView.swift b/flo/Shared/Utils/IAPLoginView.swift index c93aafa..e48ca2e 100644 --- a/flo/Shared/Utils/IAPLoginView.swift +++ b/flo/Shared/Utils/IAPLoginView.swift @@ -36,6 +36,8 @@ struct IAPLoginView: View { } .background(Color(.systemBackground)) .foregroundColor(.accent) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) .alert(isPresented: Binding( get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } } diff --git a/flo/SongsView.swift b/flo/SongsView.swift index 48ddf90..35759ea 100644 --- a/flo/SongsView.swift +++ b/flo/SongsView.swift @@ -86,6 +86,9 @@ struct SongsView: View { .padding(.top, 10) .padding(.bottom, 100) .navigationTitle("Songs") + .refreshable { + await viewModel.refreshAllSongs() + } .searchable( text: $searchSong, placement: .navigationBarDrawer(displayMode: .always), diff --git a/flo/flo.xcdatamodeld/flo.xcdatamodel/contents b/flo/flo.xcdatamodeld/flo.xcdatamodel/contents index 40af39c..8909cc4 100644 --- a/flo/flo.xcdatamodeld/flo.xcdatamodel/contents +++ b/flo/flo.xcdatamodeld/flo.xcdatamodel/contents @@ -37,6 +37,28 @@ + + + + + + + + + + + + + + + + + + + + + +