├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ios-deployment.yml ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── cliff.toml ├── fastlane ├── Appfile ├── Fastfile ├── Gymfile ├── Matchfile └── README.md ├── flo.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── WorkspaceSettings.xcsettings │ └── swiftpm │ └── Package.resolved ├── flo ├── AlbumView.swift ├── AlbumViewModel.swift ├── AlbumsView.swift ├── App.swift ├── ArtistDetailView.swift ├── ArtistsView.swift ├── AuthViewModel.swift ├── ContentView.swift ├── DownloadButtonView.swift ├── DownloadQueueView.swift ├── DownloadViewModel.swift ├── FloatingPlayerView.swift ├── FloooViewModel.swift ├── Info.plist ├── LoginView.swift ├── Navigation │ ├── DownloadsView.swift │ ├── HomeView.swift │ ├── LibraryView.swift │ └── PreferencesView.swift ├── PlayerCustomSlider.swift ├── PlayerView.swift ├── PlayerViewModel.swift ├── PlaylistDetailView.swift ├── PlaylistView.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ └── flo.png │ │ ├── Contents.json │ │ ├── Downloads.imageset │ │ │ ├── Contents.json │ │ │ └── jumble-travel-destination-hand-pointing-to-a-globe.png │ │ ├── Home.imageset │ │ │ ├── Contents.json │ │ │ └── jumble-music-streaming-services-with-vintage-ambience-bust-sculpture-and-gramophone-1.png │ │ ├── PlayerColor.colorset │ │ │ └── Contents.json │ │ ├── logo.imageset │ │ │ ├── Contents.json │ │ │ └── logo.png │ │ ├── logo_alt.imageset │ │ │ ├── Contents.json │ │ │ └── flo_color.png │ │ └── placeholder.imageset │ │ │ ├── Contents.json │ │ │ └── placeholder.png │ ├── Fonts │ │ └── PlusJakartaSans-VariableFont_wght.ttf │ ├── Localizable.xcstrings │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Shared │ ├── Models │ │ ├── AccountLinkStatus.swift │ │ ├── Album.swift │ │ ├── Artist.swift │ │ ├── NowPlaying.swift │ │ ├── Playable.swift │ │ ├── Playlist.swift │ │ ├── ScanStatus.swift │ │ ├── Song.swift │ │ ├── Stats.swift │ │ ├── Subsonic.swift │ │ └── UserAuth.swift │ ├── Services │ │ ├── APIManager.swift │ │ ├── AlbumService.swift │ │ ├── AuthService.swift │ │ ├── CoreDataManager.swift │ │ ├── FloooService.swift │ │ ├── KeychainManager.swift │ │ ├── LocalFileManager.swift │ │ ├── PlaybackService.swift │ │ ├── ScanStatusService.swift │ │ └── UserDefaultsManager.swift │ └── Utils │ │ ├── Constants.swift │ │ ├── Errors.swift │ │ ├── Fonts.swift │ │ └── Strings.swift ├── SongView.swift ├── SongsView.swift ├── StatCardView.swift └── flo.xcdatamodeld │ └── flo.xcdatamodel │ └── contents └── meta └── guthib.jpeg /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: let's make flo better, together 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Context (please complete the following information):** 27 | - Device: [e.g. iPhone XS] 28 | - OS: [e.g. iOS 18.1] 29 | - Navidrome Version [e.g. 0.53.3 (13af8ed4)] 30 | - flo Version [e.g. 1.6.0 (160)] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[TBD] " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | > [!TIP] 11 | > You may consider starting a [new discussion](https://github.com/kepelet/flo/discussions/new?category=ideas) before making an issue, unless you are sure this issue is not something that needs to be discussed 12 | 13 | --- 14 | 15 | **Is your feature request related to a problem? Please describe.** 16 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 17 | 18 | **Describe the solution you'd like** 19 | A clear and concise description of what you want to happen. 20 | 21 | **Describe alternatives you've considered** 22 | A clear and concise description of any alternative solutions or features you've considered. 23 | 24 | **Additional context** 25 | Add any other context or screenshots about the feature request here. 26 | -------------------------------------------------------------------------------- /.github/workflows/ios-deployment.yml: -------------------------------------------------------------------------------- 1 | name: ios-deployment 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'release/*' 7 | - 'develop' 8 | 9 | jobs: 10 | check_draft: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | is_draft: ${{ github.event.pull_request.draft }} 14 | steps: 15 | - run: echo "checking PR status" 16 | 17 | deploy: 18 | needs: check_draft 19 | if: github.event_name == 'push' && (github.event_name == 'pull_request' && needs.check_draft.outputs.is_draft != 'true') 20 | runs-on: macos-latest 21 | env: 22 | APPLE_ID: ${{ secrets.APPLE_ID }} 23 | ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} 24 | ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} 25 | ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} 26 | MATCH_GIT_PRIVATE_KEY: ${{ secrets.MATCH_GIT_PRIVATE_KEY }} 27 | MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: maxim-lobanov/setup-xcode@v1 32 | with: 33 | xcode-version: '15.4' 34 | - uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: 3.2.1 37 | bundler-cache: true 38 | - run: | 39 | if [[ "${{ github.event_name }}" == "pull_request" ]]; then 40 | bundle exec fastlane ios beta 41 | elif [[ "${{ github.event_name }}" == "push" ]]; then 42 | bundle exec fastlane ios beta public:true 43 | fi 44 | - uses: actions/upload-artifact@v4 45 | with: 46 | name: appstore ipa & dsym 47 | path: | 48 | ${{ github.workspace }}/fastlane/builds/flo.ipa 49 | ${{ github.workspace }}/fastlane/builds/flo.app.dSYM.zip 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.7) 5 | base64 6 | nkf 7 | rexml 8 | addressable (2.8.7) 9 | public_suffix (>= 2.0.2, < 7.0) 10 | artifactory (3.0.17) 11 | atomos (0.1.3) 12 | aws-eventstream (1.3.0) 13 | aws-partitions (1.1005.0) 14 | aws-sdk-core (3.212.0) 15 | aws-eventstream (~> 1, >= 1.3.0) 16 | aws-partitions (~> 1, >= 1.992.0) 17 | aws-sigv4 (~> 1.9) 18 | jmespath (~> 1, >= 1.6.1) 19 | aws-sdk-kms (1.95.0) 20 | aws-sdk-core (~> 3, >= 3.210.0) 21 | aws-sigv4 (~> 1.5) 22 | aws-sdk-s3 (1.170.1) 23 | aws-sdk-core (~> 3, >= 3.210.0) 24 | aws-sdk-kms (~> 1) 25 | aws-sigv4 (~> 1.5) 26 | aws-sigv4 (1.10.1) 27 | aws-eventstream (~> 1, >= 1.0.2) 28 | babosa (1.0.4) 29 | base64 (0.2.0) 30 | claide (1.1.0) 31 | colored (1.2) 32 | colored2 (3.1.2) 33 | commander (4.6.0) 34 | highline (~> 2.0.0) 35 | declarative (0.0.20) 36 | digest-crc (0.6.5) 37 | rake (>= 12.0.0, < 14.0.0) 38 | domain_name (0.6.20240107) 39 | dotenv (2.8.1) 40 | emoji_regex (3.2.3) 41 | excon (0.112.0) 42 | faraday (1.10.4) 43 | faraday-em_http (~> 1.0) 44 | faraday-em_synchrony (~> 1.0) 45 | faraday-excon (~> 1.1) 46 | faraday-httpclient (~> 1.0) 47 | faraday-multipart (~> 1.0) 48 | faraday-net_http (~> 1.0) 49 | faraday-net_http_persistent (~> 1.0) 50 | faraday-patron (~> 1.0) 51 | faraday-rack (~> 1.0) 52 | faraday-retry (~> 1.0) 53 | ruby2_keywords (>= 0.0.4) 54 | faraday-cookie_jar (0.0.7) 55 | faraday (>= 0.8.0) 56 | http-cookie (~> 1.0.0) 57 | faraday-em_http (1.0.0) 58 | faraday-em_synchrony (1.0.0) 59 | faraday-excon (1.1.0) 60 | faraday-httpclient (1.0.1) 61 | faraday-multipart (1.0.4) 62 | multipart-post (~> 2) 63 | faraday-net_http (1.0.2) 64 | faraday-net_http_persistent (1.2.0) 65 | faraday-patron (1.0.0) 66 | faraday-rack (1.0.0) 67 | faraday-retry (1.0.3) 68 | faraday_middleware (1.2.1) 69 | faraday (~> 1.0) 70 | fastimage (2.3.1) 71 | fastlane (2.225.0) 72 | CFPropertyList (>= 2.3, < 4.0.0) 73 | addressable (>= 2.8, < 3.0.0) 74 | artifactory (~> 3.0) 75 | aws-sdk-s3 (~> 1.0) 76 | babosa (>= 1.0.3, < 2.0.0) 77 | bundler (>= 1.12.0, < 3.0.0) 78 | colored (~> 1.2) 79 | commander (~> 4.6) 80 | dotenv (>= 2.1.1, < 3.0.0) 81 | emoji_regex (>= 0.1, < 4.0) 82 | excon (>= 0.71.0, < 1.0.0) 83 | faraday (~> 1.0) 84 | faraday-cookie_jar (~> 0.0.6) 85 | faraday_middleware (~> 1.0) 86 | fastimage (>= 2.1.0, < 3.0.0) 87 | fastlane-sirp (>= 1.0.0) 88 | gh_inspector (>= 1.1.2, < 2.0.0) 89 | google-apis-androidpublisher_v3 (~> 0.3) 90 | google-apis-playcustomapp_v1 (~> 0.1) 91 | google-cloud-env (>= 1.6.0, < 2.0.0) 92 | google-cloud-storage (~> 1.31) 93 | highline (~> 2.0) 94 | http-cookie (~> 1.0.5) 95 | json (< 3.0.0) 96 | jwt (>= 2.1.0, < 3) 97 | mini_magick (>= 4.9.4, < 5.0.0) 98 | multipart-post (>= 2.0.0, < 3.0.0) 99 | naturally (~> 2.2) 100 | optparse (>= 0.1.1, < 1.0.0) 101 | plist (>= 3.1.0, < 4.0.0) 102 | rubyzip (>= 2.0.0, < 3.0.0) 103 | security (= 0.1.5) 104 | simctl (~> 1.6.3) 105 | terminal-notifier (>= 2.0.0, < 3.0.0) 106 | terminal-table (~> 3) 107 | tty-screen (>= 0.6.3, < 1.0.0) 108 | tty-spinner (>= 0.8.0, < 1.0.0) 109 | word_wrap (~> 1.0.0) 110 | xcodeproj (>= 1.13.0, < 2.0.0) 111 | xcpretty (~> 0.3.0) 112 | xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) 113 | fastlane-sirp (1.0.0) 114 | sysrandom (~> 1.0) 115 | gh_inspector (1.1.3) 116 | google-apis-androidpublisher_v3 (0.54.0) 117 | google-apis-core (>= 0.11.0, < 2.a) 118 | google-apis-core (0.11.3) 119 | addressable (~> 2.5, >= 2.5.1) 120 | googleauth (>= 0.16.2, < 2.a) 121 | httpclient (>= 2.8.1, < 3.a) 122 | mini_mime (~> 1.0) 123 | representable (~> 3.0) 124 | retriable (>= 2.0, < 4.a) 125 | rexml 126 | google-apis-iamcredentials_v1 (0.17.0) 127 | google-apis-core (>= 0.11.0, < 2.a) 128 | google-apis-playcustomapp_v1 (0.13.0) 129 | google-apis-core (>= 0.11.0, < 2.a) 130 | google-apis-storage_v1 (0.31.0) 131 | google-apis-core (>= 0.11.0, < 2.a) 132 | google-cloud-core (1.7.1) 133 | google-cloud-env (>= 1.0, < 3.a) 134 | google-cloud-errors (~> 1.0) 135 | google-cloud-env (1.6.0) 136 | faraday (>= 0.17.3, < 3.0) 137 | google-cloud-errors (1.4.0) 138 | google-cloud-storage (1.47.0) 139 | addressable (~> 2.8) 140 | digest-crc (~> 0.4) 141 | google-apis-iamcredentials_v1 (~> 0.1) 142 | google-apis-storage_v1 (~> 0.31.0) 143 | google-cloud-core (~> 1.6) 144 | googleauth (>= 0.16.2, < 2.a) 145 | mini_mime (~> 1.0) 146 | googleauth (1.8.1) 147 | faraday (>= 0.17.3, < 3.a) 148 | jwt (>= 1.4, < 3.0) 149 | multi_json (~> 1.11) 150 | os (>= 0.9, < 2.0) 151 | signet (>= 0.16, < 2.a) 152 | highline (2.0.3) 153 | http-cookie (1.0.7) 154 | domain_name (~> 0.5) 155 | httpclient (2.8.3) 156 | jmespath (1.6.2) 157 | json (2.8.1) 158 | jwt (2.9.3) 159 | base64 160 | mini_magick (4.13.2) 161 | mini_mime (1.1.5) 162 | multi_json (1.15.0) 163 | multipart-post (2.4.1) 164 | nanaimo (0.4.0) 165 | naturally (2.2.1) 166 | nkf (0.2.0) 167 | optparse (0.6.0) 168 | os (1.1.4) 169 | plist (3.7.1) 170 | public_suffix (6.0.1) 171 | rake (13.2.1) 172 | representable (3.2.0) 173 | declarative (< 0.1.0) 174 | trailblazer-option (>= 0.1.1, < 0.2.0) 175 | uber (< 0.2.0) 176 | retriable (3.1.2) 177 | rexml (3.3.9) 178 | rouge (2.0.7) 179 | ruby2_keywords (0.0.5) 180 | rubyzip (2.3.2) 181 | security (0.1.5) 182 | signet (0.19.0) 183 | addressable (~> 2.8) 184 | faraday (>= 0.17.5, < 3.a) 185 | jwt (>= 1.5, < 3.0) 186 | multi_json (~> 1.10) 187 | simctl (1.6.10) 188 | CFPropertyList 189 | naturally 190 | sysrandom (1.0.5) 191 | terminal-notifier (2.0.0) 192 | terminal-table (3.0.2) 193 | unicode-display_width (>= 1.1.1, < 3) 194 | trailblazer-option (0.1.2) 195 | tty-cursor (0.7.1) 196 | tty-screen (0.8.2) 197 | tty-spinner (0.9.3) 198 | tty-cursor (~> 0.7) 199 | uber (0.1.0) 200 | unicode-display_width (2.6.0) 201 | word_wrap (1.0.0) 202 | xcodeproj (1.27.0) 203 | CFPropertyList (>= 2.3.3, < 4.0) 204 | atomos (~> 0.1.3) 205 | claide (>= 1.0.2, < 2.0) 206 | colored2 (~> 3.1) 207 | nanaimo (~> 0.4.0) 208 | rexml (>= 3.3.6, < 4.0) 209 | xcpretty (0.3.0) 210 | rouge (~> 2.0.7) 211 | xcpretty-travis-formatter (1.0.1) 212 | xcpretty (~> 0.2, >= 0.0.7) 213 | 214 | PLATFORMS 215 | arm64-darwin-23 216 | ruby 217 | 218 | DEPENDENCIES 219 | fastlane 220 | 221 | BUNDLED WITH 222 | 2.5.23 223 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2024 Kelompok Penerbang Walet 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | flo 3 |

4 |

Meet flo, an open source Navidrome client written in Swift.

5 |
6 | 7 | # flo 8 | 9 | As mentioned many times, flo is an open source Navidrome client written in Swift. It has modern yet familiar user interfaces 10 | built on top of Apple's latest UI framework: SwiftUI. While Navidrome supports Subsonic APIs, flo was purposely designed for Navidrome servers. 11 | 12 | However, flo is still under heavy development. Bugs and regular updates are expected to improve flo over time. It's worth noting that flo at 13 | this stage is unlikely to harm your iPhone or your beloved Navidrome server. 14 | 15 | ## Features 16 | 17 | Everything you can expect from a music player: it plays music. However, here are some features you may enjoy: 18 | 19 | - Online streaming (save your storage) 20 | - Offline streaming (save your bandwidth) 21 | - Play by album (shuffle for surprises) 22 | - Background playback (just don't close the app) 23 | - Control playback via the "command center" (something in your "notification") 24 | 25 | flo may have opt-in "social" features in the future to make the listening experience more fun and extroverted. But for now, flo is intended to become one of the best 26 | Navidrome clients in the Apple ecosystem! 27 | 28 | To learn more about flo, visit flo's [landing page.](https://client.flooo.club) 29 | 30 | ## Development 31 | 32 | For now just clone it and figure it out :) 33 | 34 | Jokes aside, make sure you have Xcode installed. The latest stable version is recommended, and as of this writing, Swift 5 is used. Another step, such as setting up a "provisioning profile," may be required to run this app in a development environment. 35 | 36 | This project uses integrated SwiftPM (Swift Package Manager) to manage app dependencies. So far, only four package are being used: 37 | 38 | - Alamofire — everyone's favorite http library 39 | - KeychainAccess — a simple wrapper for Keychain access 40 | - Nuke — image loading system 41 | - Pulse — network logger for Apple platforms 42 | 43 | The minimum number of dependencies is intended to make the project easier to maintain. 44 | 45 | If you're part of the Kepelet org, make sure you have [fastlane](https://fastlane.tools) installed. Then, you can run `fastlane match development` and you're ready to go without having to mess with the provisioning profile too much! 46 | 47 | Practically, this project uses the Gitflow workflow, where: 48 | 49 | - `main` is the "App Store" version 50 | - `develop` is the "TestFlight" public version 51 | - `release/xxx` is the "TestFlight" internal version 52 | - `features/yyy` or `bugfix/zzz` is the "staging" area of the current release (feature/bugfix branches) 53 | 54 | Realistically, sometimes feature branches are unnecessary, as the project doesn't run tests (yet) and the developer tests the app anyway. 55 | 56 | So, the flow is: 57 | 58 | - Draft a release branch 59 | - Every week or so, if no critical errors are present, merge to develop and submit to the TestFlight external group 60 | - Wait for approval 61 | - Test the beta app 62 | - Every week or so, if no critical errors are present, submit for review to the App Store 63 | - Wait for approval 64 | - When it's live, merge to main 65 | - Repeat 66 | 67 | Coming from Web Development, where no one technically controls the release process, I hate this cycle — I used to ship as soon as it was ready and figure it out later. This time, I have to draft a release every 1-2 weeks. It might get approved, or it might be rejected. But at least I tried! 68 | 69 | ## Localization 70 | 71 | One of the promises of flo is customization — to make flo look the way you want. More importantly, it aims to make flo easier to use, and one of the efforts is localization: to make flo speak the language you know best. 72 | 73 | Unfortunately, we don't use third-party apps/services to manage localizations in flo, which means Xcode is required. While the process itself is [relatively easy](https://developer.apple.com/documentation/xcode/localizing-and-varying-text-with-a-string-catalog), but, still, the existence of Xcode become a significant barrier to contributing more languages. 74 | 75 | ## Support 76 | 77 | Bug reports, typos, errors and feedback are welcome! Please use GitHub Issues for reports and GitHub Discussions for... discussion. For anything private, 78 | you can reach me via email at oss [at] rizaldy.club. I don't check email often but I have push notifications turned on! 79 | 80 | ## License 81 | 82 | MIT. 83 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | [changelog] 2 | header = """ 3 | # Changelog\n 4 | All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.\n 5 | """ 6 | 7 | body = """ 8 | --- 9 | {% if version %}\ 10 | {% if previous.version %}\ 11 | ## [{{ version | trim_start_matches(pat="v") }}]($REPO/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} 12 | {% else %}\ 13 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 14 | {% endif %}\ 15 | {% else %}\ 16 | ## [unreleased] 17 | {% endif %}\ 18 | {% for group, commits in commits | group_by(attribute="group") %} 19 | ### {{ group | striptags | trim | upper_first }} 20 | {% for commit in commits 21 | | filter(attribute="scope") 22 | | sort(attribute="scope") %} 23 | - **({{commit.scope}})**{% if commit.breaking %} [**breaking**]{% endif %} \ 24 | {{ commit.message }} - ([{{ commit.id | truncate(length=7, end="") }}]($REPO/commit/{{ commit.id }})) 25 | {%- endfor -%} 26 | {% raw %}\n{% endraw %}\ 27 | {%- for commit in commits %} 28 | {%- if commit.scope -%} 29 | {% else -%} 30 | - {% if commit.breaking %} [**breaking**]{% endif %}\ 31 | {{ commit.message }} - ([{{ commit.id | truncate(length=7, end="") }}]($REPO/commit/{{ commit.id }})) 32 | {% endif -%} 33 | {% endfor -%} 34 | {% endfor %}\n 35 | """ 36 | 37 | footer = """ 38 | 39 | """ 40 | trim = true 41 | 42 | postprocessors = [ 43 | { pattern = '\$REPO', replace = "https://github.com/kepelet/flo" }, 44 | ] 45 | 46 | [git] 47 | conventional_commits = true 48 | filter_unconventional = true 49 | split_commits = false 50 | commit_preprocessors = [ 51 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/kepelet/flo/issues/${2}))"}, 52 | ] 53 | 54 | commit_parsers = [ 55 | { message = "^feat", group = "Features" }, 56 | { message = "^fix", group = "Bug Fixes" }, 57 | { message = "^doc", group = "Documentation" }, 58 | { message = "^perf", group = "Performance" }, 59 | { message = "^refactor", group = "Refactoring" }, 60 | { message = "^style", group = "Style" }, 61 | { message = "^revert", group = "Revert" }, 62 | { message = "^test", group = "Tests" }, 63 | { message = "^chore\\(version\\):", skip = true }, 64 | { message = "^chore", group = "Miscellaneous Chores" }, 65 | { body = ".*security", group = "Security" }, 66 | ] 67 | 68 | filter_commits = false 69 | topo_order = false 70 | sort_commits = "oldest" 71 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier("com.penerbangwalet.flo") 2 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | fastlane_version "2.225.0" 2 | default_platform(:ios) 3 | 4 | groups = ["Kepelet"] 5 | configuration = "Release" 6 | 7 | platform :ios do 8 | desc "load App Store Connect API key" 9 | lane :load_asc_api_key do 10 | app_store_connect_api_key( 11 | key_id: ENV["ASC_KEY_ID"], 12 | issuer_id: ENV["ASC_ISSUER_ID"], 13 | key_content: ENV["ASC_KEY_CONTENT"], 14 | is_key_content_base64: false, 15 | in_house: false 16 | ) 17 | end 18 | 19 | desc "sync certs thing" 20 | lane :sync_certs do 21 | api_key = lane_context[SharedValues::APP_STORE_CONNECT_API_KEY] 22 | 23 | match({readonly: true, type: "appstore", api_key: api_key}) 24 | end 25 | 26 | desc "bump build number based on latest TestFlight build number" 27 | lane :fetch_and_increment_build_number do 28 | app_identifier = CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier) 29 | api_key = lane_context[SharedValues::APP_STORE_CONNECT_API_KEY] 30 | 31 | latest_build_number = latest_testflight_build_number( 32 | api_key: api_key, 33 | version: get_version_number, 34 | app_identifier: app_identifier 35 | ) 36 | 37 | increment_build_number(build_number: (latest_build_number + 1)) 38 | end 39 | 40 | desc "build app" 41 | lane :build do 42 | load_asc_api_key 43 | sync_certs 44 | fetch_and_increment_build_number 45 | build_app(configuration: configuration) 46 | end 47 | 48 | desc "push a new build to TestFlight" 49 | lane :beta do |options| 50 | is_public = options[:public] || false 51 | 52 | setup_ci if ENV['CI'] 53 | 54 | app_identifier = CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier) 55 | api_key = lane_context[SharedValues::APP_STORE_CONNECT_API_KEY] 56 | 57 | build 58 | 59 | upload_to_testflight( 60 | changelog: "Build #{get_build_number}", 61 | api_key: api_key, 62 | app_identifier: app_identifier, 63 | distribute_external: is_public, 64 | groups: groups, 65 | ) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /fastlane/Gymfile: -------------------------------------------------------------------------------- 1 | scheme("flo") 2 | 3 | export_method("app-store") 4 | 5 | output_directory("./fastlane/builds") 6 | 7 | include_bitcode(false) 8 | include_symbols(false) 9 | -------------------------------------------------------------------------------- /fastlane/Matchfile: -------------------------------------------------------------------------------- 1 | git_url("git@github.com:kepelet/match.git") 2 | storage_mode("git") 3 | 4 | type("appstore") 5 | 6 | username(ENV["APPLE_ID"]) 7 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios load_asc_api_key 19 | 20 | ```sh 21 | [bundle exec] fastlane ios load_asc_api_key 22 | ``` 23 | 24 | load App Store Connect API key 25 | 26 | ### ios sync_certs 27 | 28 | ```sh 29 | [bundle exec] fastlane ios sync_certs 30 | ``` 31 | 32 | sync certs thing 33 | 34 | ### ios fetch_and_increment_build_number 35 | 36 | ```sh 37 | [bundle exec] fastlane ios fetch_and_increment_build_number 38 | ``` 39 | 40 | bump build number based on latest TestFlight build number 41 | 42 | ### ios build 43 | 44 | ```sh 45 | [bundle exec] fastlane ios build 46 | ``` 47 | 48 | build app 49 | 50 | ### ios beta 51 | 52 | ```sh 53 | [bundle exec] fastlane ios beta 54 | ``` 55 | 56 | push a new build to TestFlight 57 | 58 | ---- 59 | 60 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 61 | 62 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 63 | 64 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 65 | -------------------------------------------------------------------------------- /flo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /flo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /flo.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /flo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "5e90a220efe8980d304087736c1d9e264f488ed6d89cfc4130b4ff4d659b92f7", 3 | "pins" : [ 4 | { 5 | "identity" : "alamofire", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/Alamofire/Alamofire", 8 | "state" : { 9 | "revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a", 10 | "version" : "5.9.1" 11 | } 12 | }, 13 | { 14 | "identity" : "keychainaccess", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/kishikawakatsumi/KeychainAccess", 17 | "state" : { 18 | "branch" : "master", 19 | "revision" : "e0c7eebc5a4465a3c4680764f26b7a61f567cdaf" 20 | } 21 | }, 22 | { 23 | "identity" : "nuke", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/kean/Nuke", 26 | "state" : { 27 | "revision" : "0ead44350d2737db384908569c012fe67c421e4d", 28 | "version" : "12.8.0" 29 | } 30 | }, 31 | { 32 | "identity" : "pulse", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/kean/Pulse", 35 | "state" : { 36 | "revision" : "c102aaa266ac69a26d08ee6861da710283988e3b", 37 | "version" : "5.1.2" 38 | } 39 | } 40 | ], 41 | "version" : 3 42 | } 43 | -------------------------------------------------------------------------------- /flo/AlbumViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumViewModel.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 07/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class AlbumViewModel: ObservableObject { 11 | @Published var artists: [Artist] = [] 12 | @Published var playlists: [Playlist] = [] 13 | @Published var playlist: Playlist = Playlist() 14 | @Published var songs: [Song] = [] 15 | @Published var artistAlbums: [Album] = [] 16 | @Published var albums: [Album] = [] 17 | @Published var album: Album = Album() 18 | @Published var downloadedAlbums: [Album] = [] 19 | 20 | @Published var isDownloadingAlbumId: String = "" 21 | @Published var isDownloaded = false 22 | 23 | @Published var isLoading = false 24 | @Published var error: Error? 25 | 26 | init(album: Album = Album(), albums: [Album] = []) { 27 | self.album = album 28 | self.albums = albums 29 | } 30 | 31 | func ifNotSharable(isDownloadScreen: Bool) -> Bool { 32 | //TODO: add logic to check server-side config 33 | if isDownloadScreen { 34 | return true 35 | } 36 | 37 | return false 38 | } 39 | 40 | func ifNotDownloadable() -> Bool { 41 | //TODO: add logic to check server-side config 42 | if isDownloaded { 43 | return true 44 | } 45 | 46 | return false 47 | } 48 | 49 | func setActiveAlbum(album: Album) { 50 | self.album = album 51 | self.album.albumCover = self.getAlbumCoverArt(id: album.id) 52 | 53 | if !album.id.isEmpty { 54 | self.getAlbumById() 55 | self.fetchSongs(id: album.id) 56 | } 57 | } 58 | 59 | func setActivePlaylist(playlist: Playlist) { 60 | self.playlist = playlist 61 | self.isDownloaded = AlbumService.shared.checkIfAlbumDownloaded(albumID: playlist.id) 62 | self.fetchSongsByPlaylist(id: playlist.id) 63 | } 64 | 65 | func fetchSongs(id: String) { 66 | let checkLocalSongs = AlbumService.shared.getSongsByAlbumId(albumId: id) 67 | 68 | self.album.songs = checkLocalSongs 69 | 70 | AlbumService.shared.getSongFromAlbum(id: id) { result in 71 | self.isLoading = true 72 | 73 | DispatchQueue.main.async { 74 | self.isLoading = false 75 | 76 | switch result { 77 | case .success(let songs): 78 | let remoteSongs = songs.filter { song in 79 | !self.album.songs.contains(where: { $0.id == song.id }) 80 | } 81 | 82 | if id == self.album.id { 83 | self.album.songs.append(contentsOf: remoteSongs) 84 | } 85 | 86 | self.album.songs.sort { $0.trackNumber < $1.trackNumber } 87 | 88 | case .failure(let error): 89 | self.error = error 90 | } 91 | } 92 | } 93 | } 94 | 95 | func fetchAllSongs() { 96 | AlbumService.shared.getAllSongs { result in 97 | self.isLoading = true 98 | 99 | DispatchQueue.main.async { 100 | self.isLoading = false 101 | 102 | switch result { 103 | case .success(let songs): 104 | self.songs = songs 105 | 106 | case .failure(let error): 107 | self.error = error 108 | } 109 | } 110 | } 111 | } 112 | 113 | func getAlbumInfo() { 114 | AlbumService.shared.getAlbumInfo(id: self.album.id) { result in 115 | DispatchQueue.main.async { 116 | switch result { 117 | case .success(let response): 118 | if let albumInfo = response.subsonicResponse.albumInfo.notes { 119 | let regex = try! NSRegularExpression(pattern: ".*\\.") 120 | let range = NSRange(location: 0, length: albumInfo.utf16.count) 121 | 122 | let stripped = regex.stringByReplacingMatches( 123 | in: albumInfo, range: range, withTemplate: "") 124 | 125 | self.album.info = stripped 126 | } else { 127 | self.album.info = "Description Unavailable" 128 | } 129 | 130 | case .failure(let error): 131 | self.error = error 132 | } 133 | } 134 | } 135 | } 136 | 137 | func getAlbumCoverArt(id: String, artistName: String = "", albumName: String = "") -> String { 138 | return AlbumService.shared.getAlbumCover( 139 | artistName: artistName, albumName: albumName, albumId: id) 140 | } 141 | 142 | func shareAlbum(description: String, completion: @escaping (String) -> Void) { 143 | AlbumService.shared.share(albumId: self.album.id, description: description, downloadable: false) 144 | { result in 145 | switch result { 146 | case .success(let share): 147 | completion("\(UserDefaultsManager.serverBaseURL)/share/\(share.id)") 148 | 149 | case .failure(let error): 150 | print("error>>>", error) 151 | } 152 | } 153 | } 154 | 155 | func getAlbumById() { 156 | self.isDownloaded = AlbumService.shared.checkIfAlbumDownloaded(albumID: self.album.id) 157 | } 158 | 159 | func downloadAlbum(_ albumToDownload: Album) { 160 | AlbumService.shared.downloadAlbumCover( 161 | artistName: albumToDownload.artist, albumId: albumToDownload.id, 162 | albumName: albumToDownload.name 163 | ) { [weak self] result in 164 | guard let self = self else { return } 165 | 166 | switch result { 167 | case .success: 168 | DispatchQueue.main.async { 169 | if !AlbumService.shared.checkIfAlbumDownloaded(albumID: albumToDownload.id) { 170 | AlbumService.shared.saveAlbum(albumToDownload) 171 | } 172 | } 173 | case .failure(let error): 174 | DispatchQueue.main.async { 175 | self.isDownloadingAlbumId = "" 176 | print("Failed to save image: \(error.localizedDescription)") 177 | } 178 | } 179 | } 180 | } 181 | 182 | func downloadPlaylist(_ playlistToDownload: Playlist, targetIdx: Int = -1) { 183 | let maxConcurrentDownloads = ProcessInfo.processInfo.activeProcessorCount / 2 184 | let downloadSemaphore = DispatchSemaphore(value: maxConcurrentDownloads) 185 | let downloadGroup = DispatchGroup() 186 | 187 | let songs = targetIdx == -1 ? playlistToDownload.songs : [playlistToDownload.songs[targetIdx]] 188 | 189 | Task(priority: .background) { 190 | AlbumService.shared.savePlaylist(playlistToDownload) 191 | 192 | songs.forEach { song in 193 | downloadGroup.enter() 194 | 195 | DispatchQueue.global(qos: .background).async { 196 | downloadSemaphore.wait() 197 | 198 | AlbumService.shared.downloadAlbumCoverForPlaylist( 199 | albumId: song.albumId, playlistName: playlistToDownload.name, trackId: song.mediaFileId 200 | ) { result in 201 | downloadSemaphore.signal() 202 | downloadGroup.leave() 203 | } 204 | } 205 | } 206 | } 207 | } 208 | 209 | func downloadSong(_ albumToDownload: Album, songIdx: Int) { 210 | var album = albumToDownload 211 | let songToDownload = albumToDownload.songs[songIdx] 212 | 213 | album.songs = [songToDownload] 214 | 215 | self.downloadAlbum(album) 216 | } 217 | 218 | func removeDownloadedAlbum(album: Album) { 219 | AlbumService.shared.removeDownloadedAlbum( 220 | artistName: album.artist, albumId: album.id, albumName: album.name 221 | ) { result in 222 | DispatchQueue.main.async { 223 | switch result { 224 | case .success: 225 | self.setActiveAlbum(album: album) 226 | case .failure(let error): 227 | print("error >>>", error) 228 | } 229 | } 230 | } 231 | } 232 | 233 | func removeDownloadedPlaylist(playlist: Playlist) { 234 | AlbumService.shared.removeDownloadedPlaylist( 235 | playlistId: playlist.id, 236 | playlistName: playlist.name 237 | ) { result in 238 | DispatchQueue.main.async { 239 | switch result { 240 | case .success: 241 | self.setActivePlaylist(playlist: playlist) 242 | case .failure(let error): 243 | print("error >>>", error) 244 | } 245 | } 246 | } 247 | } 248 | 249 | func removeDownloadSong(album: Playable, songId: String, isFromPlaylist: Bool = false) { 250 | AlbumService.shared.removeDownloadedSong(albumId: album.id, songId: songId) { result in 251 | DispatchQueue.main.async { 252 | switch result { 253 | case .success: 254 | if isFromPlaylist { 255 | self.fetchSongsByPlaylist(id: album.id) 256 | } else { 257 | self.fetchSongs(id: album.id) 258 | } 259 | case .failure(let error): 260 | print("error >>>", error) 261 | } 262 | } 263 | } 264 | } 265 | 266 | func fetchAlbums() { 267 | isLoading = true 268 | AlbumService.shared.getAlbum { result in 269 | DispatchQueue.main.async { 270 | self.isLoading = false 271 | switch result { 272 | case .success(let albums): 273 | self.albums = albums 274 | case .failure(let error): 275 | print("error>>>>", error) 276 | self.error = error 277 | } 278 | } 279 | } 280 | } 281 | 282 | func fetchAlbumsByArtist(id: String) { 283 | AlbumService.shared.getAlbumsByArtist(id: id) { result in 284 | DispatchQueue.main.async { 285 | switch result { 286 | case .success(let albums): 287 | self.artistAlbums = albums 288 | case .failure(let error): 289 | self.error = error 290 | } 291 | } 292 | } 293 | } 294 | 295 | func fetchSongsByPlaylist(id: String) { 296 | let checkLocalSongs = AlbumService.shared.getSongsByAlbumId(albumId: id) 297 | 298 | self.playlist.songs = checkLocalSongs 299 | 300 | AlbumService.shared.getSongsByPlaylist(id: id) { result in 301 | DispatchQueue.main.async { 302 | switch result { 303 | case .success(let songs): 304 | let remoteSongs = songs.filter { song in 305 | !self.playlist.songs.contains(where: { $0.mediaFileId == song.mediaFileId }) 306 | } 307 | 308 | self.playlist.songs.append(contentsOf: remoteSongs) 309 | self.playlist.songs.sort { $0.trackNumber < $1.trackNumber } 310 | 311 | case .failure(let error): 312 | self.error = error 313 | } 314 | } 315 | } 316 | } 317 | 318 | func getPlaylists() { 319 | AlbumService.shared.getPlaylists { result in 320 | DispatchQueue.main.async { 321 | switch result { 322 | case .success(let playlists): 323 | self.playlists = playlists 324 | case .failure(let error): 325 | self.error = error 326 | } 327 | } 328 | } 329 | } 330 | 331 | func getArtists() { 332 | AlbumService.shared.getArtists { result in 333 | DispatchQueue.main.async { 334 | switch result { 335 | case .success(let artists): 336 | self.artists = artists 337 | case .failure(let error): 338 | self.error = error 339 | } 340 | } 341 | } 342 | } 343 | 344 | func fetchDownloadedAlbums() { 345 | AlbumService.shared.getDownloadedAlbum { result in 346 | DispatchQueue.main.async { 347 | switch result { 348 | case .success(let albums): 349 | // TODO: is this expensive? 350 | self.downloadedAlbums = albums.filter { album in 351 | let songs = AlbumService.shared.getSongsByAlbumId(albumId: album.id) 352 | 353 | return !songs.isEmpty 354 | } 355 | 356 | case .failure(let error): 357 | self.error = error 358 | } 359 | } 360 | } 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /flo/AlbumsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumsView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 26/06/24. 6 | // 7 | 8 | import NukeUI 9 | import SwiftUI 10 | 11 | struct AlbumsView: View { 12 | var viewModel: AlbumViewModel 13 | var album: Album 14 | 15 | var isDownloadScreen: Bool = false 16 | 17 | var body: some View { 18 | Group { 19 | VStack(alignment: .leading) { 20 | if self.isDownloadScreen { 21 | if let image = UIImage( 22 | contentsOfFile: viewModel.getAlbumCoverArt( 23 | id: album.id, artistName: album.artist, albumName: album.name)) 24 | { 25 | Image(uiImage: image) 26 | .resizable() 27 | .aspectRatio(contentMode: .fill) 28 | .frame(maxWidth: .infinity, maxHeight: 300) 29 | .clipShape( 30 | RoundedRectangle(cornerRadius: 5, style: .continuous) 31 | ) 32 | } else { 33 | if let image = UIImage(named: "placeholder") { 34 | Image(uiImage: image) 35 | .resizable() 36 | .aspectRatio(contentMode: .fill) 37 | .frame(maxWidth: .infinity, maxHeight: 300) 38 | .clipShape( 39 | RoundedRectangle(cornerRadius: 5, style: .continuous) 40 | ) 41 | } 42 | } 43 | } else { 44 | if let image = UIImage( 45 | contentsOfFile: viewModel.getAlbumCoverArt(id: album.id)) 46 | { 47 | Image(uiImage: image) 48 | .resizable() 49 | .aspectRatio(contentMode: .fill) 50 | .frame(maxWidth: .infinity, maxHeight: 300) 51 | .clipShape( 52 | RoundedRectangle(cornerRadius: 5, style: .continuous) 53 | ) 54 | } else { 55 | LazyImage(url: URL(string: viewModel.getAlbumCoverArt(id: album.id))) { state in 56 | if let image = state.image { 57 | image 58 | .resizable() 59 | .aspectRatio(contentMode: .fill) 60 | .frame(maxWidth: .infinity, maxHeight: 300) 61 | .clipShape( 62 | RoundedRectangle(cornerRadius: 5, style: .continuous) 63 | ) 64 | } else { 65 | if let image = UIImage(named: "placeholder") { 66 | Image(uiImage: image) 67 | .resizable() 68 | .aspectRatio(contentMode: .fill) 69 | .frame(maxWidth: .infinity, maxHeight: 300) 70 | .clipShape( 71 | RoundedRectangle(cornerRadius: 5, style: .continuous) 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | Text(album.name) 80 | .customFont(.caption1) 81 | .fontWeight(.bold) 82 | .foregroundColor(.primary) 83 | .truncationMode(.tail) 84 | .padding(.trailing, 20) 85 | .lineLimit(1) 86 | .multilineTextAlignment(.leading) 87 | .frame(maxWidth: .infinity, alignment: .leading) 88 | 89 | Text(album.albumArtist) 90 | .customFont(.caption2) 91 | .foregroundColor(.gray) 92 | .truncationMode(.tail) 93 | .padding(.trailing, 20) 94 | .lineLimit(1) 95 | .frame(maxWidth: .infinity, alignment: .leading) 96 | }.padding() 97 | } 98 | } 99 | } 100 | 101 | struct AlbumsView_Preview: PreviewProvider { 102 | @StateObject static private var viewModel: AlbumViewModel = AlbumViewModel() 103 | 104 | static private var albumData = Album(name: "Album 1", artist: "Artist 1") 105 | 106 | static var previews: some View { 107 | AlbumsView(viewModel: viewModel, album: albumData) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /flo/App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 01/06/24. 6 | // 7 | 8 | import AVFoundation 9 | import SwiftUI 10 | 11 | @main 12 | struct FloApp: App { 13 | init() { 14 | do { 15 | try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) 16 | try AVAudioSession.sharedInstance().setActive(true) 17 | } catch { 18 | print(error) 19 | } 20 | } 21 | 22 | var body: some Scene { 23 | WindowGroup { 24 | ContentView() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /flo/ArtistDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistDetailView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 17/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ArtistDetailView: View { 11 | @EnvironmentObject var viewModel: AlbumViewModel 12 | 13 | @State private var isExpanded = false 14 | 15 | var artist: Artist 16 | 17 | let columns = [ 18 | GridItem(.flexible()), 19 | GridItem(.flexible()), 20 | ] 21 | 22 | func stripBiography(biography: String) -> String { 23 | let regex = try! NSRegularExpression(pattern: "]*>.*?") 24 | let range = NSRange(location: 0, length: biography.utf16.count) 25 | 26 | let stripped = regex.stringByReplacingMatches( 27 | in: biography, range: range, withTemplate: "") 28 | 29 | return stripped == "" ? "No biography available" : stripped 30 | } 31 | 32 | var body: some View { 33 | ScrollView { 34 | VStack(alignment: .leading) { 35 | Text(artist.name) 36 | .customFont(.title) 37 | .fontWeight(.bold) 38 | .multilineTextAlignment(.leading) 39 | .padding(.bottom, 3) 40 | .frame(maxWidth: .infinity, alignment: .leading) 41 | 42 | Text(stripBiography(biography: artist.biography)) 43 | .customFont(.subheadline) 44 | .lineSpacing(3) 45 | .multilineTextAlignment(.leading) 46 | .lineLimit(isExpanded ? nil : 3) 47 | .onTapGesture { 48 | isExpanded.toggle() 49 | } 50 | } 51 | .padding() 52 | .onAppear { 53 | viewModel.fetchAlbumsByArtist(id: artist.id) 54 | } 55 | 56 | LazyVGrid(columns: columns) { 57 | ForEach(viewModel.artistAlbums) { album in 58 | NavigationLink { 59 | AlbumView(viewModel: viewModel) 60 | .onAppear { 61 | viewModel.setActiveAlbum(album: album) 62 | } 63 | } label: { 64 | AlbumsView(viewModel: viewModel, album: album) 65 | } 66 | } 67 | }.padding(.bottom, 100) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /flo/ArtistsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistsView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 30/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ArtistsView: View { 11 | @EnvironmentObject private var viewModel: AlbumViewModel 12 | 13 | @State private var searchArtist = "" 14 | 15 | let artists: [Artist] 16 | 17 | var filteredArtists: [Artist] { 18 | if searchArtist.isEmpty { 19 | return artists 20 | } else { 21 | return artists.filter { artist in 22 | artist.name.localizedCaseInsensitiveContains(searchArtist) 23 | || artist.fullText.localizedCaseInsensitiveContains(searchArtist) 24 | } 25 | } 26 | } 27 | 28 | var body: some View { 29 | NavigationStack { 30 | ScrollView { 31 | LazyVStack { 32 | ForEach(filteredArtists) { artist in 33 | NavigationLink { 34 | ArtistDetailView(artist: artist) 35 | .environmentObject(viewModel) 36 | } label: { 37 | VStack { 38 | HStack { 39 | Text(artist.name) 40 | .customFont(.headline) 41 | .multilineTextAlignment(.leading) 42 | 43 | Spacer() 44 | 45 | Image(systemName: "chevron.right") 46 | .foregroundColor(.gray) 47 | .font(.caption) 48 | } 49 | .padding(.horizontal) 50 | .padding(.vertical, 5) 51 | 52 | Divider() 53 | } 54 | } 55 | } 56 | }.padding(.bottom, 100) 57 | } 58 | .navigationTitle("Artists") 59 | .searchable( 60 | text: $searchArtist, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search" 61 | ) 62 | } 63 | } 64 | } 65 | 66 | struct ArtistsView_Previews: PreviewProvider { 67 | static var previews: some View { 68 | ArtistsView(artists: []) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /flo/AuthViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthViewModel.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 06/06/24. 6 | // 7 | 8 | import Foundation 9 | import KeychainAccess 10 | 11 | class AuthViewModel: ObservableObject { 12 | @Published var user: UserAuth? 13 | 14 | @Published var serverUrl: String = "" { 15 | didSet { 16 | validateURL() 17 | } 18 | } 19 | 20 | @Published var username: String = "" 21 | @Published var password: String = "" 22 | 23 | @Published var showAlert: Bool = false 24 | @Published var alertMessage: String = "" 25 | @Published var extraMessage: String = "" 26 | @Published var experimentalSaveLoginInfo: Bool = false 27 | 28 | @Published var isSubmitting: Bool = false 29 | @Published var isLoggedIn: Bool = false 30 | 31 | static let shared = AuthViewModel() 32 | 33 | private func validateURL() { 34 | if serverUrl.lowercased().hasPrefix("http://") { 35 | extraMessage = 36 | "http:// is only supported within private IP ranges: 192.168.0.0/16, 10.0.0.0/8, and 172.16.0.0/12 — learn more at https://dub.sh/flo-ats" 37 | } else { 38 | extraMessage = "" 39 | } 40 | } 41 | 42 | init() { 43 | // TODO: invalidate authz token somewhere here 44 | do { 45 | if let jsonString = try KeychainManager.getAuthCreds(), 46 | let jsonData = jsonString.data(using: .utf8) 47 | { 48 | let data: UserAuth = try JSONDecoder().decode(UserAuth.self, from: jsonData) 49 | 50 | self.serverUrl = UserDefaultsManager.serverBaseURL 51 | self.username = data.username 52 | 53 | if UserDefaultsManager.saveLoginInfo { 54 | do { 55 | self.password = try KeychainManager.getAuthPassword() ?? "" 56 | } catch { 57 | print("Error loading password from Keychain: \(error)") 58 | } 59 | 60 | self.login() 61 | } else { 62 | 63 | self.user = UserAuth( 64 | id: data.id, username: data.username, name: data.name, isAdmin: data.isAdmin, 65 | lastFMApiKey: data.lastFMApiKey) 66 | self.isLoggedIn = true 67 | } 68 | } 69 | } catch { 70 | print("Error loading data from Keychain: \(error)") 71 | } 72 | } 73 | 74 | func login() { 75 | isSubmitting = true 76 | 77 | AuthService.shared.login(serverUrl: serverUrl, username: username, password: password) { 78 | result in 79 | switch result { 80 | case .success(let data): 81 | self.persistAuthData(data) 82 | 83 | if self.experimentalSaveLoginInfo { 84 | do { 85 | try KeychainManager.setAuthPassword(newValue: self.password) 86 | UserDefaultsManager.saveLoginInfo = true 87 | 88 | self.experimentalSaveLoginInfo = false 89 | } catch { 90 | print("error saving password to Keychain: \(error)") 91 | } 92 | } 93 | 94 | DispatchQueue.main.async { 95 | self.isSubmitting = false 96 | self.isLoggedIn = true 97 | self.username = "" 98 | self.password = "" 99 | self.serverUrl = "" 100 | } 101 | 102 | case .failure(let error): 103 | DispatchQueue.main.async { 104 | self.isSubmitting = false 105 | 106 | switch error { 107 | case .server(let message): 108 | self.alertMessage = message 109 | 110 | case .unknown: 111 | self.alertMessage = "Unknown error ocurred" 112 | } 113 | 114 | self.showAlert = true 115 | } 116 | } 117 | } 118 | } 119 | 120 | // TODO: how to deal with "last playing" data? 121 | func logout() { 122 | do { 123 | try KeychainManager.removeAuthCreds() 124 | 125 | self.destroySavedPassword() 126 | 127 | UserDefaultsManager.removeObject(key: UserDefaultsKeys.serverURL) 128 | 129 | self.user = nil 130 | self.isLoggedIn = false 131 | } catch let error { 132 | print("error>>>>> \(error)") 133 | } 134 | } 135 | 136 | func destroySavedPassword() { 137 | do { 138 | try KeychainManager.removeAuthPassword() 139 | 140 | UserDefaultsManager.saveLoginInfo = false 141 | UserDefaultsManager.removeObject(key: UserDefaultsKeys.saveLoginInfo) 142 | } catch let error { 143 | print("error>>>>> \(error)") 144 | } 145 | } 146 | 147 | func persistAuthData(_ data: UserAuth) { 148 | do { 149 | let jsonData = try JSONEncoder().encode(data) 150 | let jsonString = String(data: jsonData, encoding: .utf8)! 151 | 152 | try KeychainManager.setAuthCreds(newValue: jsonString) 153 | 154 | AuthService.shared.setCreds(data) 155 | UserDefaultsManager.serverBaseURL = self.serverUrl 156 | 157 | self.user = UserAuth( 158 | id: data.id, username: data.username, name: data.name, isAdmin: data.isAdmin, 159 | lastFMApiKey: data.lastFMApiKey) 160 | } catch { 161 | print("Error saving data to Keychain: \(error)") 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /flo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 01/06/24. 6 | // 7 | 8 | import PulseUI 9 | import SwiftUI 10 | 11 | struct ContentView: View { 12 | @AppStorage(UserDefaultsKeys.enableDebug) private var enableDebug = false 13 | 14 | @State private var isPlayerExpanded: Bool = false 15 | @State private var tabViewID = UUID() 16 | 17 | @StateObject private var authViewModel = AuthViewModel() 18 | @StateObject private var playerViewModel = PlayerViewModel() 19 | @StateObject private var albumViewModel = AlbumViewModel() 20 | @StateObject private var floooViewModel = FloooViewModel() 21 | @StateObject private var downloadViewModel = DownloadViewModel() 22 | 23 | @State private var floatingPlayerOffsetX: CGFloat = .zero 24 | @State private var isSwipping = false 25 | 26 | private var swipeThreshold: CGFloat = 150.0 27 | 28 | var body: some View { 29 | ZStack { 30 | TabView { 31 | HomeView(viewModel: authViewModel).tabItem { 32 | Label("Home", systemImage: "house") 33 | }.environmentObject(floooViewModel) 34 | 35 | if authViewModel.isLoggedIn { 36 | LibraryView(viewModel: albumViewModel).tabItem { 37 | Label("Library", systemImage: "square.grid.2x2") 38 | }.environmentObject(playerViewModel).environmentObject(downloadViewModel) 39 | .onAppear { 40 | albumViewModel.fetchAlbums() 41 | } 42 | } 43 | 44 | DownloadsView(viewModel: albumViewModel).tabItem { 45 | Label("Downloads", systemImage: "arrow.down.circle") 46 | }.environmentObject(playerViewModel).environmentObject(downloadViewModel).onAppear { 47 | albumViewModel.fetchDownloadedAlbums() 48 | }.badge(downloadViewModel.getRemainingDownloadItems()) 49 | 50 | PreferencesView(authViewModel: authViewModel).tabItem { 51 | Label("Preferences", systemImage: "gear") 52 | }.environmentObject(playerViewModel).environmentObject(floooViewModel) 53 | 54 | if UserDefaultsManager.enableDebug { 55 | ConsoleView().tabItem { 56 | Label("Debug", systemImage: "terminal") 57 | } 58 | } 59 | } 60 | .id(tabViewID) 61 | .onChange(of: enableDebug) { _ in 62 | tabViewID = UUID() 63 | } 64 | 65 | ZStack { 66 | if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { 67 | PlayerView(isExpanded: $isPlayerExpanded, viewModel: playerViewModel) 68 | .offset(y: isPlayerExpanded ? 0 : UIScreen.main.bounds.height) 69 | .animation(.spring(duration: 0.2), value: isPlayerExpanded) 70 | } 71 | } 72 | 73 | VStack { 74 | Spacer() 75 | 76 | if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { 77 | FloatingPlayerView(viewModel: playerViewModel) 78 | .padding(.bottom, 50) 79 | .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) 80 | .offset( 81 | x: self.floatingPlayerOffsetX, y: isPlayerExpanded ? UIScreen.main.bounds.height : 0 82 | ) 83 | .animation(.spring(duration: 0.2), value: isPlayerExpanded) 84 | .onTapGesture { 85 | self.isPlayerExpanded = true 86 | } 87 | .gesture( 88 | DragGesture() 89 | .onChanged { value in 90 | // only care with swipe left :)) 91 | if value.translation.width < .zero { 92 | floatingPlayerOffsetX = value.translation.width 93 | } 94 | 95 | // debounce thing 96 | if abs(floatingPlayerOffsetX) > swipeThreshold && !isSwipping { 97 | isSwipping = true 98 | } 99 | } 100 | .onEnded { value in 101 | if abs(floatingPlayerOffsetX) > swipeThreshold && isSwipping { 102 | playerViewModel.destroyPlayerAndQueue() 103 | } 104 | 105 | self.floatingPlayerOffsetX = .zero 106 | self.isSwipping = false 107 | } 108 | ) 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | struct ContentView_Previews: PreviewProvider { 116 | static var previews: some View { 117 | ContentView() 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /flo/DownloadButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadButtonView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 12/01/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DownloadButton: View { 11 | var isDownloading = false 12 | var isDownloaded = false 13 | 14 | var progress: Double = 0 15 | 16 | let action: () async -> Void 17 | 18 | var body: some View { 19 | Button { 20 | Task { 21 | await action() 22 | } 23 | } label: { 24 | ZStack { 25 | if isDownloaded { 26 | Image(systemName: "checkmark.circle.fill").transition(.opacity) 27 | } else { 28 | Circle() 29 | .trim(from: 0, to: 1) 30 | .stroke( 31 | isDownloading ? Color.gray.opacity(0.2) : Color(.accent), 32 | style: StrokeStyle(lineWidth: 1.5) 33 | ) 34 | .overlay( 35 | Circle() 36 | .trim(from: 0, to: progress) 37 | .stroke(Color(.accent), style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) 38 | .rotationEffect(isDownloading ? .degrees(-90) : .zero) 39 | ) 40 | 41 | Image(systemName: isDownloading ? "stop.fill" : "arrow.down") 42 | .resizable() 43 | .scaledToFit() 44 | .padding(4) 45 | .frame(width: 17, height: 17) 46 | .font(.system(size: 17, weight: .bold)) 47 | } 48 | } 49 | .frame(width: 21, height: 21) 50 | .animation(.easeInOut, value: isDownloaded) 51 | .animation(.easeInOut, value: progress) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /flo/DownloadQueueView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadQueueView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 12/01/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DownloadQueueView: View { 11 | @EnvironmentObject var viewModel: DownloadViewModel 12 | 13 | var range: ClosedRange = 0...100 14 | 15 | private func iconName(for status: DownloadStatus) -> String { 16 | switch status { 17 | case .completed: 18 | return "checkmark.circle.fill" 19 | case .failed, .cancelled: 20 | return "arrow.clockwise.circle.fill" 21 | case .downloading: 22 | return "stop.circle.fill" 23 | default: 24 | return "xmark.circle.fill" 25 | } 26 | } 27 | 28 | private func iconColor(for status: DownloadStatus) -> Color { 29 | return status == .failed ? .red : .accent 30 | } 31 | 32 | var body: some View { 33 | ScrollView { 34 | Text("Download Queue") 35 | .customFont(.headline) 36 | .padding(.horizontal) 37 | .padding(.top, 10) 38 | .padding(.bottom, 20) 39 | 40 | Divider() 41 | 42 | LazyVStack { 43 | ForEach(viewModel.downloadItems, id: \.id) { item in 44 | VStack(alignment: .center) { 45 | HStack { 46 | Text(item.title) 47 | .customFont(.subheadline) 48 | .multilineTextAlignment(.leading) 49 | .lineLimit(2) 50 | .padding(.top, 5) 51 | .padding(.horizontal) 52 | 53 | Spacer() 54 | 55 | Text("\(Int64(item.progress).description)%") 56 | .customFont(.caption1) 57 | 58 | Button(action: { 59 | if item.status == .cancelled || item.status == .failed { 60 | viewModel.retryDownload(item.id) 61 | } else if item.status == .downloading { 62 | viewModel.cancelDownload(item.id) 63 | } else { 64 | viewModel.removeFromQueue(item.id) 65 | } 66 | }) { 67 | Label("", systemImage: iconName(for: item.status)) 68 | .foregroundColor(iconColor(for: item.status)) 69 | .customFont(.headline) 70 | } 71 | } 72 | 73 | if item.status == DownloadStatus.downloading { 74 | GeometryReader { geometry in 75 | ZStack(alignment: .leading) { 76 | Rectangle() 77 | .foregroundColor(Color.gray.opacity(0.3)) 78 | .frame(height: 3) 79 | .cornerRadius(10) 80 | 81 | Rectangle() 82 | .foregroundColor(Color("PlayerColor")) 83 | .frame( 84 | width: CGFloat( 85 | (item.progress - range.lowerBound) / (range.upperBound - range.lowerBound)) 86 | * geometry.size.width, height: 3 87 | ) 88 | .cornerRadius(10) 89 | .clipped() 90 | }.frame(height: 3) 91 | }.frame(height: 3).padding(.horizontal).padding(.bottom, 5) 92 | } 93 | } 94 | .frame(maxWidth: .infinity, alignment: .leading) 95 | 96 | Divider() 97 | } 98 | } 99 | 100 | Spacer() 101 | 102 | if viewModel.hasDownloadQueue() { 103 | Button(action: { 104 | viewModel.retryAllFailedQueue() 105 | }) { 106 | Text("Retry all Failed Queue") 107 | .foregroundColor(.white) 108 | .customFont(.headline) 109 | .padding(.vertical, 10) 110 | .padding(.horizontal, 30) 111 | .frame(maxWidth: .infinity) 112 | .background(Color("PlayerColor")) 113 | .cornerRadius(5) 114 | } 115 | .padding(.top, 10) 116 | .padding(.horizontal) 117 | 118 | Button(action: { 119 | viewModel.clearCompletedQueue() 120 | }) { 121 | Text("Clear Downloaded/Canceled Queue") 122 | .customFont(.headline) 123 | .padding(.vertical, 10) 124 | .padding(.horizontal, 30) 125 | .frame(maxWidth: .infinity) 126 | .cornerRadius(5) 127 | } 128 | .padding(.top, 10) 129 | .padding(.horizontal) 130 | } 131 | }.padding(.vertical) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /flo/DownloadViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadViewModel.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 12/01/25. 6 | // 7 | 8 | import Alamofire 9 | import SwiftUI 10 | 11 | enum DownloadStatus { 12 | case idle 13 | case queued 14 | case downloading 15 | case completed 16 | case failed 17 | case cancelled 18 | } 19 | 20 | struct DownloadItem: Identifiable { 21 | let id: String 22 | let albumId: String 23 | let album: String 24 | let isPlaylist: Bool 25 | let title: String 26 | let song: Song 27 | var progress: Double = 0 28 | var status: DownloadStatus = .idle 29 | } 30 | 31 | struct DownloadTrackCount: Identifiable { 32 | let id: String 33 | let name: String 34 | var elapsed: Double 35 | let total: Int 36 | } 37 | 38 | class DownloadViewModel: ObservableObject { 39 | @Published private(set) var downloadItems: [DownloadItem] = [] 40 | @Published private(set) var currentDownloads: Set = [] 41 | @Published var downloadedTrackCount: [DownloadTrackCount] = [] 42 | 43 | @Published var downloadWatcher: Bool = true 44 | 45 | private var activeDownloads: [String: DownloadRequest] = [:] 46 | 47 | func isDownloading(_ albumName: String) -> Bool { 48 | return downloadItems.filter({ $0.status == .downloading }).count > 0 49 | && downloadItems.filter({ $0.album == albumName }).count > 0 50 | } 51 | 52 | func isDownloaded(_ albumName: String) -> Bool { 53 | if let index = downloadedTrackCount.firstIndex(where: { $0.name == albumName }) { 54 | return downloadedTrackCount[index].elapsed >= 1.0 55 | } 56 | 57 | return false 58 | } 59 | 60 | func getRemainingDownloadItems() -> Int { 61 | return downloadItems.count - downloadItems.filter({ $0.status == .completed }).count 62 | } 63 | 64 | func addItem(_ album: Album, forceAll: Bool = false, isFromPlaylist: Bool = false) { 65 | let songs = forceAll ? album.songs : album.songs.filter { $0.fileUrl.isEmpty } 66 | 67 | let downloadingAlbum = DownloadTrackCount( 68 | id: album.id, name: album.name, elapsed: 0, total: songs.count) 69 | downloadedTrackCount.append(downloadingAlbum) 70 | 71 | songs.forEach { song in 72 | let songId = isFromPlaylist ? song.mediaFileId : song.id 73 | let albumId = isFromPlaylist ? album.id : song.albumId 74 | 75 | guard !downloadItems.contains(where: { $0.id == songId }) else { 76 | retryDownload(songId) 77 | 78 | return 79 | } 80 | 81 | let queue = DownloadItem( 82 | id: songId, albumId: albumId, album: album.name, isPlaylist: isFromPlaylist, 83 | title: "\(song.artist) - \(song.title)", song: song) 84 | downloadItems.append(queue) 85 | } 86 | 87 | processQueue() 88 | } 89 | 90 | func addIndividualItem(album: Album, song: Song, isFromPlaylist: Bool = false) { 91 | guard !downloadItems.contains(where: { $0.id == song.id }) else { return } 92 | 93 | let songId = isFromPlaylist ? song.mediaFileId : song.id 94 | let albumId = isFromPlaylist ? album.id : song.albumId 95 | 96 | let queue = DownloadItem( 97 | id: songId, albumId: albumId, album: album.name, isPlaylist: isFromPlaylist, 98 | title: "\(song.artist) - \(song.title)", song: song) 99 | downloadItems.append(queue) 100 | 101 | processQueue() 102 | } 103 | 104 | func processQueue() { 105 | let maxConcurrentDownloads = ProcessInfo.processInfo.activeProcessorCount / 2 106 | let downloadedTracks = downloadItems.filter { $0.status == .completed } 107 | 108 | if downloadedTracks.count >= maxConcurrentDownloads * 2 { 109 | clearCompletedQueue() 110 | } 111 | 112 | guard currentDownloads.count < maxConcurrentDownloads else { return } 113 | 114 | let availableSlots = maxConcurrentDownloads - currentDownloads.count 115 | 116 | let pendingDownloads = 117 | downloadItems 118 | .enumerated() 119 | .filter { $0.element.status == .idle || $0.element.status == .queued } 120 | .prefix(availableSlots) 121 | 122 | for (index, _) in pendingDownloads { 123 | startDownload(index: index) 124 | } 125 | } 126 | 127 | func getDownloadedTrackProgress(albumName: String) -> Double { 128 | if let index = self.downloadedTrackCount.firstIndex(where: { $0.name == albumName }) { 129 | return self.downloadedTrackCount[index].elapsed * 100 130 | } else { 131 | return .zero 132 | } 133 | } 134 | 135 | private func startDownload(index: Int) { 136 | var hasPassedThreshold = false 137 | 138 | let item = downloadItems[index] 139 | 140 | guard item.status != .downloading && item.status != .completed else { return } 141 | 142 | currentDownloads.insert(item.id) 143 | 144 | let progressUpdate: (Double) -> Void = { progress in 145 | self.updateItemProgress(itemId: item.id, progress: progress) 146 | 147 | if let index = self.downloadedTrackCount.firstIndex(where: { 148 | $0.name == item.album 149 | }) { 150 | let totalTracks = self.downloadedTrackCount[index].total 151 | 152 | if totalTracks == 1 { 153 | self.downloadedTrackCount[index].elapsed = progress / 100 154 | } else { 155 | if progress >= 100.0 && !hasPassedThreshold { 156 | hasPassedThreshold = true 157 | self.downloadedTrackCount[index].elapsed += 1.0 / Double(totalTracks) 158 | } 159 | } 160 | } 161 | } 162 | 163 | self.updateItemStatus(itemId: item.id, status: DownloadStatus.downloading) 164 | 165 | Task(priority: .background) { 166 | do { 167 | let downloadRequest = AlbumService.shared.downloadNew( 168 | artistName: item.isPlaylist ? "Various Artists" : item.song.artist, 169 | albumName: item.album, 170 | id: item.id, 171 | trackNumber: item.song.trackNumber.description, 172 | title: item.song.title, 173 | suffix: item.song.suffix, 174 | progressUpdate: progressUpdate 175 | ) { [weak self] result in 176 | Task { @MainActor in 177 | switch result { 178 | case .success(let fileURL): 179 | 180 | if fileURL != nil { 181 | AlbumService.shared.saveDownload( 182 | albumId: item.albumId, 183 | albumName: item.album, 184 | song: item.song, 185 | status: "Downloaded", 186 | isFromPlaylist: item.isPlaylist 187 | ) 188 | self?.updateItemStatus(itemId: item.id, status: DownloadStatus.completed) 189 | self?.currentDownloads.remove(item.id) 190 | self?.processQueue() 191 | self?.downloadWatcher = true 192 | 193 | if let index = self?.downloadedTrackCount.firstIndex(where: { 194 | $0.name == item.album && $0.total == 1 195 | }) { 196 | self?.downloadedTrackCount.remove(at: index) 197 | } 198 | } 199 | 200 | case .failure(let error): 201 | if let afError = error.asAFError, case .explicitlyCancelled = afError { 202 | self?.updateItemStatus(itemId: item.id, status: .cancelled) 203 | 204 | if let index = self?.downloadedTrackCount.firstIndex(where: { 205 | $0.name == item.album && $0.total == 1 206 | }) { 207 | self?.downloadedTrackCount[index].elapsed = 0 208 | } 209 | } else { 210 | print(error) 211 | self?.updateItemStatus(itemId: item.id, status: .failed) 212 | } 213 | } 214 | } 215 | } 216 | 217 | await MainActor.run { 218 | activeDownloads[item.id] = downloadRequest 219 | } 220 | } 221 | } 222 | } 223 | 224 | func clearCurrentAlbumDownload(albumName: String) { 225 | let newDownloadItems = downloadItems.filter { 226 | $0.album != albumName 227 | } 228 | 229 | downloadItems = newDownloadItems 230 | } 231 | 232 | func cancelCurrentAlbumDownload(albumName: String) { 233 | downloadItems 234 | .filter { $0.album == albumName } 235 | .forEach { cancelDownload($0.id) } 236 | } 237 | 238 | func cancelDownload(_ itemId: String) { 239 | if let request = activeDownloads[itemId] { 240 | request.cancel() 241 | 242 | if let index = downloadItems.firstIndex(where: { $0.id == itemId }) { 243 | downloadItems[index].status = .cancelled 244 | downloadItems[index].progress = .zero 245 | 246 | currentDownloads.remove(itemId) 247 | } 248 | 249 | activeDownloads.removeValue(forKey: itemId) 250 | self.processQueue() 251 | } 252 | } 253 | 254 | func retryDownload(_ itemId: String) { 255 | if let index = downloadItems.firstIndex(where: { $0.id == itemId }) { 256 | downloadItems[index].status = .queued 257 | self.processQueue() 258 | } 259 | } 260 | 261 | func hasDownloadQueue() -> Bool { 262 | return !downloadItems.isEmpty 263 | } 264 | 265 | func removeFromQueue(_ itemId: String) { 266 | let newDownloadItems = downloadItems.filter { $0.id != itemId } 267 | 268 | downloadItems = newDownloadItems 269 | } 270 | 271 | func retryAllFailedQueue() { 272 | downloadItems 273 | .filter { $0.status == .failed } 274 | .forEach { self.updateItemStatus(itemId: $0.id, status: .queued) } 275 | 276 | processQueue() 277 | } 278 | 279 | func clearCompletedQueue() { 280 | let newDownloadItems = downloadItems.filter { 281 | $0.status != .completed && $0.status != .cancelled 282 | } 283 | 284 | downloadItems = newDownloadItems 285 | } 286 | 287 | private func updateItemProgress(itemId: String, progress: Double) { 288 | if let index = downloadItems.firstIndex(where: { $0.id == itemId }) { 289 | downloadItems[index].progress = progress 290 | } 291 | } 292 | 293 | private func updateItemStatus(itemId: String, status: DownloadStatus) { 294 | if let index = downloadItems.firstIndex(where: { $0.id == itemId }) { 295 | downloadItems[index].status = status 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /flo/FloatingPlayerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingPlayerView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 08/06/24. 6 | // 7 | 8 | import NukeUI 9 | import SwiftUI 10 | 11 | struct FloatingPlayerView: View { 12 | @ObservedObject var viewModel: PlayerViewModel 13 | 14 | var range: ClosedRange = 0...1 15 | 16 | var body: some View { 17 | ZStack { 18 | HStack { 19 | if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { 20 | Image(uiImage: image) 21 | .resizable() 22 | .aspectRatio(contentMode: .fit) 23 | .frame(width: 50, height: 50) 24 | .clipShape( 25 | RoundedRectangle(cornerRadius: 10, style: .continuous) 26 | ) 27 | } else { 28 | LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in 29 | if let image = state.image { 30 | image 31 | .resizable() 32 | .aspectRatio(contentMode: .fit) 33 | .frame(width: 50, height: 50) 34 | .clipShape( 35 | RoundedRectangle(cornerRadius: 5, style: .continuous) 36 | ) 37 | } else { 38 | Color.gray.opacity(0.3).frame(width: 50, height: 50) 39 | .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) 40 | } 41 | } 42 | } 43 | 44 | VStack(alignment: .leading) { 45 | Text(viewModel.nowPlaying.songName ?? "") 46 | .foregroundColor(.white) 47 | .customFont(.headline) 48 | .lineLimit(1) 49 | Text(viewModel.nowPlaying.artistName ?? "") 50 | .foregroundColor(.white) 51 | .customFont(.subheadline) 52 | .lineLimit(1) 53 | 54 | GeometryReader { geometry in 55 | ZStack(alignment: .leading) { 56 | Rectangle() 57 | .foregroundColor(Color.gray.opacity(0.3)) 58 | .frame(height: 3) 59 | .cornerRadius(10) 60 | 61 | Rectangle() 62 | .foregroundColor(Color.white) 63 | .frame( 64 | width: CGFloat( 65 | (viewModel.progress - range.lowerBound) / (range.upperBound - range.lowerBound)) 66 | * geometry.size.width, height: 3 67 | ) 68 | .cornerRadius(10).opacity(viewModel.isMediaLoading ? 0 : 1) 69 | }.frame(height: 3) 70 | }.frame(height: 3) 71 | } 72 | 73 | HStack(spacing: 20) { 74 | if viewModel.isMediaLoading { 75 | ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)) 76 | } else { 77 | Button { 78 | viewModel.isPlaying ? viewModel.pause() : viewModel.play() 79 | } label: { 80 | Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") 81 | .font(.system(size: 20)) 82 | .disabled(viewModel.isMediaLoading) 83 | }.opacity(viewModel.isMediaFailed ? 0 : 1) 84 | } 85 | }.padding() 86 | }.padding(8).foregroundColor(.white) 87 | }.background { 88 | if UserDefaultsManager.playerBackground == PlayerBackground.translucent { 89 | ZStack { 90 | if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { 91 | Image(uiImage: image) 92 | .resizable() 93 | .frame(maxWidth: .infinity, maxHeight: .infinity) 94 | .blur(radius: 50, opaque: true) 95 | .edgesIgnoringSafeArea(.all) 96 | } else { 97 | LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in 98 | if let image = state.image { 99 | image 100 | .resizable() 101 | .frame(maxWidth: .infinity, maxHeight: .infinity) 102 | .blur(radius: 50, opaque: true) 103 | .edgesIgnoringSafeArea(.all) 104 | } 105 | } 106 | } 107 | 108 | Rectangle().fill(.thinMaterial).edgesIgnoringSafeArea(.all) 109 | }.environment(\.colorScheme, .dark) 110 | } else { 111 | Rectangle().fill(Color("PlayerColor")) 112 | } 113 | } 114 | .cornerRadius(10).padding(8) 115 | } 116 | } 117 | 118 | struct FloatingMusicPlayerView_previews: PreviewProvider { 119 | @StateObject static var viewModel = PlayerViewModel() 120 | 121 | static var previews: some View { 122 | FloatingPlayerView(viewModel: viewModel) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /flo/FloooViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloooViewModel.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 11/01/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | class FloooViewModel: ObservableObject { 11 | @Published var scanStatus: SubsonicResponse? = nil 12 | @Published var downloadedAlbums: Int = 0 13 | @Published var downloadedSongs: Int = 0 14 | 15 | @Published var localDirectorySize: String = "0 MB" 16 | 17 | @Published var stats: Stats? 18 | @Published var totalPlay: Int = 0 19 | 20 | @Published var isListenBrainzLinked: Bool = false 21 | @Published var isLastFmLinked: Bool = false 22 | 23 | @Published var userDefaultsItems: [String: Any] = [:] 24 | @Published var keychainItems: [String: Any] = [:] 25 | 26 | private var isGeneratingStats = false 27 | private var isScrobbleAccountStatusChecked = false 28 | 29 | static let shared = FloooViewModel() 30 | 31 | func getUserDefaults() { 32 | userDefaultsItems = UserDefaultsManager.getAll() 33 | keychainItems = KeychainManager.getAuthCredsAndPasswords() 34 | } 35 | 36 | // FIXME: i think everything that is related to listening history 37 | // and stats should live in FloooViewModel 38 | func getListeningHistory() { 39 | // TODO: is this ok? 40 | Task { @MainActor in 41 | let totalListens = await FloooService.shared.getListeningHistory() 42 | 43 | self.totalPlay = totalListens.count 44 | 45 | guard !isGeneratingStats else { return } 46 | isGeneratingStats = true 47 | 48 | self.stats = await FloooService.shared.generateStats(totalListens) 49 | } 50 | } 51 | 52 | func clearListeningHistory() { 53 | FloooService.shared.clearListeningHistory() 54 | } 55 | 56 | func getLocalStorageInformation() { 57 | self.downloadedAlbums = ScanStatusService.shared.getDownloadedAlbumsCount() 58 | self.downloadedSongs = ScanStatusService.shared.getDownloadedSongsCount() 59 | 60 | Task { 61 | do { 62 | let calculateDirectorySize = try await LocalFileManager.shared.calculateDirectorySize() 63 | 64 | await MainActor.run { 65 | self.localDirectorySize = calculateDirectorySize 66 | } 67 | } catch { 68 | print("Error: \(error)") 69 | } 70 | } 71 | } 72 | 73 | func optimizeLocalStorage() { 74 | LocalFileManager.shared.deleteDownloadedAlbums { result in 75 | switch result { 76 | case .success(let shouldProceed): 77 | if shouldProceed { 78 | CoreDataManager.shared.clearEverything() 79 | } 80 | 81 | self.getLocalStorageInformation() 82 | 83 | case .failure(let error): 84 | print("error in optimizeLocalStorage>>>", error) 85 | } 86 | } 87 | } 88 | 89 | func fetchAccountLinkStatus(completion: @escaping (AccountLinkStatus) -> Void) { 90 | return FloooService.shared.getAccountLinkStatuses { result in 91 | switch result { 92 | case .success(let status): 93 | self.isListenBrainzLinked = status.listenBrainz 94 | self.isLastFmLinked = status.lastFM 95 | self.isScrobbleAccountStatusChecked = true 96 | 97 | completion(status) 98 | 99 | case .failure(let error): 100 | print("error>>>>", error) 101 | } 102 | } 103 | } 104 | 105 | func checkAccountLinkStatus() { 106 | self.fetchAccountLinkStatus { status in 107 | self.isListenBrainzLinked = status.listenBrainz 108 | self.isLastFmLinked = status.lastFM 109 | } 110 | } 111 | 112 | func checkScanStatus() { 113 | ScanStatusService.shared.getScanStatus { [weak self] result in 114 | DispatchQueue.main.async { 115 | switch result { 116 | case .success(let status): 117 | self?.scanStatus = status.subsonicResponse 118 | case .failure(let error): 119 | print("error>>>", error) 120 | } 121 | } 122 | } 123 | } 124 | 125 | func saveListeningHistory(nowPlayingData: QueueEntity) { 126 | FloooService.shared.saveListeningHistory(payload: nowPlayingData) 127 | } 128 | 129 | func setNowPlayingToScrobbleServer(nowPlaying: QueueEntity) { 130 | processScrobble(submission: false, nowPlaying: nowPlaying) 131 | } 132 | 133 | func scrobble(submission: Bool, nowPlaying: QueueEntity) { 134 | FloooService.shared.saveListeningHistory(payload: nowPlaying) 135 | processScrobble(submission: submission, nowPlaying: nowPlaying) 136 | } 137 | 138 | private func processScrobble(submission: Bool, nowPlaying: QueueEntity) { 139 | guard let songId = nowPlaying.id else { return } 140 | 141 | if isScrobbleAccountStatusChecked { 142 | let shouldSubmit = isListenBrainzLinked || isLastFmLinked 143 | 144 | if shouldSubmit { 145 | sendScrobble(submission: submission, songId: songId) 146 | } 147 | } else { 148 | fetchAccountLinkStatus { status in 149 | let shouldSubmit = status.listenBrainz || status.lastFM 150 | 151 | if shouldSubmit { 152 | self.sendScrobble(submission: submission, songId: songId) 153 | } 154 | } 155 | } 156 | } 157 | 158 | private func sendScrobble(submission: Bool, songId: String) { 159 | FloooService.shared.scrobbleToBuiltinEndpoint(submission: submission, songId: songId) { 160 | result in 161 | // TODO: handle when this fail 162 | // TODO: also, add "check offline mode" later 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /flo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | UIAppFonts 8 | 9 | PlusJakartaSans-VariableFont_wght.ttf 10 | 11 | UIBackgroundModes 12 | 13 | audio 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /flo/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 01/06/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Login: View { 11 | @ObservedObject var viewModel: AuthViewModel 12 | @Binding var showLoginSheet: Bool 13 | 14 | var isSubmitButtonDisabled: Bool { 15 | viewModel.serverUrl.isEmpty || viewModel.username.isEmpty || viewModel.password.isEmpty 16 | || viewModel.isSubmitting 17 | } 18 | 19 | var body: some View { 20 | ScrollView { 21 | if !viewModel.extraMessage.isEmpty { 22 | extraMessage 23 | } 24 | headerSection 25 | formSection 26 | } 27 | .alert(isPresented: $viewModel.showAlert) { 28 | Alert( 29 | title: Text("Login Failed"), 30 | message: Text(viewModel.alertMessage), 31 | dismissButton: .default(Text("OK")) 32 | ) 33 | } 34 | .background(Color(.systemBackground)) 35 | .foregroundColor(.accent) 36 | } 37 | 38 | private var extraMessage: some View { 39 | VStack { 40 | Text(viewModel.extraMessage) 41 | .customFont(.caption1) 42 | .lineSpacing(2) 43 | .multilineTextAlignment(.center) 44 | .padding() 45 | } 46 | .overlay( 47 | Rectangle().stroke(.accent, lineWidth: 1) 48 | ) 49 | } 50 | 51 | private var headerSection: some View { 52 | VStack { 53 | Image("logo_alt") 54 | .resizable() 55 | .scaledToFit() 56 | .frame(width: 100) 57 | .padding(.vertical, 20) 58 | 59 | if viewModel.experimentalSaveLoginInfo { 60 | Text("Login to your Navidrome server to continue") 61 | .customFont(.title1) 62 | .fontWeight(.bold) 63 | .multilineTextAlignment(.center) 64 | .padding(.bottom, 10) 65 | 66 | Text("The password is stored securely in Keychain") 67 | .customFont(.body) 68 | .multilineTextAlignment(.center) 69 | } else { 70 | Text("Thanks for choosing flo!") 71 | .customFont(.title1) 72 | .fontWeight(.bold) 73 | .multilineTextAlignment(.center) 74 | .padding(.bottom, 10) 75 | 76 | Text("Login to your Navidrome server to continue") 77 | .customFont(.body) 78 | .multilineTextAlignment(.center) 79 | } 80 | } 81 | .padding(.horizontal, 20) 82 | .padding(.vertical, 30) 83 | } 84 | 85 | private var formSection: some View { 86 | VStack { 87 | formField( 88 | title: "Server URL", text: $viewModel.serverUrl, 89 | placeholder: "https://navidrome․your-server․net", keyboardType: .URL) 90 | formField(title: "Username", text: $viewModel.username, placeholder: "sigma") 91 | secureFormField(title: "Password", text: $viewModel.password, placeholder: "*************") 92 | submitButton 93 | } 94 | .padding(.bottom, 30) 95 | .padding(.horizontal, 10) 96 | } 97 | 98 | private func formField( 99 | title: String, text: Binding, placeholder: String, 100 | keyboardType: UIKeyboardType = .default 101 | ) -> some View { 102 | VStack(alignment: .leading) { 103 | Text(title) 104 | .font(.headline) 105 | TextField(placeholder, text: text) 106 | .padding() 107 | .overlay( 108 | RoundedRectangle(cornerRadius: 8) 109 | .stroke(.accent, lineWidth: 1) 110 | ) 111 | .keyboardType(keyboardType) 112 | .autocapitalization(.none) 113 | .disableAutocorrection(true) 114 | .textContentType(.none) 115 | } 116 | .padding(.horizontal, 15) 117 | .padding(.bottom, 10) 118 | } 119 | 120 | private func secureFormField(title: String, text: Binding, placeholder: String) 121 | -> some View 122 | { 123 | VStack(alignment: .leading) { 124 | Text(title) 125 | .font(.headline) 126 | SecureField(placeholder, text: text) 127 | .padding() 128 | .overlay( 129 | RoundedRectangle(cornerRadius: 8) 130 | .stroke(.accent, lineWidth: 1) 131 | ) 132 | } 133 | .padding(.horizontal, 15) 134 | .padding(.bottom, 10) 135 | } 136 | 137 | private var submitButton: some View { 138 | VStack(alignment: .leading) { 139 | Button(action: viewModel.login) { 140 | Text(viewModel.experimentalSaveLoginInfo ? "Save" : "Login") 141 | .foregroundColor(.white) 142 | .fontWeight(.bold) 143 | .customFont(.headline) 144 | .textCase(.uppercase) 145 | .padding() 146 | .frame(maxWidth: .infinity) 147 | .background(Color("PlayerColor")) 148 | .cornerRadius(5) 149 | .opacity(isSubmitButtonDisabled ? 0.5 : 1) 150 | .shadow(radius: isSubmitButtonDisabled ? 0 : 10) 151 | } 152 | .padding(.top, 10) 153 | .padding() 154 | .disabled(isSubmitButtonDisabled) 155 | } 156 | } 157 | } 158 | 159 | struct LoginView_Previews: PreviewProvider { 160 | @StateObject static var viewModel: AuthViewModel = AuthViewModel() 161 | @State static var showLoginSheet: Bool = true 162 | 163 | static var previews: some View { 164 | Login(viewModel: viewModel, showLoginSheet: $showLoginSheet) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /flo/Navigation/DownloadsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadsView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 08/06/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DownloadsView: View { 11 | @State private var searchAlbum = "" 12 | 13 | @ObservedObject var viewModel: AlbumViewModel 14 | 15 | @EnvironmentObject var playerViewModel: PlayerViewModel 16 | 17 | let columns = [ 18 | GridItem(.flexible()), 19 | GridItem(.flexible()), 20 | ] 21 | 22 | var filteredAlbums: [Album] { 23 | if searchAlbum.isEmpty { 24 | return viewModel.downloadedAlbums 25 | } else { 26 | return viewModel.downloadedAlbums.filter { album in 27 | album.name.localizedCaseInsensitiveContains(searchAlbum) 28 | } 29 | } 30 | } 31 | 32 | var body: some View { 33 | NavigationStack { 34 | ScrollView { 35 | if viewModel.downloadedAlbums.isEmpty { 36 | VStack(alignment: .leading) { 37 | Image("Downloads").resizable().aspectRatio(contentMode: .fit).frame(width: 300) 38 | .padding() 39 | .padding(.bottom, 10) 40 | Group { 41 | Text("Going off the grid?") 42 | .customFont(.title1) 43 | .fontWeight(.bold) 44 | .multilineTextAlignment(.leading) 45 | .padding(.bottom, 10) 46 | Text( 47 | "Bring your music anywhere, even when you're offline. Your downloaded music will be here." 48 | ) 49 | .customFont(.subheadline) 50 | 51 | }.padding(.horizontal, 20).foregroundColor(.accent) 52 | } 53 | } 54 | 55 | LazyVGrid(columns: columns, spacing: 20) { 56 | ForEach(filteredAlbums) { album in 57 | NavigationLink { 58 | AlbumView(viewModel: viewModel, isDownloadScreen: true) 59 | .onAppear { 60 | viewModel.setActiveAlbum(album: album) 61 | } 62 | } label: { 63 | AlbumsView(viewModel: viewModel, album: album, isDownloadScreen: true) 64 | } 65 | } 66 | }.padding(.top, 10).padding( 67 | .bottom, playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer ? 100 : 0 68 | ).navigationTitle("Downloads") 69 | .searchable( 70 | text: $searchAlbum, 71 | placement: .navigationBarDrawer(displayMode: .always), 72 | prompt: "Search" 73 | ) 74 | } 75 | } 76 | } 77 | } 78 | 79 | struct DownloadsView_Previews: PreviewProvider { 80 | @StateObject static var viewModel: AlbumViewModel = AlbumViewModel() 81 | 82 | static var previews: some View { 83 | DownloadsView(viewModel: viewModel) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /flo/Navigation/HomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 08/06/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HomeView: View { 11 | @ObservedObject var viewModel: AuthViewModel 12 | @State private var showLoginSheet: Bool = false 13 | 14 | @EnvironmentObject var floooViewModel: FloooViewModel 15 | 16 | private func shouldShowLoginSheet() -> Binding { 17 | Binding( 18 | get: { 19 | showLoginSheet && !viewModel.isLoggedIn 20 | }, 21 | set: { newValue in 22 | showLoginSheet = newValue 23 | } 24 | ) 25 | } 26 | 27 | var body: some View { 28 | VStack { 29 | HStack { 30 | Text("Home").font(.system(size: 32)).foregroundColor(.primary).fontWeight(.bold).padding( 31 | .vertical) 32 | Spacer() 33 | Menu { 34 | Button(action: { 35 | showLoginSheet = true 36 | }) { 37 | if !viewModel.isLoggedIn { 38 | Text("Login") 39 | } else { 40 | Text("Logged in as \(viewModel.user?.name ?? "")") 41 | } 42 | }.disabled(viewModel.isLoggedIn) 43 | if viewModel.isLoggedIn { 44 | Button(action: { 45 | viewModel.logout() 46 | }) { 47 | Text("Logout") 48 | } 49 | } 50 | } label: { 51 | Image(systemName: "person.crop.circle.fill") 52 | .font(.largeTitle) 53 | } 54 | }.padding(.top) 55 | .sheet(isPresented: shouldShowLoginSheet()) { 56 | Login(viewModel: viewModel, showLoginSheet: $showLoginSheet) 57 | .onDisappear { 58 | if viewModel.isLoggedIn { 59 | self.floooViewModel.checkScanStatus() 60 | } 61 | } 62 | } 63 | .padding() 64 | 65 | ScrollView { 66 | VStack(alignment: .leading, spacing: 16) { 67 | if !viewModel.isLoggedIn { 68 | VStack { 69 | Text("Login to start streaming your music by tapping the icon above") 70 | .customFont(.body) 71 | .multilineTextAlignment(.center) 72 | .frame(maxWidth: .infinity) 73 | } 74 | .padding() 75 | .overlay( 76 | RoundedRectangle(cornerRadius: 8).stroke(Color("PlayerColor"), lineWidth: 0.8) 77 | ) 78 | .padding(.top, 10) 79 | .padding(.bottom) 80 | } 81 | 82 | Text("Listening Activity (all time)").customFont(.title2).fontWeight(.bold) 83 | .multilineTextAlignment(.leading) 84 | 85 | HStack(alignment: .top, spacing: 16) { 86 | StatCard( 87 | title: "Total Listens", 88 | value: floooViewModel.totalPlay.description, 89 | icon: "headphones", 90 | color: .purple 91 | ) 92 | 93 | StatCard( 94 | title: "Top Artist", 95 | value: floooViewModel.stats?.topArtist ?? "N/A", 96 | icon: "music.mic", 97 | color: .blue, 98 | showArrow: true 99 | ) 100 | } 101 | 102 | HStack(alignment: .top, spacing: 16) { 103 | StatCard( 104 | title: "Top Album", 105 | value: floooViewModel.stats?.topAlbum ?? "N/A", 106 | subtitle: floooViewModel.stats?.topAlbumArtist ?? "N/A", 107 | icon: "record.circle", 108 | color: .pink, 109 | isWide: true, 110 | showArrow: true 111 | ) 112 | } 113 | 114 | HStack(spacing: 16) { 115 | StatCard( 116 | title: "Experimental", 117 | value: "More data is cooking soon", 118 | icon: "chart.pie", 119 | color: .indigo, 120 | isWide: false, 121 | showArrow: false 122 | ) 123 | } 124 | Text( 125 | "This stat is generated on-device (once every session) and no data is stored or shared with a third party — #selfhosting, baby!" 126 | ) 127 | .frame(maxWidth: .infinity) 128 | .multilineTextAlignment(.center) 129 | .customFont(.caption1) 130 | .lineSpacing(2) 131 | } 132 | .padding(.bottom, 100) 133 | .padding(.horizontal) 134 | } 135 | } 136 | .onAppear { 137 | self.floooViewModel.getListeningHistory() 138 | } 139 | } 140 | } 141 | 142 | struct HomeViewPreviews_Previews: PreviewProvider { 143 | @StateObject static var viewModel: AuthViewModel = AuthViewModel() 144 | @StateObject static var floooViewModel: FloooViewModel = FloooViewModel() 145 | 146 | static var previews: some View { 147 | HomeView(viewModel: viewModel).environmentObject(floooViewModel) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /flo/Navigation/LibraryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 08/06/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LibraryView: View { 11 | @State private var searchAlbum = "" 12 | @State private var showDownloadSheet: Bool = false 13 | 14 | @ObservedObject var viewModel: AlbumViewModel 15 | 16 | @EnvironmentObject var playerViewModel: PlayerViewModel 17 | @EnvironmentObject var downloadViewModel: DownloadViewModel 18 | 19 | let columns = [ 20 | GridItem(.flexible()), 21 | GridItem(.flexible()), 22 | ] 23 | 24 | var filteredAlbums: [Album] { 25 | if searchAlbum.isEmpty { 26 | return viewModel.albums 27 | } else { 28 | return viewModel.albums.filter { album in 29 | album.name.localizedCaseInsensitiveContains(searchAlbum) 30 | } 31 | } 32 | } 33 | 34 | var body: some View { 35 | NavigationStack { 36 | ScrollView { 37 | if viewModel.albums.isEmpty && viewModel.error != nil { 38 | VStack(alignment: .leading) { 39 | Image("Home").resizable().aspectRatio(contentMode: .fit).frame( 40 | maxWidth: .infinity, maxHeight: 300 41 | ).padding() 42 | Group { 43 | Text("Your Navidrome session may have expired") 44 | .customFont(.title1) 45 | .fontWeight(.bold) 46 | .multilineTextAlignment(.leading) 47 | .padding(.bottom, 10) 48 | Text( 49 | "The quickest action you can take is to log back in — for now." 50 | ) 51 | .customFont(.subheadline) 52 | 53 | }.padding(.horizontal, 20).foregroundColor(.accent) 54 | } 55 | } else { 56 | if searchAlbum.isEmpty { 57 | NavigationLink { 58 | ArtistsView(artists: viewModel.artists) 59 | .environmentObject(viewModel) 60 | .onAppear { 61 | viewModel.getArtists() 62 | } 63 | } label: { 64 | HStack { 65 | Image(systemName: "music.mic") 66 | .frame(width: 20, height: 10) 67 | .foregroundColor(.accent) 68 | Text("Artists") 69 | .customFont(.headline) 70 | .padding(.leading, 8) 71 | Spacer() 72 | Image(systemName: "chevron.right") 73 | .foregroundColor(.gray) 74 | .font(.caption) 75 | }.padding(.horizontal).padding(.vertical, 5) 76 | } 77 | 78 | Divider() 79 | 80 | NavigationLink { 81 | PlaylistView() 82 | .environmentObject(viewModel) 83 | .environmentObject(playerViewModel) 84 | .environmentObject(downloadViewModel) 85 | .onAppear { 86 | viewModel.getPlaylists() 87 | } 88 | } label: { 89 | HStack { 90 | Image(systemName: "music.note.list") 91 | .frame(width: 20, height: 10) 92 | .foregroundColor(.accent) 93 | Text("Playlists") 94 | .customFont(.headline) 95 | .padding(.leading, 8) 96 | Spacer() 97 | Image(systemName: "chevron.right") 98 | .foregroundColor(.gray) 99 | .font(.caption) 100 | }.padding(.horizontal).padding(.vertical, 5) 101 | } 102 | 103 | Divider() 104 | 105 | NavigationLink { 106 | SongsView() 107 | .environmentObject(viewModel) 108 | .environmentObject(playerViewModel) 109 | .onAppear { 110 | viewModel.fetchAllSongs() 111 | } 112 | } label: { 113 | HStack { 114 | Image(systemName: "music.note") 115 | .frame(width: 20, height: 10) 116 | .foregroundColor(.accent) 117 | Text("Songs") 118 | .customFont(.headline) 119 | .padding(.leading, 8) 120 | Spacer() 121 | Image(systemName: "chevron.right") 122 | .foregroundColor(.gray) 123 | .font(.caption) 124 | }.padding(.horizontal).padding(.vertical, 5) 125 | } 126 | 127 | Divider() 128 | } 129 | 130 | LazyVGrid(columns: columns) { 131 | ForEach(filteredAlbums) { album in 132 | NavigationLink { 133 | AlbumView(viewModel: viewModel) 134 | .environmentObject(downloadViewModel) 135 | .onAppear { 136 | viewModel.setActiveAlbum(album: album) 137 | } 138 | } label: { 139 | AlbumsView(viewModel: viewModel, album: album) 140 | } 141 | } 142 | } 143 | .padding(.top, 10) 144 | .padding( 145 | .bottom, playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer ? 100 : 0 146 | ) 147 | .searchable( 148 | text: $searchAlbum, 149 | placement: .navigationBarDrawer(displayMode: .always), 150 | prompt: "Search" 151 | ) 152 | } 153 | } 154 | .sheet(isPresented: $showDownloadSheet) { 155 | DownloadQueueView().environmentObject(downloadViewModel) 156 | } 157 | .toolbar { 158 | if downloadViewModel.hasDownloadQueue() { 159 | Button(action: { 160 | showDownloadSheet.toggle() 161 | }) { 162 | Label("", systemImage: "icloud.and.arrow.down") 163 | } 164 | } 165 | } 166 | .navigationTitle("Library") 167 | } 168 | } 169 | } 170 | 171 | struct LibraryView_Previews: PreviewProvider { 172 | static private var songs: [Song] = [ 173 | Song( 174 | id: "0", title: "Song name", albumId: "", artist: "", trackNumber: 1, discNumber: 0, 175 | bitRate: 0, 176 | sampleRate: 44100, 177 | suffix: "m4a", duration: 100, mediaFileId: "0") 178 | ] 179 | 180 | static private var albums: [Album] = [ 181 | Album( 182 | name: "Album 1", 183 | artist: "Artist 1", 184 | songs: songs 185 | ) 186 | ] 187 | @StateObject static private var playerViewModel: PlayerViewModel = PlayerViewModel() 188 | @StateObject static private var viewModel: AlbumViewModel = AlbumViewModel(albums: albums) 189 | 190 | static var previews: some View { 191 | LibraryView(viewModel: viewModel).environmentObject(playerViewModel) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /flo/Navigation/PreferencesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreferencesView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 08/06/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PreferencesView: View { 11 | @ObservedObject var authViewModel: AuthViewModel 12 | @State private var storeCredsInKeychain = false 13 | @State private var optimizeLocalStorageAlert = false 14 | @State private var showLoginSheet = false 15 | 16 | @State private var accentColor = Color(.accent) 17 | @State private var playerColor = Color(.player) 18 | @State private var customFontFamily = "Plus Jakarta Sans" 19 | 20 | @EnvironmentObject var floooViewModel: FloooViewModel 21 | @EnvironmentObject var playerViewModel: PlayerViewModel 22 | 23 | let themeColors = ["Blue", "Green", "Red", "Ohio"] 24 | 25 | @State private var experimentalMaxBitrate = UserDefaultsManager.maxBitRate 26 | @State private var experimentalPlayerBackground = UserDefaultsManager.playerBackground 27 | 28 | var shouldShowLoginSheet: Binding { 29 | Binding( 30 | get: { 31 | return showLoginSheet && authViewModel.experimentalSaveLoginInfo 32 | }, 33 | set: { newValue in 34 | showLoginSheet = newValue 35 | } 36 | ) 37 | } 38 | 39 | func getAppVersion() -> String { 40 | if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { 41 | return appVersion 42 | } 43 | 44 | return "dev" 45 | } 46 | 47 | func getBuildNumber() -> String { 48 | if let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { 49 | return buildNumber 50 | } 51 | 52 | return "000000" 53 | } 54 | 55 | var body: some View { 56 | NavigationStack { 57 | Form { 58 | Section(header: Text("Local Storage")) { 59 | HStack { 60 | Text("Downloaded Albums") 61 | Spacer() 62 | Text(floooViewModel.downloadedAlbums.description) 63 | } 64 | 65 | HStack { 66 | Text("Downloaded Songs") 67 | Spacer() 68 | Text(floooViewModel.downloadedSongs.description) 69 | } 70 | 71 | HStack { 72 | Text("Total usage") 73 | Spacer() 74 | Text(floooViewModel.localDirectorySize) 75 | } 76 | 77 | Button( 78 | role: .destructive, 79 | action: { 80 | floooViewModel.clearListeningHistory() 81 | } 82 | ) { 83 | Text("Clear listening history (no alert and irreversible)") 84 | } 85 | 86 | Button(action: { 87 | self.optimizeLocalStorageAlert.toggle() 88 | }) { 89 | Text("Optimize local storage") 90 | }.alert( 91 | "Optimize Local Storage", isPresented: $optimizeLocalStorageAlert, 92 | actions: { 93 | Button( 94 | "Continue", role: .destructive, 95 | action: { 96 | floooViewModel.optimizeLocalStorage() 97 | playerViewModel.destroyPlayerAndQueue() 98 | }) 99 | }, 100 | message: { 101 | Text( 102 | "For now this action means 'Delete all downloaded albums and songs' including its content. Continue?" 103 | ) 104 | }) 105 | } 106 | 107 | if authViewModel.isLoggedIn { 108 | Section(header: Text("Server Information")) { 109 | HStack { 110 | Text("Server URL") 111 | Spacer() 112 | Text(UserDefaultsManager.serverBaseURL) // TODO: is this safe? 113 | } 114 | HStack { 115 | Text("Navidrome Version") 116 | Spacer() 117 | Text(floooViewModel.scanStatus?.serverVersion ?? "undefined") 118 | } 119 | HStack { 120 | Text("Subsonic Version") 121 | Spacer() 122 | Text(floooViewModel.scanStatus?.version ?? "undefined") 123 | } 124 | HStack { 125 | Text("Total Folders Scanned") 126 | Spacer() 127 | Text(floooViewModel.scanStatus?.data?.folderCount.description ?? "0") 128 | } 129 | HStack { 130 | Text("Total Files Scanned") 131 | Spacer() 132 | Text(floooViewModel.scanStatus?.data?.count.description ?? "0") 133 | } 134 | } 135 | } 136 | 137 | // TODO: finish this later 138 | if false { 139 | Section(header: Text("Make it yours")) { 140 | ColorPicker("Accent color", selection: $accentColor).disabled(true) 141 | ColorPicker("Player color", selection: $playerColor).disabled(true) 142 | 143 | Picker(selection: $customFontFamily, label: Text("Font Family")) { 144 | ForEach( 145 | ["Plus Jakarta Sans", "System", "JetBrains Mono", "Comic Sans MS"], id: \.self 146 | ) { 147 | Text($0) 148 | } 149 | }.disabled(true) 150 | } 151 | } 152 | 153 | // TODO: finish this later 154 | Section(header: Text("Experimental")) { 155 | VStack(alignment: .leading) { 156 | Toggle( 157 | "Enable Debug", 158 | isOn: Binding( 159 | get: { UserDefaultsManager.enableDebug }, 160 | set: { value in 161 | UserDefaultsManager.enableDebug = value 162 | APIManager.shared.reconfigureSession() 163 | } 164 | )) 165 | 166 | Text( 167 | "Enabling this option may affect the experience." 168 | ).font(.caption).foregroundColor(.gray) 169 | } 170 | 171 | VStack(alignment: .leading) { 172 | Picker(selection: $experimentalMaxBitrate, label: Text("Max Bitrate")) { 173 | ForEach(TranscodingSettings.availableBitRate, id: \.self) { bitrate in 174 | Text(bitrate == "0" ? "Source" : bitrate).tag(bitrate) 175 | } 176 | } 177 | .onChange(of: experimentalMaxBitrate) { value in 178 | UserDefaultsManager.maxBitRate = value 179 | } 180 | 181 | Text( 182 | "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." 183 | ).font(.caption).foregroundColor(.gray) 184 | } 185 | 186 | Toggle( 187 | "Use translucent backgrounds", 188 | isOn: Binding( 189 | get: { UserDefaultsManager.playerBackground == PlayerBackground.translucent }, 190 | set: { 191 | UserDefaultsManager.playerBackground = 192 | $0 ? PlayerBackground.translucent : PlayerBackground.solid 193 | } 194 | )) 195 | 196 | VStack(alignment: .leading) { 197 | Toggle( 198 | "Save login info", 199 | isOn: Binding( 200 | get: { UserDefaultsManager.saveLoginInfo }, 201 | set: { 202 | if $0 { 203 | authViewModel.experimentalSaveLoginInfo = true 204 | showLoginSheet = true 205 | } else { 206 | authViewModel.destroySavedPassword() 207 | 208 | if UserDefaultsManager.enableDebug { 209 | floooViewModel.getUserDefaults() 210 | } 211 | } 212 | } 213 | )) 214 | 215 | Text( 216 | "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)" 217 | ).font(.caption).foregroundColor(.gray) 218 | } 219 | .sheet(isPresented: shouldShowLoginSheet) { 220 | Login(viewModel: authViewModel, showLoginSheet: $showLoginSheet) 221 | .onDisappear { 222 | if authViewModel.isLoggedIn { 223 | self.floooViewModel.checkScanStatus() 224 | self.floooViewModel.checkAccountLinkStatus() 225 | } 226 | 227 | if UserDefaultsManager.enableDebug { 228 | floooViewModel.getUserDefaults() 229 | } 230 | 231 | if !showLoginSheet && authViewModel.experimentalSaveLoginInfo { 232 | authViewModel.experimentalSaveLoginInfo = false 233 | } 234 | } 235 | } 236 | 237 | if authViewModel.isLoggedIn { 238 | VStack(alignment: .leading) { 239 | Toggle(isOn: $floooViewModel.isLastFmLinked) { 240 | Text("Scrobble to Last.fm") 241 | }.disabled(true) 242 | 243 | Text("To change this, please do so via the Navidrome Web UI").font(.caption) 244 | .foregroundColor(.gray) 245 | } 246 | 247 | Toggle(isOn: $floooViewModel.isListenBrainzLinked) { 248 | Text("Scrobble to ListenBrainz") 249 | }.disabled(true) 250 | 251 | Text("To change this, please do so via the Navidrome Web UI").font(.caption) 252 | .foregroundColor(.gray) 253 | } 254 | } 255 | 256 | Section(header: Text("Development")) { 257 | Button(action: { 258 | if let url = URL(string: "https://client.flooo.club/about") { 259 | UIApplication.shared.open(url) 260 | } 261 | }) { 262 | Text("About flo") 263 | } 264 | 265 | Button(action: { 266 | if let url = URL(string: "https://github.com/kepelet/flo") { 267 | UIApplication.shared.open(url) 268 | } 269 | }) { 270 | Text("Source Code") 271 | } 272 | 273 | HStack { 274 | Text("App Version") 275 | Spacer() 276 | Text("\(self.getAppVersion()) (\(self.getBuildNumber()))") 277 | } 278 | } 279 | 280 | if authViewModel.isLoggedIn { 281 | Section(header: Text("Logged in as \(authViewModel.user?.username ?? "sigma")")) { 282 | Button(action: { 283 | authViewModel.logout() 284 | 285 | if UserDefaultsManager.enableDebug { 286 | floooViewModel.getUserDefaults() 287 | } 288 | }) { 289 | Text("Logout") 290 | .foregroundColor(.red) 291 | } 292 | } 293 | } 294 | 295 | if UserDefaultsManager.enableDebug { 296 | Section(header: Text("Troubleshoot")) { 297 | List { 298 | ForEach(floooViewModel.userDefaultsItems.keys.sorted(), id: \.self) { key in 299 | VStack(alignment: .leading, spacing: 8) { 300 | Text("UserDefaults.\(key)") 301 | .font(.headline) 302 | .foregroundColor(.primary) 303 | 304 | Text(String(describing: floooViewModel.userDefaultsItems[key])) 305 | .font(.subheadline) 306 | .foregroundColor(.secondary) 307 | } 308 | .padding(.vertical, 4) 309 | } 310 | 311 | ForEach(floooViewModel.keychainItems.keys.sorted(), id: \.self) { key in 312 | VStack(alignment: .leading, spacing: 8) { 313 | Text("Keychain.\(key)") 314 | .font(.headline) 315 | .foregroundColor(.primary) 316 | 317 | Text(String(describing: floooViewModel.keychainItems[key])) 318 | .font(.subheadline) 319 | .foregroundColor(.secondary) 320 | } 321 | .padding(.vertical, 4) 322 | } 323 | } 324 | 325 | Button(action: { 326 | floooViewModel.getUserDefaults() 327 | }) { 328 | Text("Refetch UserDefaults & Keychains") 329 | } 330 | 331 | Button(action: { 332 | authViewModel.logout() 333 | floooViewModel.getUserDefaults() 334 | }) { 335 | Text("Force Logout").foregroundColor(.red) 336 | } 337 | } 338 | } 339 | 340 | if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { 341 | Color.clear.frame(height: 50).listRowBackground(Color.clear) 342 | } 343 | }.navigationBarTitle("Preferences", displayMode: .inline) 344 | }.onAppear { 345 | floooViewModel.getLocalStorageInformation() 346 | 347 | if authViewModel.isLoggedIn { 348 | self.floooViewModel.checkScanStatus() 349 | self.floooViewModel.checkAccountLinkStatus() 350 | } 351 | 352 | if UserDefaultsManager.enableDebug { 353 | floooViewModel.getUserDefaults() 354 | } 355 | } 356 | } 357 | } 358 | 359 | struct PreferencesView_Previews: PreviewProvider { 360 | @State static var authViewModel: AuthViewModel = AuthViewModel() 361 | @State static var floooViewModel: FloooViewModel = FloooViewModel() 362 | 363 | static var previews: some View { 364 | PreferencesView(authViewModel: authViewModel).environmentObject(floooViewModel) 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /flo/PlayerCustomSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomSlider.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 05/06/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PlayerCustomSlider: View { 11 | var isMediaLoading: Bool = false 12 | 13 | @Binding var isSeeking: Bool 14 | @Binding var value: Double 15 | 16 | @State private var tempValue: Double = 0.0 17 | 18 | var range: ClosedRange 19 | var onEnded: (Double) -> Void 20 | 21 | var body: some View { 22 | GeometryReader { geometry in 23 | ZStack(alignment: .leading) { 24 | Rectangle() 25 | .foregroundColor(Color.gray.opacity(0.8)) 26 | .frame(height: 5) 27 | .cornerRadius(5) 28 | 29 | Rectangle() 30 | .foregroundColor(Color.white) 31 | .frame( 32 | width: CGFloat( 33 | (self.value - self.range.lowerBound) / (self.range.upperBound - self.range.lowerBound) 34 | ) * geometry.size.width, height: 4 35 | ) 36 | .opacity(isMediaLoading ? 0 : 1) 37 | .cornerRadius(2) 38 | 39 | if self.isSeeking { 40 | Circle() 41 | .fill(Color.white) 42 | .frame(width: 12, height: 12) 43 | .opacity(0.8) 44 | .offset( 45 | x: CGFloat( 46 | (self.tempValue - self.range.lowerBound) 47 | / (self.range.upperBound - self.range.lowerBound)) * geometry.size.width - 6) 48 | } 49 | 50 | Circle() 51 | .fill(Color.white) 52 | .frame(width: 12, height: 12) 53 | .offset( 54 | x: CGFloat( 55 | (self.value - self.range.lowerBound) / (self.range.upperBound - self.range.lowerBound) 56 | ) * geometry.size.width - 6 57 | ) 58 | .opacity(isMediaLoading ? 0 : 1) 59 | .animation(.easeInOut(duration: 0.3), value: self.value) 60 | .gesture( 61 | DragGesture() 62 | .onChanged { gesture in 63 | self.isSeeking = true 64 | 65 | let newValue = 66 | self.range.lowerBound + Double(gesture.location.x / geometry.size.width) 67 | * (self.range.upperBound - self.range.lowerBound) 68 | 69 | self.tempValue = newValue 70 | }.onEnded { gesture in 71 | let newValue = 72 | self.range.lowerBound + Double(gesture.location.x / geometry.size.width) 73 | * (self.range.upperBound - self.range.lowerBound) 74 | 75 | onEnded(newValue) 76 | 77 | self.isSeeking = false 78 | } 79 | ) 80 | } 81 | } 82 | .frame(height: 20) 83 | } 84 | } 85 | 86 | struct CustomSliders_Previews: PreviewProvider { 87 | static var previews: some View { 88 | PreviewWrapper() 89 | } 90 | 91 | struct PreviewWrapper: View { 92 | @State private var value: Double = 0.30 93 | @State private var isSeeking: Bool = false 94 | 95 | var body: some View { 96 | ZStack { 97 | Color.accent 98 | HStack { 99 | PlayerCustomSlider(isSeeking: $isSeeking, value: $value, range: 0...1) { value in 100 | self.value = value 101 | } 102 | }.padding() 103 | }.ignoresSafeArea() 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /flo/PlayerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 01/06/24. 6 | // 7 | 8 | import NukeUI 9 | import SwiftUI 10 | 11 | struct PlayerView: View { 12 | @Binding var isExpanded: Bool 13 | 14 | @ObservedObject var viewModel: PlayerViewModel 15 | 16 | @State private var offset = CGSize.zero 17 | @State private var isDragging = false 18 | 19 | @State private var showQueue = false 20 | 21 | var body: some View { 22 | GeometryReader { 23 | let size = $0.size 24 | let imageSize: CGFloat = 300 25 | 26 | // FIXME: Refactor this? 27 | ZStack(alignment: .topLeading) { 28 | Color(.systemBackground) 29 | .ignoresSafeArea() 30 | .clipShape( 31 | RoundedRectangle(cornerRadius: 15, style: .continuous) 32 | ) 33 | VStack(alignment: .leading) { 34 | HStack { 35 | Text("Queue") 36 | .customFont(.title2) 37 | .fontWeight(.bold) 38 | Spacer() 39 | Button { 40 | self.showQueue = false 41 | } label: { 42 | Text("Close").customFont(.callout).fontWeight(.medium) 43 | } 44 | }.padding() 45 | VStack(alignment: .leading, spacing: 3) { 46 | Text("Playing Next").customFont(.headline) 47 | 48 | HStack(alignment: .bottom, spacing: 10) { 49 | if viewModel.queue.isEmpty { 50 | Text("").customFont(.subheadline) 51 | } else { 52 | Text("From \(viewModel.nowPlaying.albumName ?? "")").customFont(.subheadline) 53 | } 54 | 55 | Spacer() 56 | 57 | Button { 58 | viewModel.shuffleCurrentQueue() 59 | } label: { 60 | Image(systemName: "shuffle") 61 | .foregroundColor(Color.accentColor) 62 | .fontWeight(.bold) 63 | .padding(5) 64 | .background( 65 | viewModel.isShuffling ? Color.gray.opacity(0.2) : Color(.systemBackground) 66 | ) 67 | .cornerRadius(5) 68 | } 69 | 70 | Button { 71 | viewModel.setPlaybackMode() 72 | } label: { 73 | Image(systemName: "repeat") 74 | .foregroundColor(Color.accentColor) 75 | .fontWeight(.bold) 76 | .overlay( 77 | Group { 78 | Text("1") 79 | .font(.caption) 80 | .clipShape(Circle()) 81 | .offset(x: 10, y: -5) 82 | .fontWeight(.bold) 83 | }.opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) 84 | ) 85 | .padding(5) 86 | .background( 87 | viewModel.playbackMode == PlaybackMode.defaultPlayback 88 | ? Color(.systemBackground) : Color.gray.opacity(0.2) 89 | ) 90 | .cornerRadius(5) 91 | } 92 | } 93 | }.padding() 94 | 95 | ScrollView { 96 | VStack(alignment: .leading) { 97 | ForEach(viewModel.queue.indices, id: \.self) { idx in 98 | if viewModel.activeQueueIdx < idx { 99 | Text(viewModel.queue[idx].songName ?? "") 100 | .customFont(.body) 101 | .fontWeight(.medium) 102 | .padding(.bottom, 20) 103 | .frame(maxWidth: .infinity, alignment: .leading) 104 | .onTapGesture { 105 | viewModel.playFromQueue(idx: idx) 106 | } 107 | } 108 | } 109 | }.padding() 110 | }.padding(.bottom, 60) 111 | } 112 | } 113 | .foregroundColor(.primary) 114 | .zIndex(1) 115 | .offset(y: showQueue ? UIScreen.main.bounds.height - 666 : UIScreen.main.bounds.height) 116 | .frame(height: 666) 117 | .animation(.spring(duration: 0.2), value: showQueue) 118 | 119 | ZStack { 120 | VStack { 121 | 122 | Rectangle() 123 | .foregroundColor(Color.gray.opacity(0.8)) 124 | .frame(width: 50, height: 5) 125 | .cornerRadius(30) 126 | .padding(.top, 20) 127 | 128 | Spacer() 129 | 130 | if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { 131 | Image(uiImage: image) 132 | .resizable() 133 | .aspectRatio(contentMode: .fit) 134 | .frame(width: imageSize, height: imageSize) 135 | .clipShape( 136 | RoundedRectangle(cornerRadius: 15, style: .continuous) 137 | ) 138 | } else { 139 | LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in 140 | if let image = state.image { 141 | image 142 | .resizable() 143 | .aspectRatio(contentMode: .fit) 144 | .frame(width: imageSize, height: imageSize) 145 | .clipShape( 146 | RoundedRectangle(cornerRadius: 15, style: .continuous) 147 | ) 148 | } else { 149 | Color.gray.opacity(0.3) 150 | .frame(width: imageSize, height: imageSize) 151 | .clipShape( 152 | RoundedRectangle(cornerRadius: 15, style: .continuous) 153 | ) 154 | } 155 | } 156 | } 157 | 158 | Spacer() 159 | 160 | VStack(alignment: .center, spacing: 10) { 161 | Text(viewModel.nowPlaying.songName ?? "") 162 | .foregroundColor(.white) 163 | .customFont(.title1) 164 | .fontWeight(.bold) 165 | .multilineTextAlignment(.center) 166 | .lineLimit(3) 167 | 168 | Text(viewModel.nowPlaying.artistName ?? "") 169 | .foregroundColor(.white.opacity(0.8)) 170 | .customFont(.title3) 171 | .multilineTextAlignment(.center) 172 | .lineLimit(2) 173 | } 174 | 175 | Spacer() 176 | 177 | HStack(spacing: size.width * 0.15) { 178 | Button { 179 | viewModel.prevSong() 180 | } label: { 181 | Image(systemName: "backward.fill").font(.title) 182 | } 183 | 184 | Button { 185 | viewModel.isPlaying ? viewModel.pause() : viewModel.play() 186 | } label: { 187 | Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") 188 | .font(.system(size: 50)) 189 | } 190 | .foregroundColor(viewModel.isMediaLoading ? .gray : .white) 191 | .disabled(viewModel.isMediaLoading) 192 | 193 | Button { 194 | viewModel.nextSong() 195 | } label: { 196 | Image(systemName: "forward.fill").font(.title) 197 | } 198 | } 199 | 200 | Spacer() 201 | 202 | VStack { 203 | 204 | PlayerCustomSlider( 205 | isMediaLoading: viewModel.isMediaLoading, 206 | isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 207 | ) { newValue in 208 | viewModel.seek(to: newValue) 209 | } 210 | 211 | HStack { 212 | Text(viewModel.currentTimeString) 213 | .foregroundColor(.white) 214 | .customFont(.caption2) 215 | .frame(width: 60, alignment: .leading) 216 | 217 | Spacer() 218 | 219 | Text( 220 | viewModel.isPlayFromSource 221 | ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" 222 | : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)" 223 | ) 224 | .foregroundColor(.white) 225 | .customFont(.caption2) 226 | .fontWeight(.bold) 227 | .textCase(.uppercase) 228 | .frame(maxWidth: .infinity, alignment: .center) 229 | 230 | Spacer() 231 | 232 | Text(viewModel.totalTimeString) 233 | .foregroundColor(.white) 234 | .customFont(.caption2) 235 | .frame(width: 60, alignment: .trailing) 236 | } 237 | } 238 | 239 | Spacer() 240 | 241 | HStack { 242 | Button { 243 | 244 | } label: { 245 | Image(systemName: "quote.bubble") 246 | .font(.title2) 247 | .foregroundColor(.gray) 248 | }.disabled(true) 249 | 250 | Spacer() 251 | 252 | Button { 253 | 254 | } label: { 255 | Image(systemName: "airplayaudio") 256 | .font(.title2) 257 | .foregroundColor(.gray) 258 | }.disabled(true) 259 | 260 | Spacer() 261 | 262 | Button { 263 | self.showQueue.toggle() 264 | } label: { 265 | Image(systemName: "list.bullet") 266 | .font(.title2) 267 | .overlay( 268 | Group { 269 | Image(systemName: "repeat") 270 | .font(.caption) 271 | .overlay( 272 | Group { 273 | Text("1") 274 | .font(.system(size: 8)) 275 | } 276 | .offset(x: 7, y: -4) 277 | .opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) 278 | ) 279 | .opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 1) 280 | } 281 | .padding(5) 282 | .background( 283 | .black.opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 0.2) 284 | ) 285 | .clipShape(Circle()) 286 | .offset(x: 10, y: -10) 287 | ) 288 | } 289 | } 290 | } 291 | .padding(.horizontal, 30) 292 | } 293 | .frame(maxHeight: .infinity) 294 | .background { 295 | ZStack { 296 | if UserDefaultsManager.playerBackground == PlayerBackground.translucent { 297 | if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { 298 | Image(uiImage: image) 299 | .resizable() 300 | .frame(maxWidth: .infinity, maxHeight: .infinity) 301 | .blur(radius: 50, opaque: true) 302 | .edgesIgnoringSafeArea(.all) 303 | } else { 304 | LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in 305 | if let image = state.image { 306 | image 307 | .resizable() 308 | .frame(maxWidth: .infinity, maxHeight: .infinity) 309 | .blur(radius: 50, opaque: true) 310 | .edgesIgnoringSafeArea(.all) 311 | } 312 | } 313 | } 314 | 315 | Rectangle().fill(.thinMaterial).edgesIgnoringSafeArea(.all) 316 | } else { 317 | Rectangle().fill(Color("PlayerColor")).edgesIgnoringSafeArea(.all) 318 | } 319 | } 320 | .environment(\.colorScheme, .dark) 321 | .clipShape( 322 | RoundedRectangle(cornerRadius: 25, style: .continuous) 323 | ).edgesIgnoringSafeArea(.all) 324 | } 325 | .offset(y: offset.height) 326 | .gesture( 327 | DragGesture() 328 | .onChanged { gesture in 329 | if gesture.translation.height > 0 { 330 | offset = gesture.translation 331 | isDragging = true 332 | } 333 | } 334 | .onEnded { _ in 335 | if offset.height > size.height / 3 { 336 | isExpanded = false 337 | } 338 | offset = .zero 339 | isDragging = false 340 | } 341 | ) 342 | 343 | } 344 | .foregroundColor(.white) 345 | } 346 | } 347 | 348 | struct PlayerView_previews: PreviewProvider { 349 | @StateObject static var viewModel = PlayerViewModel() 350 | @State static var isExpanded: Bool = true 351 | 352 | static var previews: some View { 353 | PlayerView(isExpanded: $isExpanded, viewModel: viewModel) 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /flo/PlaylistDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistDetailView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 16/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PlaylistDetailView: View { 11 | @EnvironmentObject private var viewModel: AlbumViewModel 12 | @EnvironmentObject private var playerViewModel: PlayerViewModel 13 | @EnvironmentObject private var downloadViewModel: DownloadViewModel 14 | 15 | @State private var showDownloadSheet: Bool = false 16 | @State private var showDeleteAlbumAlert: Bool = false 17 | 18 | var body: some View { 19 | ScrollView { 20 | VStack { 21 | if let image = UIImage(named: "placeholder") { 22 | Image(uiImage: image) 23 | .resizable() 24 | .aspectRatio(contentMode: .fit) 25 | .frame(width: 300, height: 300) 26 | .clipShape( 27 | RoundedRectangle(cornerRadius: 10, style: .continuous) 28 | ) 29 | .shadow(radius: 5) 30 | .padding(.top, 10) 31 | } 32 | 33 | Text(viewModel.playlist.name) 34 | .customFont(.title) 35 | .fontWeight(.bold) 36 | .multilineTextAlignment(.center) 37 | .padding(.bottom, 5) 38 | 39 | Text(viewModel.playlist.comment) 40 | .customFont(.body) 41 | .multilineTextAlignment(.center) 42 | .padding(.bottom, 10) 43 | 44 | Text( 45 | "by \(viewModel.playlist.ownerName) (\(viewModel.playlist.isPublic ? "public" : "private"))" 46 | ) 47 | .customFont(.caption1) 48 | .multilineTextAlignment(.center) 49 | .padding(.bottom, 10) 50 | 51 | HStack(spacing: 20) { 52 | Button(action: { 53 | playerViewModel.playItem( 54 | item: viewModel.playlist, 55 | isFromLocal: false) 56 | 57 | }) { 58 | Text("Play") 59 | .foregroundColor(.white) 60 | .customFont(.headline) 61 | .padding(.vertical, 10) 62 | .padding(.horizontal, 30) 63 | .background(Color("PlayerColor")) 64 | .cornerRadius(5) 65 | }.disabled(viewModel.playlist.songs.isEmpty) 66 | 67 | Button(action: { 68 | playerViewModel.shuffleItem( 69 | item: viewModel.playlist, 70 | isFromLocal: false) 71 | }) { 72 | Text("Shuffle") 73 | .foregroundColor(.white) 74 | .customFont(.headline) 75 | .padding(.vertical, 10) 76 | .padding(.horizontal, 30) 77 | .background(Color("PlayerColor")) 78 | .cornerRadius(5) 79 | }.disabled(viewModel.playlist.songs.isEmpty) 80 | } 81 | .padding(.bottom, 20) 82 | 83 | ForEach(Array(viewModel.playlist.songs.enumerated()), id: \.element) { idx, song in 84 | VStack { 85 | HStack(alignment: .top) { 86 | Text(idx.description) 87 | .customFont(.caption1) 88 | .foregroundColor(.gray) 89 | .padding(.trailing, 5) 90 | 91 | VStack(alignment: .leading) { 92 | Text(song.title) 93 | .fontWeight(.medium) 94 | 95 | Text(song.artist).customFont(.caption1).offset(y: 5) 96 | 97 | Spacer() 98 | } 99 | 100 | Spacer() 101 | 102 | if !song.fileUrl.isEmpty { 103 | Image(systemName: "arrow.down.circle.fill") 104 | .font(.system(size: 14)) 105 | } 106 | 107 | Text(timeString(for: song.duration)).customFont(.caption1) 108 | } 109 | } 110 | .padding() 111 | .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) 112 | .listRowSeparator(.hidden) 113 | .contentShape(Rectangle()) 114 | .onTapGesture { 115 | playerViewModel.playBySong( 116 | idx: idx, item: viewModel.playlist, isFromLocal: false) 117 | } 118 | .contextMenu { 119 | VStack { 120 | if !song.fileUrl.isEmpty { 121 | Button(role: .destructive) { 122 | viewModel.removeDownloadSong( 123 | album: viewModel.playlist, songId: song.id, isFromPlaylist: true) 124 | viewModel.setActivePlaylist(playlist: viewModel.playlist) 125 | } label: { 126 | HStack { 127 | Text("Remove Download") 128 | Image(systemName: "arrow.down.circle") 129 | } 130 | } 131 | } else { 132 | Button { 133 | let playlist = Album.init(from: viewModel.playlist) 134 | 135 | viewModel.downloadPlaylist(viewModel.playlist, targetIdx: idx) 136 | downloadViewModel.addIndividualItem( 137 | album: playlist, song: viewModel.playlist.songs[idx], isFromPlaylist: true) 138 | } label: { 139 | HStack { 140 | Text("Download") 141 | Image(systemName: "arrow.down.circle") 142 | } 143 | } 144 | } 145 | } 146 | } 147 | } 148 | .listStyle(PlainListStyle()).customFont(.body) 149 | } 150 | .toolbar { 151 | DownloadButton( 152 | isDownloading: downloadViewModel.isDownloading(viewModel.playlist.name), 153 | isDownloaded: downloadViewModel.isDownloading(viewModel.playlist.name) 154 | ? downloadViewModel.isDownloaded(viewModel.playlist.name) : viewModel.isDownloaded, 155 | progress: downloadViewModel.getDownloadedTrackProgress(albumName: viewModel.playlist.name) 156 | / 100 157 | ) { 158 | if viewModel.isDownloaded { 159 | showDeleteAlbumAlert.toggle() 160 | } else { 161 | if downloadViewModel.isDownloading(viewModel.playlist.name) { 162 | downloadViewModel.cancelCurrentAlbumDownload(albumName: viewModel.playlist.name) 163 | } else { 164 | let playlist = Album.init(from: viewModel.playlist) 165 | 166 | viewModel.downloadPlaylist(viewModel.playlist) 167 | downloadViewModel.addItem(playlist, isFromPlaylist: true) 168 | } 169 | } 170 | } 171 | 172 | if downloadViewModel.hasDownloadQueue() { 173 | Button(action: { 174 | showDownloadSheet.toggle() 175 | }) { 176 | Label("", systemImage: "icloud.and.arrow.down") 177 | } 178 | } 179 | } 180 | .alert("'\(viewModel.playlist.name)' has been downloaded", isPresented: $showDeleteAlbumAlert) 181 | { 182 | let playlist = Album.init(from: viewModel.playlist) 183 | 184 | Button("Cancel", role: .cancel) { 185 | showDeleteAlbumAlert.toggle() 186 | } 187 | Button("Redownload Playlist") { 188 | viewModel.downloadPlaylist(viewModel.playlist) 189 | downloadViewModel.addItem(playlist, isFromPlaylist: true) 190 | } 191 | Button("Redownload Playlist (force)", role: .destructive) { 192 | viewModel.downloadPlaylist(viewModel.playlist) 193 | downloadViewModel.addItem(playlist, forceAll: true, isFromPlaylist: true) 194 | } 195 | Button("Remove Download", role: .destructive) { 196 | viewModel.removeDownloadedPlaylist(playlist: viewModel.playlist) 197 | downloadViewModel.clearCurrentAlbumDownload(albumName: viewModel.playlist.name) 198 | } 199 | } 200 | .sheet(isPresented: $showDownloadSheet) { 201 | DownloadQueueView().environmentObject(downloadViewModel) 202 | .onDisappear { 203 | viewModel.setActivePlaylist(playlist: viewModel.playlist) 204 | } 205 | } 206 | .onReceive(downloadViewModel.$downloadWatcher) { newValue in 207 | if newValue { 208 | viewModel.setActivePlaylist(playlist: viewModel.playlist) 209 | downloadViewModel.downloadWatcher = false // uh anti pattern 210 | } 211 | } 212 | .padding(.bottom, 100) 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /flo/PlaylistView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 15/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PlaylistView: View { 11 | @EnvironmentObject private var viewModel: AlbumViewModel 12 | @EnvironmentObject private var playerViewModel: PlayerViewModel 13 | @EnvironmentObject private var downloadViewModel: DownloadViewModel 14 | 15 | @State private var searchPlaylist = "" 16 | @State private var showDownloadSheet: Bool = false 17 | 18 | var filteredPlaylists: [Playlist] { 19 | if searchPlaylist.isEmpty { 20 | return viewModel.playlists 21 | } else { 22 | return viewModel.playlists.filter { playlist in 23 | playlist.name.localizedCaseInsensitiveContains(searchPlaylist) 24 | } 25 | } 26 | } 27 | 28 | var body: some View { 29 | NavigationStack { 30 | ScrollView { 31 | LazyVStack { 32 | ForEach(filteredPlaylists) { playlist in 33 | NavigationLink { 34 | PlaylistDetailView() 35 | .environmentObject(viewModel) 36 | .environmentObject(playerViewModel) 37 | .environmentObject(downloadViewModel) 38 | .onAppear { 39 | viewModel.setActivePlaylist(playlist: playlist) 40 | } 41 | } label: { 42 | VStack { 43 | HStack { 44 | VStack(alignment: .leading) { 45 | Text("\(playlist.name)\(playlist.isPublic ? "" : " 🔒")") 46 | .customFont(.headline) 47 | .multilineTextAlignment(.leading) 48 | 49 | Text(playlist.comment) 50 | .customFont(.caption1) 51 | .multilineTextAlignment(.leading) 52 | } 53 | 54 | Spacer() 55 | 56 | Image(systemName: "chevron.right") 57 | .foregroundColor(.gray) 58 | .font(.caption) 59 | } 60 | .padding(.horizontal) 61 | .padding(.vertical, 5) 62 | 63 | Divider() 64 | } 65 | } 66 | } 67 | }.padding(.bottom, 100) 68 | } 69 | .toolbar { 70 | if downloadViewModel.hasDownloadQueue() { 71 | Button(action: { 72 | showDownloadSheet.toggle() 73 | }) { 74 | Label("", systemImage: "icloud.and.arrow.down") 75 | } 76 | } 77 | } 78 | .sheet(isPresented: $showDownloadSheet) { 79 | DownloadQueueView().environmentObject(downloadViewModel) 80 | } 81 | .navigationTitle("Playlists") 82 | .searchable( 83 | text: $searchPlaylist, placement: .navigationBarDrawer(displayMode: .always), 84 | prompt: "Search") 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "94", 9 | "green" : "42", 10 | "red" : "43" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "204", 27 | "green" : "152", 28 | "red" : "153" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "flo.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/AppIcon.appiconset/flo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kepelet/flo/052fce89d0f66d9302ac0e1307ae0fb4524c81c5/flo/Resources/Assets.xcassets/AppIcon.appiconset/flo.png -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/Downloads.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "jumble-travel-destination-hand-pointing-to-a-globe.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/Downloads.imageset/jumble-travel-destination-hand-pointing-to-a-globe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kepelet/flo/052fce89d0f66d9302ac0e1307ae0fb4524c81c5/flo/Resources/Assets.xcassets/Downloads.imageset/jumble-travel-destination-hand-pointing-to-a-globe.png -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/Home.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "jumble-music-streaming-services-with-vintage-ambience-bust-sculpture-and-gramophone-1.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/Home.imageset/jumble-music-streaming-services-with-vintage-ambience-bust-sculpture-and-gramophone-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kepelet/flo/052fce89d0f66d9302ac0e1307ae0fb4524c81c5/flo/Resources/Assets.xcassets/Home.imageset/jumble-music-streaming-services-with-vintage-ambience-bust-sculpture-and-gramophone-1.png -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/PlayerColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "94", 9 | "green" : "42", 10 | "red" : "43" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "logo.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/logo.imageset/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kepelet/flo/052fce89d0f66d9302ac0e1307ae0fb4524c81c5/flo/Resources/Assets.xcassets/logo.imageset/logo.png -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/logo_alt.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "flo_color.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/logo_alt.imageset/flo_color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kepelet/flo/052fce89d0f66d9302ac0e1307ae0fb4524c81c5/flo/Resources/Assets.xcassets/logo_alt.imageset/flo_color.png -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "placeholder.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /flo/Resources/Assets.xcassets/placeholder.imageset/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kepelet/flo/052fce89d0f66d9302ac0e1307ae0fb4524c81c5/flo/Resources/Assets.xcassets/placeholder.imageset/placeholder.png -------------------------------------------------------------------------------- /flo/Resources/Fonts/PlusJakartaSans-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kepelet/flo/052fce89d0f66d9302ac0e1307ae0fb4524c81c5/flo/Resources/Fonts/PlusJakartaSans-VariableFont_wght.ttf -------------------------------------------------------------------------------- /flo/Resources/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /flo/Shared/Models/AccountLinkStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountLinkStatus.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 11/01/25. 6 | // 7 | 8 | struct AccountLinkStatus: Decodable { 9 | let listenBrainz: Bool 10 | let lastFM: Bool 11 | } 12 | -------------------------------------------------------------------------------- /flo/Shared/Models/Album.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Album.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 07/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AlbumInfo: Codable { 11 | struct SubsonicResponse: Codable { 12 | struct AlbumInfo: Codable { 13 | let notes: String? 14 | } 15 | 16 | let albumInfo: AlbumInfo 17 | } 18 | 19 | let subsonicResponse: SubsonicResponse 20 | 21 | enum CodingKeys: String, CodingKey { 22 | // FIXME: constants? 23 | case subsonicResponse = "subsonic-response" 24 | } 25 | } 26 | 27 | struct AlbumShare: Codable { 28 | var id: String 29 | } 30 | 31 | struct Album: Codable, Identifiable, Playable { 32 | var id: String = "" 33 | var name: String = "" 34 | var albumArtist: String = "" 35 | var artist: String = "" 36 | var albumCover: String = "" 37 | var info: String = "" 38 | var songs: [Song] = [] 39 | var genre: String = "" 40 | var minYear: Int = 0 41 | 42 | enum CodingKeys: String, CodingKey { 43 | case id 44 | case name 45 | case albumArtist 46 | case artist 47 | case genre 48 | case minYear 49 | case songs 50 | } 51 | 52 | init(from decoder: any Decoder) throws { 53 | let container = try decoder.container(keyedBy: CodingKeys.self) 54 | 55 | self.id = try container.decode(String.self, forKey: .id) 56 | self.name = try container.decode(String.self, forKey: .name) 57 | self.albumArtist = try container.decode(String.self, forKey: .albumArtist) 58 | 59 | // pre BFR compatibility 60 | // FIXME(@faultables): fix this in 2.x 61 | if let artist = try? container.decode(String.self, forKey: .artist) { 62 | self.artist = artist 63 | } else { 64 | self.artist = self.albumArtist 65 | } 66 | 67 | self.genre = try container.decode(String.self, forKey: .genre) 68 | self.minYear = try container.decode(Int.self, forKey: .minYear) 69 | self.songs = try container.decodeIfPresent([Song].self, forKey: .songs) ?? [] 70 | } 71 | 72 | init( 73 | id: String = "", name: String = "", albumArtist: String = "", artist: String = "", 74 | songs: [Song] = [], genre: String = "", 75 | minYear: Int = 0 76 | ) { 77 | self.id = id 78 | self.name = name 79 | self.albumArtist = albumArtist 80 | self.artist = artist 81 | self.songs = songs 82 | self.genre = genre 83 | self.minYear = minYear 84 | } 85 | 86 | init(from playlist: PlaylistEntity) { 87 | self.id = playlist.id ?? UUID().uuidString 88 | self.name = playlist.name ?? "Unknown Album" 89 | self.albumArtist = playlist.albumArtist ?? playlist.artistName ?? "Unknown Artist" 90 | self.artist = playlist.artistName ?? "Unknown Artist" 91 | self.genre = playlist.genre ?? "Unknown Genre" 92 | self.minYear = Int(playlist.minYear) 93 | self.albumCover = playlist.albumCover ?? "" 94 | } 95 | 96 | init(from playlist: Playlist) { 97 | self.id = playlist.id 98 | self.name = playlist.name 99 | self.albumArtist = "Various Artists" 100 | self.artist = "Various Artists" 101 | self.songs = playlist.songs 102 | self.genre = "\(playlist.comment) by \(playlist.ownerName)" 103 | self.minYear = 0 104 | self.albumCover = "" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /flo/Shared/Models/Artist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Artist.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 14/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Artist: Codable, Identifiable, Hashable { 11 | let id: String 12 | let name: String 13 | let fullText: String 14 | let biography: String 15 | 16 | init(from decoder: any Decoder) throws { 17 | let container = try decoder.container(keyedBy: CodingKeys.self) 18 | 19 | self.id = try container.decode(String.self, forKey: .id) 20 | self.name = try container.decode(String.self, forKey: .name) 21 | self.fullText = try container.decodeIfPresent(String.self, forKey: .fullText) ?? "" 22 | self.biography = try container.decodeIfPresent(String.self, forKey: .biography) ?? "" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /flo/Shared/Models/NowPlaying.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlaying.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 24/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct NowPlaying: Codable { 11 | var artistName: String = "Unknown Artist" 12 | var songName: String = "Untitled" 13 | var albumName: String = "Unknown Album" 14 | var albumCover: String = "" 15 | var streamUrl: String = "" 16 | var bitRate: Int = 0 17 | var suffix: String = "" 18 | } 19 | -------------------------------------------------------------------------------- /flo/Shared/Models/Playable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Playable.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 16/11/24. 6 | // 7 | 8 | protocol Playable { 9 | var id: String { get } 10 | var name: String { get } 11 | var songs: [Song] { get set } 12 | var artist: String { get } 13 | } 14 | -------------------------------------------------------------------------------- /flo/Shared/Models/Playlist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Playlist.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 15/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Playlist: Codable, Identifiable, Hashable, Playable { 11 | let id: String 12 | let name: String 13 | let comment: String 14 | let isPublic: Bool 15 | let ownerName: String 16 | let artist: String 17 | var songs: [Song] = [] 18 | 19 | enum CodingKeys: String, CodingKey { 20 | case id 21 | case name 22 | case comment 23 | case isPublic = "public" 24 | case ownerName 25 | case songs 26 | } 27 | 28 | init( 29 | id: String = "", name: String = "", comment: String = "", isPublic: Bool = false, 30 | ownerName: String = "", songs: [Song] = [] 31 | ) { 32 | self.id = id 33 | self.name = name 34 | self.comment = comment 35 | self.isPublic = isPublic 36 | self.ownerName = ownerName 37 | self.songs = songs 38 | self.artist = ownerName 39 | } 40 | 41 | init(from decoder: any Decoder) throws { 42 | let container = try decoder.container(keyedBy: CodingKeys.self) 43 | 44 | self.id = try container.decode(String.self, forKey: .id) 45 | self.name = try container.decode(String.self, forKey: .name) 46 | self.comment = try container.decode(String.self, forKey: .comment) 47 | self.isPublic = try container.decode(Bool.self, forKey: .isPublic) 48 | self.ownerName = try container.decode(String.self, forKey: .ownerName) 49 | self.songs = try container.decodeIfPresent([Song].self, forKey: .songs) ?? [] 50 | self.artist = try container.decode(String.self, forKey: .ownerName) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /flo/Shared/Models/ScanStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScanStatus.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 14/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ScanStatus: SubsonicResponseData { 11 | let scanning: Bool 12 | let count: Int 13 | let folderCount: Int 14 | let lastScan: String 15 | 16 | static var key: String { 17 | return "scanStatus" 18 | } 19 | } 20 | 21 | struct ScanStatusResponse: Codable { 22 | let subsonicResponse: SubsonicResponse 23 | 24 | private enum CodingKeys: String, CodingKey { 25 | case subsonicResponse = "subsonic-response" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /flo/Shared/Models/Song.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Song.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 09/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Song: Codable, Identifiable, Hashable { 11 | let id: String 12 | let title: String 13 | let artist: String 14 | let albumId: String 15 | let trackNumber: Int 16 | let discNumber: Int 17 | let bitRate: Int 18 | let sampleRate: Int 19 | let suffix: String 20 | let duration: Double 21 | 22 | var mediaFileId: String = "" 23 | var fileUrl: String = "" 24 | 25 | enum CodingKeys: CodingKey { 26 | case id 27 | case title 28 | case artist 29 | case albumId 30 | case trackNumber 31 | case discNumber 32 | case bitRate 33 | case sampleRate 34 | case suffix 35 | case duration 36 | case mediaFileId 37 | } 38 | 39 | init(from decoder: any Decoder) throws { 40 | let container = try decoder.container(keyedBy: CodingKeys.self) 41 | 42 | self.id = try container.decode(String.self, forKey: .id) 43 | self.title = try container.decode(String.self, forKey: .title) 44 | self.artist = try container.decode(String.self, forKey: .artist) 45 | self.albumId = try container.decode(String.self, forKey: .albumId) 46 | self.trackNumber = try container.decode(Int.self, forKey: .trackNumber) 47 | self.discNumber = try container.decode(Int.self, forKey: .discNumber) 48 | self.bitRate = try container.decode(Int.self, forKey: .bitRate) 49 | self.sampleRate = try container.decode(Int.self, forKey: .sampleRate) 50 | self.suffix = try container.decode(String.self, forKey: .suffix) 51 | self.duration = try container.decode(Double.self, forKey: .duration) 52 | self.mediaFileId = try container.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "" 53 | } 54 | 55 | init( 56 | id: String, title: String, albumId: String, artist: String, trackNumber: Int, discNumber: Int, 57 | bitRate: Int, 58 | sampleRate: Int, 59 | suffix: String, duration: Double, mediaFileId: String 60 | ) { 61 | self.id = id 62 | self.title = title 63 | self.artist = artist 64 | self.albumId = albumId 65 | self.trackNumber = Int(trackNumber) 66 | self.discNumber = Int(discNumber) 67 | self.bitRate = Int(bitRate) 68 | self.sampleRate = Int(sampleRate) 69 | self.suffix = suffix 70 | self.duration = duration 71 | self.mediaFileId = mediaFileId 72 | } 73 | 74 | init(from song: SongEntity) { 75 | self.id = song.id ?? "" 76 | self.title = song.title ?? "N/A" 77 | self.artist = song.artistName ?? "N/A" 78 | self.albumId = song.albumId ?? "" 79 | self.trackNumber = Int(song.trackNumber) 80 | self.discNumber = Int(song.discNumber) 81 | self.bitRate = Int(song.bitRate) 82 | self.sampleRate = Int(song.sampleRate) 83 | self.suffix = song.suffix ?? "N/A" 84 | self.duration = song.duration 85 | self.fileUrl = song.fileURL ?? "" 86 | self.mediaFileId = song.mediaFileId ?? "" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /flo/Shared/Models/Stats.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stats.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 25/11/24. 6 | // 7 | 8 | struct Stats { 9 | let topArtist: String 10 | let topAlbum: String 11 | let topAlbumArtist: String 12 | } 13 | -------------------------------------------------------------------------------- /flo/Shared/Models/Subsonic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Subsonic.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 11/01/25. 6 | // 7 | 8 | protocol SubsonicResponseData: Codable { 9 | static var key: String { get } 10 | } 11 | 12 | struct BasicResponse: SubsonicResponseData { 13 | static var key = "" 14 | } 15 | 16 | struct SubsonicResponse: Codable { 17 | let status: String 18 | let version: String 19 | let type: String 20 | let serverVersion: String 21 | let openSubsonic: Bool 22 | let data: T? 23 | 24 | enum CodingKeys: String, CodingKey { 25 | case status 26 | case version 27 | case type 28 | case serverVersion 29 | case openSubsonic 30 | } 31 | 32 | init(from decoder: Decoder) throws { 33 | let container = try decoder.container(keyedBy: CodingKeys.self) 34 | 35 | status = try container.decode(String.self, forKey: .status) 36 | version = try container.decode(String.self, forKey: .version) 37 | type = try container.decode(String.self, forKey: .type) 38 | serverVersion = try container.decode(String.self, forKey: .serverVersion) 39 | openSubsonic = try container.decode(Bool.self, forKey: .openSubsonic) 40 | 41 | let rootContainer = try decoder.container(keyedBy: ExtraField.self) 42 | data = try rootContainer.decodeIfPresent(T.self, forKey: ExtraField(stringValue: T.key)) 43 | } 44 | 45 | func encode(to encoder: Encoder) throws { 46 | var container = encoder.container(keyedBy: CodingKeys.self) 47 | 48 | try container.encode(status, forKey: .status) 49 | try container.encode(version, forKey: .version) 50 | try container.encode(type, forKey: .type) 51 | try container.encode(serverVersion, forKey: .serverVersion) 52 | try container.encode(openSubsonic, forKey: .openSubsonic) 53 | 54 | if let data = data, let dynamicKey = CodingKeys(rawValue: T.key) { 55 | try container.encode(data, forKey: dynamicKey) 56 | } 57 | } 58 | } 59 | 60 | extension SubsonicResponse { 61 | struct ExtraField: CodingKey { 62 | let stringValue: String 63 | let intValue: Int? 64 | 65 | init(stringValue: String) { 66 | self.stringValue = stringValue 67 | self.intValue = nil 68 | } 69 | 70 | init?(intValue: Int) { 71 | self.stringValue = "\(intValue)" 72 | self.intValue = intValue 73 | } 74 | } 75 | } 76 | 77 | typealias BasicSubsonicResponse = SubsonicResponse 78 | -------------------------------------------------------------------------------- /flo/Shared/Models/UserAuth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAuth.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 01/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct UserAuth: Codable { 11 | let id: String 12 | let name: String 13 | let username: String 14 | let isAdmin: Bool 15 | let lastFMApiKey: String 16 | let subsonicSalt: String 17 | let subsonicToken: String 18 | let token: String 19 | 20 | init( 21 | id: String, username: String, name: String, isAdmin: Bool, lastFMApiKey: String = "", 22 | subsonicSalt: String = "", subsonicToken: String = "", token: String = "" 23 | ) { 24 | self.id = id 25 | self.name = name 26 | self.username = username 27 | self.isAdmin = isAdmin 28 | self.lastFMApiKey = lastFMApiKey 29 | self.subsonicSalt = subsonicSalt 30 | self.subsonicToken = subsonicToken 31 | self.token = token 32 | } 33 | 34 | init(from decoder: any Decoder) throws { 35 | let container = try decoder.container(keyedBy: CodingKeys.self) 36 | 37 | self.id = try container.decode(String.self, forKey: .id) 38 | self.name = try container.decode(String.self, forKey: .name) 39 | self.username = try container.decode(String.self, forKey: .username) 40 | self.isAdmin = try container.decode(Bool.self, forKey: .isAdmin) 41 | self.lastFMApiKey = try container.decodeIfPresent(String.self, forKey: .lastFMApiKey) ?? "" 42 | self.subsonicSalt = try container.decode(String.self, forKey: .subsonicSalt) 43 | self.subsonicToken = try container.decode(String.self, forKey: .subsonicToken) 44 | self.token = try container.decode(String.self, forKey: .token) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /flo/Shared/Services/APIManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIManager.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 08/06/24. 6 | // 7 | 8 | import Alamofire 9 | import Foundation 10 | import Pulse 11 | 12 | // TODO: refactor this 13 | struct NetworkLoggerEventMonitor: EventMonitor { 14 | var logger: NetworkLogger = .shared 15 | 16 | func request(_ request: Request, didCreateTask task: URLSessionTask) { 17 | logger.logTaskCreated(task) 18 | } 19 | 20 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { 21 | logger.logDataTask(dataTask, didReceive: data) 22 | } 23 | 24 | func urlSession( 25 | _ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics 26 | ) { 27 | logger.logTask(task, didFinishCollecting: metrics) 28 | } 29 | 30 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 31 | logger.logTask(task, didCompleteWithError: error) 32 | } 33 | } 34 | 35 | class APIManager { 36 | static let shared = APIManager() 37 | 38 | private(set) var session: Alamofire.Session 39 | 40 | private init() { 41 | session = Self.createSession() 42 | } 43 | 44 | private static func createSession() -> Session { 45 | LoggerStore.shared.removeAll() 46 | 47 | let configuration = URLSessionConfiguration.default 48 | configuration.timeoutIntervalForRequest = 30 49 | 50 | let retrier = RetryPolicy(retryLimit: 3) 51 | let monitor = NetworkLoggerEventMonitor() 52 | 53 | return Alamofire.Session( 54 | configuration: configuration, interceptor: retrier, 55 | eventMonitors: UserDefaultsManager.enableDebug ? [monitor] : []) 56 | } 57 | 58 | func reconfigureSession() { 59 | session = Self.createSession() 60 | } 61 | 62 | func NDEndpointRequest( 63 | endpoint: String, method: HTTPMethod = .get, parameters: Parameters?, 64 | encoding: ParameterEncoding = URLEncoding.queryString, 65 | completion: @escaping (DataResponse) -> Void 66 | ) { 67 | let token: String = AuthService.shared.getCreds(key: "NDToken") 68 | 69 | let url = "\(UserDefaultsManager.serverBaseURL)\(endpoint)" 70 | let headers: HTTPHeaders = [API.NDAuthHeader: "Bearer \(token)"] 71 | 72 | session.request( 73 | url, method: method, parameters: parameters, encoding: encoding, headers: headers 74 | ) 75 | .validate(statusCode: 200..<500) 76 | .responseDecodable(of: T.self) { response in 77 | completion(response) 78 | } 79 | } 80 | 81 | func SubsonicEndpointRequest( 82 | endpoint: String, method: HTTPMethod = .get, parameters: Parameters?, 83 | encoding: ParameterEncoding = URLEncoding.queryString, 84 | completion: @escaping (DataResponse) -> Void 85 | ) { 86 | 87 | // FIXME: refactor getCreds(key: "subsonicToken") 88 | let url = 89 | "\(UserDefaultsManager.serverBaseURL)\(endpoint)\(AuthService.shared.getCreds(key: "subsonicToken"))" 90 | 91 | session.request( 92 | url, method: method, parameters: parameters, encoding: encoding 93 | ) 94 | .validate(statusCode: 200..<500) 95 | .responseDecodable(of: T.self) { response in 96 | completion(response) 97 | } 98 | } 99 | 100 | // FIXME: refactor later 101 | func SubsonicEndpointDownloadNew( 102 | endpoint: String, method: HTTPMethod = .get, parameters: Parameters?, 103 | encoding: ParameterEncoding = URLEncoding.queryString, 104 | progressUpdate: ((Double) -> Void)?, 105 | completion: @escaping (Result) -> Void 106 | ) -> DownloadRequest { 107 | 108 | // FIXME: refactor getCreds(key: "subsonicToken") 109 | let url = 110 | "\(UserDefaultsManager.serverBaseURL)\(endpoint)\(AuthService.shared.getCreds(key: "subsonicToken"))" 111 | 112 | return session.download( 113 | url, method: method, parameters: parameters, encoding: encoding, 114 | requestModifier: { $0.timeoutInterval = 60 } 115 | ) 116 | .downloadProgress { progressValue in 117 | progressUpdate?(progressValue.fractionCompleted * 100) 118 | } 119 | .validate() 120 | .responseURL { response in 121 | switch response.result { 122 | case .success(let fileURL): 123 | completion(.success(fileURL)) 124 | case .failure(let error): 125 | completion(.failure(error)) 126 | } 127 | } 128 | } 129 | 130 | func SubsonicEndpointDownload( 131 | endpoint: String, method: HTTPMethod = .get, parameters: Parameters?, 132 | encoding: ParameterEncoding = URLEncoding.queryString, 133 | completion: @escaping (Result) -> Void 134 | ) { 135 | 136 | // FIXME: refactor getCreds(key: "subsonicToken") 137 | let url = 138 | "\(UserDefaultsManager.serverBaseURL)\(endpoint)\(AuthService.shared.getCreds(key: "subsonicToken"))" 139 | 140 | session.download( 141 | url, method: method, parameters: parameters, encoding: encoding, 142 | requestModifier: { $0.timeoutInterval = 60 } 143 | ) 144 | .validate() 145 | .responseURL { response in 146 | switch response.result { 147 | case .success(let fileURL): 148 | completion(.success(fileURL)) 149 | case .failure(let error): 150 | completion(.failure(error)) 151 | } 152 | } 153 | } 154 | } 155 | 156 | extension APIManager { 157 | func login( 158 | endpoint: String, parameters: Parameters?, 159 | completion: @escaping (DataResponse) -> Void 160 | ) { 161 | session.request( 162 | endpoint, 163 | method: .post, 164 | parameters: parameters, 165 | encoding: JSONEncoding.default, 166 | requestModifier: { request in 167 | request.timeoutInterval = 10 168 | } 169 | ) 170 | .validate(statusCode: 200..<500) 171 | .responseDecodable(of: T.self) { response in 172 | completion(response) 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /flo/Shared/Services/AuthService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthService.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 08/06/24. 6 | // 7 | 8 | import Alamofire 9 | import Foundation 10 | import Pulse 11 | 12 | class AuthService { 13 | static let shared = AuthService() 14 | 15 | private var NDToken: String? 16 | private var subsonicParams: String? 17 | 18 | private init() { 19 | if let jsonString = try? KeychainManager.getAuthCreds(), 20 | let jsonData = jsonString.data(using: .utf8) 21 | { 22 | if let data: UserAuth = try? JSONDecoder().decode(UserAuth.self, from: jsonData) { 23 | NDToken = data.token 24 | subsonicParams = 25 | "?u=\(data.username)&t=\(data.subsonicToken)&s=\(data.subsonicSalt)&v=\(AppMeta.subsonicApiVersion)&c=\(AppMeta.name)&f=json" 26 | } 27 | } 28 | } 29 | 30 | func getCreds(key: String = "") -> String { 31 | if key == "NDToken" { 32 | if let token = NDToken { 33 | return token 34 | } 35 | } 36 | 37 | if key == "subsonicToken" { 38 | if let token = subsonicParams { 39 | return token 40 | } 41 | } 42 | 43 | return "" 44 | } 45 | 46 | func setCreds(_ data: UserAuth) { 47 | let subsonicParams = 48 | "?u=\(data.username)&t=\(data.subsonicToken)&s=\(data.subsonicSalt)&v=\(AppMeta.subsonicApiVersion)&c=\(AppMeta.name)&f=json" 49 | 50 | self.NDToken = data.token 51 | self.subsonicParams = subsonicParams 52 | } 53 | 54 | func login( 55 | serverUrl: String, username: String, password: String, 56 | completion: @escaping (AuthResult) -> Void 57 | ) { 58 | let serverBaseUrl = UserDefaultsManager.serverBaseURL 59 | let isServerBaseURLExist = serverBaseUrl != "" 60 | 61 | let url = "\(isServerBaseURLExist ? serverBaseUrl : serverUrl)\(API.NDEndpoint.login)" 62 | 63 | let parameters: [String: Any] = ["username": username, "password": password] 64 | 65 | APIManager.shared.login(endpoint: url, parameters: parameters) { 66 | (response: DataResponse) in 67 | switch response.result { 68 | case .success(let authResponse): 69 | completion(.success(authResponse)) 70 | case .failure(let afError): 71 | ErrorHandler.handleFailure(afError, response: response) { result in 72 | // FIXME: temporary solution 73 | let debugResponse = response.debugDescription.replacingOccurrences( 74 | of: #"(?s)"password"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)""#, 75 | with: #""password":"[REDACTED]""#, 76 | options: .regularExpression 77 | ) 78 | 79 | // FIXME: move to general Logger 80 | LoggerStore.shared.storeMessage( 81 | label: "AuthService.login", 82 | level: .debug, 83 | message: debugResponse 84 | ) 85 | completion(AuthResult(result: result)) 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /flo/Shared/Services/CoreDataManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataManager.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 29/06/24. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | 11 | class CoreDataManager: ObservableObject { 12 | static let shared = CoreDataManager() 13 | 14 | private init() {} 15 | 16 | lazy var persistentContainer: NSPersistentContainer = { 17 | let container = NSPersistentContainer(name: "flo") //FIXME: constants? 18 | 19 | container.loadPersistentStores { _, error in 20 | if let error { 21 | fatalError("Failed to load persistent stores: \(error.localizedDescription)") 22 | } 23 | } 24 | 25 | return container 26 | }() 27 | 28 | var viewContext: NSManagedObjectContext { 29 | return self.persistentContainer.viewContext 30 | } 31 | 32 | func getRecordsByEntity( 33 | entity: T.Type, sortDescriptors: [NSSortDescriptor]? = nil 34 | ) -> [T] { 35 | let request: NSFetchRequest = NSFetchRequest(entityName: String(describing: T.self)) 36 | 37 | request.sortDescriptors = sortDescriptors 38 | 39 | do { 40 | return try self.viewContext.fetch(request) 41 | } catch { 42 | return [] 43 | } 44 | } 45 | 46 | func getRecordsByEntityBatched( 47 | entity: T.Type, sortDescriptors: [NSSortDescriptor]? = nil, 48 | batchSize: Int = 100 49 | ) async -> [T] { 50 | let request: NSFetchRequest = NSFetchRequest(entityName: String(describing: T.self)) 51 | 52 | request.sortDescriptors = sortDescriptors 53 | request.fetchBatchSize = batchSize 54 | 55 | return await withCheckedContinuation { continuation in 56 | viewContext.perform { 57 | do { 58 | let results = try self.viewContext.fetch(request) 59 | 60 | continuation.resume(returning: results) 61 | } catch { 62 | print("Fetch error: \(error)") 63 | 64 | continuation.resume(returning: []) 65 | } 66 | } 67 | } 68 | } 69 | 70 | func getRecordByKey( 71 | entity: T.Type, 72 | key: KeyPath, 73 | value: V?, 74 | limit: Int = 0, 75 | sortDescriptors: [NSSortDescriptor]? = nil 76 | ) -> [T] { 77 | let request: NSFetchRequest = NSFetchRequest(entityName: String(describing: T.self)) 78 | let keyPathString = key._kvcKeyPathString! 79 | 80 | let predicate: NSPredicate 81 | 82 | if let identifier = value as? CVarArg { 83 | predicate = NSPredicate(format: "%K == %@", keyPathString, identifier) 84 | } else { 85 | predicate = NSPredicate(format: "%K == NULL", keyPathString) 86 | } 87 | 88 | request.predicate = predicate 89 | request.fetchLimit = limit > 0 ? limit : 0 90 | request.sortDescriptors = sortDescriptors 91 | 92 | do { 93 | return try self.viewContext.fetch(request) 94 | } catch let error { 95 | print(error.localizedDescription) 96 | 97 | return [] 98 | } 99 | } 100 | 101 | func countRecords(entity: T.Type) -> Int { 102 | let request: NSFetchRequest = NSFetchRequest(entityName: String(describing: T.self)) 103 | 104 | request.resultType = .countResultType 105 | 106 | do { 107 | let count = try self.viewContext.count(for: request) 108 | return count 109 | } catch { 110 | print(error.localizedDescription) 111 | 112 | return 0 113 | } 114 | } 115 | 116 | func saveRecord() { 117 | do { 118 | try self.viewContext.save() 119 | } catch { 120 | self.viewContext.rollback() 121 | 122 | print(error.localizedDescription) 123 | } 124 | } 125 | 126 | func deleteRecords(entity: T.Type) { 127 | let request: NSFetchRequest = NSFetchRequest( 128 | entityName: String(describing: T.self)) 129 | let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) 130 | 131 | do { 132 | try self.viewContext.execute(deleteRequest) 133 | try self.viewContext.save() 134 | } catch { 135 | print("Failed to delete records: \(error.localizedDescription)") 136 | } 137 | } 138 | 139 | func deleteRecordByKey( 140 | entity: T.Type, 141 | key: KeyPath, 142 | value: V? 143 | ) { 144 | let request: NSFetchRequest = NSFetchRequest(entityName: String(describing: T.self)) 145 | let keyPathString = key._kvcKeyPathString! 146 | 147 | let predicate: NSPredicate 148 | 149 | if let identifier = value as? CVarArg { 150 | predicate = NSPredicate(format: "%K == %@", keyPathString, identifier) 151 | } else { 152 | predicate = NSPredicate(format: "%K == NULL", keyPathString) 153 | } 154 | 155 | request.predicate = predicate 156 | 157 | let deleteRequest = NSBatchDeleteRequest( 158 | fetchRequest: request as! NSFetchRequest) 159 | 160 | do { 161 | try self.viewContext.execute(deleteRequest) 162 | try self.viewContext.save() 163 | } catch { 164 | print("Failed to delete records: \(error.localizedDescription)") 165 | } 166 | } 167 | 168 | func clearEverything() { 169 | let entities = ["QueueEntity", "SongEntity", "PlaylistEntity"] 170 | 171 | for entity in entities { 172 | let fetchRequest = NSFetchRequest(entityName: entity) 173 | let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) 174 | 175 | do { 176 | try self.viewContext.execute(batchDeleteRequest) 177 | 178 | print("Successfully deleted all records for \(entity).") 179 | } catch { 180 | print("Failed to delete records for \(entity): \(error.localizedDescription)") 181 | } 182 | } 183 | 184 | do { 185 | try self.viewContext.save() 186 | } catch { 187 | print("Failed to save context: \(error.localizedDescription)") 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /flo/Shared/Services/FloooService.swift: -------------------------------------------------------------------------------- 1 | import Alamofire 2 | import Foundation 3 | 4 | // 5 | // FloooService.swift 6 | // flo 7 | // 8 | // Created by rizaldy on 22/11/24. 9 | // 10 | 11 | class FloooService { 12 | static let shared: FloooService = FloooService() 13 | 14 | func getListeningHistory() async -> [HistoryEntity] { 15 | return await CoreDataManager.shared.getRecordsByEntityBatched(entity: HistoryEntity.self) 16 | } 17 | 18 | func saveListeningHistory(payload: QueueEntity) { 19 | let currentSession = HistoryEntity(context: CoreDataManager.shared.viewContext) 20 | 21 | currentSession.albumId = payload.albumId 22 | currentSession.artistName = payload.artistName 23 | currentSession.trackName = payload.songName 24 | currentSession.albumName = payload.albumName 25 | currentSession.artistName = payload.artistName 26 | currentSession.timestamp = Date() 27 | 28 | CoreDataManager.shared.saveRecord() 29 | } 30 | 31 | func clearListeningHistory() { 32 | CoreDataManager.shared.deleteRecords(entity: HistoryEntity.self) 33 | } 34 | 35 | func generateStats(_ listeningActivity: [HistoryEntity]) async -> Stats? { 36 | return await Task.detached(priority: .userInitiated) { 37 | let albumCounts = Dictionary(grouping: listeningActivity) { scrobble in 38 | let album = scrobble.value(forKey: "albumName") as? String ?? "" 39 | let artist = scrobble.value(forKey: "artistName") as? String ?? "" 40 | 41 | return "\(album)|\(artist)" 42 | } 43 | .mapValues { $0.count } 44 | 45 | let topAlbum = albumCounts.max(by: { $0.value < $1.value }) 46 | 47 | let artistCounts = Dictionary(grouping: listeningActivity) { scrobble in 48 | scrobble.value(forKey: "artistName") as? String ?? "" 49 | } 50 | .mapValues { $0.count } 51 | 52 | let topArtist = artistCounts.max(by: { $0.value < $1.value }) 53 | 54 | let components = topAlbum?.key.split(separator: "|") 55 | let album = String(components?[0] ?? "N/A") 56 | let artist = String(components?[1] ?? "N/A") 57 | 58 | let stats = Stats(topArtist: topArtist?.key ?? "N/A", topAlbum: album, topAlbumArtist: artist) 59 | 60 | return stats 61 | }.value 62 | } 63 | 64 | func getAccountLinkStatuses(completion: @escaping (Result) -> Void) { 65 | let group = DispatchGroup() 66 | 67 | var listenBrainzStatus: Bool? 68 | var lastFMStatus: Bool? 69 | var error: Error? 70 | 71 | group.enter() 72 | 73 | checkListenBrainzAccountStatus { result in 74 | switch result { 75 | case .success(let status): 76 | listenBrainzStatus = status 77 | case .failure(let err): 78 | error = err 79 | } 80 | 81 | group.leave() 82 | } 83 | 84 | group.enter() 85 | 86 | checkLastFMAccountStatus { result in 87 | switch result { 88 | case .success(let status): 89 | lastFMStatus = status 90 | case .failure(let err): 91 | error = err 92 | } 93 | 94 | group.leave() 95 | } 96 | 97 | group.notify(queue: .main) { 98 | if let error = error { 99 | completion(.failure(error)) 100 | 101 | return 102 | } 103 | 104 | guard let listenBrainz = listenBrainzStatus, let lastFm = lastFMStatus else { 105 | completion(.failure(NSError(domain: "", code: -1))) 106 | 107 | return 108 | } 109 | 110 | completion(.success(AccountLinkStatus(listenBrainz: listenBrainz, lastFM: lastFm))) 111 | } 112 | } 113 | 114 | func checkListenBrainzAccountStatus(completion: @escaping (Result) -> Void) { 115 | APIManager.shared.NDEndpointRequest(endpoint: API.NDEndpoint.listenBrainzLink, parameters: [:]) 116 | { 117 | (response: DataResponse) in 118 | switch response.result { 119 | case .success(let status): 120 | completion(.success(status.status)) 121 | case .failure(let error): 122 | completion(.failure(error)) 123 | } 124 | } 125 | } 126 | 127 | func checkLastFMAccountStatus(completion: @escaping (Result) -> Void) { 128 | APIManager.shared.NDEndpointRequest(endpoint: API.NDEndpoint.lastFMLink, parameters: [:]) { 129 | (response: DataResponse) in 130 | switch response.result { 131 | case .success(let status): 132 | completion(.success(status.status)) 133 | case .failure(let error): 134 | completion(.failure(error)) 135 | } 136 | } 137 | } 138 | 139 | func scrobbleToBuiltinEndpoint( 140 | submission: Bool, songId: String, 141 | completion: @escaping (Result) -> Void 142 | ) { 143 | var params: [String: Any] = ["submission": String(submission), "id": songId] 144 | 145 | if submission { 146 | params["time"] = Int(Date().timeIntervalSince1970 * 1000) 147 | } 148 | 149 | APIManager.shared.SubsonicEndpointRequest( 150 | endpoint: API.SubsonicEndpoint.scrobble, parameters: params 151 | ) { 152 | (response: DataResponse) in 153 | switch response.result { 154 | case .success(let response): 155 | completion(.success(response)) 156 | case .failure(let error): 157 | completion(.failure(error)) 158 | } 159 | } 160 | } 161 | } 162 | 163 | extension FloooService { 164 | struct AccountStatusResponse: Decodable { 165 | let status: Bool 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /flo/Shared/Services/KeychainManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainManager.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 09/06/24. 6 | // 7 | 8 | import Foundation 9 | import KeychainAccess 10 | 11 | class KeychainManager { 12 | private static let keychain = Keychain(service: KeychainKeys.service) 13 | 14 | static func getAuthCredsAndPasswords() -> [String: Any] { 15 | var keychainData: [String: Any] = [:] 16 | 17 | do { 18 | if let creds = try getAuthCreds() { 19 | keychainData["authCreds"] = creds 20 | } else { 21 | keychainData["authCreds"] = "nil" 22 | } 23 | } catch { 24 | keychainData["authCreds"] = "Error: \(error.localizedDescription)" 25 | } 26 | 27 | do { 28 | if let password = try getAuthPassword() { 29 | keychainData["authPassword"] = password 30 | } else { 31 | keychainData["authPassword"] = "nil" 32 | } 33 | } catch { 34 | keychainData["authPassword"] = "Error: \(error.localizedDescription)" 35 | } 36 | 37 | return keychainData 38 | } 39 | 40 | static func getAuthCreds() throws -> String? { 41 | return try keychain.get(KeychainKeys.dataKey) 42 | } 43 | 44 | static func getAuthPassword() throws -> String? { 45 | return try keychain.get(KeychainKeys.serverPassword) 46 | } 47 | 48 | static func removeAuthCreds() throws { 49 | try keychain.remove(KeychainKeys.dataKey) 50 | } 51 | 52 | static func removeAuthPassword() throws { 53 | try keychain.remove(KeychainKeys.serverPassword) 54 | } 55 | 56 | static func setAuthCreds(newValue: String) throws { 57 | try keychain.set(newValue, key: KeychainKeys.dataKey) 58 | } 59 | 60 | static func setAuthPassword(newValue: String) throws { 61 | try keychain.set(newValue, key: KeychainKeys.serverPassword) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /flo/Shared/Services/LocalFileManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalFileManager.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 01/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class LocalFileManager { 11 | static let shared = LocalFileManager() 12 | 13 | let fileManager: FileManager 14 | let documentsDirectory: URL? 15 | 16 | private init() { 17 | self.fileManager = FileManager.default 18 | self.documentsDirectory = 19 | self.fileManager.urls(for: .documentDirectory, in: .userDomainMask).first 20 | } 21 | 22 | func fileURL(for fileName: String) -> URL? { 23 | guard let documentsDirectory = self.documentsDirectory else { 24 | return nil 25 | } 26 | 27 | return documentsDirectory.appendingPathComponent(fileName) 28 | } 29 | 30 | func _calculateDirectorySize() throws -> String { 31 | var totalSize: Int64 = 0 32 | 33 | guard let folderURL = self.fileURL(for: "Media") else { 34 | return "0 MB" 35 | } 36 | 37 | do { 38 | guard 39 | let enumerator = fileManager.enumerator( 40 | at: folderURL, 41 | includingPropertiesForKeys: [.totalFileAllocatedSizeKey], 42 | options: [.skipsHiddenFiles]) 43 | else { 44 | return "0 MB" 45 | } 46 | 47 | for case let fileURL as URL in enumerator { 48 | do { 49 | let resourceValues = try fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) 50 | 51 | if let size = resourceValues.totalFileAllocatedSize { 52 | totalSize += Int64(size) 53 | } 54 | } catch { 55 | print("Error calculating size for \(fileURL.path): \(error)") 56 | } 57 | } 58 | 59 | let formattedSize = bytesToMBOrGB(totalSize) 60 | 61 | return formattedSize 62 | } 63 | } 64 | 65 | func calculateDirectorySize() async throws -> String { 66 | let calculateDirSize = self._calculateDirectorySize 67 | 68 | return try await withCheckedThrowingContinuation { continuation in 69 | DispatchQueue.global(qos: .userInitiated).async { 70 | do { 71 | let result = try calculateDirSize() 72 | 73 | continuation.resume(returning: result) 74 | } catch { 75 | continuation.resume(throwing: error) 76 | } 77 | } 78 | } 79 | } 80 | 81 | func fileExists(fileName: String) -> Bool { 82 | guard let fileURL = self.fileURL(for: fileName) else { 83 | return false 84 | } 85 | 86 | return self.fileManager.fileExists(atPath: fileURL.path) 87 | } 88 | 89 | func deleteFile(fileName: String, completion: @escaping (Result) -> Void) { 90 | guard let fileURL = self.fileURL(for: fileName) else { 91 | completion(.success(false)) 92 | 93 | return 94 | } 95 | 96 | do { 97 | try self.fileManager.removeItem(at: fileURL) 98 | completion(.success(true)) 99 | } catch { 100 | completion(.failure(error)) 101 | } 102 | } 103 | 104 | func moveFile( 105 | source: URL, target: URL, forceOverride: Bool = true, 106 | completion: @escaping (Result) -> Void 107 | ) { 108 | do { 109 | let parentDirectory = target.deletingLastPathComponent() 110 | 111 | if !self.fileManager.fileExists(atPath: parentDirectory.path) { 112 | try self.fileManager.createDirectory( 113 | at: parentDirectory, withIntermediateDirectories: true, attributes: nil) 114 | } 115 | 116 | if forceOverride { 117 | if fileManager.fileExists(atPath: target.path) { 118 | try self.fileManager.removeItem(at: target) 119 | } 120 | } 121 | 122 | try self.fileManager.moveItem(at: source, to: target) 123 | completion(.success(target)) 124 | } catch { 125 | completion(.failure(error)) 126 | } 127 | } 128 | 129 | func saveFile( 130 | target: URL, fileName: String, content: Data, 131 | completion: @escaping (Result) -> Void 132 | ) { 133 | do { 134 | if !self.fileExists(fileName: target.path) { 135 | try self.fileManager.createDirectory( 136 | at: target, withIntermediateDirectories: true, attributes: nil) 137 | } 138 | 139 | let fileURL = target.appendingPathComponent(fileName) 140 | 141 | try content.write(to: fileURL) 142 | completion(.success(fileURL)) 143 | 144 | } catch { 145 | completion(.failure(error)) 146 | } 147 | } 148 | 149 | func deleteDownloadedAlbum(target: URL, completion: @escaping (Result) -> Void) { 150 | do { 151 | if fileManager.fileExists(atPath: target.path) { 152 | try fileManager.removeItem(at: target) 153 | print("Folder \(target.path) deleted successfully") 154 | 155 | completion(.success(true)) 156 | } else { 157 | print("Folder \(target.path) somehow does not exist") 158 | 159 | completion(.success(false)) 160 | } 161 | } catch { 162 | print("Error deleting folder: \(error.localizedDescription)") 163 | 164 | completion(.failure(error)) 165 | } 166 | } 167 | 168 | func deleteDownloadedAlbums(completion: @escaping (Result) -> Void) { 169 | guard let folderURL = self.fileURL(for: "Media") else { 170 | completion(.success(false)) 171 | 172 | return 173 | } 174 | 175 | do { 176 | if fileManager.fileExists(atPath: folderURL.path) { 177 | try fileManager.removeItem(at: folderURL) 178 | print("Folder media deleted successfully") 179 | 180 | completion(.success(true)) 181 | } else { 182 | print("Folder media somehow does not exist") 183 | 184 | completion(.success(false)) 185 | } 186 | } catch { 187 | print("Error deleting folder: \(error.localizedDescription)") 188 | 189 | completion(.failure(error)) 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /flo/Shared/Services/PlaybackService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaybackService.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 28/08/24. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | 11 | class PlaybackService { 12 | static let shared = PlaybackService() 13 | 14 | func getQueue() -> [QueueEntity] { 15 | return CoreDataManager.shared.getRecordsByEntity(entity: QueueEntity.self) 16 | } 17 | 18 | func clearQueue() { 19 | CoreDataManager.shared.deleteRecords(entity: QueueEntity.self) 20 | } 21 | 22 | func shuffleQueue(currentIdx: Int) -> [QueueEntity] { 23 | let queue = getQueue() 24 | 25 | let head = Array(queue[...currentIdx]) 26 | let tail = Array(queue[(currentIdx + 1)...]).shuffled() 27 | 28 | return head + tail 29 | } 30 | 31 | func addToQueue(item: T, isFromLocal: Bool = false) -> [QueueEntity] { 32 | self.clearQueue() 33 | 34 | for song in item.songs { 35 | let queue = QueueEntity(context: CoreDataManager.shared.viewContext) 36 | 37 | queue.id = song.mediaFileId == "" ? song.id : song.mediaFileId 38 | queue.albumId = song.albumId 39 | queue.albumName = item.name 40 | queue.artistName = song.artist 41 | queue.bitRate = Int16(song.bitRate) 42 | queue.sampleRate = Int32(song.sampleRate) 43 | queue.songName = song.title 44 | queue.suffix = song.suffix 45 | queue.isFromLocal = isFromLocal 46 | queue.duration = song.duration 47 | 48 | CoreDataManager.shared.saveRecord() 49 | } 50 | 51 | return self.getQueue() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /flo/Shared/Services/ScanStatusService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScanStatusService.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 14/06/24. 6 | // 7 | 8 | import Alamofire 9 | import Foundation 10 | 11 | class ScanStatusService { 12 | static let shared = ScanStatusService() 13 | 14 | func getDownloadedAlbumsCount() -> Int { 15 | return CoreDataManager.shared.countRecords(entity: PlaylistEntity.self) 16 | } 17 | 18 | func getDownloadedSongsCount() -> Int { 19 | return CoreDataManager.shared.countRecords(entity: SongEntity.self) 20 | } 21 | 22 | func getScanStatus(completion: @escaping (Result) -> Void) { 23 | let params: [String: Any] = [:] 24 | 25 | APIManager.shared.SubsonicEndpointRequest( 26 | endpoint: API.SubsonicEndpoint.scanStatus, parameters: params 27 | ) { 28 | (response: DataResponse) in 29 | switch response.result { 30 | case .success(let status): 31 | completion(.success(status)) 32 | case .failure(let error): 33 | completion(.failure(error)) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /flo/Shared/Services/UserDefaultsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsManager.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 09/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class UserDefaultsManager { 11 | static func getAll() -> [String: Any] { 12 | var result = [String: Any]() 13 | 14 | // filter only the "important" part because the rest is displayed via the UI 15 | let keys = [ 16 | UserDefaultsKeys.serverURL, 17 | UserDefaultsKeys.nowPlayingProgress, 18 | UserDefaultsKeys.queueActiveIdx, 19 | UserDefaultsKeys.playbackMode, 20 | ] 21 | 22 | for key in keys { 23 | if let value = UserDefaults.standard.object(forKey: key) { 24 | result[key] = value 25 | } 26 | } 27 | 28 | return result 29 | } 30 | 31 | static func removeObject(key: String) { 32 | UserDefaults.standard.removeObject(forKey: key) 33 | } 34 | 35 | static var serverBaseURL: String { 36 | get { 37 | return UserDefaults.standard.string(forKey: UserDefaultsKeys.serverURL) ?? "" 38 | } 39 | set { 40 | UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.serverURL) 41 | } 42 | } 43 | 44 | static var queueActiveIdx: Int { 45 | get { 46 | return UserDefaults.standard.integer(forKey: UserDefaultsKeys.queueActiveIdx) 47 | } 48 | set { 49 | UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.queueActiveIdx) 50 | } 51 | } 52 | 53 | static var nowPlayingProgress: Double { 54 | get { 55 | return UserDefaults.standard.double(forKey: UserDefaultsKeys.nowPlayingProgress) 56 | } 57 | set { 58 | UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.nowPlayingProgress) 59 | } 60 | } 61 | 62 | static var playbackMode: String { 63 | get { 64 | return UserDefaults.standard.string(forKey: UserDefaultsKeys.playbackMode) 65 | ?? PlaybackMode.defaultPlayback 66 | } 67 | set { 68 | UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.playbackMode) 69 | } 70 | } 71 | 72 | static var enableDebug: Bool { 73 | get { 74 | return UserDefaults.standard.bool(forKey: UserDefaultsKeys.enableDebug) 75 | } 76 | set { 77 | UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.enableDebug) 78 | } 79 | } 80 | 81 | static var maxBitRate: String { 82 | get { 83 | return UserDefaults.standard.string(forKey: UserDefaultsKeys.enableMaxBitRate) 84 | ?? TranscodingSettings.sourceBitRate 85 | } 86 | set { 87 | UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.enableMaxBitRate) 88 | } 89 | } 90 | 91 | static var playerBackground: String { 92 | get { 93 | return UserDefaults.standard.string(forKey: UserDefaultsKeys.playerBackground) 94 | ?? PlayerBackground.translucent 95 | } 96 | set { 97 | UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.playerBackground) 98 | } 99 | } 100 | 101 | static var saveLoginInfo: Bool { 102 | get { 103 | return UserDefaults.standard.bool(forKey: UserDefaultsKeys.saveLoginInfo) 104 | } 105 | 106 | set { 107 | UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.saveLoginInfo) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /flo/Shared/Utils/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 06/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct API { 11 | static let NDAuthHeader = "X-ND-Authorization" 12 | 13 | struct NDEndpoint { 14 | static let login = "/auth/login" 15 | static let getAlbum = "/api/album" 16 | static let getArtists = "/api/artist" 17 | static let getPlaylists = "/api/playlist" 18 | static let getSong = "/api/song" 19 | static let shareAlbum = "/api/share" 20 | static let listenBrainzLink = "/api/listenbrainz/link" 21 | static let lastFMLink = "/api/lastfm/link" 22 | } 23 | 24 | struct SubsonicEndpoint { 25 | static let stream = "/rest/stream" 26 | static let coverArt = "/rest/getCoverArt" 27 | static let albuminfo = "/rest/getAlbumInfo" 28 | static let scanStatus = "/rest/getScanStatus" 29 | static let download = "/rest/download" 30 | static let scrobble = "/rest/scrobble" 31 | } 32 | } 33 | 34 | enum PlaybackMode { 35 | static let defaultPlayback = "default" 36 | static let repeatAlbum = "repeatAlbum" 37 | static let repeatOnce = "repeatOnce" 38 | } 39 | 40 | enum AppMeta { 41 | static let name = "flo" 42 | static let identifier = "net.faultables.flo" 43 | static let subsonicApiVersion = "1.16.1" // FIXME: should we respect the subsonic-response? 44 | } 45 | 46 | enum UserDefaultsKeys { 47 | static let serverURL = "serverURL" 48 | static let queueActiveIdx = "queueActiveIdx" 49 | static let nowPlayingProgress = "nowPlayingProgress" 50 | static let playbackMode = "playbackMode" 51 | static let enableDebug = "enableDebug" 52 | static let enableMaxBitRate = "enableMaxBitRate" 53 | static let playerBackground = "playerBackground" 54 | static let saveLoginInfo = "saveLoginInfo" 55 | } 56 | 57 | enum KeychainKeys { 58 | static let service = AppMeta.identifier 59 | static let dataKey = "authCreds" 60 | static let serverPassword = "serverPassword" 61 | } 62 | 63 | enum TranscodingSettings { 64 | static let availableBitRate = [ 65 | "0", "32", "48", "64", "80", "96", "112", "128", "160", "192", "224", "256", "320", 66 | ] 67 | static let sourceBitRate = "0" 68 | static let sourceFormat = "raw" 69 | static let targetFormat = "mp3" 70 | } 71 | 72 | enum PlayerBackground { 73 | static let availablePlayerBackground = ["solid", "translucent"] 74 | static let solid = "solid" 75 | static let translucent = "translucent" 76 | } 77 | -------------------------------------------------------------------------------- /flo/Shared/Utils/Errors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Errors.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 09/06/24. 6 | // 7 | 8 | import Alamofire 9 | import Foundation 10 | 11 | enum AuthResult { 12 | case success(T) 13 | case failure(AuthError) 14 | 15 | init(result: Result) { 16 | switch result { 17 | case .success(let value): 18 | self = .success(value) 19 | case .failure(let error): 20 | if let authError = error as? AuthError { 21 | self = .failure(authError) 22 | } else { 23 | self = .failure(.unknown) 24 | } 25 | } 26 | } 27 | } 28 | 29 | enum AuthError: Error { 30 | case server(message: String) 31 | case unknown 32 | } 33 | 34 | struct ErrorResponse: Decodable { 35 | let error: String 36 | } 37 | 38 | class ErrorHandler { 39 | static func mapError(_ error: AFError) -> Error { 40 | if let underlyingError = error.underlyingError as? URLError { 41 | return AuthError.server(message: underlyingError.localizedDescription) 42 | } 43 | return AuthError.unknown 44 | } 45 | 46 | static func handleFailure( 47 | _ afError: AFError, response: DataResponse, 48 | completion: @escaping (Result) -> Void 49 | ) { 50 | if let data = response.data { 51 | do { 52 | let decoder = JSONDecoder() 53 | let errorResponse = try decoder.decode(ErrorResponse.self, from: data) 54 | let errorMessage = errorResponse.error 55 | completion(.failure(AuthError.server(message: errorMessage))) 56 | } catch { 57 | completion(.failure(mapError(afError))) 58 | } 59 | } else { 60 | completion(.failure(mapError(afError))) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /flo/Shared/Utils/Fonts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fonts.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 06/06/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum TextStyle { 11 | case largeTitle 12 | case title 13 | case title1 14 | case title2 15 | case title3 16 | case headline 17 | case subheadline 18 | case body 19 | case callout 20 | case footnote 21 | case caption1 22 | case caption2 23 | } 24 | 25 | struct CustomFont: ViewModifier { 26 | var textStyle: TextStyle 27 | 28 | func body(content: Content) -> some View { 29 | let font: Font 30 | 31 | switch textStyle { 32 | case .largeTitle: 33 | font = .custom("Plus Jakarta Sans", size: 34) 34 | case .title: 35 | font = .custom("Plus Jakarta Sans", size: 28) 36 | case .title1: 37 | font = .custom("Plus Jakarta Sans", size: 28) 38 | case .title2: 39 | font = .custom("Plus Jakarta Sans", size: 22) 40 | case .title3: 41 | font = .custom("Plus Jakarta Sans", size: 20) 42 | case .headline: 43 | font = .custom("Plus Jakarta Sans", size: 17).weight(.bold) 44 | case .body: 45 | font = .custom("Plus Jakarta Sans", size: 17) 46 | case .callout: 47 | font = .custom("Plus Jakarta Sans", size: 16) 48 | case .subheadline: 49 | font = .custom("Plus Jakarta Sans", size: 15) 50 | case .footnote: 51 | font = .custom("Plus Jakarta Sans", size: 13) 52 | case .caption1: 53 | font = .custom("Plus Jakarta Sans", size: 12) 54 | case .caption2: 55 | font = .custom("Plus Jakarta Sans", size: 11) 56 | } 57 | 58 | return content.font(font) 59 | } 60 | } 61 | 62 | extension View { 63 | func customFont(_ textStyle: TextStyle) -> some View { 64 | // FIXME: this is fishy 65 | self.modifier(CustomFont(textStyle: textStyle)).foregroundColor(.accent) 66 | } 67 | } 68 | 69 | #Preview { 70 | VStack { 71 | Text("Large Title") 72 | .customFont(.largeTitle) 73 | Text("Title 1") 74 | .customFont(.title1) 75 | Text("Title 2") 76 | .customFont(.title2) 77 | Text("Title 3") 78 | .customFont(.title3) 79 | Text("Headline") 80 | .customFont(.headline) 81 | Text("Subhead") 82 | .customFont(.subheadline) 83 | Text("Body") 84 | .customFont(.body) 85 | Text("Callout") 86 | .customFont(.callout) 87 | Text("Footnote") 88 | .customFont(.footnote) 89 | Text("Caption 1") 90 | .customFont(.caption1) 91 | Text("Caption 2") 92 | .customFont(.caption2) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /flo/Shared/Utils/Strings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Strings.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 09/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | func timeString(for seconds: Double) -> String { 11 | if seconds.isFinite, !seconds.isNaN { 12 | let minutes = Int(seconds) / 60 13 | let seconds = Int(seconds) % 60 14 | 15 | return String(format: "%02d:%02d", minutes, seconds) 16 | } 17 | 18 | return "00:00" 19 | } 20 | 21 | func bytesToMBOrGB(_ bytes: Int64) -> String { 22 | let gigabyte: Int64 = 1024 * 1024 * 1024 23 | let megabyte: Int64 = 1024 * 1024 24 | 25 | if bytes >= gigabyte { 26 | let gb = Double(bytes) / Double(gigabyte) 27 | 28 | return String(format: "%.0f GB", gb) 29 | } else { 30 | let mb = Double(bytes) / Double(megabyte) 31 | 32 | return String(format: "%.0f MB", mb) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /flo/SongView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 26/06/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SongView: View { 11 | @Environment(\.dismiss) private var dismiss 12 | @EnvironmentObject var downloadViewModel: DownloadViewModel 13 | 14 | @ObservedObject var viewModel: AlbumViewModel 15 | var playerViewModel: PlayerViewModel 16 | var isDownloadScreen: Bool = false 17 | 18 | var body: some View { 19 | VStack { 20 | ForEach(Array(viewModel.album.songs.enumerated()), id: \.element) { idx, song in 21 | VStack { 22 | HStack(alignment: .top) { 23 | Text("\(song.trackNumber.description)") 24 | .customFont(.caption1) 25 | .foregroundColor(.gray) 26 | .padding(.trailing, 5) 27 | 28 | VStack(alignment: .leading) { 29 | Text(song.title) 30 | .fontWeight(.medium) 31 | 32 | if song.id.hasPrefix("pl:") { 33 | Text(song.artist).customFont(.caption1).offset(y: 5) 34 | } else { 35 | if viewModel.album.albumArtist != viewModel.album.artist { 36 | Text(song.artist).customFont(.caption1).offset(y: 5) 37 | } 38 | } 39 | 40 | Spacer() 41 | } 42 | 43 | Spacer() 44 | 45 | if !song.fileUrl.isEmpty { 46 | Image(systemName: "arrow.down.circle.fill") 47 | .font(.system(size: 14)) 48 | } 49 | 50 | Text(timeString(for: song.duration)).customFont(.caption1) 51 | } 52 | } 53 | .padding() 54 | .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) 55 | .listRowSeparator(.hidden) 56 | .contentShape(Rectangle()) 57 | .onTapGesture { 58 | playerViewModel.playBySong( 59 | idx: idx, item: viewModel.album, isFromLocal: viewModel.isDownloaded) 60 | } 61 | .contextMenu { 62 | VStack { 63 | if !song.fileUrl.isEmpty { 64 | Button(role: .destructive) { 65 | viewModel.removeDownloadSong(album: viewModel.album, songId: song.id) 66 | viewModel.setActiveAlbum(album: viewModel.album) 67 | if isDownloadScreen { 68 | dismiss() 69 | viewModel.fetchDownloadedAlbums() 70 | } 71 | } label: { 72 | HStack { 73 | Text("Remove Download") 74 | Image(systemName: "arrow.down.circle") 75 | } 76 | } 77 | } else { 78 | Button { 79 | viewModel.downloadAlbum(viewModel.album) 80 | downloadViewModel.addIndividualItem( 81 | album: viewModel.album, song: viewModel.album.songs[idx]) 82 | } label: { 83 | HStack { 84 | Text("Download") 85 | Image(systemName: "arrow.down.circle") 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | .listStyle(PlainListStyle()).customFont(.body) 93 | } 94 | } 95 | } 96 | 97 | struct SongView_Previews: PreviewProvider { 98 | static let songs: [Song] = [ 99 | Song( 100 | id: "0", title: "Song 1", albumId: "", artist: "Artist Name", trackNumber: 1, discNumber: 0, 101 | bitRate: 0, 102 | sampleRate: 44100, 103 | suffix: "mp4a", duration: 200, mediaFileId: "0"), 104 | Song( 105 | id: "1", title: "Song 2", albumId: "", artist: "Artist Name", trackNumber: 2, discNumber: 0, 106 | bitRate: 0, 107 | sampleRate: 44100, 108 | suffix: "mp4a", duration: 200, mediaFileId: "1"), 109 | Song( 110 | id: "2", title: "Song 3", albumId: "", artist: "Artist Name", trackNumber: 3, discNumber: 0, 111 | bitRate: 0, 112 | sampleRate: 44100, 113 | suffix: "mp4a", duration: 200, mediaFileId: "2"), 114 | Song( 115 | id: "3", title: "Song 4", albumId: "", artist: "Artist Name", trackNumber: 4, discNumber: 0, 116 | bitRate: 0, 117 | sampleRate: 44100, 118 | suffix: "mp4a", duration: 200, mediaFileId: "3"), 119 | Song( 120 | id: "4", title: "Song 5", albumId: "", artist: "Artist Name", trackNumber: 5, discNumber: 0, 121 | bitRate: 0, 122 | sampleRate: 44100, 123 | suffix: "mp4a", duration: 200, mediaFileId: "4"), 124 | Song( 125 | id: "5", title: "Song 6", albumId: "", artist: "Artist Name", trackNumber: 6, discNumber: 0, 126 | bitRate: 0, 127 | sampleRate: 44100, 128 | suffix: "mp4a", duration: 200, mediaFileId: "5"), 129 | Song( 130 | id: "6", title: "Song 7", albumId: "", artist: "Artist Name", trackNumber: 7, discNumber: 0, 131 | bitRate: 0, 132 | sampleRate: 44100, 133 | suffix: "mp4a", duration: 200, mediaFileId: "6"), 134 | Song( 135 | id: "7", title: "Song 8", albumId: "", artist: "Artist Name", trackNumber: 8, discNumber: 0, 136 | bitRate: 0, 137 | sampleRate: 44100, 138 | suffix: "mp4a", duration: 200, mediaFileId: "7"), 139 | ] 140 | 141 | static let album: Album = Album( 142 | name: "Album name", artist: "Artist name", songs: songs) 143 | 144 | @StateObject static var viewModel: AlbumViewModel = AlbumViewModel(album: album) 145 | @StateObject static var playerViewModel: PlayerViewModel = PlayerViewModel() 146 | 147 | static var previews: some View { 148 | SongView(viewModel: viewModel, playerViewModel: playerViewModel) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /flo/SongsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongsView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 17/11/24. 6 | // 7 | 8 | import NukeUI 9 | import SwiftUI 10 | 11 | struct SongsView: View { 12 | @EnvironmentObject private var viewModel: AlbumViewModel 13 | @EnvironmentObject private var playerViewModel: PlayerViewModel 14 | 15 | @State private var searchSong = "" 16 | 17 | var filteredSongs: [Song] { 18 | if searchSong.isEmpty { 19 | return viewModel.songs 20 | } else { 21 | return viewModel.songs.filter { song in 22 | song.title.localizedCaseInsensitiveContains(searchSong) 23 | } 24 | } 25 | } 26 | 27 | var body: some View { 28 | ScrollView { 29 | LazyVStack { 30 | ForEach(Array(filteredSongs.enumerated()), id: \.element) { idx, song in 31 | VStack { 32 | HStack { 33 | LazyImage(url: URL(string: viewModel.getAlbumCoverArt(id: song.albumId))) { state in 34 | if let image = state.image { 35 | image 36 | .resizable() 37 | .aspectRatio(contentMode: .fit) 38 | .frame(width: 60, height: 60) 39 | .clipShape( 40 | RoundedRectangle(cornerRadius: 10, style: .continuous) 41 | ) 42 | } else { 43 | Color("PlayerColor").frame(width: 60, height: 60) 44 | .cornerRadius(5) 45 | } 46 | } 47 | 48 | VStack(alignment: .leading) { 49 | Text(song.title) 50 | .customFont(.headline) 51 | .multilineTextAlignment(.leading) 52 | .lineLimit(2) 53 | .padding(.bottom, 3) 54 | 55 | Text(song.artist) 56 | .customFont(.subheadline) 57 | .foregroundColor(.gray) 58 | .lineLimit(2) 59 | .multilineTextAlignment(.leading) 60 | } 61 | .padding(.horizontal, 10) 62 | 63 | Spacer() 64 | } 65 | .padding(.horizontal) 66 | .background(Color(UIColor.systemBackground)) 67 | 68 | Divider() 69 | } 70 | .onTapGesture { 71 | var playlist = Playlist(name: "\"All Tracks\"") 72 | let songs = filteredSongs.dropFirst(idx) 73 | 74 | playlist.songs = Array(songs) 75 | 76 | playerViewModel.playBySong( 77 | idx: 0, item: playlist, isFromLocal: false) 78 | } 79 | .frame(maxWidth: .infinity, alignment: .leading) 80 | } 81 | } 82 | .padding(.top, 10) 83 | .padding(.bottom, 100) 84 | .navigationTitle("Songs") 85 | .searchable( 86 | text: $searchSong, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search") 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /flo/StatCardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatCardView.swift 3 | // flo 4 | // 5 | // Created by rizaldy on 22/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StatCard: View { 11 | let title: String 12 | let value: String 13 | let subtitle: String? 14 | let icon: String 15 | let color: Color 16 | let isWide: Bool 17 | let showArrow: Bool 18 | 19 | init( 20 | title: String, 21 | value: String, 22 | subtitle: String? = nil, 23 | icon: String, 24 | color: Color, 25 | isWide: Bool = false, 26 | showArrow: Bool = false 27 | ) { 28 | self.title = title 29 | self.value = value 30 | self.subtitle = subtitle 31 | self.icon = icon 32 | self.color = color 33 | self.isWide = isWide 34 | self.showArrow = false // FIXME: use `showArrow` after implement deeplinks 35 | } 36 | 37 | var body: some View { 38 | VStack(alignment: .leading, spacing: 12) { 39 | HStack { 40 | Image(systemName: icon) 41 | .foregroundColor(color) 42 | Text(title) 43 | .foregroundColor(.secondary) 44 | .customFont(.body) 45 | 46 | Spacer() 47 | 48 | if showArrow { 49 | Image(systemName: "chevron.right") 50 | .foregroundColor(.secondary) 51 | .font(.system(size: 14)) 52 | } 53 | } 54 | .customFont(.subheadline) 55 | 56 | VStack(alignment: .leading, spacing: 4) { 57 | Text(value) 58 | .customFont(.title2) 59 | .lineSpacing(2) 60 | .fontWeight(.bold) 61 | .lineLimit(2) 62 | 63 | if let subtitle = subtitle { 64 | Text(subtitle) 65 | .foregroundColor(.secondary) 66 | .customFont(.subheadline) 67 | .lineSpacing(2) 68 | .lineLimit(2) 69 | } 70 | } 71 | } 72 | .padding() 73 | .frame(maxWidth: isWide ? .infinity : nil) 74 | .background(Color(UIColor.systemBackground)) 75 | .overlay( 76 | RoundedRectangle(cornerRadius: 16) 77 | .stroke(Color(UIColor.separator), lineWidth: 0.8) 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /flo/flo.xcdatamodeld/flo.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /meta/guthib.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kepelet/flo/052fce89d0f66d9302ac0e1307ae0fb4524c81c5/meta/guthib.jpeg --------------------------------------------------------------------------------