├── .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 |

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
--------------------------------------------------------------------------------