├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .ruby-version
├── .swiftlint.yml
├── BlackCandy.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
└── xcshareddata
│ └── xcschemes
│ └── BlackCandy.xcscheme
├── BlackCandy
├── AppDelegate.swift
├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── Contents.json
│ │ └── blackcandy_logo.png
│ ├── BlackCandyLogo.imageset
│ │ ├── Contents.json
│ │ ├── logo.png
│ │ ├── logo@2x.png
│ │ └── logo@3x.png
│ └── Contents.json
├── Clients
│ ├── APIClient
│ │ ├── APIClient.swift
│ │ └── LiveAPIClient.swift
│ ├── CookiesClient
│ │ ├── CookiesClient.swift
│ │ └── LiveCookiesClient.swift
│ ├── FlashMessageClient
│ │ ├── FlashMessageClient.swift
│ │ └── LiveFlashMessageClient.swift
│ ├── GlobalQueueClient
│ │ ├── GlobalQueueClient.swift
│ │ └── LiveGlobalQueueClient.swift
│ ├── JSONDataClient
│ │ ├── JSONDataClient.swift
│ │ └── LiveJSONDataClient.swift
│ ├── KeychainClient
│ │ ├── KeychainClient.swift
│ │ └── LiveKeychainClient.swift
│ ├── NowPlayingClient
│ │ ├── LiveNowPlayingClient.swift
│ │ └── NowPlayingClient.swift
│ ├── PlayerClient
│ │ ├── LivePlayerClient.swift
│ │ └── PlayerClient.swift
│ ├── UserDefaultsClient
│ │ ├── LiveUserDefaultsClient.swift
│ │ └── UserDefaultsClient.swift
│ └── WindowClient
│ │ ├── LiveWindowClient.swift
│ │ └── WindowClient.swift
├── Info.plist
├── Localizable.strings
├── Models
│ ├── Playlist.swift
│ ├── Song.swift
│ ├── SystemInfo.swift
│ └── User.swift
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── SceneDelegate.swift
├── States
│ ├── LoginState.swift
│ └── ServerAddressState.swift
├── Store
│ ├── AppReducer.swift
│ ├── AppStore.swift
│ ├── LoginReducer.swift
│ └── PlayerReducer.swift
├── Turbo
│ ├── TurboNavigationController.swift
│ ├── TurboScriptMessageHandler.swift
│ ├── TurboSession.swift
│ └── TurboVisitableViewController.swift
├── Utils
│ ├── AudioSessionControl.swift
│ ├── Constants.swift
│ ├── CustomFormatter.swift
│ ├── CustomStyle.swift
│ ├── NotificationName.swift
│ └── RemoteControl.swift
├── ViewControllers
│ ├── LoginViewController.swift
│ ├── MainViewController.swift
│ ├── PlayerViewController.swift
│ ├── SideBar
│ │ └── SideBarNavigationViewController.swift
│ ├── SideBarViewController.swift
│ └── TabBarViewController.swift
├── Views
│ ├── AccountView.swift
│ ├── Login
│ │ ├── LoginAuthenticationView.swift
│ │ └── LoginConnectionView.swift
│ ├── LoginView.swift
│ ├── Player
│ │ ├── PlayerActionsView.swift
│ │ ├── PlayerControlView.swift
│ │ ├── PlayerPlaylistView.swift
│ │ ├── PlayerSliderView.swift
│ │ └── PlayerSongInfoView.swift
│ └── PlayerView.swift
└── path-configuration.json
├── BlackCandyTests
├── Clients
│ ├── APIClientTests.swift
│ ├── CookiesClientTests.swift
│ ├── JSONDataClientTests.swift
│ ├── KeychainClientTests.swift
│ ├── NowPlayingClientTests.swift
│ ├── PlayerClientTests.swift
│ └── UserDefaultsClientTests.swift
├── Fixtures
│ ├── Files
│ │ ├── cover_image.jpg
│ │ └── song.mp3
│ ├── songs.json
│ └── users.json
├── Models
│ ├── PlaylistTests.swift
│ ├── SongTests.swift
│ ├── SystemInfoTests.swift
│ └── UserTests.swift
├── States
│ ├── LoginStateTests.swift
│ └── ServerAddressStateTests.swift
├── Store
│ ├── AppReducerTests.swift
│ ├── LoginReducerTests.swift
│ └── PlayerReducerTests.swift
├── TestHelper.swift
└── Utils
│ └── CustomFormatterTests.swift
├── BlackCandyUITests
├── BlackCandyUITests.swift
└── BlackCandyUITestsLaunchTests.swift
├── Gemfile
├── Gemfile.lock
├── LICENSE
├── PRIVACY.md
├── README.md
├── Scripts
└── setup.sh
├── fastlane
├── Appfile
├── Fastfile
└── screenshots
│ ├── Framefile.json
│ ├── background.jpg
│ ├── en-US
│ └── title.strings
│ └── fonts
│ └── .keep
└── images
├── appstore_badge.png
└── screenshot_main.png
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | test_lint:
7 | runs-on: macos-13
8 |
9 | steps:
10 | - uses: actions/checkout@v3
11 | - name: Setup
12 | run: ./Scripts/setup.sh
13 | env:
14 | APP_IDENTIFIER: org.blackcandy
15 | - name: Test and Lint
16 | run: |
17 | swiftlint
18 | set -o pipefail && xcodebuild test -scheme "BlackCandy" -destination platform="iOS Simulator,name=iPhone 15 Pro Max,OS=17.2" | xcpretty --test
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .env
3 | *.zip
4 | *.ipa
5 | xcuserdata/
6 |
7 | /fastlane/screenshots/**/*.png
8 | /fastlane/screenshots/fonts/*
9 | /fastlane/report.xml
10 |
11 | !/fastlane/screenshots/fonts/.keep
12 | BlackCandy.xcconfig
13 |
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.1.2
2 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | disabled_rules:
2 | - identifier_name
3 | - force_cast
4 | - line_length
5 | - force_try
6 | - cyclomatic_complexity
7 | - function_body_length
8 | - file_length
9 | - type_body_length
10 |
11 | opt_in_rules:
12 | - indentation_width
13 |
14 | indentation_width:
15 | indentation_width: 2
16 |
--------------------------------------------------------------------------------
/BlackCandy.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/BlackCandy.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/BlackCandy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "alamofire",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/Alamofire/Alamofire",
7 | "state" : {
8 | "revision" : "8dd85aee02e39dd280c75eef88ffdb86eed4b07b",
9 | "version" : "5.6.2"
10 | }
11 | },
12 | {
13 | "identity" : "alertkit",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/sparrowcode/AlertKit",
16 | "state" : {
17 | "revision" : "5fd3e2923d26f0f0b40f0e6c10fca458a61a73c4",
18 | "version" : "4.2.0"
19 | }
20 | },
21 | {
22 | "identity" : "combine-schedulers",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/pointfreeco/combine-schedulers",
25 | "state" : {
26 | "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb",
27 | "version" : "1.0.0"
28 | }
29 | },
30 | {
31 | "identity" : "lnpopupcontroller",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/LeoNatan/LNPopupController.git",
34 | "state" : {
35 | "revision" : "be669a1bff4cb88655b3827226a1b4d14e2e5508",
36 | "version" : "2.14.8"
37 | }
38 | },
39 | {
40 | "identity" : "ohhttpstubs",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/AliSoftware/OHHTTPStubs",
43 | "state" : {
44 | "revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9",
45 | "version" : "9.1.0"
46 | }
47 | },
48 | {
49 | "identity" : "swift-case-paths",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/pointfreeco/swift-case-paths",
52 | "state" : {
53 | "revision" : "5da6989aae464f324eef5c5b52bdb7974725ab81",
54 | "version" : "1.0.0"
55 | }
56 | },
57 | {
58 | "identity" : "swift-clocks",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/pointfreeco/swift-clocks",
61 | "state" : {
62 | "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb",
63 | "version" : "1.0.0"
64 | }
65 | },
66 | {
67 | "identity" : "swift-collections",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/apple/swift-collections",
70 | "state" : {
71 | "revision" : "48254824bb4248676bf7ce56014ff57b142b77eb",
72 | "version" : "1.0.2"
73 | }
74 | },
75 | {
76 | "identity" : "swift-composable-architecture",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/pointfreeco/swift-composable-architecture",
79 | "state" : {
80 | "revision" : "195284b94b799b326729640453f547f08892293a",
81 | "version" : "1.0.0"
82 | }
83 | },
84 | {
85 | "identity" : "swift-concurrency-extras",
86 | "kind" : "remoteSourceControl",
87 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
88 | "state" : {
89 | "revision" : "ea631ce892687f5432a833312292b80db238186a",
90 | "version" : "1.0.0"
91 | }
92 | },
93 | {
94 | "identity" : "swift-custom-dump",
95 | "kind" : "remoteSourceControl",
96 | "location" : "https://github.com/pointfreeco/swift-custom-dump",
97 | "state" : {
98 | "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c",
99 | "version" : "1.3.0"
100 | }
101 | },
102 | {
103 | "identity" : "swift-dependencies",
104 | "kind" : "remoteSourceControl",
105 | "location" : "https://github.com/pointfreeco/swift-dependencies",
106 | "state" : {
107 | "revision" : "4e1eb6e28afe723286d8cc60611237ffbddba7c5",
108 | "version" : "1.0.0"
109 | }
110 | },
111 | {
112 | "identity" : "swift-identified-collections",
113 | "kind" : "remoteSourceControl",
114 | "location" : "https://github.com/pointfreeco/swift-identified-collections",
115 | "state" : {
116 | "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8",
117 | "version" : "1.0.0"
118 | }
119 | },
120 | {
121 | "identity" : "swiftui-navigation",
122 | "kind" : "remoteSourceControl",
123 | "location" : "https://github.com/pointfreeco/swiftui-navigation",
124 | "state" : {
125 | "revision" : "f5bcdac5b6bb3f826916b14705f37a3937c2fd34",
126 | "version" : "1.0.0"
127 | }
128 | },
129 | {
130 | "identity" : "turbo-ios",
131 | "kind" : "remoteSourceControl",
132 | "location" : "https://github.com/hotwired/turbo-ios",
133 | "state" : {
134 | "revision" : "7ce71d4210b077d6e9f1004bbc4c584a580ce594",
135 | "version" : "7.0.1"
136 | }
137 | },
138 | {
139 | "identity" : "xctest-dynamic-overlay",
140 | "kind" : "remoteSourceControl",
141 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
142 | "state" : {
143 | "revision" : "302891700c7fa3b92ebde9fe7b42933f8349f3c7",
144 | "version" : "1.0.0"
145 | }
146 | }
147 | ],
148 | "version" : 2
149 | }
150 |
--------------------------------------------------------------------------------
/BlackCandy.xcodeproj/xcshareddata/xcschemes/BlackCandy.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
32 |
33 |
39 |
40 |
41 |
42 |
44 |
50 |
51 |
52 |
54 |
60 |
61 |
62 |
63 |
64 |
74 |
76 |
82 |
83 |
84 |
85 |
91 |
93 |
99 |
100 |
101 |
102 |
104 |
105 |
108 |
109 |
110 |
--------------------------------------------------------------------------------
/BlackCandy/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import ComposableArchitecture
3 |
4 | @main
5 | class AppDelegate: UIResponder, UIApplicationDelegate {
6 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
7 | guard !_XCTIsTesting else { return true }
8 |
9 | let store = AppStore.shared
10 | let playerStore = store.scope(state: \.player, action: AppReducer.Action.player)
11 |
12 | store.send(.restoreStates)
13 |
14 | AudioSessionControl.setup(store: playerStore)
15 | RemoteControl.setup(store: playerStore)
16 |
17 | return true
18 | }
19 |
20 | // MARK: UISceneSession Lifecycle
21 |
22 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
23 | // Called when a new scene session is being created.
24 | // Use this method to select a configuration to create the new scene with.
25 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
26 | }
27 |
28 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
29 | // Called when the user discards a scene session.
30 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
31 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/BlackCandy/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.553",
9 | "green" : "0.259",
10 | "red" : "0.467"
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" : "0.749",
27 | "green" : "0.353",
28 | "red" : "0.631"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/BlackCandy/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "blackcandy_logo.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/BlackCandy/Assets.xcassets/AppIcon.appiconset/blackcandy_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/BlackCandy/Assets.xcassets/AppIcon.appiconset/blackcandy_logo.png
--------------------------------------------------------------------------------
/BlackCandy/Assets.xcassets/BlackCandyLogo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "logo.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "logo@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "logo@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/BlackCandy/Assets.xcassets/BlackCandyLogo.imageset/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/BlackCandy/Assets.xcassets/BlackCandyLogo.imageset/logo.png
--------------------------------------------------------------------------------
/BlackCandy/Assets.xcassets/BlackCandyLogo.imageset/logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/BlackCandy/Assets.xcassets/BlackCandyLogo.imageset/logo@2x.png
--------------------------------------------------------------------------------
/BlackCandy/Assets.xcassets/BlackCandyLogo.imageset/logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/BlackCandy/Assets.xcassets/BlackCandyLogo.imageset/logo@3x.png
--------------------------------------------------------------------------------
/BlackCandy/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/APIClient/APIClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 | import Alamofire
4 |
5 | struct APIClient {
6 | var login: (LoginState) async throws -> AuthenticationResponse
7 | var logout: () async throws -> NoContentResponse
8 | var getSongsFromCurrentPlaylist: () async throws -> [Song]
9 | var addSongToFavorite: (Song) async throws -> Int
10 | var deleteSongInFavorite: (Song) async throws -> Int
11 | var deleteSongInCurrentPlaylist: (Song) async throws -> NoContentResponse
12 | var moveSongInCurrentPlaylist: (Int, Int) async throws -> NoContentResponse
13 | var getSong: (Int) async throws -> Song
14 | var getSystemInfo: (ServerAddressState) async throws -> SystemInfo
15 | var addSongToCurrentPlaylist: (Int, Song?, String?) async throws -> Song
16 | var replaceCurrentPlaylistWithAlbumSongs: (Int) async throws -> [Song]
17 | var replaceCurrentPlaylistWithPlaylistSongs: (Int) async throws -> [Song]
18 | }
19 |
20 | extension APIClient: TestDependencyKey {
21 | static let testValue = Self(
22 | login: unimplemented("\(Self.self).login"),
23 |
24 | logout: unimplemented("\(Self.self).logout"),
25 |
26 | getSongsFromCurrentPlaylist: unimplemented("\(Self.self).getSongsFromCurrentPlaylist"),
27 |
28 | addSongToFavorite: unimplemented("\(Self.self).addSongToFavorite"),
29 |
30 | deleteSongInFavorite: unimplemented("\(Self.self).deleteSongInFavorite"),
31 |
32 | deleteSongInCurrentPlaylist: { _ in
33 | NoContentResponse()
34 | },
35 |
36 | moveSongInCurrentPlaylist: { _, _ in
37 | NoContentResponse()
38 | },
39 |
40 | getSong: unimplemented("\(Self.self).getSong"),
41 |
42 | getSystemInfo: unimplemented("\(Self.self).getSystemInfo"),
43 |
44 | addSongToCurrentPlaylist: unimplemented("\(Self.self).addSongToCurrentPlaylist"),
45 |
46 | replaceCurrentPlaylistWithAlbumSongs: unimplemented("\(Self.self).replaceCurrentPlaylistWithAlbumSongs"),
47 |
48 | replaceCurrentPlaylistWithPlaylistSongs: unimplemented("\(Self.self).replaceCurrentPlaylistWithPlaylistSongs")
49 | )
50 |
51 | static let previewValue = testValue
52 | }
53 |
54 | extension DependencyValues {
55 | var apiClient: APIClient {
56 | get { self[APIClient.self] }
57 | set { self[APIClient.self] = newValue }
58 | }
59 | }
60 |
61 | extension APIClient {
62 | struct AuthenticationResponse: Equatable {
63 | let token: String
64 | let user: User
65 | let cookies: [HTTPCookie]
66 | }
67 |
68 | struct NoContentResponse: Codable, Equatable, EmptyResponse {
69 | static let value = NoContentResponse()
70 | static func emptyValue() -> APIClient.NoContentResponse {
71 | value
72 | }
73 | }
74 |
75 | enum APIError: Error, Equatable {
76 | case invalidRequest
77 | case invalidResponse
78 | case unauthorized
79 | case badRequest(String?)
80 | case unknown
81 |
82 | var localizedString: String {
83 | switch self {
84 | case .invalidRequest:
85 | return NSLocalizedString("text.invalidRequest", comment: "")
86 |
87 | case .invalidResponse:
88 | return NSLocalizedString("text.invalidResponse", comment: "")
89 |
90 | case .unauthorized:
91 | return NSLocalizedString("text.invalidUserCredential", comment: "")
92 |
93 | case let .badRequest(message):
94 | guard let message = message else {
95 | return NSLocalizedString("text.badRequest", comment: "")
96 | }
97 |
98 | return message
99 |
100 | case .unknown:
101 | return NSLocalizedString("text.unknownNetworkError", comment: "")
102 | }
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/APIClient/LiveAPIClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 | import Alamofire
4 |
5 | extension APIClient: DependencyKey {
6 | static func live() -> Self {
7 | @Dependency(\.userDefaultsClient) var userDefaultClient
8 | @Dependency(\.keychainClient) var keychainClient
9 |
10 | lazy var session: Session = {
11 | let configuration = URLSessionConfiguration.af.default
12 |
13 | configuration.waitsForConnectivity = true
14 | configuration.timeoutIntervalForResource = 300
15 |
16 | return Session(configuration: configuration)
17 | }()
18 |
19 | var headers: HTTPHeaders {
20 | var basicHeaders: HTTPHeaders = [
21 | .userAgent(BLACK_CANDY_USER_AGENT)
22 | ]
23 |
24 | if let token = keychainClient.apiToken() {
25 | basicHeaders.add(.authorization("Token \(token)"))
26 | }
27 |
28 | return basicHeaders
29 | }
30 |
31 | var jsonDecoder: JSONDecoder {
32 | let decoder = JSONDecoder()
33 | decoder.keyDecodingStrategy = .convertFromSnakeCase
34 |
35 | return decoder
36 | }
37 |
38 | func requestURL(_ path: String) -> URL {
39 | userDefaultClient.serverAddress()!.appendingPathComponent("/api/v1\(path)")
40 | }
41 |
42 | func decodeJSON(_ data: Data) -> [String: Any]? {
43 | let json = try? JSONSerialization.jsonObject(with: data)
44 | return json as? [String: Any]
45 | }
46 |
47 | func handleRequest(_ request: DataTask, handle: (DataTask, DataResponse) async throws -> T) async throws -> T {
48 | let response = await request.response
49 |
50 | do {
51 | return try await handle(request, response)
52 | } catch {
53 | throw handleError(error, response.data)
54 | }
55 | }
56 |
57 | func handleError(_ error: Error, _ responseData: Data?) -> APIError {
58 | guard let error = error as? AFError else { return .unknown }
59 |
60 | switch error {
61 | case .invalidURL,
62 | .parameterEncodingFailed,
63 | .parameterEncoderFailed,
64 | .requestAdaptationFailed:
65 | return .invalidRequest
66 |
67 | case .responseSerializationFailed:
68 | return .invalidResponse
69 |
70 | case let .responseValidationFailed(reason):
71 | switch reason {
72 | case let .unacceptableStatusCode(code):
73 | return handleUnacceptableStatusCode(code, responseData)
74 | default:
75 | return .invalidResponse
76 | }
77 |
78 | default:
79 | return .unknown
80 | }
81 | }
82 |
83 | func handleUnacceptableStatusCode(_ code: Int, _ responseData: Data?) -> APIError {
84 | switch code {
85 | case 401:
86 | return .unauthorized
87 |
88 | case 400:
89 | guard let data = responseData,
90 | let errorMessage = decodeJSON(data)?["message"] as? String else {
91 | return .badRequest(nil)
92 | }
93 |
94 | return .badRequest(errorMessage)
95 |
96 | default:
97 | return .invalidResponse
98 | }
99 | }
100 |
101 | return Self(
102 | login: { loginState in
103 | let parameters: [String: Any] = [
104 | "with_cookie": "true",
105 | "session": [
106 | "email": loginState.email,
107 | "password": loginState.password
108 | ]
109 | ]
110 |
111 | let request = session.request(
112 | requestURL("/authentication"),
113 | method: .post,
114 | parameters: parameters,
115 | headers: headers
116 | )
117 | .validate()
118 | .serializingData()
119 |
120 | return try await handleRequest(request) { request, response in
121 | let value = try await request.value
122 | let jsonData = decodeJSON(value)?["user"] as! [String: Any]
123 | let token = jsonData["api_token"] as! String
124 | let id = jsonData["id"] as! Int
125 | let email = jsonData["email"] as! String
126 | let isAdmin = jsonData["is_admin"] as! Bool
127 | let responseHeaders = response.response?.allHeaderFields as! [String: String]
128 |
129 | return AuthenticationResponse(
130 | token: token,
131 | user: User(id: id, email: email, isAdmin: isAdmin),
132 | cookies: HTTPCookie.cookies(withResponseHeaderFields: responseHeaders, for: userDefaultClient.serverAddress()!)
133 | )
134 | }
135 | },
136 |
137 | logout: {
138 | let request = session.request(
139 | requestURL("/authentication"),
140 | method: .delete,
141 | headers: headers
142 | )
143 | .validate()
144 | .serializingDecodable(NoContentResponse.self)
145 |
146 | return try await handleRequest(request) { request, _ in
147 | try await request.value
148 | }
149 | },
150 |
151 | getSongsFromCurrentPlaylist: {
152 | let request = session.request(
153 | requestURL("/current_playlist/songs"),
154 | headers: headers
155 | )
156 | .validate()
157 | .serializingDecodable([Song].self, decoder: jsonDecoder)
158 |
159 | return try await handleRequest(request) { request, _ in
160 | try await request.value
161 | }
162 | },
163 |
164 | addSongToFavorite: { song in
165 | let request = session.request(
166 | requestURL("/favorite_playlist/songs"),
167 | method: .post,
168 | parameters: ["song_id": song.id],
169 | headers: headers
170 | )
171 | .validate()
172 | .serializingData()
173 |
174 | return try await handleRequest(request) { request, _ in
175 | let value = try await request.value
176 | return decodeJSON(value)?["id"] as! Int
177 | }
178 | },
179 |
180 | deleteSongInFavorite: { song in
181 | let request = session.request(
182 | requestURL("/favorite_playlist/songs/\(song.id)"),
183 | method: .delete,
184 | headers: headers
185 | )
186 | .validate()
187 | .serializingData()
188 |
189 | return try await handleRequest(request) { request, _ in
190 | let value = try await request.value
191 | return decodeJSON(value)?["id"] as! Int
192 | }
193 | },
194 |
195 | deleteSongInCurrentPlaylist: { song in
196 | let request = session.request(
197 | requestURL("/current_playlist/songs/\(song.id)"),
198 | method: .delete,
199 | headers: headers
200 | )
201 | .validate()
202 | .serializingDecodable(NoContentResponse.self)
203 |
204 | return try await handleRequest(request) { request, _ in
205 | try await request.value
206 | }
207 | },
208 |
209 | moveSongInCurrentPlaylist: { songId, destinationSongId in
210 | let request = session.request(
211 | requestURL("/current_playlist/songs/\(songId)/move"),
212 | method: .put,
213 | parameters: ["destination_song_id": destinationSongId],
214 | headers: headers
215 | )
216 | .validate()
217 | .serializingDecodable(NoContentResponse.self)
218 |
219 | return try await handleRequest(request) { request, _ in
220 | try await request.value
221 | }
222 | },
223 |
224 | getSong: { songId in
225 | let request = session.request(
226 | requestURL("/songs/\(songId)"),
227 | headers: headers
228 | )
229 | .validate()
230 | .serializingDecodable(Song.self, decoder: jsonDecoder)
231 |
232 | return try await handleRequest(request) { request, _ in
233 | try await request.value
234 | }
235 | },
236 |
237 | getSystemInfo: { serverAddressState in
238 | let url = "\(serverAddressState.url)/api/v1/system"
239 |
240 | let request = session.request(
241 | url,
242 | headers: headers
243 | )
244 | .validate()
245 | .serializingDecodable(SystemInfo.self, decoder: jsonDecoder)
246 |
247 | return try await handleRequest(request) { request, response in
248 | var systemInfo = try await request.value
249 | var serverAddressUrlComponents = URLComponents(url: (response.response?.url)!, resolvingAgainstBaseURL: false)!
250 | serverAddressUrlComponents.path = ""
251 | systemInfo.serverAddress = serverAddressUrlComponents.url
252 |
253 | return systemInfo
254 | }
255 | },
256 |
257 | addSongToCurrentPlaylist: { songId, currentSong, location in
258 | var parameters: [String: Any] = ["song_id": songId]
259 |
260 | if let currentSongId = currentSong?.id {
261 | parameters["current_song_id"] = currentSongId
262 | }
263 |
264 | if let location = location {
265 | parameters["location"] = location
266 | }
267 |
268 | let request = session.request(
269 | requestURL("/current_playlist/songs"),
270 | method: .post,
271 | parameters: parameters,
272 | headers: headers
273 | )
274 | .validate()
275 | .serializingDecodable(Song.self, decoder: jsonDecoder)
276 |
277 | return try await handleRequest(request) { request, _ in
278 | try await request.value
279 | }
280 | },
281 |
282 | replaceCurrentPlaylistWithAlbumSongs: { albumId in
283 | let request = session.request(
284 | requestURL("/current_playlist/songs/albums/\(albumId)"),
285 | method: .put,
286 | headers: headers
287 | )
288 | .validate()
289 | .serializingDecodable([Song].self, decoder: jsonDecoder)
290 |
291 | return try await handleRequest(request) { request, _ in
292 | try await request.value
293 | }
294 | },
295 |
296 | replaceCurrentPlaylistWithPlaylistSongs: { playlistId in
297 | let request = session.request(
298 | requestURL("/current_playlist/songs/playlists/\(playlistId)"),
299 | method: .put,
300 | headers: headers
301 | )
302 | .validate()
303 | .serializingDecodable([Song].self, decoder: jsonDecoder)
304 |
305 | return try await handleRequest(request) { request, _ in
306 | try await request.value
307 | }
308 | }
309 | )
310 | }
311 |
312 | static let liveValue = live()
313 | }
314 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/CookiesClient/CookiesClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 | import WebKit
4 |
5 | struct CookiesClient {
6 | var updateCookies: ([HTTPCookie]) async -> Void
7 | var cleanCookies: () async -> Void
8 | }
9 |
10 | extension CookiesClient: TestDependencyKey {
11 | static let testValue = Self(
12 | updateCookies: { _ in },
13 | cleanCookies: { }
14 | )
15 | }
16 |
17 | extension DependencyValues {
18 | var cookiesClient: CookiesClient {
19 | get { self[CookiesClient.self] }
20 | set { self[CookiesClient.self] = newValue }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/CookiesClient/LiveCookiesClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 | import WebKit
4 |
5 | extension CookiesClient: DependencyKey {
6 | static func live(dataStore: WKWebsiteDataStore) -> Self {
7 | @Dependency(\.userDefaultsClient) var userDefaultClient
8 |
9 | return Self(
10 | updateCookies: { cookies in
11 | // WKWebsiteDataStore.httpCookieStore must be used from main thread only
12 | let cookieStore = await MainActor.run { dataStore.httpCookieStore }
13 |
14 | await withTaskGroup(of: Void.self) { taskGroup in
15 | for cookie in cookies {
16 | // SetCookie must be running in the main thread, otherwise it will throw an error.
17 | taskGroup.addTask { @MainActor in
18 | await cookieStore.setCookie(cookie)
19 | }
20 | }
21 | }
22 | },
23 |
24 | cleanCookies: {
25 | // WKWebsiteDataStore.httpCookieStore must be used from main thread only
26 | let cookieStore = await MainActor.run { dataStore.httpCookieStore }
27 | let cookies = await cookieStore.allCookies()
28 |
29 | await withTaskGroup(of: Void.self) { taskGroup in
30 | for cookie in cookies {
31 | taskGroup.addTask { await cookieStore.deleteCookie(cookie) }
32 | }
33 | }
34 | }
35 | )
36 | }
37 |
38 | static let liveValue = live(dataStore: WKWebsiteDataStore.default())
39 | }
40 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/FlashMessageClient/FlashMessageClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | struct FlashMessageClient {
5 | var showLocalizedMessage: (String.LocalizationValue) -> Void
6 | var showMessage: (String) -> Void
7 | }
8 |
9 | extension FlashMessageClient: TestDependencyKey {
10 | static let testValue = Self(
11 | showLocalizedMessage: { _ in },
12 | showMessage: { _ in }
13 | )
14 | }
15 |
16 | extension DependencyValues {
17 | var flashMessageClient: FlashMessageClient {
18 | get { self[FlashMessageClient.self] }
19 | set { self[FlashMessageClient.self] = newValue }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/FlashMessageClient/LiveFlashMessageClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import UIKit
3 | import Dependencies
4 | import SPAlert
5 |
6 | extension FlashMessageClient: DependencyKey {
7 | static func live() -> Self {
8 | func presentMessage(_ message: String) {
9 | let alertView = SPAlertView(message: message)
10 |
11 | alertView.subtitleLabel?.font = .preferredFont(forTextStyle: .headline)
12 | alertView.subtitleLabel?.textColor = .secondaryLabel
13 | alertView.present()
14 | }
15 |
16 | return Self(
17 | showLocalizedMessage: { localizedMessage in
18 | presentMessage(String(localized: localizedMessage))
19 | },
20 |
21 | showMessage: { message in
22 | presentMessage(message)
23 | }
24 | )
25 | }
26 |
27 | static let liveValue = live()
28 | }
29 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/GlobalQueueClient/GlobalQueueClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | struct GlobalQueueClient {
5 | var async: (DispatchQoS.QoSClass, @escaping () -> Void) -> Void
6 | }
7 |
8 | extension GlobalQueueClient: TestDependencyKey {
9 | static let testValue = Self(
10 | async: { _, work in work() }
11 | )
12 | }
13 |
14 | extension DependencyValues {
15 | var globalQueueClient: GlobalQueueClient {
16 | get { self[GlobalQueueClient.self] }
17 | set { self[GlobalQueueClient.self] = newValue }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/GlobalQueueClient/LiveGlobalQueueClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | extension GlobalQueueClient: DependencyKey {
5 | static let liveValue = Self(
6 | async: { qos, work in
7 | DispatchQueue.global(qos: qos).async {
8 | work()
9 | }
10 | }
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/JSONDataClient/JSONDataClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | struct JSONDataClient {
5 | var currentUser: () -> User?
6 | var updateCurrentUser: (User) -> Void
7 | var deleteCurrentUser: () -> Void
8 | }
9 |
10 | extension JSONDataClient: TestDependencyKey {
11 | static let testValue = Self(
12 | currentUser: unimplemented("\(Self.self).currentUser"),
13 | updateCurrentUser: { _ in },
14 | deleteCurrentUser: {}
15 | )
16 | }
17 |
18 | extension DependencyValues {
19 | var jsonDataClient: JSONDataClient {
20 | get { self[JSONDataClient.self] }
21 | set { self[JSONDataClient.self] = newValue }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/JSONDataClient/LiveJSONDataClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | extension JSONDataClient: DependencyKey {
5 | static func live(userSavedFile: String) -> Self {
6 | @Dependency(\.globalQueueClient) var globalQueueClient
7 |
8 | func fileUrl(_ file: String) throws -> URL {
9 | guard let documentsFolder = try? FileManager.default.url(
10 | for: .documentDirectory,
11 | in: .userDomainMask,
12 | appropriateFor: nil,
13 | create: false) else {
14 | fatalError("Resource not found: \(file)")
15 | }
16 |
17 | return documentsFolder.appendingPathComponent(file)
18 | }
19 |
20 | func load(file: String) throws -> T {
21 | let data = try Data(contentsOf: fileUrl(file))
22 |
23 | return try JSONDecoder().decode(T.self, from: data)
24 | }
25 |
26 | func save(file: String, data: T) {
27 | globalQueueClient.async(.background) {
28 | guard let data = try? JSONEncoder().encode(data) else { return }
29 | try? data.write(to: fileUrl(file))
30 | }
31 | }
32 |
33 | return Self(
34 | currentUser: {
35 | try? load(file: userSavedFile)
36 | },
37 |
38 | updateCurrentUser: { user in
39 | save(file: userSavedFile, data: user)
40 | },
41 |
42 | deleteCurrentUser: {
43 | try! FileManager.default.removeItem(at: fileUrl(userSavedFile))
44 | }
45 | )
46 | }
47 |
48 | static let liveValue = live(userSavedFile: "current_user.json")
49 | }
50 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/KeychainClient/KeychainClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | struct KeychainClient {
5 | var apiToken: () -> String?
6 | var updateAPIToken: (String) -> Void
7 | var deleteAPIToken: () -> Void
8 | }
9 |
10 | extension KeychainClient: TestDependencyKey {
11 | static let testValue = Self(
12 | apiToken: {
13 | "test_token"
14 | },
15 | updateAPIToken: { _ in },
16 | deleteAPIToken: {}
17 | )
18 | }
19 |
20 | extension DependencyValues {
21 | var keychainClient: KeychainClient {
22 | get { self[KeychainClient.self] }
23 | set { self[KeychainClient.self] = newValue }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/KeychainClient/LiveKeychainClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | extension KeychainClient: DependencyKey {
5 | static func live(apiTokenKey: String) -> Self {
6 | return Self(
7 | apiToken: {
8 | let query: [String: Any] = [
9 | kSecClass as String: kSecClassGenericPassword,
10 | kSecAttrAccount as String: apiTokenKey,
11 | kSecReturnData as String: true,
12 | kSecMatchLimit as String: kSecMatchLimitOne
13 | ]
14 |
15 | var dataTypeRef: AnyObject?
16 | let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
17 |
18 | if status == errSecSuccess {
19 | if let tokenData = dataTypeRef as? Data {
20 | return String(data: tokenData, encoding: .utf8)
21 | } else {
22 | return nil
23 | }
24 | } else {
25 | return nil
26 | }
27 | },
28 |
29 | updateAPIToken: { token in
30 | let query: [String: Any] = [
31 | kSecClass as String: kSecClassGenericPassword as String,
32 | kSecAttrAccount as String: apiTokenKey,
33 | kSecValueData as String: token.data(using: .utf8)!
34 | ]
35 |
36 | SecItemDelete(query as CFDictionary)
37 | SecItemAdd(query as CFDictionary, nil)
38 | },
39 |
40 | deleteAPIToken: {
41 | let query: [String: Any] = [
42 | kSecClass as String: kSecClassGenericPassword as String,
43 | kSecAttrAccount as String: apiTokenKey
44 | ]
45 |
46 | SecItemDelete(query as CFDictionary)
47 | }
48 | )
49 | }
50 |
51 | static let liveValue = live(apiTokenKey: "org.BlackCandy.apiTokenKey")
52 | }
53 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/NowPlayingClient/LiveNowPlayingClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 | import MediaPlayer
4 | import UIKit
5 | import Alamofire
6 |
7 | extension NowPlayingClient: DependencyKey {
8 | static func live() -> Self {
9 | func updateAlbumImage(url: URL) async {
10 | var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
11 |
12 | let fileURL = try? await AF.download(url).serializingDownloadedFileURL().value
13 |
14 | guard
15 | let imagePath = fileURL?.path,
16 | let image = UIImage(contentsOfFile: imagePath) else { return }
17 |
18 | nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ in
19 | return image
20 | })
21 |
22 | MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
23 | }
24 |
25 | return Self(
26 | updateInfo: { song in
27 | var nowPlayingInfo = [String: Any]()
28 |
29 | nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.audio.rawValue
30 | nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = 0
31 | nowPlayingInfo[MPMediaItemPropertyTitle] = song.name
32 | nowPlayingInfo[MPMediaItemPropertyArtist] = song.artistName
33 | nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = song.albumName
34 | nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = song.duration
35 |
36 | MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
37 |
38 | await updateAlbumImage(url: song.albumImageUrl.large)
39 | },
40 |
41 | updatePlaybackInfo: { position, rate in
42 | var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
43 |
44 | nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = position
45 | nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
46 | nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
47 |
48 | MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
49 | }
50 | )
51 | }
52 |
53 | static let liveValue = live()
54 | }
55 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/NowPlayingClient/NowPlayingClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | struct NowPlayingClient {
5 | var updateInfo: (Song) async -> Void
6 | var updatePlaybackInfo: (Float, Float) -> Void
7 | }
8 |
9 | extension NowPlayingClient: TestDependencyKey {
10 | static let testValue = Self(
11 | updateInfo: { _ in },
12 | updatePlaybackInfo: { _, _ in }
13 | )
14 | }
15 |
16 | extension DependencyValues {
17 | var nowPlayingClient: NowPlayingClient {
18 | get { self[NowPlayingClient.self] }
19 | set { self[NowPlayingClient.self] = newValue }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/PlayerClient/LivePlayerClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 | import AVFoundation
4 |
5 | extension PlayerClient: DependencyKey {
6 | static func live(player: AVPlayer) -> Self {
7 | @Dependency(\.keychainClient) var keychainClient
8 |
9 | let apiToken = keychainClient.apiToken() ?? ""
10 |
11 | return Self(
12 | hasCurrentItem: {
13 | player.currentItem != nil
14 | },
15 |
16 | playOn: { songUrl in
17 | let asset = AVURLAsset(url: songUrl, options: [
18 | "AVURLAssetHTTPHeaderFieldsKey": [
19 | "Authorization": "Token \(apiToken)",
20 | "User-Agent": BLACK_CANDY_USER_AGENT
21 | ]
22 | ])
23 |
24 | let playerItem = AVPlayerItem(asset: asset)
25 |
26 | player.pause()
27 | player.replaceCurrentItem(with: playerItem)
28 | player.play()
29 | },
30 |
31 | play: {
32 | player.play()
33 | },
34 |
35 | pause: {
36 | player.pause()
37 | },
38 |
39 | replay: {
40 | player.seek(to: CMTime.zero)
41 | player.play()
42 | },
43 |
44 | seek: { time in
45 | player.seek(to: time)
46 | },
47 |
48 | stop: {
49 | player.seek(to: CMTime.zero)
50 | player.pause()
51 | player.replaceCurrentItem(with: nil)
52 | },
53 |
54 | getCurrentTime: {
55 | AsyncStream { continuation in
56 | let observer = player.addPeriodicTimeObserver(forInterval: .init(seconds: 1, preferredTimescale: 1), queue: .global(qos: .background), using: { _ in
57 | let seconds = player.currentTime().seconds
58 | continuation.yield(seconds.isNaN ? 0 : seconds)
59 | })
60 |
61 | continuation.onTermination = { @Sendable _ in
62 | player.removeTimeObserver(observer)
63 | }
64 | }
65 | },
66 |
67 | getStatus: {
68 | AsyncStream { continuation in
69 | let timeControlStatusObserver = player.observe(\AVPlayer.timeControlStatus, changeHandler: { (player, _) in
70 | switch player.timeControlStatus {
71 | case .paused:
72 | continuation.yield(.pause)
73 | case .waitingToPlayAtSpecifiedRate:
74 | continuation.yield(.loading)
75 | case .playing:
76 | continuation.yield(.playing)
77 | @unknown default:
78 | continuation.yield(.pause)
79 | }
80 | })
81 |
82 | let playToEndObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main, using: { _ in
83 | continuation.yield(.end)
84 | })
85 |
86 | continuation.onTermination = { @Sendable _ in
87 | timeControlStatusObserver.invalidate()
88 | NotificationCenter.default.removeObserver(playToEndObserver)
89 | }
90 | }
91 | },
92 |
93 | getPlaybackRate: {
94 | player.rate
95 | }
96 | )
97 | }
98 |
99 | static var liveValue = live(player: AVPlayer())
100 | }
101 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/PlayerClient/PlayerClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 | import AVFoundation
4 |
5 | struct PlayerClient {
6 | var hasCurrentItem: () -> Bool
7 | var playOn: (URL) -> Void
8 | var play: () -> Void
9 | var pause: () -> Void
10 | var replay: () -> Void
11 | var seek: (CMTime) -> Void
12 | var stop: () -> Void
13 | var getCurrentTime: () -> AsyncStream
14 | var getStatus: () -> AsyncStream
15 | var getPlaybackRate: () -> Float
16 | }
17 |
18 | extension PlayerClient: TestDependencyKey {
19 | static let testValue = Self(
20 | hasCurrentItem: unimplemented("\(Self.self).hasCurrentItem"),
21 | playOn: { _ in },
22 | play: { },
23 | pause: { },
24 | replay: {},
25 | seek: { _ in },
26 | stop: {},
27 | getCurrentTime: unimplemented("\(Self.self).getCurrentTime"),
28 | getStatus: unimplemented("\(Self.self).getStatus"),
29 | getPlaybackRate: { 1 }
30 | )
31 | }
32 |
33 | extension DependencyValues {
34 | var playerClient: PlayerClient {
35 | get { self[PlayerClient.self] }
36 | set { self[PlayerClient.self] = newValue }
37 | }
38 | }
39 |
40 | extension PlayerClient {
41 | enum Status: String {
42 | case pause
43 | case playing
44 | case loading
45 | case end
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/UserDefaultsClient/LiveUserDefaultsClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | extension UserDefaultsClient: DependencyKey {
5 | static func live(serverAddressKey: String) -> Self {
6 | return Self(
7 | serverAddress: {
8 | UserDefaults.standard.url(forKey: serverAddressKey)
9 | },
10 |
11 | updateServerAddress: { url in
12 | UserDefaults.standard.set(url, forKey: serverAddressKey)
13 | }
14 | )
15 | }
16 |
17 | static var liveValue = live(serverAddressKey: "org.BlackCandy.serverAddressKey")
18 | }
19 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/UserDefaultsClient/UserDefaultsClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 |
4 | struct UserDefaultsClient {
5 | var serverAddress: () -> URL?
6 | var updateServerAddress: (URL?) -> Void
7 | }
8 |
9 | extension UserDefaultsClient: TestDependencyKey {
10 | static let testValue = Self(
11 | serverAddress: {
12 | URL(string: "http://localhost:3000")
13 | },
14 |
15 | updateServerAddress: { _ in }
16 | )
17 |
18 | static let previewValue = testValue
19 | }
20 |
21 | extension DependencyValues {
22 | var userDefaultsClient: UserDefaultsClient {
23 | get { self[UserDefaultsClient.self] }
24 | set { self[UserDefaultsClient.self] = newValue }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/WindowClient/LiveWindowClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 | import UIKit
4 | import Dependencies
5 |
6 | extension WindowClient: DependencyKey {
7 | static func live() -> Self {
8 | return Self(
9 | changeRootViewController: { viewController in
10 | let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate
11 | sceneDelegate?.window?.rootViewController = viewController
12 | }
13 | )
14 | }
15 |
16 | static let liveValue = live()
17 | }
18 |
--------------------------------------------------------------------------------
/BlackCandy/Clients/WindowClient/WindowClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dependencies
3 | import UIKit
4 |
5 | struct WindowClient {
6 | var changeRootViewController: (UIViewController) -> Void
7 | }
8 |
9 | extension WindowClient: TestDependencyKey {
10 | static let testValue = Self(
11 | changeRootViewController: { _ in }
12 | )
13 | }
14 |
15 | extension DependencyValues {
16 | var windowClient: WindowClient {
17 | get { self[WindowClient.self] }
18 | set { self[WindowClient.self] = newValue }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/BlackCandy/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSAppTransportSecurity
6 |
7 | NSAllowsArbitraryLoads
8 |
9 |
10 | UIApplicationSceneManifest
11 |
12 | UIApplicationSupportsMultipleScenes
13 |
14 | UISceneConfigurations
15 |
16 | UIWindowSceneSessionRoleApplication
17 |
18 |
19 | UISceneConfigurationName
20 | Default Configuration
21 | UISceneDelegateClassName
22 | $(PRODUCT_MODULE_NAME).SceneDelegate
23 |
24 |
25 |
26 |
27 | UIBackgroundModes
28 |
29 | audio
30 |
31 | UILaunchScreen
32 |
33 | UITabBar
34 |
35 | UIImageName
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/BlackCandy/Localizable.strings:
--------------------------------------------------------------------------------
1 | "label.home" = "Home";
2 | "label.library" = "Library";
3 | "label.account" = "Account";
4 | "label.serverAddress" = "Server Address";
5 | "label.email" = "Email";
6 | "label.password" = "Password";
7 | "label.login" = "Login";
8 | "label.settings" = "Settings";
9 | "label.manageUsers" = "Manage Users";
10 | "label.updateProfile" = "Update Profile";
11 | "label.logout" = "Logout";
12 | "label.noItems" = "No items";
13 | "label.notPlaying" = "Not Playing";
14 | "label.tracks(%lld)" = "%lld tracks";
15 | "label.done" = "Done";
16 | "label.connect" = "Connect";
17 | "label.ok" = "OK";
18 |
19 | "text.loginToBC" = "Login to Black Candy";
20 | "text.connectToBC" = "Connect to Black Candy";
21 | "text.invalidServerAddress" = "Invalid Server Address";
22 | "text.invalidUserCredential" = "Wrong email or password";
23 | "text.invalidRequest" = "Invalid Request";
24 | "text.invalidResponse" = "Invalid Response";
25 | "text.unknownNetworkError" = "Unknown Network Error";
26 | "text.badRequest" = "Bad Request";
27 | "text.unsupportedServer" = "Unsupported Black Candy Server";
28 | "text.addedToPlaylist" = "Added to Playlist";
29 |
--------------------------------------------------------------------------------
/BlackCandy/Models/Playlist.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Playlist: Equatable {
4 | var isShuffled = false
5 | var orderedSongs: [Song] = []
6 | private var shuffledSongs: [Song] = []
7 |
8 | var songs: [Song] {
9 | isShuffled ? shuffledSongs : orderedSongs
10 | }
11 |
12 | func index(of song: Song) -> Int? {
13 | songs.firstIndex(of: song)
14 | }
15 |
16 | func index(by songId: Int) -> Int? {
17 | songs.firstIndex(where: { $0.id == songId })
18 | }
19 |
20 | func find(bySongId id: Int) -> Song? {
21 | songs.first(where: { $0.id == id })
22 | }
23 |
24 | func find(byIndex index: Int) -> Song? {
25 | if index >= songs.count {
26 | return songs.first
27 | } else if index < 0 {
28 | return songs.last
29 | } else {
30 | return songs[index]
31 | }
32 | }
33 |
34 | mutating func update(songs: [Song]) {
35 | orderedSongs = songs
36 | shuffledSongs = songs.shuffled()
37 | }
38 |
39 | mutating func update(song: Song) {
40 | if let index = orderedSongs.firstIndex(where: { $0.id == song.id }) {
41 | orderedSongs[index] = song
42 | }
43 |
44 | if let index = shuffledSongs.firstIndex(where: { $0.id == song.id }) {
45 | shuffledSongs[index] = song
46 | }
47 | }
48 |
49 | mutating func remove(songs: [Song]) {
50 | orderedSongs.removeAll(where: { songs.contains($0) })
51 | shuffledSongs.removeAll(where: { songs.contains($0) })
52 | }
53 |
54 | mutating func insert(_ song: Song, at index: Int) {
55 | orderedSongs.insert(song, at: index)
56 | shuffledSongs.insert(song, at: index)
57 | }
58 |
59 | mutating func append(_ song: Song) {
60 | orderedSongs.append(song)
61 | shuffledSongs.append(song)
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/BlackCandy/Models/Song.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Song: Codable, Equatable, Identifiable {
4 | let id: Int
5 | let name: String
6 | let duration: Double
7 | let url: URL
8 | let albumName: String
9 | let artistName: String
10 | let format: String
11 | let albumImageUrl: ImageURL
12 |
13 | var isFavorited: Bool
14 | }
15 |
16 | extension Song {
17 | struct ImageURL: Codable, Equatable {
18 | let small: URL
19 | let medium: URL
20 | let large: URL
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/BlackCandy/Models/SystemInfo.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct SystemInfo: Codable, Equatable {
4 | static let supportedMinimumMajorVersion = 3
5 |
6 | let version: Version
7 | var serverAddress: URL?
8 |
9 | var isSupported: Bool {
10 | version.major >= Self.supportedMinimumMajorVersion
11 | }
12 | }
13 |
14 | extension SystemInfo {
15 | struct Version: Codable, Equatable {
16 | let major: Int
17 | let minor: Int
18 | let patch: Int
19 | let pre: String
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/BlackCandy/Models/User.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct User: Codable, Equatable {
4 | let id: Int
5 | let email: String
6 | let isAdmin: Bool
7 | }
8 |
--------------------------------------------------------------------------------
/BlackCandy/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/BlackCandy/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 | import Combine
4 | import ComposableArchitecture
5 |
6 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
7 | var window: UIWindow?
8 | var cancellables: Set = []
9 |
10 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
11 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
12 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
13 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
14 | guard !_XCTIsTesting else { return }
15 | guard let windowScene = scene as? UIWindowScene else { return }
16 |
17 | let window = UIWindow(windowScene: windowScene)
18 | let store = AppStore.shared
19 |
20 | if store.withState(\.isLoggedIn) {
21 | window.rootViewController = MainViewController(store: store)
22 | } else {
23 | window.rootViewController = LoginViewController(store: store)
24 | }
25 |
26 | store.publisher.currentTheme
27 | .map { $0.interfaceStyle }
28 | .assign(to: \.overrideUserInterfaceStyle, on: window)
29 | .store(in: &self.cancellables)
30 |
31 | self.window = window
32 |
33 | window.makeKeyAndVisible()
34 | }
35 |
36 | func sceneDidDisconnect(_ scene: UIScene) {
37 | // Called as the scene is being released by the system.
38 | // This occurs shortly after the scene enters the background, or when its session is discarded.
39 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
40 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
41 | }
42 |
43 | func sceneDidBecomeActive(_ scene: UIScene) {
44 | // Called when the scene has moved from an inactive state to an active state.
45 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
46 | }
47 |
48 | func sceneWillResignActive(_ scene: UIScene) {
49 | // Called when the scene will move from an active state to an inactive state.
50 | // This may occur due to temporary interruptions (ex. an incoming phone call).
51 | }
52 |
53 | func sceneWillEnterForeground(_ scene: UIScene) {
54 | let store = AppStore.shared
55 |
56 | if store.withState(\.isLoggedIn) {
57 | store.send(.player(.getCurrentPlaylist))
58 | }
59 | }
60 |
61 | func sceneDidEnterBackground(_ scene: UIScene) {
62 | // Called as the scene transitions from the foreground to the background.
63 | // Use this method to save data, release shared resources, and store enough scene-specific state information
64 | // to restore the scene back to its current state.
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/BlackCandy/States/LoginState.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class LoginState: ObservableObject, Equatable {
4 | @Published var email = ""
5 | @Published var password = ""
6 |
7 | var hasEmptyField: Bool {
8 | email.isEmpty || password.isEmpty
9 | }
10 |
11 | static func == (lhs: LoginState, rhs: LoginState) -> Bool {
12 | lhs.email == rhs.email && lhs.password == rhs.password
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/BlackCandy/States/ServerAddressState.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import UIKit
3 |
4 | class ServerAddressState: ObservableObject, Equatable {
5 | @Published var url = ""
6 |
7 | var hasEmptyField: Bool {
8 | url.isEmpty
9 | }
10 |
11 | func validateUrl() -> Bool {
12 | let schemeRegex = try! NSRegularExpression(pattern: "^https?://.*", options: .caseInsensitive)
13 | let hasScheme = schemeRegex.firstMatch(in: url, range: .init(location: 0, length: url.utf16.count)) != nil
14 |
15 | if !hasScheme {
16 | url = "http://" + url
17 | }
18 |
19 | guard let serverUrl = URL(string: url) else { return false }
20 |
21 | return UIApplication.shared.canOpenURL(serverUrl)
22 | }
23 |
24 | static func == (lhs: ServerAddressState, rhs: ServerAddressState) -> Bool {
25 | lhs.url == rhs.url
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/BlackCandy/Store/AppReducer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 | import ComposableArchitecture
4 |
5 | struct AppReducer: Reducer {
6 | @Dependency(\.apiClient) var apiClient
7 | @Dependency(\.userDefaultsClient) var userDefaultsClient
8 | @Dependency(\.cookiesClient) var cookiesClient
9 | @Dependency(\.keychainClient) var keychainClient
10 | @Dependency(\.jsonDataClient) var jsonDataClient
11 | @Dependency(\.windowClient) var windowClient
12 |
13 | struct State: Equatable {
14 | @PresentationState var alert: AlertState?
15 |
16 | var currentUser: User?
17 | var currentTheme = Theme.auto
18 |
19 | var isLoggedIn: Bool {
20 | currentUser != nil
21 | }
22 |
23 | var player: PlayerReducer.State {
24 | get {
25 | var state = _playerState
26 | state.alert = self.alert
27 |
28 | return state
29 | }
30 |
31 | set {
32 | self._playerState = newValue
33 | self.alert = newValue.alert
34 | }
35 | }
36 |
37 | var login: LoginReducer.State {
38 | get {
39 | var state = _loginState
40 | state.currentUser = self.currentUser
41 |
42 | return state
43 | }
44 |
45 | set {
46 | self._loginState = newValue
47 | self.currentUser = newValue.currentUser
48 | }
49 | }
50 |
51 | private var _loginState: LoginReducer.State = .init()
52 | private var _playerState: PlayerReducer.State = .init()
53 | }
54 |
55 | enum Action: Equatable {
56 | case alert(PresentationAction)
57 | case dismissAlert
58 | case restoreStates
59 | case logout
60 | case logoutResponse(TaskResult)
61 | case updateTheme(State.Theme)
62 | case player(PlayerReducer.Action)
63 | case login(LoginReducer.Action)
64 | }
65 |
66 | enum AlertAction: Equatable {}
67 |
68 | var body: some ReducerOf {
69 | Reduce { state, action in
70 | switch action {
71 | case .restoreStates:
72 | state.currentUser = jsonDataClient.currentUser()
73 |
74 | return .none
75 |
76 | case .logout:
77 | return .run { send in
78 | await send(
79 | .logoutResponse(
80 | TaskResult { try await apiClient.logout() }
81 | )
82 | )
83 | }
84 |
85 | case .logoutResponse:
86 | keychainClient.deleteAPIToken()
87 | jsonDataClient.deleteCurrentUser()
88 | windowClient.changeRootViewController(LoginViewController(store: AppStore.shared))
89 |
90 | state.currentUser = nil
91 |
92 | return .run { _ in
93 | await cookiesClient.cleanCookies()
94 | }
95 |
96 | case let .updateTheme(theme):
97 | state.currentTheme = theme
98 | return .none
99 |
100 | case .dismissAlert:
101 | return .send(.alert(.dismiss))
102 |
103 | case .player:
104 | return .none
105 |
106 | case .login:
107 | return .none
108 |
109 | case .alert:
110 | return .none
111 | }
112 | }
113 | .ifLet(\.$alert, action: /Action.alert)
114 |
115 | Scope(state: \.player, action: /Action.player) {
116 | PlayerReducer()
117 | }
118 |
119 | Scope(state: \.login, action: /Action.login) {
120 | LoginReducer()
121 | }
122 | }
123 | }
124 |
125 | extension AppReducer.State {
126 | enum Theme: String {
127 | case auto
128 | case light
129 | case dark
130 |
131 | var interfaceStyle: UIUserInterfaceStyle {
132 | switch self {
133 | case .dark:
134 | return .dark
135 | case .light:
136 | return .light
137 | case .auto:
138 | return .unspecified
139 | }
140 | }
141 |
142 | var colorScheme: ColorScheme? {
143 | switch self {
144 | case .dark:
145 | return .dark
146 | case .light:
147 | return .light
148 | case .auto:
149 | return nil
150 | }
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/BlackCandy/Store/AppStore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ComposableArchitecture
3 |
4 | struct AppStore {
5 | static let shared = Store(initialState: AppReducer.State()) {
6 | AppReducer()
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/BlackCandy/Store/LoginReducer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ComposableArchitecture
3 |
4 | struct LoginReducer: Reducer {
5 | @Dependency(\.apiClient) var apiClient
6 | @Dependency(\.userDefaultsClient) var userDefaultsClient
7 | @Dependency(\.cookiesClient) var cookiesClient
8 | @Dependency(\.keychainClient) var keychainClient
9 | @Dependency(\.jsonDataClient) var jsonDataClient
10 | @Dependency(\.windowClient) var windowClient
11 |
12 | struct State: Equatable {
13 | @PresentationState var alert: AlertState?
14 | @BindingState var isAuthenticationViewVisible = false
15 |
16 | var currentUser: User?
17 | }
18 |
19 | enum Action: Equatable, BindableAction {
20 | case alert(PresentationAction)
21 | case getSystemInfo(ServerAddressState)
22 | case systemInfoResponse(TaskResult)
23 | case login(LoginState)
24 | case loginResponse(TaskResult)
25 | case binding(BindingAction)
26 | }
27 |
28 | enum AlertAction: Equatable {}
29 |
30 | var body: some ReducerOf {
31 | BindingReducer()
32 |
33 | Reduce { state, action in
34 | switch action {
35 | case let .getSystemInfo(serverAddressState):
36 | if serverAddressState.validateUrl() {
37 | return .run { send in
38 | await send(
39 | .systemInfoResponse(
40 | TaskResult { try await apiClient.getSystemInfo(serverAddressState) }
41 | )
42 | )
43 | }
44 | } else {
45 | state.alert = .init(title: .init("text.invalidServerAddress"))
46 | return .none
47 | }
48 |
49 | case let .systemInfoResponse(.success(systemInfo)):
50 | guard let serverAddress = systemInfo.serverAddress else {
51 | state.alert = .init(title: .init("text.invalidServerAddress"))
52 | return .none
53 | }
54 |
55 | guard systemInfo.isSupported else {
56 | state.alert = .init(title: .init("text.unsupportedServer"))
57 | return .none
58 | }
59 |
60 | userDefaultsClient.updateServerAddress(serverAddress)
61 |
62 | state.isAuthenticationViewVisible = true
63 |
64 | return .none
65 |
66 | case let .login(loginState):
67 | return .run { send in
68 | await send(
69 | .loginResponse(
70 | TaskResult { try await apiClient.login(loginState) }
71 | )
72 | )
73 | }
74 |
75 | case .binding(\.$isAuthenticationViewVisible):
76 | return .none
77 |
78 | case let .loginResponse(.success(response)):
79 | keychainClient.updateAPIToken(response.token)
80 | jsonDataClient.updateCurrentUser(response.user)
81 | windowClient.changeRootViewController(MainViewController(store: AppStore.shared, initPlaylist: true))
82 |
83 | state.currentUser = response.user
84 |
85 | return .run { _ in
86 | await cookiesClient.updateCookies(response.cookies)
87 | }
88 |
89 | case let .loginResponse(.failure(error)),
90 | let .systemInfoResponse(.failure(error)):
91 | guard let error = error as? APIClient.APIError else { return .none }
92 | state.alert = .init(title: .init(error.localizedString))
93 |
94 | return .none
95 |
96 | case .binding:
97 | return .none
98 |
99 | case .alert:
100 | return .none
101 | }
102 | }
103 | .ifLet(\.$alert, action: /Action.alert)
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/BlackCandy/Store/PlayerReducer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ComposableArchitecture
3 | import CoreMedia
4 |
5 | struct PlayerReducer: Reducer {
6 | @Dependency(\.apiClient) var apiClient
7 | @Dependency(\.playerClient) var playerClient
8 | @Dependency(\.nowPlayingClient) var nowPlayingClient
9 | @Dependency(\.cookiesClient) var cookiesClient
10 | @Dependency(\.flashMessageClient) var flashMessageClient
11 |
12 | struct State: Equatable {
13 | var alert: AlertState?
14 | var playlist = Playlist()
15 | var currentSong: Song?
16 | var currentTime: Double = 0
17 | var isPlaylistVisible = false
18 | var status = PlayerClient.Status.pause
19 | var mode = Mode.noRepeat
20 |
21 | var isPlaying: Bool {
22 | status == .playing || status == .loading
23 | }
24 |
25 | var currentIndex: Int {
26 | guard let currentSong = currentSong else { return 0 }
27 | return playlist.songs.firstIndex(of: currentSong) ?? 0
28 | }
29 |
30 | var hasCurrentSong: Bool {
31 | currentSong != nil
32 | }
33 |
34 | mutating func insertSongNextToCurrent(song: Song) -> Int {
35 | let insertIndex = min(currentIndex + 1, playlist.songs.endIndex)
36 | playlist.insert(song, at: insertIndex)
37 |
38 | return insertIndex
39 | }
40 | }
41 |
42 | enum Action: Equatable {
43 | case play
44 | case pause
45 | case stop
46 | case next
47 | case previous
48 | case playOn(Int)
49 | case updateCurrentTime(Double)
50 | case toggleFavorite
51 | case toggleFavoriteResponse(TaskResult)
52 | case togglePlaylistVisible
53 | case seekToRatio(Double)
54 | case seekToPosition(TimeInterval)
55 | case getStatus
56 | case getCurrentTime
57 | case getLivingStates
58 | case handleStatusChange(PlayerClient.Status)
59 | case nextMode
60 | case deleteSongs(IndexSet)
61 | case deleteSongsResponse(TaskResult)
62 | case moveSongs(IndexSet, Int)
63 | case moveSongsResponse(TaskResult)
64 | case getCurrentPlaylist
65 | case currentPlaylistResponse(TaskResult<[Song]>)
66 | case playAlbum(Int)
67 | case playAlbumBeginWith(Int, Int)
68 | case playPlaylist(Int)
69 | case playPlaylistBeginWith(Int, Int)
70 | case playSongsResponse(TaskResult<[Song]>)
71 | case playSongsBeginWithResponse(TaskResult<[Song]>, Int)
72 | case playNow(Int)
73 | case playNext(Int)
74 | case playLast(Int)
75 | case playNowResponse(TaskResult)
76 | case playNextResponse(TaskResult)
77 | case playLastResponse(TaskResult)
78 | }
79 |
80 | var body: some ReducerOf {
81 | Reduce { state, action in
82 | switch action {
83 | case .play:
84 | if playerClient.hasCurrentItem() {
85 | playerClient.play()
86 | return .none
87 | } else {
88 | return self.playOn(state: &state, index: state.currentIndex)
89 | }
90 |
91 | case .pause:
92 | playerClient.pause()
93 | return .none
94 |
95 | case .stop:
96 | state.currentSong = nil
97 | playerClient.stop()
98 |
99 | return .none
100 |
101 | case .next:
102 | return self.playOn(state: &state, index: state.currentIndex + 1)
103 |
104 | case .previous:
105 | return self.playOn(state: &state, index: state.currentIndex - 1)
106 |
107 | case let .playOn(index):
108 | return self.playOn(state: &state, index: index)
109 |
110 | case let .updateCurrentTime(currentTime):
111 | state.currentTime = currentTime
112 |
113 | return .none
114 |
115 | case .toggleFavorite:
116 | guard let currentSong = state.currentSong else { return .none }
117 |
118 | return .run { send in
119 | await send(
120 | .toggleFavoriteResponse(
121 | TaskResult {
122 | if currentSong.isFavorited {
123 | return try await apiClient.deleteSongInFavorite(currentSong)
124 | } else {
125 | return try await apiClient.addSongToFavorite(currentSong)
126 | }
127 | }
128 | )
129 | )
130 | }
131 |
132 | case let .toggleFavoriteResponse(.success(songId)):
133 | guard var song = state.playlist.find(bySongId: songId) else { return .none }
134 | song.isFavorited.toggle()
135 |
136 | state.playlist.update(song: song)
137 |
138 | if state.currentSong?.id == songId {
139 | state.currentSong = song
140 | }
141 |
142 | return .none
143 |
144 | case .togglePlaylistVisible:
145 | state.isPlaylistVisible.toggle()
146 |
147 | return .none
148 |
149 | case let .seekToRatio(ratio):
150 | guard let currentSong = state.currentSong else { return .none }
151 | let position = currentSong.duration * ratio
152 |
153 | return .send(.seekToPosition(position))
154 |
155 | case let .seekToPosition(position):
156 | let time = CMTime(seconds: position, preferredTimescale: 1)
157 | playerClient.seek(time)
158 |
159 | return .none
160 |
161 | case .getCurrentTime:
162 | return .run { send in
163 | for await currentTime in playerClient.getCurrentTime() {
164 | await send(.updateCurrentTime(currentTime))
165 | }
166 | }
167 |
168 | case .getStatus:
169 | return .run { send in
170 | for await status in playerClient.getStatus() {
171 | await send(.handleStatusChange(status))
172 | }
173 | }
174 |
175 | case .getLivingStates:
176 | return .run { send in
177 | await withTaskGroup(of: Void.self) { taskGroup in
178 | taskGroup.addTask {
179 | await send(.getStatus)
180 | }
181 |
182 | taskGroup.addTask {
183 | await send(.getCurrentTime)
184 | }
185 | }
186 | }
187 |
188 | case let .handleStatusChange(status):
189 | let playbackRate = playerClient.getPlaybackRate()
190 | nowPlayingClient.updatePlaybackInfo(Float(state.currentTime), playbackRate)
191 |
192 | state.status = status
193 |
194 | guard status == .end else { return .none }
195 |
196 | switch state.mode {
197 | case .noRepeat:
198 | if state.currentIndex == state.playlist.songs.count - 1 {
199 | playerClient.stop()
200 | state.currentSong = state.playlist.songs.first
201 |
202 | return .none
203 | } else {
204 | return .send(.next)
205 | }
206 | case .single:
207 | playerClient.replay()
208 | return .none
209 | default:
210 | return .send(.next)
211 | }
212 |
213 | case .nextMode:
214 | state.mode = state.mode.next()
215 | state.playlist.isShuffled = (state.mode == .shuffle)
216 |
217 | return .none
218 |
219 | case let .deleteSongs(indexSet):
220 | let songs = indexSet.map { state.playlist.songs[$0] }
221 | let currentIndex = state.currentIndex
222 |
223 | state.playlist.remove(songs: songs)
224 |
225 | if let currentSong = state.currentSong, songs.contains(currentSong) {
226 | playerClient.stop()
227 | state.currentSong = state.playlist.find(byIndex: currentIndex)
228 | }
229 |
230 | return .run { send in
231 | await withTaskGroup(of: Void.self) { taskGroup in
232 | for song in songs {
233 | taskGroup.addTask {
234 | await send(
235 | .deleteSongsResponse(
236 | TaskResult { try await apiClient.deleteSongInCurrentPlaylist(song) }
237 | )
238 | )
239 | }
240 | }
241 | }
242 | }
243 |
244 | case let .moveSongs(fromOffsets, toOffset):
245 | guard let fromIndex = fromOffsets.first else { return .none }
246 | var destinationIndex = toOffset
247 |
248 | if fromIndex < toOffset {
249 | destinationIndex -= 1
250 | }
251 |
252 | let movingSong = state.playlist.orderedSongs[fromIndex]
253 | let destinationSong = state.playlist.orderedSongs[destinationIndex]
254 |
255 | state.playlist.orderedSongs.move(fromOffsets: fromOffsets, toOffset: toOffset)
256 |
257 | return .run { send in
258 | await send(
259 | .moveSongsResponse(
260 | TaskResult { try await apiClient.moveSongInCurrentPlaylist(movingSong.id, destinationSong.id) }
261 | )
262 | )
263 | }
264 |
265 | case .deleteSongsResponse(.success), .moveSongsResponse(.success):
266 | return .none
267 |
268 | case .getCurrentPlaylist:
269 | return .run { send in
270 | await send(
271 | .currentPlaylistResponse(
272 | TaskResult { try await apiClient.getSongsFromCurrentPlaylist() }
273 | )
274 | )
275 | }
276 |
277 | case let .currentPlaylistResponse(.success(songs)):
278 | state.playlist.update(songs: songs)
279 |
280 | guard let currentSong = state.currentSong else {
281 | state.currentSong = songs.first
282 | return .none
283 | }
284 |
285 | if !state.isPlaying && (state.playlist.index(of: currentSong) == nil) {
286 | state.currentSong = songs.first
287 | playerClient.stop()
288 | }
289 |
290 | return .none
291 |
292 | case let .playAlbum(albumId):
293 | return .run { send in
294 | await send(
295 | .playSongsResponse(
296 | TaskResult {
297 | try await apiClient.replaceCurrentPlaylistWithAlbumSongs(albumId)
298 | }
299 | )
300 | )
301 | }
302 |
303 | case let .playAlbumBeginWith(albumId, songId):
304 | return .run { send in
305 | await send(
306 | .playSongsBeginWithResponse(
307 | TaskResult { try await apiClient.replaceCurrentPlaylistWithAlbumSongs(albumId) },
308 | songId
309 | )
310 | )
311 | }
312 |
313 | case let .playPlaylist(playlistId):
314 | return .run { send in
315 | await send(
316 | .playSongsResponse(
317 | TaskResult {
318 | try await apiClient.replaceCurrentPlaylistWithPlaylistSongs(playlistId)
319 | }
320 | )
321 | )
322 | }
323 |
324 | case let .playPlaylistBeginWith(playlistId, songId):
325 | return .run { send in
326 | await send(
327 | .playSongsBeginWithResponse(
328 | TaskResult { try await apiClient.replaceCurrentPlaylistWithPlaylistSongs(playlistId) },
329 | songId
330 | )
331 | )
332 | }
333 |
334 | case let .playSongsResponse(.success(songs)):
335 | state.playlist.update(songs: songs)
336 | state.currentSong = songs.first
337 |
338 | return self.playOn(state: &state, index: 0)
339 |
340 | case let .playSongsBeginWithResponse(.success(songs), songId):
341 | state.playlist.update(songs: songs)
342 |
343 | if let songIndex = state.playlist.index(by: songId) {
344 | state.currentSong = state.playlist.find(byIndex: songIndex)
345 | return self.playOn(state: &state, index: songIndex)
346 | } else {
347 | state.currentSong = songs.first
348 | return self.playOn(state: &state, index: 0)
349 | }
350 |
351 | case let .playNow(songId):
352 | if let songIndex = state.playlist.index(by: songId) {
353 | return self.playOn(state: &state, index: songIndex)
354 | } else {
355 | return .run { [currentSong = state.currentSong] send in
356 | await send(
357 | .playNowResponse(
358 | TaskResult { try await apiClient.addSongToCurrentPlaylist(songId, currentSong, nil) }
359 | )
360 | )
361 | }
362 | }
363 |
364 | case let .playNext(songId):
365 | return .run { [currentSong = state.currentSong] send in
366 | await send(
367 | .playNextResponse(
368 | TaskResult { try await apiClient.addSongToCurrentPlaylist(songId, currentSong, nil) }
369 | )
370 | )
371 | }
372 |
373 | case let .playLast(songId):
374 | return .run { send in
375 | await send(
376 | .playLastResponse(
377 | TaskResult { try await apiClient.addSongToCurrentPlaylist(songId, nil, "last") }
378 | )
379 | )
380 | }
381 |
382 | case let .playNowResponse(.success(song)):
383 | let insertIndex = state.insertSongNextToCurrent(song: song)
384 | return self.playOn(state: &state, index: insertIndex)
385 |
386 | case let .playNextResponse(.success(song)):
387 | _ = state.insertSongNextToCurrent(song: song)
388 | flashMessageClient.showLocalizedMessage("text.addedToPlaylist")
389 |
390 | return .none
391 |
392 | case let .playLastResponse(.success(song)):
393 | state.playlist.append(song)
394 | flashMessageClient.showLocalizedMessage("text.addedToPlaylist")
395 |
396 | return .none
397 |
398 | case let .deleteSongsResponse(.failure(error)),
399 | let .moveSongsResponse(.failure(error)),
400 | let .currentPlaylistResponse(.failure(error)),
401 | let .playSongsResponse(.failure(error)),
402 | let .playSongsBeginWithResponse(.failure(error), _),
403 | let .playNowResponse(.failure(error)),
404 | let .playNextResponse(.failure(error)),
405 | let .playLastResponse(.failure(error)),
406 | let .toggleFavoriteResponse(.failure(error)):
407 | guard let error = error as? APIClient.APIError else { return .none }
408 |
409 | if error == .unauthorized {
410 | AppStore.shared.send(.logout)
411 | } else {
412 | state.alert = .init(title: .init(error.localizedString))
413 | }
414 |
415 | return .none
416 | }
417 | }
418 | }
419 |
420 | func playOn(state: inout State, index: Int) -> Effect {
421 | state.currentSong = state.playlist.find(byIndex: index)
422 | guard let currentSong = state.currentSong else { return .none }
423 |
424 | playerClient.playOn(currentSong.url)
425 |
426 | return .run { _ in
427 | await nowPlayingClient.updateInfo(currentSong)
428 | }
429 | }
430 | }
431 |
432 | extension PlayerReducer.State {
433 | enum Mode: CaseIterable {
434 | case noRepeat
435 | case repead
436 | case single
437 | case shuffle
438 |
439 | var symbol: String {
440 | switch self {
441 | case .noRepeat:
442 | return "repeat"
443 | case .repead:
444 | return "repeat"
445 | case .single:
446 | return "repeat.1"
447 | case .shuffle:
448 | return "shuffle"
449 | }
450 | }
451 |
452 | func next() -> Self {
453 | Self.allCases[(Self.allCases.firstIndex(of: self)! + 1) % Self.allCases.count]
454 | }
455 | }
456 | }
457 |
--------------------------------------------------------------------------------
/BlackCandy/Turbo/TurboNavigationController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 | import ComposableArchitecture
4 | import Turbo
5 |
6 | class TurboNavigationController: UINavigationController, SessionDelegate {
7 | @Dependency(\.userDefaultsClient) var userDefaultsClient
8 |
9 | let initPath: String
10 | let store: StoreOf
11 |
12 | init(_ initPath: String, store: StoreOf = AppStore.shared) {
13 | self.initPath = initPath
14 | self.store = store
15 |
16 | super.init(nibName: nil, bundle: nil)
17 | }
18 |
19 | required init?(coder aDecoder: NSCoder) {
20 | fatalError("init(coder:) has not been implemented")
21 | }
22 |
23 | lazy var session: Session = {
24 | let session = TurboSession.create(store: store)
25 | session.delegate = self
26 |
27 | return session
28 | }()
29 |
30 | private lazy var modalSession: Session = {
31 | let session = TurboSession.create(store: store)
32 | session.delegate = self
33 |
34 | return session
35 | }()
36 |
37 | func route(_ path: String) {
38 | let url = userDefaultsClient.serverAddress()!.appendingPathComponent(path)
39 | let options = VisitOptions(action: .advance, response: nil)
40 | let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties()
41 | let proposal = VisitProposal(url: url, options: options, properties: properties)
42 |
43 | route(proposal: proposal)
44 | }
45 |
46 | func route(proposal: VisitProposal) {
47 | let presentation = proposal.properties["presentation"] as? String
48 | let visitOptions = proposal.options
49 | let viewController = makeViewController(for: proposal.url, properties: proposal.properties)
50 |
51 | // Dismiss any modals when receiving a new navigation
52 | if presentedViewController != nil {
53 | // After finishing the operation on the modal session, then clear the snapshot cache in the default session,
54 | // to avoid getting stale cached snapshot.
55 | session.clearSnapshotCache()
56 |
57 | dismiss(animated: true)
58 | }
59 |
60 | if presentation == "modal" {
61 | let modalViewController = UINavigationController(rootViewController: viewController)
62 |
63 | present(modalViewController, animated: true)
64 | visit(viewController: viewController, with: visitOptions, modal: true)
65 | return
66 | }
67 |
68 | if session.activeVisitable?.visitableURL == proposal.url || visitOptions.action == .replace {
69 | let viewControllers = Array(viewControllers.dropLast()) + [viewController]
70 |
71 | setViewControllers(viewControllers, animated: false)
72 | visit(viewController: viewController, with: visitOptions)
73 | return
74 | }
75 |
76 | pushViewController(viewController, animated: true)
77 | visit(viewController: viewController, with: visitOptions)
78 | }
79 |
80 | override func viewDidLoad() {
81 | super.viewDidLoad()
82 |
83 | let navigationBarAppearance = UINavigationBarAppearance()
84 | navigationBarAppearance.configureWithDefaultBackground()
85 |
86 | navigationBar.standardAppearance = navigationBarAppearance
87 | navigationBar.scrollEdgeAppearance = navigationBarAppearance
88 |
89 | route(initPath)
90 | }
91 |
92 | func session(_ session: Turbo.Session, didProposeVisit proposal: Turbo.VisitProposal) {
93 | route(proposal: proposal)
94 | }
95 |
96 | func sessionWebViewProcessDidTerminate(_ session: Turbo.Session) {
97 | session.reload()
98 | }
99 |
100 | func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
101 | if let turboError = error as? TurboError {
102 | switch turboError {
103 | case .http(let statusCode):
104 | if statusCode == 401 {
105 | store.send(.logout)
106 | }
107 | case .networkFailure, .timeoutFailure:
108 | return
109 | case .contentTypeMismatch:
110 | return
111 | case .pageLoadFailure:
112 | return
113 | }
114 | } else {
115 | NSLog("didFailRequestForVisitable: \(error)")
116 | }
117 | }
118 |
119 | private func makeViewController(for url: URL, properties: PathProperties) -> UIViewController {
120 | let defaultViewController = TurboVisitableViewController(url, properties: properties)
121 |
122 | if let rootView = properties["root_view"] as? String {
123 | switch rootView {
124 | case "account":
125 | return UIHostingController(
126 | rootView: AccountView(
127 | store: store,
128 | navItemTapped: { path in
129 | self.route(path)
130 | }
131 | )
132 | )
133 | default:
134 | return defaultViewController
135 | }
136 | }
137 |
138 | return defaultViewController
139 | }
140 |
141 | private func visit(viewController: UIViewController, with options: VisitOptions, modal: Bool = false) {
142 | guard let visitable = viewController as? Visitable else { return }
143 |
144 | if modal {
145 | modalSession.visit(visitable, options: options)
146 | } else {
147 | session.visit(visitable, options: options)
148 | }
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/BlackCandy/Turbo/TurboScriptMessageHandler.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import WebKit
3 | import ComposableArchitecture
4 |
5 | class TurboScriptMessageHandler: NSObject, WKScriptMessageHandler {
6 | @Dependency(\.flashMessageClient) var flashMessageClient
7 |
8 | let store: StoreOf
9 |
10 | init(store: StoreOf) {
11 | self.store = store
12 | super.init()
13 | }
14 |
15 | func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
16 | guard let body = message.body as? [String: Any],
17 | let actionName = body["name"] as? String else { return }
18 |
19 | switch actionName {
20 | case "playAlbum":
21 | guard let albumId = body["albumId"] as? Int else { return }
22 | store.send(.player(.playAlbum(albumId)))
23 |
24 | case "playAlbumBeginWith":
25 | guard
26 | let albumId = body["albumId"] as? Int,
27 | let songId = body["songId"] as? Int else { return }
28 |
29 | store.send(.player(.playAlbumBeginWith(albumId, songId)))
30 |
31 | case "playPlaylist":
32 | guard let playlistId = body["playlistId"] as? Int else { return }
33 | store.send(.player(.playPlaylist(playlistId)))
34 |
35 | case "playPlaylistBeginWith":
36 | guard
37 | let playlistId = body["playlistId"] as? Int,
38 | let songId = body["songId"] as? Int else { return }
39 |
40 | store.send(.player(.playPlaylistBeginWith(playlistId, songId)))
41 |
42 | case "playNow":
43 | guard let songId = body["songId"] as? Int else { return }
44 | store.send(.player(.playNow(songId)))
45 |
46 | case "playNext":
47 | guard let songId = body["songId"] as? Int else { return }
48 | store.send(.player(.playNext(songId)))
49 |
50 | case "playLast":
51 | guard let songId = body["songId"] as? Int else { return }
52 | store.send(.player(.playLast(songId)))
53 |
54 | case "showFlashMessage":
55 | guard let message = body["message"] as? String else { return }
56 | flashMessageClient.showMessage(message)
57 |
58 | case "updateTheme":
59 | guard
60 | let theme = body["theme"] as? String,
61 | let currentTheme = AppReducer.State.Theme(rawValue: theme) else { return }
62 |
63 | store.send(.updateTheme(currentTheme))
64 |
65 | default:
66 | return
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/BlackCandy/Turbo/TurboSession.swift:
--------------------------------------------------------------------------------
1 | import WebKit
2 | import ComposableArchitecture
3 | import Turbo
4 |
5 | struct TurboSession {
6 | static let processPool = WKProcessPool()
7 |
8 | static func create(store: StoreOf) -> Session {
9 | let configuration = WKWebViewConfiguration()
10 | let scriptMessageHandler = TurboScriptMessageHandler(store: store)
11 | let pathConfiguration = PathConfiguration(sources: [
12 | .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!)
13 | ])
14 |
15 | configuration.applicationNameForUserAgent = BLACK_CANDY_USER_AGENT
16 | configuration.processPool = TurboSession.processPool
17 | configuration.userContentController.add(scriptMessageHandler, name: "nativeApp")
18 |
19 | // Set the webview frame more than zero to avoid logs of `maximumViewportInset cannot be larger than frame`
20 | let webView = WKWebView(
21 | frame: CGRect(x: 0.0, y: 0.0, width: 0.1, height: 0.1),
22 | configuration: configuration
23 | )
24 |
25 | webView.allowsLinkPreview = false
26 |
27 | let session = Session(webView: webView)
28 | session.pathConfiguration = pathConfiguration
29 |
30 | return session
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/BlackCandy/Turbo/TurboVisitableViewController.swift:
--------------------------------------------------------------------------------
1 | import Turbo
2 | import UIKit
3 |
4 | class TurboVisitableViewController: VisitableViewController, UISearchBarDelegate {
5 | var properties: PathProperties!
6 |
7 | convenience init(_ url: URL, properties: PathProperties) {
8 | self.init(url: url)
9 | self.properties = properties
10 | }
11 |
12 | override func viewDidLoad() {
13 | super.viewDidLoad()
14 | view.backgroundColor = .systemBackground
15 |
16 | if properties["has_search_bar"] as? Bool ?? false {
17 | setSearchBar()
18 | }
19 |
20 | if let navButtonProperty = properties["nav_button"] as? [String: String] {
21 | setNavButton(navButtonProperty)
22 | }
23 | }
24 |
25 | func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
26 | guard let searchText = searchBar.searchTextField.text, !searchText.isEmpty else { return }
27 |
28 | visitableView.webView?.evaluateJavaScript("App.nativeBridge.search('\(searchText)')")
29 | }
30 |
31 | private func setSearchBar() {
32 | let searchController = UISearchController(searchResultsController: nil)
33 | searchController.searchBar.delegate = self
34 |
35 | navigationItem.searchController = searchController
36 | navigationItem.hidesSearchBarWhenScrolling = false
37 | }
38 |
39 | private func setNavButton(_ property: [String: String]) {
40 | let button = UIBarButtonItem()
41 |
42 | button.title = property["title"]
43 |
44 | button.primaryAction = .init(handler: { [weak self] _ in
45 | guard
46 | let path = property["path"],
47 | let viewController = self?.navigationController as? TurboNavigationController else {
48 | return
49 | }
50 |
51 | viewController.route(path)
52 | })
53 |
54 | if let iconName = property["icon"] {
55 | button.image = .init(systemName: iconName)
56 | }
57 |
58 | navigationItem.rightBarButtonItem = button
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/BlackCandy/Utils/AudioSessionControl.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import AVFAudio
3 | import ComposableArchitecture
4 |
5 | class AudioSessionControl {
6 | static func setup(store: StoreOf) {
7 | let audioSession = AVAudioSession.sharedInstance()
8 | let notificationCenter = NotificationCenter.default
9 | let audioSessionControl = AudioSessionControl(store: store)
10 |
11 | try? audioSession.setCategory(.playback)
12 |
13 | notificationCenter.addObserver(
14 | audioSessionControl,
15 | selector: #selector(handleInterruption),
16 | name: AVAudioSession.interruptionNotification,
17 | object: audioSession
18 | )
19 |
20 | notificationCenter.addObserver(
21 | audioSessionControl,
22 | selector: #selector(handleRouteChange),
23 | name: AVAudioSession.routeChangeNotification,
24 | object: nil
25 | )
26 | }
27 |
28 | let store: StoreOf
29 |
30 | init(store: StoreOf) {
31 | self.store = store
32 | }
33 |
34 | @objc func handleInterruption(notification: Notification) {
35 | guard
36 | let userInfo = notification.userInfo,
37 | let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
38 | let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
39 | return
40 | }
41 |
42 | switch type {
43 | case .began:
44 | store.send(.pause)
45 | case .ended:
46 | // An interruption ended. Resume playback, if appropriate.
47 | guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
48 |
49 | let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
50 |
51 | if options.contains(.shouldResume) {
52 | store.send(.play)
53 | }
54 |
55 | default:
56 | store.send(.pause)
57 | }
58 | }
59 |
60 | @objc func handleRouteChange(notification: Notification) {
61 | guard
62 | let userInfo = notification.userInfo,
63 | let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
64 | let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
65 | return
66 | }
67 |
68 | switch reason {
69 | case .newDeviceAvailable: () // New device found.
70 | case .oldDeviceUnavailable: // Old device removed.
71 | guard let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription else {
72 | return
73 | }
74 |
75 | if hasHeadphones(in: previousRoute) {
76 | store.send(.pause)
77 | }
78 |
79 | default: ()
80 | }
81 | }
82 |
83 | func hasHeadphones(in routeDescription: AVAudioSessionRouteDescription) -> Bool {
84 | // Filter the outputs to only those with a port type of headphones.
85 | return !routeDescription.outputs.filter({ $0.portType == .headphones }).isEmpty
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/BlackCandy/Utils/Constants.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | let BLACK_CANDY_USER_AGENT = "Black Candy iOS"
4 |
--------------------------------------------------------------------------------
/BlackCandy/Utils/CustomFormatter.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class DurationFormatter: DateComponentsFormatter {
4 | override init() {
5 | super.init()
6 |
7 | allowedUnits = [.minute, .second]
8 | unitsStyle = .positional
9 | zeroFormattingBehavior = .pad
10 | }
11 |
12 | required init?(coder: NSCoder) {
13 | fatalError("init(coder:) has not been implemented")
14 | }
15 |
16 | override func string(from seconds: Double) -> String? {
17 | super.string(from: TimeInterval(seconds))
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/BlackCandy/Utils/CustomStyle.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | struct CustomStyle {
5 | enum Spacing: CGFloat {
6 | case tiny = 4
7 | case narrow = 8
8 | case small = 12
9 | case medium = 16
10 | case large = 20
11 | case wide = 24
12 | case extraWide = 30
13 | case ultraWide = 60
14 | case ultraWide2x = 120
15 | }
16 |
17 | enum CornerRadius: CGFloat {
18 | case small = 2
19 | case medium = 4
20 | case large = 8
21 | }
22 |
23 | enum FontSize: CGFloat {
24 | case small = 12
25 | case medium = 16
26 | case large = 20
27 | }
28 |
29 | enum Style {
30 | case largeSymbol
31 | case extraLargeSymbol
32 | case smallFont
33 | case mediumFont
34 | case playerProgressLoader
35 | }
36 |
37 | static let playerImageSize: CGFloat = 200
38 | static let playerMaxWidth: CGFloat = 350
39 | static let sideBarPlayerHeight: CGFloat = 550
40 |
41 | static func spacing(_ spacing: Spacing) -> CGFloat {
42 | spacing.rawValue
43 | }
44 |
45 | static func cornerRadius(_ radius: CornerRadius) -> CGFloat {
46 | radius.rawValue
47 | }
48 |
49 | static func fontSize(_ fontSize: FontSize) -> CGFloat {
50 | fontSize.rawValue
51 | }
52 | }
53 |
54 | extension View {
55 | @ViewBuilder func customStyle(_ style: CustomStyle.Style) -> some View {
56 | switch style {
57 | case .largeSymbol:
58 | font(.system(size: CustomStyle.spacing(.wide)))
59 |
60 | case .extraLargeSymbol:
61 | font(.system(size: CustomStyle.spacing(.extraWide)))
62 |
63 | case .smallFont:
64 | font(.system(size: CustomStyle.fontSize(.small)))
65 |
66 | case .mediumFont:
67 | font(.system(size: CustomStyle.fontSize(.medium)))
68 |
69 | case .playerProgressLoader:
70 | scaleEffect(0.6, anchor: .center)
71 | .frame(width: 10, height: 10)
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/BlackCandy/Utils/NotificationName.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension NSNotification.Name {
4 | static let selectedTabDidChange = Notification.Name("org.BlackCandy.selectedTabDidChange")
5 | static let splitViewDidExpand = Notification.Name("org.BlackCandy.splitViewDidExpand")
6 | static let splitViewDidCollapse = Notification.Name("org.BlackCandy.splitViewDidCollapse")
7 | }
8 |
9 | enum NotificationKeys: String {
10 | case selectedTab
11 | }
12 |
--------------------------------------------------------------------------------
/BlackCandy/Utils/RemoteControl.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MediaPlayer
3 | import ComposableArchitecture
4 |
5 | struct RemoteControl {
6 | static func setup(store: StoreOf) {
7 | let commandCenter = MPRemoteCommandCenter.shared()
8 |
9 | commandCenter.playCommand.addTarget { _ in
10 | store.send(.play)
11 | return .success
12 | }
13 |
14 | commandCenter.pauseCommand.addTarget { _ in
15 | store.send(.pause)
16 | return .success
17 | }
18 |
19 | commandCenter.stopCommand.addTarget { _ in
20 | store.send(.stop)
21 | return .success
22 | }
23 |
24 | commandCenter.togglePlayPauseCommand.addTarget { _ in
25 | if store.withState(\.isPlaying) {
26 | store.send(.pause)
27 | } else {
28 | store.send(.play)
29 | }
30 |
31 | return .success
32 | }
33 |
34 | commandCenter.nextTrackCommand.addTarget { _ in
35 | store.send(.next)
36 | return .success
37 | }
38 |
39 | commandCenter.previousTrackCommand.addTarget { _ in
40 | store.send(.previous)
41 | return .success
42 | }
43 |
44 | commandCenter.changePlaybackPositionCommand.addTarget { remoteEvent in
45 | guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
46 |
47 | store.send(.seekToPosition(event.positionTime))
48 | return .success
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/BlackCandy/ViewControllers/LoginViewController.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ComposableArchitecture
3 | import SwiftUI
4 |
5 | class LoginViewController: UIHostingController {
6 | init(store: StoreOf) {
7 | super.init(rootView: LoginView(
8 | store: store.scope(state: \.login, action: AppReducer.Action.login)
9 | ))
10 | }
11 |
12 | @MainActor required dynamic init?(coder aDecoder: NSCoder) {
13 | fatalError("init(coder:) has not been implemented")
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/BlackCandy/ViewControllers/MainViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import ComposableArchitecture
3 | import SwiftUI
4 | import Combine
5 |
6 | class MainViewController: UISplitViewController, UISplitViewControllerDelegate {
7 | let store: StoreOf
8 | let initPlaylist: Bool
9 | var cancellables: Set = []
10 |
11 | init(store: StoreOf, initPlaylist: Bool = false) {
12 | self.store = store
13 | self.initPlaylist = initPlaylist
14 |
15 | super.init(style: .doubleColumn)
16 |
17 | let playerStore = store.scope(state: \.player, action: AppReducer.Action.player)
18 | let tabBarViewController = TabBarViewController(store: playerStore)
19 | let sidebarViewController = SideBarViewController(store: playerStore)
20 |
21 | preferredDisplayMode = .oneBesideSecondary
22 | preferredSplitBehavior = .tile
23 | presentsWithGesture = false
24 | delegate = self
25 |
26 | setViewController(sidebarViewController, for: .primary)
27 | setViewController(tabBarViewController, for: .secondary)
28 | setViewController(tabBarViewController, for: .compact)
29 | }
30 |
31 | required init?(coder: NSCoder) {
32 | fatalError("init(coder:) has not been implemented")
33 | }
34 |
35 | override func viewDidLoad() {
36 | if initPlaylist {
37 | store.send(.player(.getCurrentPlaylist))
38 | }
39 |
40 | store.publisher.alert
41 | .sink { [weak self] alert in
42 | guard let self = self else { return }
43 | guard let alert = alert else { return }
44 |
45 | let alertController = UIAlertController(
46 | title: String(state: alert.title),
47 | message: nil,
48 | preferredStyle: .alert
49 | )
50 |
51 | alertController.addAction(
52 | UIAlertAction(title: NSLocalizedString("label.ok", comment: ""), style: .default) { _ in
53 | self.store.send(.dismissAlert)
54 | }
55 | )
56 |
57 | self.present(alertController, animated: true, completion: nil)
58 | }
59 | .store(in: &self.cancellables)
60 | }
61 |
62 | func splitViewControllerDidExpand(_ svc: UISplitViewController) {
63 | NotificationCenter.default.post(
64 | name: .splitViewDidExpand,
65 | object: self
66 | )
67 | }
68 |
69 | func splitViewControllerDidCollapse(_ svc: UISplitViewController) {
70 | NotificationCenter.default.post(
71 | name: .splitViewDidCollapse,
72 | object: self
73 | )
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/BlackCandy/ViewControllers/PlayerViewController.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 | import LNPopupController
4 | import Combine
5 | import Alamofire
6 |
7 | class PlayerViewController: UIHostingController {
8 | @objc var _ln_interactionLimitRect: CGRect = .zero
9 |
10 | let store: StoreOf
11 | var cancellables: Set = []
12 |
13 | init(store: StoreOf) {
14 | self.store = store
15 | super.init(
16 | rootView: PlayerView(
17 | store: store,
18 | padding: .init(
19 | top: CustomStyle.spacing(.medium),
20 | leading: 0,
21 | bottom: CustomStyle.spacing(.narrow),
22 | trailing: 0
23 | )
24 | )
25 | )
26 | }
27 |
28 | override func viewDidLoad() {
29 | super.viewDidLoad()
30 |
31 | self.store.publisher.currentSong
32 | .map { $0?.name ?? NSLocalizedString("label.notPlaying", comment: "") }
33 | .assign(to: \.title, on: popupItem)
34 | .store(in: &self.cancellables)
35 |
36 | self.store.publisher
37 | .map { state in
38 | let pauseButton = UIBarButtonItem(image: .init(systemName: "pause.fill"), style: .plain, target: self, action: #selector(self.pause))
39 | let playButton = UIBarButtonItem(image: .init(systemName: "play.fill"), style: .plain, target: self, action: #selector(self.play))
40 | let nextButton = UIBarButtonItem(image: .init(systemName: "forward.fill"), style: .plain, target: self, action: #selector(self.nextSong))
41 |
42 | pauseButton.isEnabled = state.hasCurrentSong
43 | pauseButton.tintColor = .label
44 | pauseButton.width = CustomStyle.spacing(.ultraWide)
45 |
46 | playButton.isEnabled = state.hasCurrentSong
47 | playButton.tintColor = .label
48 | playButton.width = CustomStyle.spacing(.ultraWide)
49 |
50 | nextButton.isEnabled = state.hasCurrentSong
51 | nextButton.tintColor = .label
52 |
53 | return state.isPlaying ? [pauseButton, nextButton] : [playButton, nextButton]
54 | }
55 | .assign(to: \.barButtonItems, on: popupItem)
56 | .store(in: &self.cancellables)
57 |
58 | self.store.publisher.currentSong
59 | .sink { [weak self] currentSong in
60 | guard let imageUrl = currentSong?.albumImageUrl.small else { return }
61 |
62 | AF.download(imageUrl).response { response in
63 | guard
64 | response.error == nil,
65 | let imagePath = response.fileURL?.path,
66 | let image = UIImage(contentsOfFile: imagePath) else { return }
67 |
68 | self?.popupItem.image = image
69 | }
70 | }
71 | .store(in: &self.cancellables)
72 | }
73 |
74 | // This function basically copy from LNPopupUI,
75 | // https://github.com/LeoNatan/LNPopupUI/blob/master/Sources/LNPopupUI/Private/LNPopupUIContentController.swift
76 | // Use this function we can control which view we want it to interact with gesture in LNPopup.
77 | // So this can avoid some view like List that can not respond to scroll gesture because of the gesture in LNPopup.
78 | override func viewDidLayoutSubviews() {
79 | super.viewDidLayoutSubviews()
80 |
81 | let viewToLimitInteractionTo = firstInteractionSubview(of: view) ?? super.viewForPopupInteractionGestureRecognizer
82 | _ln_interactionLimitRect = view.convert(viewToLimitInteractionTo.bounds, from: viewToLimitInteractionTo)
83 | }
84 |
85 | private func firstInteractionSubview(of view: UIView) -> PopupUIInteractionView? {
86 | if let view = view as? PopupUIInteractionView {
87 | return view
88 | }
89 |
90 | var interactionView: PopupUIInteractionView?
91 |
92 | for subview in view.subviews {
93 | if let view = firstInteractionSubview(of: subview) {
94 | interactionView = view
95 | break
96 | }
97 | }
98 |
99 | return interactionView
100 | }
101 |
102 | @MainActor required dynamic init?(coder aDecoder: NSCoder) {
103 | fatalError("init(coder:) has not been implemented")
104 | }
105 |
106 | @objc private func pause() {
107 | self.store.send(.pause)
108 | }
109 |
110 | @objc private func play() {
111 | self.store.send(.play)
112 | }
113 |
114 | @objc private func nextSong() {
115 | self.store.send(.next)
116 | }
117 | }
118 |
119 | internal class PopupUIInteractionView: UIView {}
120 |
121 | internal struct PopupUIInteractionBackgroundView: UIViewRepresentable {
122 | func makeUIView(context: Context) -> PopupUIInteractionView {
123 | return PopupUIInteractionView()
124 | }
125 |
126 | func updateUIView(_ uiView: PopupUIInteractionView, context: Context) { }
127 | }
128 |
129 | extension View {
130 | func popupInteractionContainer() -> some View {
131 | return background(PopupUIInteractionBackgroundView())
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/BlackCandy/ViewControllers/SideBar/SideBarNavigationViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 |
4 | class SideBarNavigationViewController: UICollectionViewController {
5 | private var dataSource: UICollectionViewDiffableDataSource!
6 |
7 | let sidebarSections: [SidebarSection] = [.tab(.home), .tab(.library)]
8 |
9 | init() {
10 | let layout = UICollectionViewCompositionalLayout { _, layoutEnvironment in
11 | let config = UICollectionLayoutListConfiguration(appearance: .sidebar)
12 |
13 | return NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment)
14 | }
15 |
16 | super.init(collectionViewLayout: layout)
17 |
18 | clearsSelectionOnViewWillAppear = false
19 | }
20 |
21 | required init?(coder: NSCoder) {
22 | fatalError("init(coder:) has not been implemented")
23 | }
24 |
25 | override func viewDidLoad() {
26 | super.viewDidLoad()
27 |
28 | NotificationCenter.default.addObserver(
29 | self,
30 | selector: #selector(selectedTabDidChanged(_:)),
31 | name: .selectedTabDidChange,
32 | object: nil
33 | )
34 |
35 | configureDataSource()
36 | configInitSelection()
37 | }
38 |
39 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
40 | let selectedSection = sidebarSections[indexPath.section]
41 | guard case let .tab(item) = selectedSection else { return }
42 |
43 | NotificationCenter.default.post(
44 | name: .selectedTabDidChange,
45 | object: self,
46 | userInfo: [NotificationKeys.selectedTab: item]
47 | )
48 | }
49 |
50 | func selectTabItem(_ tabItem: TabItem) {
51 | guard let selectedSectionIndex = sidebarSections.firstIndex(where: { section in
52 | guard case let .tab(item) = section else { return false }
53 | return item == tabItem
54 | }) else { return }
55 |
56 | collectionView.selectItem(
57 | at: IndexPath(row: 0, section: selectedSectionIndex),
58 | animated: false,
59 | scrollPosition: UICollectionView.ScrollPosition.centeredVertically
60 | )
61 | }
62 |
63 | private func configureDataSource() {
64 | let cellRegistration = UICollectionView.CellRegistration { (cell, _, item) in
65 | var content = cell.defaultContentConfiguration()
66 |
67 | content.text = item.title
68 | content.image = item.icon
69 | cell.contentConfiguration = content
70 | cell.accessories = []
71 | }
72 |
73 | dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView: UICollectionView, indexPath: IndexPath, item: TabItem) -> UICollectionViewCell? in
74 | return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
75 | }
76 |
77 | var snapshot = NSDiffableDataSourceSnapshot()
78 | snapshot.appendSections(sidebarSections)
79 | dataSource.apply(snapshot, animatingDifferences: false)
80 |
81 | for section in sidebarSections {
82 | switch section {
83 | case let .tab(item):
84 | var sectionSnapshot = NSDiffableDataSourceSectionSnapshot()
85 | sectionSnapshot.append([item])
86 | dataSource.apply(sectionSnapshot, to: section)
87 | }
88 | }
89 | }
90 |
91 | private func configInitSelection() {
92 | selectTabItem(.home)
93 | }
94 |
95 | @objc private func selectedTabDidChanged(_ notification: Notification) {
96 | guard let userInfo = notification.userInfo,
97 | let selectedTab = userInfo[NotificationKeys.selectedTab] as? TabItem else { return }
98 |
99 | selectTabItem(selectedTab)
100 | }
101 | }
102 |
103 | extension SideBarNavigationViewController {
104 | enum SidebarSection: Hashable {
105 | case tab(TabItem)
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/BlackCandy/ViewControllers/SideBarViewController.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ComposableArchitecture
3 | import SwiftUI
4 | import UIKit
5 |
6 | class SideBarViewController: UIViewController {
7 | var navViewController: SideBarNavigationViewController
8 | var playerViewController: UIViewController
9 |
10 | init(store: StoreOf) {
11 | navViewController = SideBarNavigationViewController()
12 | playerViewController = UIHostingController(
13 | rootView: VStack {
14 | Divider()
15 | PlayerView(
16 | store: store,
17 | padding: .init(
18 | top: CustomStyle.spacing(.tiny),
19 | leading: CustomStyle.spacing(.tiny),
20 | bottom: 0,
21 | trailing: CustomStyle.spacing(.tiny)
22 | )
23 | )
24 | }
25 | )
26 |
27 | super.init(nibName: nil, bundle: nil)
28 | }
29 |
30 | required init?(coder: NSCoder) {
31 | fatalError("init(coder:) has not been implemented")
32 | }
33 |
34 | override func loadView() {
35 | super.loadView()
36 |
37 | addChild(navViewController)
38 | addChild(playerViewController)
39 | }
40 |
41 | override func viewDidLoad() {
42 | super.viewDidLoad()
43 |
44 | let playerView = playerViewController.view!
45 | let navView = navViewController.collectionView!
46 |
47 | navView.translatesAutoresizingMaskIntoConstraints = false
48 | playerView.translatesAutoresizingMaskIntoConstraints = false
49 | playerView.backgroundColor = navView.backgroundColor
50 |
51 | self.view.addSubview(navView)
52 | self.view.addSubview(playerView)
53 |
54 | NSLayoutConstraint.activate([
55 | playerView.heightAnchor.constraint(equalToConstant: CustomStyle.sideBarPlayerHeight),
56 | playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
57 | playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
58 | playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
59 |
60 | navView.topAnchor.constraint(equalTo: view.topAnchor),
61 | navView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
62 | navView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
63 | navView.bottomAnchor.constraint(equalTo: playerView.topAnchor)
64 | ])
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/BlackCandy/ViewControllers/TabBarViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import ComposableArchitecture
3 | import SwiftUI
4 |
5 | class TabBarViewController: UITabBarController, UITabBarControllerDelegate {
6 | let playerViewController: PlayerViewController
7 | let tabItems: [TabItem] = [.home, .library]
8 |
9 | init(store: StoreOf) {
10 | self.playerViewController = PlayerViewController(store: store)
11 | super.init(nibName: nil, bundle: nil)
12 | }
13 |
14 | required init?(coder: NSCoder) {
15 | fatalError("init(coder:) has not been implemented")
16 | }
17 |
18 | override func viewDidLoad() {
19 | super.viewDidLoad()
20 |
21 | delegate = self
22 | tabBar.isHidden = true
23 | viewControllers = tabItems.map { tabItem in
24 | let viewController = tabItem.viewController
25 | viewController.tabBarItem = .init(title: tabItem.title, image: tabItem.icon, tag: tabItem.tagIndex)
26 |
27 | return viewController
28 | }
29 |
30 | let notificationCenter = NotificationCenter.default
31 |
32 | notificationCenter.addObserver(
33 | self,
34 | selector: #selector(selectedTabDidChanged(_:)),
35 | name: .selectedTabDidChange,
36 | object: nil
37 | )
38 |
39 | notificationCenter.addObserver(
40 | self,
41 | selector: #selector(splitViewDidExpand(_:)),
42 | name: .splitViewDidExpand,
43 | object: nil
44 | )
45 |
46 | notificationCenter.addObserver(
47 | self,
48 | selector: #selector(splitViewDidCollapse(_:)),
49 | name: .splitViewDidCollapse,
50 | object: nil
51 | )
52 | }
53 |
54 | func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
55 | let selectedTagIndex = viewController.tabBarItem.tag
56 | guard let selectedTabItem = tabItems.first(where: { $0.tagIndex == selectedTagIndex}) else { return }
57 |
58 | NotificationCenter.default.post(
59 | name: .selectedTabDidChange,
60 | object: self,
61 | userInfo: [NotificationKeys.selectedTab: selectedTabItem]
62 | )
63 | }
64 |
65 | @objc private func selectedTabDidChanged(_ notification: Notification) {
66 | guard let userInfo = notification.userInfo,
67 | let selectedTab = userInfo[NotificationKeys.selectedTab] as? TabItem else { return }
68 |
69 | selectedIndex = selectedTab.tagIndex
70 | }
71 |
72 | @objc private func splitViewDidExpand(_ notification: Notification) {
73 | tabBar.isHidden = true
74 | dismissPopupBar(animated: false)
75 | }
76 |
77 | @objc private func splitViewDidCollapse(_ notification: Notification) {
78 | tabBar.isHidden = false
79 | presentPopupBar(withContentViewController: playerViewController, animated: false)
80 | }
81 | }
82 |
83 | enum TabItem: String {
84 | case home
85 | case library
86 |
87 | var title: String {
88 | rawValue.capitalized
89 | }
90 |
91 | var icon: UIImage? {
92 | switch self {
93 | case .home:
94 | return .init(systemName: "house")
95 | case .library:
96 | return .init(systemName: "square.stack")
97 | }
98 | }
99 |
100 | var viewController: UIViewController {
101 | switch self {
102 | case .home:
103 | return TurboNavigationController("/")
104 | case .library:
105 | return TurboNavigationController("/library")
106 | }
107 | }
108 |
109 | var tagIndex: Int {
110 | switch self {
111 | case .home:
112 | return 0
113 | case .library:
114 | return 1
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/BlackCandy/Views/AccountView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 |
4 | struct AccountView: View {
5 | let store: StoreOf
6 | let navItemTapped: (String) -> Void
7 |
8 | struct ViewState: Equatable {
9 | let currentUser: User?
10 | let isAdmin: Bool
11 |
12 | init(state: AppReducer.State) {
13 | self.currentUser = state.currentUser
14 | self.isAdmin = state.currentUser?.isAdmin ?? false
15 | }
16 | }
17 |
18 | var body: some View {
19 | WithViewStore(self.store, observe: ViewState.init) { viewStore in
20 | List {
21 | Button("label.settings") {
22 | navItemTapped("/setting")
23 | }
24 |
25 | if viewStore.isAdmin {
26 | Button("label.manageUsers") {
27 | navItemTapped("/users")
28 | }
29 | }
30 |
31 | Button("label.updateProfile") {
32 | navItemTapped("/users/\(viewStore.currentUser!.id)/edit")
33 | }
34 |
35 | Section {
36 | Button(
37 | role: .destructive,
38 | action: {
39 | viewStore.send(.logout)
40 | },
41 | label: {
42 | Text("label.logout")
43 | }
44 | )
45 | .frame(maxWidth: .infinity)
46 | }
47 | }
48 | .listStyle(.insetGrouped)
49 | .navigationTitle("label.account")
50 | .navigationBarTitleDisplayMode(.inline)
51 | }
52 | }
53 | }
54 |
55 | struct AccountView_Previews: PreviewProvider {
56 | static var previews: some View {
57 | var state = AppReducer.State()
58 | state.currentUser = User(
59 | id: 0,
60 | email: "test@test.com",
61 | isAdmin: true
62 | )
63 |
64 | return AccountView(
65 | store: Store(initialState: state) {},
66 | navItemTapped: { _ in }
67 | )
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/BlackCandy/Views/Login/LoginAuthenticationView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 |
4 | struct LoginAuthenticationView: View {
5 | @StateObject var loginState = LoginState()
6 | let store: StoreOf
7 |
8 | var body: some View {
9 | Form {
10 | Section {
11 | TextField("label.email", text: $loginState.email)
12 | .textInputAutocapitalization(.never)
13 | .autocorrectionDisabled(true)
14 | .keyboardType(.emailAddress)
15 |
16 | SecureField("label.password", text: $loginState.password)
17 | }
18 |
19 | Button(action: {
20 | store.send(.login(loginState))
21 | }, label: {
22 | Text("label.login")
23 | })
24 | .frame(maxWidth: .infinity)
25 | .disabled(loginState.hasEmptyField)
26 | }
27 | .navigationTitle("text.loginToBC")
28 | .navigationBarTitleDisplayMode(.inline)
29 | }
30 | }
31 |
32 | struct LoginAuthenticationView_Previews: PreviewProvider {
33 | static var previews: some View {
34 | LoginAuthenticationView(
35 | store: Store(initialState: LoginReducer.State()) {}
36 | )
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/BlackCandy/Views/Login/LoginConnectionView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 |
4 | struct LoginConnectionView: View {
5 | @Dependency(\.userDefaultsClient) var userDefaultsClient
6 | @StateObject var serverAddressState = ServerAddressState()
7 |
8 | let store: StoreOf
9 |
10 | var body: some View {
11 | Form {
12 | Section(content: {
13 | TextField("label.serverAddress", text: $serverAddressState.url)
14 | .textInputAutocapitalization(.never)
15 | .autocorrectionDisabled(true)
16 | .keyboardType(.URL)
17 | }, header: {
18 | Image("BlackCandyLogo")
19 | .frame(maxWidth: .infinity)
20 | .padding(.bottom)
21 | })
22 |
23 | Button(action: {
24 | store.send(.getSystemInfo(serverAddressState))
25 | }, label: {
26 | Text("label.connect")
27 | })
28 | .frame(maxWidth: .infinity)
29 | .disabled(serverAddressState.hasEmptyField)
30 | }
31 | .navigationTitle("text.connectToBC")
32 | .navigationBarTitleDisplayMode(.inline)
33 | .onAppear {
34 | serverAddressState.url = userDefaultsClient.serverAddress()?.absoluteString ?? ""
35 | }
36 | }
37 | }
38 |
39 | struct LoginConnectionView_Previews: PreviewProvider {
40 | static var previews: some View {
41 | LoginConnectionView(
42 | store: Store(initialState: LoginReducer.State()) {}
43 | )
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/BlackCandy/Views/LoginView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 |
4 | struct LoginView: View {
5 | let store: StoreOf
6 |
7 | struct ViewState: Equatable {
8 | @BindingViewState var isAuthenticationViewVisible: Bool
9 |
10 | init(store: BindingViewStore) {
11 | self._isAuthenticationViewVisible = store.$isAuthenticationViewVisible
12 | }
13 | }
14 |
15 | var body: some View {
16 | WithViewStore(self.store, observe: ViewState.init) { viewStore in
17 | NavigationView {
18 | VStack {
19 | LoginConnectionView(store: store)
20 |
21 | NavigationLink(
22 | destination: LoginAuthenticationView(store: store),
23 | isActive: viewStore.$isAuthenticationViewVisible,
24 | label: { EmptyView() }
25 | )
26 | .hidden()
27 | }
28 | }
29 | .navigationViewStyle(.stack)
30 | .alert(
31 | store: store.scope(state: \.$alert, action: { .alert($0) })
32 | )
33 | }
34 | }
35 | }
36 |
37 | struct LoginView_Previews: PreviewProvider {
38 | static var previews: some View {
39 | let systemInfoResponse = SystemInfo(
40 | version: .init(major: 3, minor: 0, patch: 0, pre: ""),
41 | serverAddress: URL(string: "http://localhost:3000")
42 | )
43 |
44 | let store = withDependencies {
45 | $0.apiClient.getSystemInfo = { _ in
46 | systemInfoResponse
47 | }
48 | } operation: {
49 | Store(initialState: LoginReducer.State()) {
50 | LoginReducer()
51 | }
52 | }
53 |
54 | LoginView(store: store)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/BlackCandy/Views/Player/PlayerActionsView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 |
4 | struct PlayerActionsView: View {
5 | let store: StoreOf
6 |
7 | var body: some View {
8 | WithViewStore(self.store, observe: { $0 }, content: { viewStore in
9 | HStack {
10 | Button(
11 | action: {
12 | viewStore.send(.nextMode)
13 | },
14 | label: {
15 | Image(systemName: viewStore.mode.symbol)
16 | .tint(viewStore.mode == .noRepeat ? .primary : .white)
17 | }
18 | )
19 | .padding(CustomStyle.spacing(.narrow))
20 | .background(viewStore.mode == .noRepeat ? .clear : .accentColor)
21 | .cornerRadius(CustomStyle.cornerRadius(.medium))
22 |
23 | Spacer()
24 |
25 | Button(
26 | action: {
27 | viewStore.send(.toggleFavorite)
28 | },
29 | label: {
30 | if viewStore.currentSong?.isFavorited ?? false {
31 | Image(systemName: "heart.fill")
32 | .tint(.red)
33 | } else {
34 | Image(systemName: "heart")
35 | .tint(.primary)
36 | }
37 | }
38 | )
39 | .padding(CustomStyle.spacing(.narrow))
40 | .disabled(!viewStore.hasCurrentSong)
41 |
42 | Spacer()
43 |
44 | Button(
45 | action: {
46 | viewStore.send(.togglePlaylistVisible)
47 | },
48 | label: {
49 | Image(systemName: "list.bullet")
50 | .tint(viewStore.isPlaylistVisible ? .white : .primary)
51 | }
52 | )
53 | .padding(CustomStyle.spacing(.narrow))
54 | .background(viewStore.isPlaylistVisible ? Color.accentColor : .clear)
55 | .cornerRadius(CustomStyle.cornerRadius(.medium))
56 | }
57 | })
58 | }
59 | }
60 |
61 | struct PlayerActionsView_Previews: PreviewProvider {
62 | static var previews: some View {
63 | PlayerActionsView(
64 | store: Store(initialState: PlayerReducer.State()) {}
65 | )
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/BlackCandy/Views/Player/PlayerControlView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 |
4 | struct PlayerControlView: View {
5 | let store: StoreOf
6 | let durationFormatter = DurationFormatter()
7 |
8 | var body: some View {
9 | WithViewStore(self.store, observe: { $0 }, content: { viewStore in
10 | VStack {
11 | songProgress(viewStore)
12 | playerControl(viewStore)
13 | .padding(CustomStyle.spacing(.large))
14 | }
15 | })
16 | }
17 |
18 | func playerControl(_ viewStore: ViewStore) -> some View {
19 | HStack {
20 | Button(
21 | action: {
22 | viewStore.send(.previous)
23 | },
24 | label: {
25 | Image(systemName: "backward.fill")
26 | .tint(.primary)
27 | .customStyle(.largeSymbol)
28 | }
29 | )
30 |
31 | Spacer()
32 |
33 | Button(
34 | action: {
35 | if viewStore.isPlaying {
36 | viewStore.send(.pause)
37 | } else {
38 | viewStore.send(.play)
39 | }
40 | },
41 | label: {
42 | if viewStore.isPlaying {
43 | Image(systemName: "pause.fill")
44 | .tint(.primary)
45 | .customStyle(.extraLargeSymbol)
46 | } else {
47 | Image(systemName: "play.fill")
48 | .tint(.primary)
49 | .customStyle(.extraLargeSymbol)
50 | }
51 | }
52 | )
53 |
54 | Spacer()
55 |
56 | Button(
57 | action: {
58 | viewStore.send(.next)
59 | },
60 | label: {
61 | Image(systemName: "forward.fill")
62 | .tint(.primary)
63 | .customStyle(.largeSymbol)
64 | }
65 | )
66 | }
67 | }
68 |
69 | func songProgress(_ viewStore: ViewStore) -> some View {
70 | let currentSong = viewStore.currentSong
71 | let noneDuration = "--:--"
72 | let duration = currentSong != nil ? durationFormatter.string(from: currentSong!.duration) : noneDuration
73 | let currentDuration = currentSong != nil ? durationFormatter.string(from: viewStore.currentTime) : noneDuration
74 | let progressValue = currentSong != nil ? viewStore.currentTime / currentSong!.duration : 0
75 |
76 | return VStack {
77 | PlayerSliderView(value: viewStore.binding(
78 | get: { _ in progressValue },
79 | send: { PlayerReducer.Action.seekToRatio($0) }
80 | ))
81 |
82 | HStack {
83 | if viewStore.status == .loading {
84 | ProgressView()
85 | .customStyle(.playerProgressLoader)
86 | } else {
87 | Text(currentDuration!)
88 | .font(.caption2)
89 | .foregroundColor(.secondary)
90 | }
91 |
92 | Spacer()
93 |
94 | Text(duration!)
95 | .font(.caption2)
96 | .foregroundColor(.secondary)
97 | }
98 | }
99 | }
100 | }
101 |
102 | struct PlayerControlView_Previews: PreviewProvider {
103 | static var previews: some View {
104 | let song = Song(
105 | id: 0,
106 | name: "Hi Hi",
107 | duration: 120,
108 | url: URL(string: "http:localhost")!,
109 | albumName: "Test",
110 | artistName: "Test artist",
111 | format: "mp3",
112 | albumImageUrl: .init(
113 | small: URL(string: "http:localhost")!,
114 | medium: URL(string: "http:localhost")!,
115 | large: URL(string: "http:localhost")!),
116 | isFavorited: true
117 | )
118 |
119 | PlayerControlView(
120 | store: Store(initialState: PlayerReducer.State(
121 | currentSong: song
122 | )) {}
123 | )
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/BlackCandy/Views/Player/PlayerPlaylistView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 |
4 | struct PlayerPlaylistView: View {
5 | let store: StoreOf
6 | let durationFormatter = DurationFormatter()
7 |
8 | struct ViewState: Equatable {
9 | let playlist: Playlist
10 | let currentSong: Song?
11 |
12 | init(state: PlayerReducer.State) {
13 | self.currentSong = state.currentSong
14 | self.playlist = state.playlist
15 | }
16 | }
17 |
18 | var body: some View {
19 | WithViewStore(self.store, observe: ViewState.init) { viewStore in
20 | VStack {
21 | HStack {
22 | Text("label.tracks(\(viewStore.playlist.songs.count))")
23 |
24 | Spacer()
25 |
26 | EditButton()
27 | }
28 | .padding(CustomStyle.spacing(.small))
29 | .background(Color.init(.systemGray5))
30 | .cornerRadius(CustomStyle.cornerRadius(.large))
31 | .popupInteractionContainer()
32 |
33 | List {
34 | ForEach(viewStore.playlist.orderedSongs) { song in
35 | HStack {
36 | VStack(alignment: .leading, spacing: CustomStyle.spacing(.small)) {
37 | Text(song.name)
38 | .customStyle(.mediumFont)
39 |
40 | Text(song.artistName)
41 | .customStyle(.smallFont)
42 | }
43 |
44 | Spacer()
45 |
46 | Text(durationFormatter.string(from: song.duration)!)
47 | .customStyle(.smallFont)
48 | }
49 | .foregroundColor(song == viewStore.currentSong ? .accentColor : .primary)
50 | .onTapGesture {
51 | guard let songIndex = viewStore.playlist.index(of: song) else { return }
52 | viewStore.send(.playOn(songIndex))
53 | }
54 | }
55 | .onDelete { indexSet in
56 | viewStore.send(.deleteSongs(indexSet))
57 | }
58 | .onMove { fromOffsets, toOffset in
59 | viewStore.send(.moveSongs(fromOffsets, toOffset))
60 | }
61 | .listRowBackground(Color.clear)
62 | }
63 | .listStyle(.plain)
64 | }
65 | }
66 | }
67 | }
68 |
69 | struct PlayerPlaylistView_Previews: PreviewProvider {
70 | static var previews: some View {
71 | let song1 = Song(
72 | id: 0,
73 | name: "Hi Hi",
74 | duration: 120,
75 | url: URL(string: "http:localhost")!,
76 | albumName: "Test",
77 | artistName: "Test artist",
78 | format: "mp3",
79 | albumImageUrl: .init(
80 | small: URL(string: "http:localhost")!,
81 | medium: URL(string: "http:localhost")!,
82 | large: URL(string: "http:localhost")!),
83 | isFavorited: true
84 | )
85 |
86 | let song2 = Song(
87 | id: 1,
88 | name: "Hi Hi 2",
89 | duration: 300,
90 | url: URL(string: "http:localhost")!,
91 | albumName: "Test",
92 | artistName: "Test artist",
93 | format: "mp3",
94 | albumImageUrl: .init(
95 | small: URL(string: "http:localhost")!,
96 | medium: URL(string: "http:localhost")!,
97 | large: URL(string: "http:localhost")!),
98 | isFavorited: false
99 | )
100 |
101 | var playlist = Playlist()
102 | playlist.update(songs: [song1, song2])
103 |
104 | return PlayerPlaylistView(
105 | store: Store(initialState: PlayerReducer.State(
106 | playlist: playlist
107 | )) {}
108 | )
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/BlackCandy/Views/Player/PlayerSliderView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct PlayerSliderView: UIViewRepresentable {
4 | @Binding var value: Double
5 |
6 | class Coordinator {
7 | @Binding var value: Double
8 |
9 | init(value: Binding) {
10 | _value = value
11 | }
12 |
13 | @objc func valueChanged(_ sender: UISlider) {
14 | value = Double(sender.value)
15 | }
16 | }
17 |
18 | func makeUIView(context: Context) -> UISlider {
19 | let slider = UISlider()
20 |
21 | slider.isContinuous = false
22 | slider.setThumbImage(
23 | .init(
24 | systemName: "circle.fill",
25 | withConfiguration: UIImage.SymbolConfiguration(pointSize: CustomStyle.fontSize(.small))
26 | ),
27 | for: .normal
28 | )
29 |
30 | slider.addTarget(
31 | context.coordinator,
32 | action: #selector(Coordinator.valueChanged(_:)),
33 | for: .valueChanged
34 | )
35 |
36 | return slider
37 | }
38 |
39 | func updateUIView(_ slider: UISlider, context: Context) {
40 | slider.value = Float(value)
41 | }
42 |
43 | func makeCoordinator() -> Coordinator {
44 | .init(value: $value)
45 | }
46 | }
47 |
48 | struct PlayerSliderView_Previews: PreviewProvider {
49 | static var previews: some View {
50 | PlayerSliderView(value: .constant(0.5))
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/BlackCandy/Views/Player/PlayerSongInfoView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct PlayerSongInfoView: View {
4 | let currentSong: Song?
5 |
6 | var body: some View {
7 | VStack {
8 | AsyncImage(url: currentSong?.albumImageUrl.large) { image in
9 | image.resizable()
10 | } placeholder: {
11 | Color.secondary
12 | }
13 | .cornerRadius(CustomStyle.cornerRadius(.medium))
14 | .frame(width: CustomStyle.playerImageSize, height: CustomStyle.playerImageSize)
15 | .padding(.bottom, CustomStyle.spacing(.extraWide))
16 |
17 | VStack(spacing: CustomStyle.spacing(.tiny)) {
18 | Text(currentSong?.name ?? NSLocalizedString("label.notPlaying", comment: ""))
19 | .font(.headline)
20 | Text(currentSong?.artistName ?? "")
21 | .font(.caption)
22 | }
23 | .padding(.bottom, CustomStyle.spacing(.wide))
24 | }
25 | }
26 | }
27 |
28 | struct PlayerSongInfoView_Previews: PreviewProvider {
29 | static var previews: some View {
30 | let song = Song(
31 | id: 0,
32 | name: "Hi Hi",
33 | duration: 120,
34 | url: URL(string: "http:localhost")!,
35 | albumName: "Test",
36 | artistName: "Test artist",
37 | format: "mp3",
38 | albumImageUrl: .init(
39 | small: URL(string: "http:localhost")!,
40 | medium: URL(string: "http:localhost")!,
41 | large: URL(string: "http:localhost")!),
42 | isFavorited: true
43 | )
44 |
45 | PlayerSongInfoView(currentSong: song)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/BlackCandy/Views/PlayerView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ComposableArchitecture
3 |
4 | struct PlayerView: View {
5 | let store: StoreOf
6 | var padding: EdgeInsets = .init()
7 |
8 | struct ViewState: Equatable {
9 | let isPlaylistVisible: Bool
10 | let currentSong: Song?
11 | let hasCurrentSong: Bool
12 |
13 | init(state: PlayerReducer.State) {
14 | self.currentSong = state.currentSong
15 | self.isPlaylistVisible = state.isPlaylistVisible
16 | self.hasCurrentSong = state.hasCurrentSong
17 | }
18 | }
19 |
20 | var body: some View {
21 | WithViewStore(self.store, observe: ViewState.init) { viewStore in
22 | VStack {
23 | Spacer()
24 |
25 | if viewStore.isPlaylistVisible {
26 | PlayerPlaylistView(store: store)
27 | .padding(.horizontal, CustomStyle.spacing(.tiny))
28 |
29 | } else {
30 | PlayerSongInfoView(currentSong: viewStore.currentSong)
31 | PlayerControlView(store: store)
32 | .disabled(!viewStore.hasCurrentSong)
33 | .padding(.horizontal, CustomStyle.spacing(.small))
34 | }
35 |
36 | Spacer()
37 |
38 | PlayerActionsView(store: store)
39 | .padding(.vertical, CustomStyle.spacing(.medium))
40 | .padding(.horizontal, CustomStyle.spacing(.large))
41 | }
42 | .padding(padding)
43 | .frame(maxWidth: CustomStyle.playerMaxWidth)
44 | .task {
45 | await viewStore.send(.getLivingStates).finish()
46 | }
47 | }
48 | }
49 | }
50 |
51 | struct PlayerView_Previews: PreviewProvider {
52 | static var previews: some View {
53 | let song1 = Song(
54 | id: 0,
55 | name: "Hi Hi",
56 | duration: 120,
57 | url: URL(string: "http:localhost")!,
58 | albumName: "Test",
59 | artistName: "Test artist",
60 | format: "mp3",
61 | albumImageUrl: .init(
62 | small: URL(string: "http:localhost")!,
63 | medium: URL(string: "http:localhost")!,
64 | large: URL(string: "http:localhost")!),
65 | isFavorited: true
66 | )
67 |
68 | let song2 = Song(
69 | id: 1,
70 | name: "Hi Hi 2",
71 | duration: 300,
72 | url: URL(string: "http:localhost")!,
73 | albumName: "Test",
74 | artistName: "Test artist",
75 | format: "mp3",
76 | albumImageUrl: .init(
77 | small: URL(string: "http:localhost")!,
78 | medium: URL(string: "http:localhost")!,
79 | large: URL(string: "http:localhost")!),
80 | isFavorited: false
81 | )
82 |
83 | var playlist = Playlist()
84 | playlist.update(songs: [song1, song2])
85 |
86 | return PlayerView(store: Store(initialState: PlayerReducer.State(
87 | playlist: playlist,
88 | currentSong: song1
89 | )) {
90 | PlayerReducer()
91 | })
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/BlackCandy/path-configuration.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": [
3 | {
4 | "patterns": [
5 | "^/$"
6 | ],
7 | "properties": {
8 | "nav_button": {
9 | "path": "/account",
10 | "title": "Account",
11 | "icon": "person.circle"
12 | }
13 | }
14 | },
15 | {
16 | "patterns": [
17 | "^/library$"
18 | ],
19 | "properties": {
20 | "has_search_bar": true
21 | }
22 | },
23 | {
24 | "patterns": [
25 | "^/dialog/*"
26 | ],
27 | "properties": {
28 | "presentation": "modal"
29 | }
30 | },
31 | {
32 | "patterns": [
33 | "^/account*"
34 | ],
35 | "properties": {
36 | "presentation": "modal",
37 | "root_view": "account"
38 | }
39 | }
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/BlackCandyTests/Clients/APIClientTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import OHHTTPStubsSwift
3 | import OHHTTPStubs
4 | @testable import BlackCandy
5 |
6 | final class APIClientTests: XCTestCase {
7 | var apiClient: APIClient!
8 |
9 | override func setUpWithError() throws {
10 | apiClient = APIClient.live()
11 | }
12 |
13 | override func tearDownWithError() throws {
14 | HTTPStubs.removeAllStubs()
15 | }
16 |
17 | func testGetSystemInfo() async throws {
18 | let responseJSON = """
19 | {
20 | "version": {
21 | "major": 3,
22 | "minor": 0,
23 | "patch": 0,
24 | "pre": "beta1"
25 | }
26 | }
27 | """.data(using: .utf8)!
28 |
29 | stub(condition: isPath("/api/v1/system")) { _ in
30 | return .init(data: responseJSON, statusCode: 200, headers: nil)
31 | }
32 |
33 | let serverAddressState = ServerAddressState()
34 | serverAddressState.url = "http://localhost:3000"
35 |
36 | let systemInfo = try await apiClient.getSystemInfo(serverAddressState)
37 |
38 | XCTAssertEqual(systemInfo.version.major, 3)
39 | XCTAssertEqual(systemInfo.version.minor, 0)
40 | XCTAssertEqual(systemInfo.version.patch, 0)
41 | XCTAssertEqual(systemInfo.version.pre, "beta1")
42 | }
43 |
44 | func testAuthentication() async throws {
45 | let responseJSON = """
46 | {
47 | "user": {
48 | "id": 1,
49 | "email": "admin@admin.com",
50 | "is_admin": true,
51 | "api_token": "fake_token"
52 | }
53 | }
54 | """.data(using: .utf8)!
55 |
56 | stub(condition: isMethodPOST() && isPath("/api/v1/authentication")) { _ in
57 | return .init(data: responseJSON, statusCode: 200, headers: ["set-cookie": "session=123456"])
58 | }
59 |
60 | let loginState = LoginState()
61 | loginState.email = "admin@admin.com"
62 | loginState.password = "foobar"
63 |
64 | let authenticationResponse = try await apiClient.login(loginState)
65 | let responseCookie = authenticationResponse.cookies.first!
66 |
67 | XCTAssertEqual(authenticationResponse.user.email, "admin@admin.com")
68 | XCTAssertEqual(authenticationResponse.token, "fake_token")
69 | XCTAssertEqual(responseCookie.name, "session")
70 | XCTAssertEqual(responseCookie.value, "123456")
71 | }
72 |
73 | func testGetCurrentPlaylistSongs() async throws {
74 | stub(condition: isPath("/api/v1/current_playlist/songs")) { _ in
75 | let stubPath = OHPathForFile("songs.json", type(of: self))
76 | return fixture(filePath: stubPath!, headers: nil)
77 | }
78 |
79 | let response = try await apiClient.getSongsFromCurrentPlaylist()
80 |
81 | XCTAssertEqual(response.count, 5)
82 | }
83 |
84 | func testAddSongToFavorite() async throws {
85 | let song = try songs(id: 1)
86 |
87 | stub(condition: isMethodPOST() && isPath("/api/v1/favorite_playlist/songs")) { _ in
88 | return .init(jsonObject: ["id": 1], statusCode: 200, headers: nil)
89 | }
90 |
91 | let response = try await apiClient.addSongToFavorite(song)
92 |
93 | XCTAssertEqual(response.self, 1)
94 | }
95 |
96 | func testDeleteSongFromFavorite() async throws {
97 | let song = try songs(id: 1)
98 |
99 | stub(condition: isMethodDELETE() && isPath("/api/v1/favorite_playlist/songs/\(song.id)")) { _ in
100 | return .init(jsonObject: ["id": 1], statusCode: 200, headers: nil)
101 | }
102 |
103 | let response = try await apiClient.deleteSongInFavorite(song)
104 |
105 | XCTAssertEqual(response.self, 1)
106 | }
107 |
108 | func testDeleteCurrentPlaylistSongs() async throws {
109 | let song = try songs(id: 1)
110 |
111 | stub(condition: isMethodDELETE() && isPath("/api/v1/current_playlist/songs/\(song.id)")) { _ in
112 | return .init(jsonObject: [] as [Any], statusCode: 200, headers: nil)
113 | }
114 |
115 | let response = try await apiClient.deleteSongInCurrentPlaylist(song)
116 |
117 | XCTAssertEqual(response.self, APIClient.NoContentResponse.value)
118 | }
119 |
120 | func testMoveCurrentPlaylistSongs() async throws {
121 | let songId = 0
122 |
123 | stub(condition: isMethodPUT() && isPath("/api/v1/current_playlist/songs/\(songId)/move")) { _ in
124 | return .init(jsonObject: [] as [Any], statusCode: 200, headers: nil)
125 | }
126 |
127 | let response = try await apiClient.moveSongInCurrentPlaylist(songId, 1)
128 |
129 | XCTAssertEqual(response.self, APIClient.NoContentResponse.value)
130 | }
131 |
132 | func testGetSong() async throws {
133 | let responseJSON = """
134 | {
135 | "id":1,
136 | "name":"sample1",
137 | "duration": 129.0,
138 | "url":"http://localhost:3000/api/v1/stream/new?song_id=1",
139 | "album_name":"sample album",
140 | "artist_name":"sample artist",
141 | "is_favorited":false,
142 | "format":"mp3",
143 | "album_image_url":{
144 | "small":"http://localhost:3000/uploads/album/image/1/small.jpg",
145 | "medium":"http://localhost:3000/uploads/album/image/1/medium.jpg",
146 | "large":"http://localhost:3000/uploads/album/image/1/large.jpg"
147 | }
148 | }
149 | """.data(using: .utf8)!
150 |
151 | stub(condition: isPath("/api/v1/songs/1")) { _ in
152 | return .init(data: responseJSON, statusCode: 200, headers: nil)
153 | }
154 |
155 | let response = try await apiClient.getSong(1)
156 |
157 | XCTAssertEqual(response.id, 1)
158 | XCTAssertEqual(response.name, "sample1")
159 | XCTAssertEqual(response.isFavorited, false)
160 | XCTAssertEqual(response.albumImageUrl.small, URL(string: "http://localhost:3000/uploads/album/image/1/small.jpg"))
161 | }
162 |
163 | func testHandleUnauthorizedError() async throws {
164 | stub(condition: isPath("/api/v1/songs/1")) { _ in
165 | return .init(jsonObject: [] as [Any], statusCode: 401, headers: nil)
166 | }
167 |
168 | do {
169 | _ = try await apiClient.getSong(1)
170 | } catch {
171 | guard let error = error as? APIClient.APIError else {
172 | return XCTFail("Wrong type of APIError.")
173 | }
174 |
175 | XCTAssertEqual(error, APIClient.APIError.unauthorized)
176 | }
177 | }
178 |
179 | func testHandleBadRequestError() async throws {
180 | let errorMessage = "Invalide request"
181 |
182 | stub(condition: isPath("/api/v1/songs/1")) { _ in
183 | return .init(jsonObject: ["message": errorMessage], statusCode: 400, headers: nil)
184 | }
185 |
186 | do {
187 | _ = try await apiClient.getSong(1)
188 | } catch {
189 | guard let error = error as? APIClient.APIError else {
190 | return XCTFail("Wrong type of APIError.")
191 | }
192 |
193 | XCTAssertEqual(error, APIClient.APIError.badRequest(errorMessage))
194 | }
195 | }
196 |
197 | func testAddSongToCurrentPlaylist() async throws {
198 | let responseJSON = """
199 | {
200 | "id":1,
201 | "name":"sample1",
202 | "duration": 129.0,
203 | "url":"http://localhost:3000/api/v1/stream/new?song_id=1",
204 | "album_name":"sample album",
205 | "artist_name":"sample artist",
206 | "is_favorited":false,
207 | "format":"mp3",
208 | "album_image_url":{
209 | "small":"http://localhost:3000/uploads/album/image/1/small.jpg",
210 | "medium":"http://localhost:3000/uploads/album/image/1/medium.jpg",
211 | "large":"http://localhost:3000/uploads/album/image/1/large.jpg"
212 | }
213 | }
214 | """.data(using: .utf8)!
215 |
216 | let currentSong = try songs(id: 2)
217 |
218 | stub(condition: isMethodPOST() && isPath("/api/v1/current_playlist/songs")) { _ in
219 | return .init(data: responseJSON, statusCode: 200, headers: nil)
220 | }
221 |
222 | let response = try await apiClient.addSongToCurrentPlaylist(1, currentSong, nil)
223 |
224 | XCTAssertEqual(response.id, 1)
225 | XCTAssertEqual(response.name, "sample1")
226 | XCTAssertEqual(response.isFavorited, false)
227 | XCTAssertEqual(response.albumImageUrl.small, URL(string: "http://localhost:3000/uploads/album/image/1/small.jpg"))
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/BlackCandyTests/Clients/CookiesClientTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import WebKit
3 | @testable import BlackCandy
4 |
5 | @MainActor
6 | final class CookiesClientTests: XCTestCase {
7 | var cookieStore: WKHTTPCookieStore!
8 | var cookiesClient: CookiesClient!
9 |
10 | override func setUp() async throws {
11 | let dataStore = WKWebsiteDataStore.nonPersistent()
12 | cookiesClient = CookiesClient.live(dataStore: dataStore)
13 | cookieStore = dataStore.httpCookieStore
14 |
15 | await cookiesClient.cleanCookies()
16 | }
17 |
18 | func testUpdateCookie() async throws {
19 | let cookie = HTTPCookie(properties: [
20 | .name: "testName",
21 | .value: "testValue",
22 | .originURL: URL(string: "http://localhost:3000")!,
23 | .path: "/"
24 | ])!
25 |
26 | await cookiesClient.updateCookies([cookie])
27 |
28 | let allCookies = await cookieStore.allCookies()
29 |
30 | XCTAssertEqual(cookie.value, allCookies.first?.value)
31 | }
32 |
33 | func testCleanCookies() async throws {
34 | let cookie = HTTPCookie(properties: [
35 | .name: "testName",
36 | .value: "testValue",
37 | .originURL: URL(string: "http://localhost:3000")!,
38 | .path: "/"
39 | ])!
40 |
41 | await cookiesClient.updateCookies([cookie])
42 | await cookiesClient.cleanCookies()
43 |
44 | let allCookies = await cookieStore.allCookies()
45 |
46 | XCTAssertTrue(allCookies.isEmpty)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/BlackCandyTests/Clients/JSONDataClientTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class JSONDataClientTests: XCTestCase {
5 | var jsonDataClient: JSONDataClient!
6 |
7 | override func setUpWithError() throws {
8 | jsonDataClient = JSONDataClient.liveValue
9 | }
10 |
11 | func testDeleteCurrentUser() throws {
12 | let user = try users(id: 1)
13 |
14 | jsonDataClient.updateCurrentUser(user)
15 | XCTAssertEqual(self.jsonDataClient.currentUser(), user)
16 |
17 | jsonDataClient.deleteCurrentUser()
18 | XCTAssertNil(jsonDataClient.currentUser())
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/BlackCandyTests/Clients/KeychainClientTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class KeychainClientTests: XCTestCase {
5 | var keychainClient: KeychainClient!
6 |
7 | override func setUpWithError() throws {
8 | keychainClient = KeychainClient.liveValue
9 | }
10 |
11 | override func tearDownWithError() throws {
12 | keychainClient.deleteAPIToken()
13 | }
14 |
15 | func testUpdateAPIToken() throws {
16 | keychainClient.updateAPIToken("test_token")
17 | XCTAssertEqual(keychainClient.apiToken(), "test_token")
18 | }
19 |
20 | func testDeleteAPIToken() throws {
21 | keychainClient.updateAPIToken("test_token")
22 | keychainClient.deleteAPIToken()
23 |
24 | XCTAssertNil(keychainClient.apiToken())
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/BlackCandyTests/Clients/NowPlayingClientTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import MediaPlayer
3 | import OHHTTPStubsSwift
4 | import OHHTTPStubs
5 | @testable import BlackCandy
6 |
7 | final class NowPlayingClientTests: XCTestCase {
8 | var nowPlayingClient: NowPlayingClient!
9 | var playingSong: Song!
10 |
11 | override func setUpWithError() throws {
12 | nowPlayingClient = NowPlayingClient.liveValue
13 | playingSong = try songs(id: 1)
14 | }
15 |
16 | override func tearDownWithError() throws {
17 | MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
18 | HTTPStubs.removeAllStubs()
19 | }
20 |
21 | func testUpdateNowPlayingInfo() async throws {
22 | stub(condition: isAbsoluteURLString(playingSong.albumImageUrl.large.absoluteString) ) { _ in
23 | return .init(fileAtPath: OHPathForFile("cover_image.jpg", type(of: self))!, statusCode: 200, headers: ["Content-Type": "image/jpeg"])
24 | }
25 |
26 | await nowPlayingClient.updateInfo(playingSong)
27 |
28 | let nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo
29 | XCTAssertEqual(nowPlayingInfo?[MPMediaItemPropertyTitle] as! String, playingSong.name)
30 | XCTAssertEqual(nowPlayingInfo?[MPMediaItemPropertyArtist] as! String, playingSong.artistName)
31 | XCTAssertEqual(nowPlayingInfo?[MPMediaItemPropertyAlbumTitle] as! String, playingSong.albumName)
32 | XCTAssertEqual(nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] as! Double, playingSong.duration)
33 | XCTAssertNotNil(nowPlayingInfo?[MPMediaItemPropertyArtwork] as! MPMediaItemArtwork)
34 | }
35 |
36 | func testUpdateNowPlayingPlaybackInfo() throws {
37 | nowPlayingClient.updatePlaybackInfo(1.5, 1)
38 |
39 | let nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo
40 | XCTAssertEqual(nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as! Float, 1.5)
41 | XCTAssertEqual(nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as! Float, 1)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/BlackCandyTests/Clients/PlayerClientTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import AVFoundation
3 | import OHHTTPStubsSwift
4 | import OHHTTPStubs
5 | import CoreMedia
6 | @testable import BlackCandy
7 |
8 | @MainActor
9 | final class PlayerClientTests: XCTestCase {
10 | var playerClient: PlayerClient!
11 |
12 | override func setUpWithError() throws {
13 | playerClient = PlayerClient.live(player: AVPlayer())
14 |
15 | stub(condition: isPath("/song.mp3") ) { _ in
16 | return .init(fileAtPath: OHPathForFile("song.mp3", type(of: self))!, statusCode: 200, headers: ["Content-Type": "audio/mpeg"])
17 | }
18 | }
19 |
20 | override func tearDownWithError() throws {
21 | HTTPStubs.removeAllStubs()
22 | }
23 |
24 | func testPlaySong() async throws {
25 | var playerStatus: [PlayerClient.Status] = []
26 |
27 | playerClient.playOn(URL(string: "http://localhost:3000/song.mp3")!)
28 |
29 | for await status in playerClient.getStatus().dropFirst().prefix(2) {
30 | playerStatus.append(status)
31 | }
32 |
33 | XCTAssertEqual(playerStatus, [.loading, .playing])
34 | }
35 |
36 | func testPauseSong() async throws {
37 | var playerStatus: [PlayerClient.Status] = []
38 |
39 | playerClient.playOn(URL(string: "http://localhost:3000/song.mp3")!)
40 |
41 | for await status in playerClient.getStatus().dropFirst().prefix(3) {
42 | playerStatus.append(status)
43 |
44 | if status == .playing {
45 | playerClient.pause()
46 | }
47 | }
48 |
49 | XCTAssertEqual(playerStatus, [.loading, .playing, .pause])
50 | }
51 |
52 | func testSeek() async throws {
53 | let time = CMTime(seconds: 2, preferredTimescale: 1)
54 |
55 | playerClient.playOn(URL(string: "http://localhost:3000/song.mp3")!)
56 | playerClient.seek(time)
57 |
58 | for await currentTime in playerClient.getCurrentTime().prefix(1) {
59 | XCTAssertEqual(currentTime.rounded(), 2)
60 | }
61 | }
62 |
63 | func testReplay() async throws {
64 | let time = CMTime(seconds: 2, preferredTimescale: 1)
65 |
66 | playerClient.playOn(URL(string: "http://localhost:3000/song.mp3")!)
67 | playerClient.seek(time)
68 | playerClient.replay()
69 |
70 | for await currentTime in playerClient.getCurrentTime().prefix(1) {
71 | XCTAssertEqual(currentTime.rounded(), 0)
72 | }
73 | }
74 |
75 | func testStop() async throws {
76 | var playerStatus: [PlayerClient.Status] = []
77 | var times: [Double] = []
78 |
79 | playerClient.playOn(URL(string: "http://localhost:3000/song.mp3")!)
80 |
81 | for await status in playerClient.getStatus().dropFirst().prefix(3) {
82 | playerStatus.append(status)
83 |
84 | if status == .playing {
85 | for await currentTime in playerClient.getCurrentTime().prefix(1) {
86 | times.append(currentTime.rounded())
87 | }
88 |
89 | playerClient.stop()
90 | }
91 | }
92 |
93 | XCTAssertEqual(playerStatus, [.loading, .playing, .pause])
94 | XCTAssertEqual(times, [0])
95 | }
96 |
97 | func testHasCurrentItem() throws {
98 | XCTAssertFalse(playerClient.hasCurrentItem())
99 |
100 | playerClient.playOn(URL(string: "http://localhost:3000/song.mp3")!)
101 |
102 | XCTAssertTrue(playerClient.hasCurrentItem())
103 | }
104 |
105 | func testGetPlaybackRate() throws {
106 | XCTAssertEqual(playerClient.getPlaybackRate(), 0)
107 |
108 | playerClient.playOn(URL(string: "http://localhost:3000/song.mp3")!)
109 |
110 | XCTAssertEqual(playerClient.getPlaybackRate(), 1)
111 | }
112 |
113 | func testWillGetEndStatusAfterEnd() async throws {
114 | var playerStatus: [PlayerClient.Status] = []
115 |
116 | playerClient.playOn(URL(string: "http://localhost:3000/song.mp3")!)
117 |
118 | for await status in playerClient.getStatus().dropFirst().prefix(3) {
119 | playerStatus.append(status)
120 | }
121 |
122 | XCTAssertEqual(playerStatus, [.loading, .playing, .end])
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/BlackCandyTests/Clients/UserDefaultsClientTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class UserDefaultsClientTests: XCTestCase {
5 | override func tearDownWithError() throws {
6 | UserDefaultsClient.liveValue.updateServerAddress(nil)
7 | }
8 |
9 | func testUpdateServerAdddress() throws {
10 | let userDefaultsClient = UserDefaultsClient.liveValue
11 | let serverAddress = URL(string: "http://localhost:3000")!
12 |
13 | XCTAssertNil(userDefaultsClient.serverAddress())
14 |
15 | userDefaultsClient.updateServerAddress(serverAddress)
16 | XCTAssertEqual(userDefaultsClient.serverAddress()!, serverAddress)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/BlackCandyTests/Fixtures/Files/cover_image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/BlackCandyTests/Fixtures/Files/cover_image.jpg
--------------------------------------------------------------------------------
/BlackCandyTests/Fixtures/Files/song.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/BlackCandyTests/Fixtures/Files/song.mp3
--------------------------------------------------------------------------------
/BlackCandyTests/Fixtures/songs.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id":1,
4 | "name":"sample1",
5 | "duration": 129.0,
6 | "url":"http://localhost:3000/api/v1/stream/new?song_id=1",
7 | "album_name":"sample album",
8 | "artist_name":"sample artist",
9 | "is_favorited":false,
10 | "format":"mp3",
11 | "album_image_url":{
12 | "small":"http://localhost:3000/uploads/album/image/1/small.jpg",
13 | "medium":"http://localhost:3000/uploads/album/image/1/medium.jpg",
14 | "large":"http://localhost:3000/uploads/album/image/1/large.jpg"
15 | }
16 | },
17 | {
18 | "id":2,
19 | "name":"sample2",
20 | "duration": 129.0,
21 | "url":"http://localhost:3000/api/v1/stream/new?song_id=2",
22 | "album_name":"sample album",
23 | "artist_name":"sample artist",
24 | "is_favorited":false,
25 | "format":"mp3",
26 | "album_image_url":{
27 | "small":"http://localhost:3000/uploads/album/image/2/small.jpg",
28 | "medium":"http://localhost:3000/uploads/album/image/2/medium.jpg",
29 | "large":"http://localhost:3000/uploads/album/image/2/large.jpg"
30 | }
31 | },
32 | {
33 | "id":3,
34 | "name":"sample3",
35 | "duration": 129.0,
36 | "url":"http://localhost:3000/api/v1/stream/new?song_id=3",
37 | "album_name":"sample album",
38 | "artist_name":"sample artist",
39 | "is_favorited":false,
40 | "format":"mp3",
41 | "album_image_url":{
42 | "small":"http://localhost:3000/uploads/album/image/3/small.jpg",
43 | "medium":"http://localhost:3000/uploads/album/image/3/medium.jpg",
44 | "large":"http://localhost:3000/uploads/album/image/3/large.jpg"
45 | }
46 | },
47 | {
48 | "id":4,
49 | "name":"sample4",
50 | "duration": 129.0,
51 | "url":"http://localhost:3000/api/v1/stream/new?song_id=4",
52 | "album_name":"sample album",
53 | "artist_name":"sample artist",
54 | "is_favorited":false,
55 | "format":"mp3",
56 | "album_image_url":{
57 | "small":"http://localhost:3000/uploads/album/image/4/small.jpg",
58 | "medium":"http://localhost:3000/uploads/album/image/4/medium.jpg",
59 | "large":"http://localhost:3000/uploads/album/image/4/large.jpg"
60 | }
61 | },
62 | {
63 | "id":5,
64 | "name":"sample5",
65 | "duration": 129.0,
66 | "url":"http://localhost:3000/api/v1/stream/new?song_id=5",
67 | "album_name":"sample album",
68 | "artist_name":"sample artist",
69 | "is_favorited":false,
70 | "format":"mp3",
71 | "album_image_url":{
72 | "small":"http://localhost:3000/uploads/album/image/5/small.jpg",
73 | "medium":"http://localhost:3000/uploads/album/image/5/medium.jpg",
74 | "large":"http://localhost:3000/uploads/album/image/4/large.jpg"
75 | }
76 | }
77 | ]
78 |
--------------------------------------------------------------------------------
/BlackCandyTests/Fixtures/users.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "email": "admin@admin.com",
5 | "isAdmin": true
6 | }
7 | ]
8 |
--------------------------------------------------------------------------------
/BlackCandyTests/Models/PlaylistTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class PlaylistTests: XCTestCase {
5 | var playlist: Playlist!
6 | var songs: [Song]!
7 |
8 | override func setUpWithError() throws {
9 | playlist = Playlist()
10 | songs = try songs()
11 |
12 | playlist.update(songs: songs)
13 | }
14 |
15 | func testUpdatePlaylist() throws {
16 | playlist.update(songs: [])
17 | XCTAssertEqual(playlist.songs, [])
18 | }
19 |
20 | func testGetShuffledSongs() throws {
21 | playlist.isShuffled = true
22 | XCTAssertNotEqual(playlist.songs, songs)
23 | }
24 |
25 | func testRemoveSongs() throws {
26 | let song = songs.first(where: { $0.id == 1 })!
27 |
28 | playlist.remove(songs: [song])
29 | XCTAssertFalse(playlist.songs.contains(song))
30 |
31 | playlist.isShuffled = true
32 | XCTAssertFalse(playlist.songs.contains(song))
33 | }
34 |
35 | func testInsertSong() throws {
36 | let song = songs.first(where: { $0.id == 1 })!
37 |
38 | playlist.remove(songs: [song])
39 | playlist.insert(song, at: 2)
40 | XCTAssertEqual(playlist.songs.map({ $0.id }), [2, 3, 1, 4, 5])
41 |
42 | playlist.isShuffled = true
43 | XCTAssertTrue(playlist.songs.contains(song))
44 | }
45 |
46 | func testGetIndex() throws {
47 | let song = songs.first(where: { $0.id == 1 })!
48 | XCTAssertEqual(playlist.index(of: song), 0)
49 | }
50 |
51 | func testGetIndexById() throws {
52 | XCTAssertEqual(playlist.index(by: 2), 1)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/BlackCandyTests/Models/SongTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class SongTests: XCTestCase {
5 | func testDecodeSong() throws {
6 | let json = """
7 | {
8 | "id":1,
9 | "name":"sample1",
10 | "duration": 129.0,
11 | "url":"http://localhost:3000/api/v1/stream/new?song_id=1",
12 | "album_name":"sample album",
13 | "artist_name":"sample artist",
14 | "is_favorited":false,
15 | "format":"mp3",
16 | "album_image_url":{
17 | "small":"http://localhost:3000/uploads/album/image/1/small.jpg",
18 | "medium":"http://localhost:3000/uploads/album/image/1/medium.jpg",
19 | "large":"http://localhost:3000/uploads/album/image/1/large.jpg"
20 | }
21 | }
22 | """
23 |
24 | let song: Song = try decodeJSON(from: json)
25 |
26 | XCTAssertEqual(song.id, 1)
27 | XCTAssertEqual(song.name, "sample1")
28 | XCTAssertEqual(song.isFavorited, false)
29 | XCTAssertEqual(song.albumImageUrl.small, URL(string: "http://localhost:3000/uploads/album/image/1/small.jpg"))
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/BlackCandyTests/Models/SystemInfoTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class SystemInfoTests: XCTestCase {
5 | func testDecodeSystemInfo() throws {
6 | let json = """
7 | {
8 | "version": {
9 | "major": 3,
10 | "minor": 0,
11 | "patch": 0,
12 | "pre": "beta1"
13 | }
14 | }
15 | """
16 |
17 | let systemInfo: SystemInfo = try decodeJSON(from: json)
18 |
19 | XCTAssertEqual(systemInfo.version.major, 3)
20 | XCTAssertEqual(systemInfo.version.minor, 0)
21 | XCTAssertEqual(systemInfo.version.patch, 0)
22 | XCTAssertEqual(systemInfo.version.pre, "beta1")
23 | XCTAssertTrue(systemInfo.isSupported)
24 | }
25 |
26 | func testIfSystemIsSupported() throws {
27 | let json = """
28 | {
29 | "version": {
30 | "major": 2,
31 | "minor": 0,
32 | "patch": 0,
33 | "pre": ""
34 | }
35 | }
36 | """
37 |
38 | let unsupportedSystemInfo: SystemInfo = try decodeJSON(from: json)
39 |
40 | XCTAssertFalse(unsupportedSystemInfo.isSupported)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/BlackCandyTests/Models/UserTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class UserTests: XCTestCase {
5 | func testDecodeUser() throws {
6 | let json = """
7 | {
8 | "id": 1,
9 | "email": "admin@admin.com",
10 | "isAdmin": true
11 | }
12 | """
13 |
14 | let user: User = try decodeJSON(from: json)
15 |
16 | XCTAssertEqual(user.id, 1)
17 | XCTAssertEqual(user.email, "admin@admin.com")
18 | XCTAssertEqual(user.isAdmin, true)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/BlackCandyTests/States/LoginStateTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class LoginStateTests: XCTestCase {
5 | func testIfHasEmptyField() throws {
6 | let state = LoginState()
7 | XCTAssertTrue(state.hasEmptyField)
8 |
9 | state.email = "test@test.com"
10 | XCTAssertTrue(state.hasEmptyField)
11 |
12 | state.password = "foobar"
13 | XCTAssertFalse(state.hasEmptyField)
14 | }
15 |
16 | func testEquatable() throws {
17 | let state1 = LoginState()
18 | let state2 = LoginState()
19 |
20 | state1.email = "test@test.com"
21 | state1.password = "foobar"
22 | state2.email = "test@test.com"
23 | state2.password = "foobar"
24 | XCTAssertEqual(state1, state2)
25 |
26 | state2.password = "foobar1"
27 | XCTAssertNotEqual(state1, state2)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/BlackCandyTests/States/ServerAddressStateTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class ServerAddressStateTests: XCTestCase {
5 | func testIfHasEmptyField() throws {
6 | let state = ServerAddressState()
7 | XCTAssertTrue(state.hasEmptyField)
8 |
9 | state.url = "http://localhost:3000"
10 | XCTAssertFalse(state.hasEmptyField)
11 | }
12 |
13 | func testEquatable() throws {
14 | let state1 = ServerAddressState()
15 | let state2 = ServerAddressState()
16 |
17 | state1.url = "http://localhost:3000"
18 | state2.url = "http://localhost:3000"
19 | XCTAssertEqual(state1, state2)
20 |
21 | state2.url = "http://localhost:4000"
22 | XCTAssertNotEqual(state1, state2)
23 | }
24 |
25 | func testHasValidUrl() throws {
26 | let state = ServerAddressState()
27 |
28 | state.url = "erro yyy"
29 | XCTAssertFalse(state.validateUrl())
30 |
31 | state.url = "http://foobar.com"
32 | XCTAssertTrue(state.validateUrl())
33 |
34 | state.url = "localhost:3000"
35 | XCTAssertTrue(state.validateUrl())
36 | }
37 |
38 | func testAutomaticllyAddHttpSchemeAfterChekUrlValidation() throws {
39 | let state = ServerAddressState()
40 |
41 | state.url = "localhost:3000"
42 | XCTAssertTrue(state.validateUrl())
43 | XCTAssertEqual(state.url, "http://localhost:3000")
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/BlackCandyTests/Store/AppReducerTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import ComposableArchitecture
3 | @testable import BlackCandy
4 |
5 | @MainActor
6 | final class AppReducerTests: XCTestCase {
7 | func testRestoreStates() async throws {
8 | let currentUser = try users(id: 1)
9 |
10 | let store = withDependencies {
11 | $0.jsonDataClient.currentUser = { currentUser }
12 | } operation: {
13 | TestStore(initialState: AppReducer.State()) {
14 | AppReducer()
15 | }
16 | }
17 |
18 | await store.send(.restoreStates) {
19 | $0.currentUser = currentUser
20 | }
21 | }
22 |
23 | func testLogout() async throws {
24 | var state = AppReducer.State()
25 | state.currentUser = try users(id: 1)
26 |
27 | let logoutResponse = APIClient.NoContentResponse()
28 |
29 | let store = withDependencies {
30 | $0.apiClient.logout = {
31 | logoutResponse
32 | }
33 | } operation: {
34 | TestStore(initialState: state) {
35 | AppReducer()
36 | }
37 | }
38 |
39 | await store.send(.logout)
40 |
41 | await store.receive(.logoutResponse(.success(logoutResponse))) {
42 | $0.currentUser = nil
43 | }
44 | }
45 |
46 | func testUpdateTheme() async throws {
47 | let store = TestStore(initialState: AppReducer.State()) {
48 | AppReducer()
49 | }
50 |
51 | await store.send(.updateTheme(.dark)) {
52 | $0.currentTheme = .dark
53 | }
54 | }
55 |
56 | func testDismissAlert() async throws {
57 | var state = AppReducer.State()
58 | state.alert = .init(title: .init("test"))
59 |
60 | let store = TestStore(initialState: state) {
61 | AppReducer()
62 | }
63 |
64 | await store.send(.dismissAlert)
65 |
66 | await store.receive(.alert(.dismiss)) {
67 | $0.alert = nil
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/BlackCandyTests/Store/LoginReducerTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import ComposableArchitecture
3 | @testable import BlackCandy
4 |
5 | @MainActor
6 | final class LoginReducerTests: XCTestCase {
7 | func testGetSystemInfo() async throws {
8 | let systemInfoResponse = SystemInfo(
9 | version: .init(major: 3, minor: 0, patch: 0, pre: ""),
10 | serverAddress: URL(string: "http://localhost:3000")
11 | )
12 |
13 | let store = withDependencies {
14 | $0.apiClient.getSystemInfo = { _ in
15 | systemInfoResponse
16 | }
17 | } operation: {
18 | TestStore(initialState: LoginReducer.State()) {
19 | LoginReducer()
20 | }
21 | }
22 |
23 | let serverAddressState = ServerAddressState()
24 | serverAddressState.url = "http://localhost:3000"
25 |
26 | await store.send(.getSystemInfo(serverAddressState))
27 |
28 | await store.receive(.systemInfoResponse(.success(systemInfoResponse))) {
29 | $0.isAuthenticationViewVisible = true
30 | }
31 | }
32 |
33 | func testGetSystemInfoWithInvalidServerAddress() async throws {
34 | let store = TestStore(initialState: LoginReducer.State()) {
35 | LoginReducer()
36 | }
37 |
38 | let serverAddressState = ServerAddressState()
39 | serverAddressState.url = "invalid address"
40 |
41 | await store.send(.getSystemInfo(serverAddressState)) {
42 | $0.alert = .init(title: .init("text.invalidServerAddress"))
43 | }
44 | }
45 |
46 | func testSystemInfoResponseWithInvalidServerAddress() async throws {
47 | let serverAddressState = ServerAddressState()
48 | serverAddressState.url = "http://localhost:3000"
49 |
50 | let systemInfoResponse = SystemInfo(
51 | version: .init(major: 3, minor: 0, patch: 0, pre: ""),
52 | serverAddress: nil
53 | )
54 |
55 | let store = withDependencies {
56 | $0.apiClient.getSystemInfo = { _ in
57 | systemInfoResponse
58 | }
59 | } operation: {
60 | TestStore(initialState: LoginReducer.State()) {
61 | LoginReducer()
62 | }
63 | }
64 |
65 | await store.send(.getSystemInfo(serverAddressState))
66 |
67 | await store.receive(.systemInfoResponse(.success(systemInfoResponse))) {
68 | $0.alert = .init(title: .init("text.invalidServerAddress"))
69 | }
70 | }
71 |
72 | func testSystemInfoResponseWithUnsupportedVersion() async throws {
73 | let serverAddressState = ServerAddressState()
74 | serverAddressState.url = "http://localhost:3000"
75 |
76 | let systemInfoResponse = SystemInfo(
77 | version: .init(major: 2, minor: 0, patch: 0, pre: ""),
78 | serverAddress: URL(string: "http://localhost:3000")
79 | )
80 |
81 | let store = withDependencies {
82 | $0.apiClient.getSystemInfo = { _ in
83 | systemInfoResponse
84 | }
85 | } operation: {
86 | TestStore(initialState: LoginReducer.State()) {
87 | LoginReducer()
88 | }
89 | }
90 |
91 | await store.send(.getSystemInfo(serverAddressState))
92 |
93 | await store.receive(.systemInfoResponse(.success(systemInfoResponse))) {
94 | $0.alert = .init(title: .init("text.unsupportedServer"))
95 | }
96 | }
97 |
98 | func testLogin() async throws {
99 | let user = try users(id: 1)
100 | let cookie = HTTPCookie(properties: [
101 | .name: "testName",
102 | .value: "testValue",
103 | .originURL: URL(string: "http://localhost:3000")!,
104 | .path: "/"
105 | ])!
106 |
107 | let loginResponse = APIClient.AuthenticationResponse(token: "test_token", user: user, cookies: [cookie])
108 |
109 | let store = withDependencies {
110 | $0.apiClient.login = { _ in
111 | loginResponse
112 | }
113 | } operation: {
114 | TestStore(initialState: LoginReducer.State()) {
115 | LoginReducer()
116 | }
117 | }
118 |
119 | let loginState = LoginState()
120 | loginState.email = "test@test.com"
121 | loginState.password = "foobar"
122 |
123 | await store.send(.login(loginState))
124 |
125 | await store.receive(.loginResponse(.success(loginResponse))) {
126 | $0.currentUser = user
127 | }
128 | }
129 |
130 | func testLoginFailed() async throws {
131 | let responseError = APIClient.APIError.unknown
132 |
133 | let store = withDependencies {
134 | $0.apiClient.login = { _ in
135 | throw responseError
136 | }
137 | } operation: {
138 | TestStore(initialState: LoginReducer.State()) {
139 | LoginReducer()
140 | }
141 | }
142 |
143 | let loginState = LoginState()
144 | loginState.email = "test@test.com"
145 | loginState.password = "foobar"
146 |
147 | await store.send(.login(loginState))
148 |
149 | await store.receive(.loginResponse(.failure(responseError))) {
150 | $0.alert = .init(title: .init(responseError.localizedString))
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/BlackCandyTests/Store/PlayerReducerTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import ComposableArchitecture
3 | import CoreMedia
4 | @testable import BlackCandy
5 |
6 | @MainActor
7 | final class PlayerReducerTests: XCTestCase {
8 | func testPlaySong() async throws {
9 | let getStatusTask = AsyncStream.makeStream(of: PlayerClient.Status.self)
10 |
11 | var playlist = Playlist()
12 | let songs = try songs()
13 |
14 | playlist.update(songs: songs)
15 |
16 | let store = withDependencies {
17 | $0.playerClient.getStatus = { getStatusTask.stream }
18 | $0.playerClient.playOn = { _ in getStatusTask.continuation.yield(.playing) }
19 | } operation: {
20 | TestStore(
21 | initialState: PlayerReducer.State(playlist: playlist),
22 | reducer: { PlayerReducer() }
23 | )
24 | }
25 |
26 | await store.send(.playOn(0)) {
27 | $0.currentSong = songs.first
28 | }
29 |
30 | await store.send(.getStatus)
31 |
32 | await store.receive(.handleStatusChange(.playing)) {
33 | $0.status = .playing
34 | }
35 |
36 | getStatusTask.continuation.finish()
37 | await store.finish()
38 | }
39 |
40 | func testPlaySongOutOfIndexRange() async throws {
41 | var playlist = Playlist()
42 | let songs = try songs()
43 |
44 | playlist.update(songs: songs)
45 |
46 | let store = TestStore(
47 | initialState: PlayerReducer.State(playlist: playlist),
48 | reducer: { PlayerReducer() }
49 | )
50 |
51 | await store.send(.playOn(-1)) {
52 | $0.currentSong = songs.last
53 | }
54 |
55 | XCTAssertEqual(store.state.currentIndex, songs.count - 1)
56 |
57 | await store.send(.playOn(songs.count + 1)) {
58 | $0.currentSong = songs.first
59 | }
60 |
61 | XCTAssertEqual(store.state.currentIndex, 0)
62 | }
63 |
64 | func testPlayCurrentSong() async throws {
65 | let getStatusTask = AsyncStream.makeStream(of: PlayerClient.Status.self)
66 |
67 | var playlist = Playlist()
68 | let songs = try songs()
69 |
70 | playlist.update(songs: songs)
71 |
72 | let store = withDependencies {
73 | $0.playerClient.hasCurrentItem = { false }
74 | $0.playerClient.getStatus = { getStatusTask.stream }
75 | $0.playerClient.playOn = { _ in getStatusTask.continuation.yield(.playing) }
76 | } operation: {
77 | TestStore(
78 | initialState: PlayerReducer.State(
79 | playlist: playlist,
80 | currentSong: songs.first
81 | ),
82 | reducer: { PlayerReducer() }
83 | )
84 | }
85 |
86 | await store.send(.play)
87 | await store.send(.getStatus)
88 |
89 | await store.receive(.handleStatusChange(.playing)) {
90 | $0.status = .playing
91 | }
92 |
93 | getStatusTask.continuation.finish()
94 | await store.finish()
95 | }
96 |
97 | func testPlayPausedSong() async throws {
98 | let getStatusTask = AsyncStream.makeStream(of: PlayerClient.Status.self)
99 |
100 | let store = withDependencies {
101 | $0.playerClient.hasCurrentItem = { true }
102 | $0.playerClient.getStatus = { getStatusTask.stream }
103 | $0.playerClient.play = { getStatusTask.continuation.yield(.playing) }
104 | } operation: {
105 | TestStore(
106 | initialState: PlayerReducer.State(),
107 | reducer: { PlayerReducer() }
108 | )
109 | }
110 |
111 | await store.send(.play)
112 | await store.send(.getStatus)
113 |
114 | await store.receive(.handleStatusChange(.playing)) {
115 | $0.status = .playing
116 | }
117 |
118 | getStatusTask.continuation.finish()
119 | await store.finish()
120 | }
121 |
122 | func testPauseSong() async throws {
123 | let getStatusTask = AsyncStream.makeStream(of: PlayerClient.Status.self)
124 |
125 | let store = withDependencies {
126 | $0.playerClient.getStatus = { getStatusTask.stream }
127 | $0.playerClient.pause = { getStatusTask.continuation.yield(.pause) }
128 | } operation: {
129 | TestStore(
130 | initialState: PlayerReducer.State(status: .playing),
131 | reducer: { PlayerReducer() }
132 | )
133 | }
134 |
135 | await store.send(.pause)
136 | await store.send(.getStatus)
137 |
138 | await store.receive(.handleStatusChange(.pause)) {
139 | $0.status = .pause
140 | }
141 |
142 | getStatusTask.continuation.finish()
143 | await store.finish()
144 | }
145 |
146 | func testStopSong() async throws {
147 | let getStatusTask = AsyncStream.makeStream(of: PlayerClient.Status.self)
148 | let currentSong = try songs(id: 1)
149 |
150 | let store = withDependencies {
151 | $0.playerClient.getStatus = { getStatusTask.stream }
152 | $0.playerClient.stop = { getStatusTask.continuation.yield(.pause) }
153 | } operation: {
154 | TestStore(
155 | initialState: PlayerReducer.State(
156 | currentSong: currentSong,
157 | status: .playing
158 | ),
159 | reducer: { PlayerReducer() }
160 | )
161 | }
162 |
163 | await store.send(.stop) {
164 | $0.currentSong = nil
165 | }
166 |
167 | await store.send(.getStatus)
168 |
169 | await store.receive(.handleStatusChange(.pause)) {
170 | $0.status = .pause
171 | }
172 |
173 | getStatusTask.continuation.finish()
174 | await store.finish()
175 | }
176 |
177 | func testPlayNextSong() async throws {
178 | var playlist = Playlist()
179 | let songs = try songs()
180 |
181 | playlist.update(songs: songs)
182 |
183 | let store = TestStore(
184 | initialState: PlayerReducer.State(playlist: playlist),
185 | reducer: { PlayerReducer() }
186 | )
187 |
188 | await store.send(.playOn(0)) {
189 | $0.currentSong = songs.first
190 | }
191 |
192 | await store.send(.next) {
193 | $0.currentSong = songs[1]
194 | }
195 | }
196 |
197 | func testPlayPreviousSong() async throws {
198 | var playlist = Playlist()
199 | let songs = try songs()
200 |
201 | playlist.update(songs: songs)
202 |
203 | let store = TestStore(
204 | initialState: PlayerReducer.State(playlist: playlist),
205 | reducer: { PlayerReducer() }
206 | )
207 |
208 | await store.send(.playOn(1)) {
209 | $0.currentSong = songs[1]
210 | }
211 |
212 | await store.send(.previous) {
213 | $0.currentSong = songs.first
214 | }
215 | }
216 |
217 | func testGetCurrentTime() async throws {
218 | let getCurrentTimeTask = AsyncStream.makeStream(of: Double.self)
219 |
220 | let store = withDependencies {
221 | $0.playerClient.getCurrentTime = { getCurrentTimeTask.stream }
222 | } operation: {
223 | TestStore(
224 | initialState: PlayerReducer.State(),
225 | reducer: { PlayerReducer() }
226 | )
227 | }
228 |
229 | await store.send(.getCurrentTime)
230 |
231 | getCurrentTimeTask.continuation.yield(2.5)
232 | getCurrentTimeTask.continuation.finish()
233 |
234 | await store.receive(.updateCurrentTime(2.5)) {
235 | $0.currentTime = 2.5
236 | }
237 |
238 | await store.finish()
239 | }
240 |
241 | func testToggleFavorite() async throws {
242 | var playlist = Playlist()
243 | let songs = try songs()
244 | let currentSong = songs.first!
245 |
246 | playlist.update(songs: songs)
247 |
248 | let store = withDependencies {
249 | $0.apiClient.addSongToFavorite = { _ in currentSong.id }
250 | } operation: {
251 | TestStore(
252 | initialState: PlayerReducer.State(
253 | playlist: playlist,
254 | currentSong: currentSong
255 | ),
256 | reducer: { PlayerReducer() }
257 | )
258 | }
259 |
260 | store.exhaustivity = .off
261 |
262 | await store.send(.toggleFavorite)
263 |
264 | await store.receive(.toggleFavoriteResponse(.success(currentSong.id))) {
265 | $0.currentSong?.isFavorited = true
266 | }
267 | }
268 |
269 | func testToogleFavoriteFailed() async throws {
270 | let currenSong = try songs(id: 1)
271 | let responseError = APIClient.APIError.unknown
272 |
273 | let store = withDependencies {
274 | $0.apiClient.addSongToFavorite = { _ in throw responseError }
275 | } operation: {
276 | TestStore(
277 | initialState: PlayerReducer.State(
278 | currentSong: currenSong
279 | ),
280 | reducer: { PlayerReducer() }
281 | )
282 | }
283 |
284 | store.exhaustivity = .off
285 |
286 | await store.send(.toggleFavorite)
287 |
288 | await store.receive(.toggleFavoriteResponse(.failure(responseError))) {
289 | $0.currentSong?.isFavorited = false
290 | $0.alert = .init(title: .init(responseError.localizedString))
291 | }
292 | }
293 |
294 | func testTogglePlaylistVisible() async throws {
295 | let store = TestStore(
296 | initialState: PlayerReducer.State(),
297 | reducer: { PlayerReducer() }
298 | )
299 |
300 | await store.send(.togglePlaylistVisible) {
301 | $0.isPlaylistVisible = true
302 | }
303 | }
304 |
305 | func testSeekToRatio() async throws {
306 | let currenSong = try songs(id: 1)
307 | let store = TestStore(
308 | initialState: PlayerReducer.State(
309 | currentSong: currenSong
310 | ),
311 | reducer: { PlayerReducer() }
312 | )
313 |
314 | let seekRation = 0.5
315 |
316 | await store.send(.seekToRatio(seekRation))
317 | await store.receive(.seekToPosition(currenSong.duration * seekRation))
318 | }
319 |
320 | func testSeekToPosition() async throws {
321 | let getCurrentTimeTask = AsyncStream.makeStream(of: Double.self)
322 | let currenSong = try songs(id: 1)
323 |
324 | let store = withDependencies {
325 | $0.playerClient.getCurrentTime = { getCurrentTimeTask.stream }
326 | $0.playerClient.seek = { time in
327 | getCurrentTimeTask.continuation.yield(time.seconds)
328 | getCurrentTimeTask.continuation.finish()
329 | }
330 | } operation: {
331 | TestStore(
332 | initialState: PlayerReducer.State(
333 | currentSong: currenSong
334 | ),
335 | reducer: { PlayerReducer() }
336 | )
337 | }
338 |
339 | let seekPosition = currenSong.duration * 0.5
340 | let seekTime = CMTime(seconds: seekPosition, preferredTimescale: 1)
341 |
342 | await store.send(.seekToPosition(seekPosition))
343 | await store.send(.getCurrentTime)
344 |
345 | await store.receive(.updateCurrentTime(seekTime.seconds)) {
346 | $0.currentTime = seekTime.seconds
347 | }
348 |
349 | await store.finish()
350 | }
351 |
352 | func testWillPlayNextSongAfterSongEnded() async throws {
353 | let getStatusTask = AsyncStream.makeStream(of: PlayerClient.Status.self)
354 |
355 | let store = withDependencies {
356 | $0.playerClient.getStatus = { getStatusTask.stream }
357 | } operation: {
358 | TestStore(
359 | initialState: PlayerReducer.State(),
360 | reducer: { PlayerReducer() }
361 | )
362 | }
363 |
364 | store.exhaustivity = .off
365 |
366 | await store.send(.getStatus)
367 |
368 | getStatusTask.continuation.yield(.end)
369 | getStatusTask.continuation.finish()
370 |
371 | await store.receive(.next)
372 | }
373 |
374 | func testWillPlayRepeatedlyAfterSongEndedWhenPlayModeIsSingle() async throws {
375 | let getStatusTask = AsyncStream.makeStream(of: PlayerClient.Status.self)
376 | var playlist = Playlist()
377 | let songs = try songs()
378 |
379 | playlist.update(songs: songs)
380 |
381 | let store = withDependencies {
382 | $0.playerClient.getStatus = { getStatusTask.stream }
383 | $0.playerClient.replay = {
384 | getStatusTask.continuation.yield(.playing)
385 | getStatusTask.continuation.finish()
386 | }
387 | } operation: {
388 | TestStore(
389 | initialState: PlayerReducer.State(
390 | playlist: playlist,
391 | currentSong: songs.first,
392 | mode: .single
393 | ),
394 | reducer: { PlayerReducer() }
395 | )
396 | }
397 |
398 | await store.send(.getStatus)
399 |
400 | getStatusTask.continuation.yield(.end)
401 |
402 | await store.receive(.handleStatusChange(.end)) {
403 | $0.status = .end
404 | }
405 |
406 | await store.receive(.handleStatusChange(.playing)) {
407 | $0.status = .playing
408 | }
409 |
410 | XCTAssertEqual(store.state.currentSong, songs.first)
411 | }
412 |
413 | func testToggleNextMode() async throws {
414 | let store = TestStore(
415 | initialState: PlayerReducer.State(
416 | mode: .repead
417 | ),
418 | reducer: { PlayerReducer() }
419 | )
420 |
421 | await store.send(.nextMode) {
422 | $0.mode = .single
423 | }
424 |
425 | await store.send(.nextMode) {
426 | $0.mode = .shuffle
427 | $0.playlist.isShuffled = true
428 | }
429 | }
430 |
431 | func testDeleteSongs() async throws {
432 | var playlist = Playlist()
433 | let songs = try songs()
434 |
435 | playlist.update(songs: songs)
436 |
437 | let store = TestStore(
438 | initialState: PlayerReducer.State(playlist: playlist),
439 | reducer: { PlayerReducer() }
440 | )
441 |
442 | store.exhaustivity = .off
443 |
444 | await store.send(.deleteSongs(.init(arrayLiteral: 0, 1)))
445 |
446 | XCTAssertFalse(store.state.playlist.songs.contains(where: { [1, 2].contains($0.id) }))
447 | }
448 |
449 | func testDeleteCurrentPlayingSong() async throws {
450 | let getStatusTask = AsyncStream.makeStream(of: PlayerClient.Status.self)
451 | var playlist = Playlist()
452 | let songs = try songs()
453 |
454 | playlist.update(songs: songs)
455 |
456 | let store = withDependencies {
457 | $0.playerClient.getStatus = { getStatusTask.stream }
458 | $0.playerClient.stop = {
459 | getStatusTask.continuation.yield(.pause)
460 | getStatusTask.continuation.finish()
461 | }
462 | } operation: {
463 | TestStore(
464 | initialState: PlayerReducer.State(
465 | playlist: playlist,
466 | currentSong: songs.first,
467 | status: .playing
468 | ),
469 | reducer: { PlayerReducer() }
470 | )
471 | }
472 |
473 | store.exhaustivity = .off
474 |
475 | await store.send(.getStatus)
476 | await store.send(.deleteSongs(.init(arrayLiteral: 0))) {
477 | $0.currentSong = songs[1]
478 | }
479 |
480 | await store.receive(.handleStatusChange(.pause))
481 |
482 | XCTAssertFalse(store.state.playlist.songs.contains(where: { $0.id == 1 }))
483 | }
484 |
485 | func testMoveSong() async throws {
486 | var playlist = Playlist()
487 | let songs = try songs()
488 |
489 | playlist.update(songs: songs)
490 |
491 | let store = TestStore(
492 | initialState: PlayerReducer.State(playlist: playlist),
493 | reducer: { PlayerReducer() }
494 | )
495 |
496 | store.exhaustivity = .off
497 |
498 | await store.send(.moveSongs(.init(arrayLiteral: 0), 2))
499 |
500 | XCTAssertEqual(store.state.playlist.songs.map({$0.id}), [2, 1, 3, 4, 5])
501 | }
502 |
503 | func testGetCurrentPlaylist() async throws {
504 | let songs = try songs()
505 | let store = withDependencies {
506 | $0.apiClient.getSongsFromCurrentPlaylist = { songs }
507 | } operation: {
508 | TestStore(
509 | initialState: PlayerReducer.State(),
510 | reducer: { PlayerReducer() }
511 | )
512 | }
513 |
514 | store.exhaustivity = .off
515 |
516 | await store.send(.getCurrentPlaylist)
517 |
518 | await store.receive(.currentPlaylistResponse(.success(songs))) {
519 | $0.playlist.orderedSongs = songs
520 | $0.currentSong = songs.first
521 | }
522 | }
523 |
524 | func testPlayAllSongFromAlbum() async throws {
525 | let songs = try songs()
526 | let store = withDependencies {
527 | $0.apiClient.replaceCurrentPlaylistWithAlbumSongs = { _ in songs }
528 | } operation: {
529 | TestStore(
530 | initialState: PlayerReducer.State(),
531 | reducer: { PlayerReducer() }
532 | )
533 | }
534 |
535 | store.exhaustivity = .off
536 |
537 | await store.send(.playAlbum(1))
538 |
539 | await store.receive(.playSongsResponse(.success(songs))) {
540 | $0.playlist.orderedSongs = songs
541 | $0.currentSong = songs.first
542 | }
543 | }
544 |
545 | func testPlayAllSongFromPlaylists() async throws {
546 | let songs = try songs()
547 | let store = withDependencies {
548 | $0.apiClient.replaceCurrentPlaylistWithPlaylistSongs = { _ in songs }
549 | } operation: {
550 | TestStore(
551 | initialState: PlayerReducer.State(),
552 | reducer: { PlayerReducer() }
553 | )
554 | }
555 |
556 | store.exhaustivity = .off
557 |
558 | await store.send(.playPlaylist(1))
559 |
560 | await store.receive(.playSongsResponse(.success(songs))) {
561 | $0.playlist.orderedSongs = songs
562 | $0.currentSong = songs.first
563 | }
564 | }
565 |
566 | func testPlaySongInPlaylist() async throws {
567 | var playlist = Playlist()
568 | let songs = try songs()
569 |
570 | playlist.update(songs: songs)
571 |
572 | let store = TestStore(
573 | initialState: PlayerReducer.State(playlist: playlist),
574 | reducer: { PlayerReducer() }
575 | )
576 |
577 | await store.send(.playNow(1)) {
578 | $0.currentSong = songs.first
579 | }
580 | }
581 |
582 | func testPlaySongNotInPlaylist() async throws {
583 | var playlist = Playlist()
584 | let song = try songs(id: 1)
585 | let playingSong = try songs(id: 2)
586 |
587 | playlist.update(songs: [song])
588 |
589 | let store = withDependencies {
590 | $0.apiClient.getSong = { _ in playingSong }
591 | $0.apiClient.addSongToCurrentPlaylist = { _, _, _ in playingSong}
592 | } operation: {
593 | TestStore(
594 | initialState: PlayerReducer.State(playlist: playlist),
595 | reducer: { PlayerReducer() }
596 | )
597 | }
598 |
599 | store.exhaustivity = .off
600 |
601 | await store.send(.playNow(2))
602 |
603 | await store.receive(.playNowResponse(.success(playingSong))) {
604 | $0.playlist.orderedSongs = [song, playingSong]
605 | $0.currentSong = playingSong
606 | }
607 | }
608 |
609 | func testAddSongIntoEmptyPlaylist() async throws {
610 | let song = try songs(id: 1)
611 |
612 | let store = withDependencies {
613 | $0.apiClient.getSong = { _ in song }
614 | $0.apiClient.addSongToCurrentPlaylist = { _, _, _ in song }
615 | } operation: {
616 | TestStore(
617 | initialState: PlayerReducer.State(),
618 | reducer: { PlayerReducer() }
619 | )
620 | }
621 |
622 | store.exhaustivity = .off
623 |
624 | await store.send(.playNow(1))
625 |
626 | await store.receive(.playNowResponse(.success(song))) {
627 | $0.playlist.orderedSongs = [song]
628 | $0.currentSong = song
629 | }
630 | }
631 | }
632 |
--------------------------------------------------------------------------------
/BlackCandyTests/TestHelper.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | @testable import BlackCandy
4 |
5 | extension XCTestCase {
6 | func songs() throws -> [Song] {
7 | try fixtureData("songs")
8 | }
9 |
10 | func songs(id: Int) throws -> Song {
11 | try songs().first(where: { $0.id == id })!
12 | }
13 |
14 | func users() throws -> [User] {
15 | try fixtureData("users")
16 | }
17 |
18 | func users(id: Int) throws -> User {
19 | try users().first(where: { $0.id == id })!
20 | }
21 |
22 | func decodeJSON(from json: String) throws -> T {
23 | let data = json.data(using: .utf8)!
24 | let decoder = JSONDecoder()
25 | decoder.keyDecodingStrategy = .convertFromSnakeCase
26 |
27 | return try decoder.decode(T.self, from: data)
28 | }
29 |
30 | private func fixtureData(_ bundleFile: String) throws -> T {
31 | let bundle = Bundle(for: type(of: self))
32 |
33 | guard let fileUrl = bundle.url(forResource: bundleFile, withExtension: "json") else {
34 | fatalError("Resource not found: \(bundleFile)")
35 | }
36 |
37 | let data = try Data(contentsOf: fileUrl)
38 | let decoder = JSONDecoder()
39 |
40 | decoder.keyDecodingStrategy = .convertFromSnakeCase
41 |
42 | return try decoder.decode(T.self, from: data)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/BlackCandyTests/Utils/CustomFormatterTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import BlackCandy
3 |
4 | final class CustomFormatterTests: XCTestCase {
5 | func testDurationFormatter() throws {
6 | let formatter = DurationFormatter()
7 |
8 | XCTAssertEqual(formatter.string(from: 9), "00:09")
9 | XCTAssertEqual(formatter.string(from: 90.3), "01:30")
10 | XCTAssertEqual(formatter.string(from: 900.3), "15:00")
11 | XCTAssertEqual(formatter.string(from: 9000.3), "150:00")
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/BlackCandyUITests/BlackCandyUITests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | class BlackCandyUITests: XCTestCase {
4 | }
5 |
--------------------------------------------------------------------------------
/BlackCandyUITests/BlackCandyUITestsLaunchTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | class BlackCandyUITestsLaunchTests: XCTestCase {
4 | }
5 |
--------------------------------------------------------------------------------
/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.6)
9 | public_suffix (>= 2.0.2, < 6.0)
10 | artifactory (3.0.17)
11 | atomos (0.1.3)
12 | aws-eventstream (1.3.0)
13 | aws-partitions (1.930.0)
14 | aws-sdk-core (3.196.1)
15 | aws-eventstream (~> 1, >= 1.3.0)
16 | aws-partitions (~> 1, >= 1.651.0)
17 | aws-sigv4 (~> 1.8)
18 | jmespath (~> 1, >= 1.6.1)
19 | aws-sdk-kms (1.81.0)
20 | aws-sdk-core (~> 3, >= 3.193.0)
21 | aws-sigv4 (~> 1.1)
22 | aws-sdk-s3 (1.151.0)
23 | aws-sdk-core (~> 3, >= 3.194.0)
24 | aws-sdk-kms (~> 1)
25 | aws-sigv4 (~> 1.8)
26 | aws-sigv4 (1.8.0)
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.110.0)
42 | faraday (1.10.3)
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.1)
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.0)
69 | faraday (~> 1.0)
70 | fastimage (2.3.1)
71 | fastlane (2.220.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 | gh_inspector (>= 1.1.2, < 2.0.0)
88 | google-apis-androidpublisher_v3 (~> 0.3)
89 | google-apis-playcustomapp_v1 (~> 0.1)
90 | google-cloud-env (>= 1.6.0, < 2.0.0)
91 | google-cloud-storage (~> 1.31)
92 | highline (~> 2.0)
93 | http-cookie (~> 1.0.5)
94 | json (< 3.0.0)
95 | jwt (>= 2.1.0, < 3)
96 | mini_magick (>= 4.9.4, < 5.0.0)
97 | multipart-post (>= 2.0.0, < 3.0.0)
98 | naturally (~> 2.2)
99 | optparse (>= 0.1.1, < 1.0.0)
100 | plist (>= 3.1.0, < 4.0.0)
101 | rubyzip (>= 2.0.0, < 3.0.0)
102 | security (= 0.1.5)
103 | simctl (~> 1.6.3)
104 | terminal-notifier (>= 2.0.0, < 3.0.0)
105 | terminal-table (~> 3)
106 | tty-screen (>= 0.6.3, < 1.0.0)
107 | tty-spinner (>= 0.8.0, < 1.0.0)
108 | word_wrap (~> 1.0.0)
109 | xcodeproj (>= 1.13.0, < 2.0.0)
110 | xcpretty (~> 0.3.0)
111 | xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
112 | gh_inspector (1.1.3)
113 | google-apis-androidpublisher_v3 (0.54.0)
114 | google-apis-core (>= 0.11.0, < 2.a)
115 | google-apis-core (0.11.3)
116 | addressable (~> 2.5, >= 2.5.1)
117 | googleauth (>= 0.16.2, < 2.a)
118 | httpclient (>= 2.8.1, < 3.a)
119 | mini_mime (~> 1.0)
120 | representable (~> 3.0)
121 | retriable (>= 2.0, < 4.a)
122 | rexml
123 | google-apis-iamcredentials_v1 (0.17.0)
124 | google-apis-core (>= 0.11.0, < 2.a)
125 | google-apis-playcustomapp_v1 (0.13.0)
126 | google-apis-core (>= 0.11.0, < 2.a)
127 | google-apis-storage_v1 (0.31.0)
128 | google-apis-core (>= 0.11.0, < 2.a)
129 | google-cloud-core (1.7.0)
130 | google-cloud-env (>= 1.0, < 3.a)
131 | google-cloud-errors (~> 1.0)
132 | google-cloud-env (1.6.0)
133 | faraday (>= 0.17.3, < 3.0)
134 | google-cloud-errors (1.4.0)
135 | google-cloud-storage (1.47.0)
136 | addressable (~> 2.8)
137 | digest-crc (~> 0.4)
138 | google-apis-iamcredentials_v1 (~> 0.1)
139 | google-apis-storage_v1 (~> 0.31.0)
140 | google-cloud-core (~> 1.6)
141 | googleauth (>= 0.16.2, < 2.a)
142 | mini_mime (~> 1.0)
143 | googleauth (1.8.1)
144 | faraday (>= 0.17.3, < 3.a)
145 | jwt (>= 1.4, < 3.0)
146 | multi_json (~> 1.11)
147 | os (>= 0.9, < 2.0)
148 | signet (>= 0.16, < 2.a)
149 | highline (2.0.3)
150 | http-cookie (1.0.5)
151 | domain_name (~> 0.5)
152 | httpclient (2.8.3)
153 | jmespath (1.6.2)
154 | json (2.7.2)
155 | jwt (2.8.1)
156 | base64
157 | mini_magick (4.12.0)
158 | mini_mime (1.1.5)
159 | multi_json (1.15.0)
160 | multipart-post (2.4.1)
161 | nanaimo (0.3.0)
162 | naturally (2.2.1)
163 | nkf (0.2.0)
164 | optparse (0.5.0)
165 | os (1.1.4)
166 | plist (3.7.1)
167 | public_suffix (5.0.5)
168 | rake (13.2.1)
169 | representable (3.2.0)
170 | declarative (< 0.1.0)
171 | trailblazer-option (>= 0.1.1, < 0.2.0)
172 | uber (< 0.2.0)
173 | retriable (3.1.2)
174 | rexml (3.2.8)
175 | strscan (>= 3.0.9)
176 | rouge (2.0.7)
177 | ruby2_keywords (0.0.5)
178 | rubyzip (2.3.2)
179 | security (0.1.5)
180 | signet (0.19.0)
181 | addressable (~> 2.8)
182 | faraday (>= 0.17.5, < 3.a)
183 | jwt (>= 1.5, < 3.0)
184 | multi_json (~> 1.10)
185 | simctl (1.6.10)
186 | CFPropertyList
187 | naturally
188 | strscan (3.1.0)
189 | terminal-notifier (2.0.0)
190 | terminal-table (3.0.2)
191 | unicode-display_width (>= 1.1.1, < 3)
192 | trailblazer-option (0.1.2)
193 | tty-cursor (0.7.1)
194 | tty-screen (0.8.2)
195 | tty-spinner (0.9.3)
196 | tty-cursor (~> 0.7)
197 | uber (0.1.0)
198 | unicode-display_width (2.5.0)
199 | word_wrap (1.0.0)
200 | xcodeproj (1.24.0)
201 | CFPropertyList (>= 2.3.3, < 4.0)
202 | atomos (~> 0.1.3)
203 | claide (>= 1.0.2, < 2.0)
204 | colored2 (~> 3.1)
205 | nanaimo (~> 0.3.0)
206 | rexml (~> 3.2.4)
207 | xcpretty (0.3.0)
208 | rouge (~> 2.0.7)
209 | xcpretty-travis-formatter (1.0.1)
210 | xcpretty (~> 0.2, >= 0.0.7)
211 |
212 | PLATFORMS
213 | arm64-darwin-22
214 |
215 | DEPENDENCIES
216 | fastlane
217 |
218 | BUNDLED WITH
219 | 2.3.7
220 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Ed Chao
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/PRIVACY.md:
--------------------------------------------------------------------------------
1 | # Privacy Policy
2 |
3 | ## Definitions
4 |
5 | Service. **Service** means the app BlackCandy.
6 |
7 | Personal Data. **Personal Data** means any information relating to an identified or identifiable natural person (‘data subject’); an identifiable natural person is one who can be identified, directly or indirectly, in particular by reference to an identifier such as a name, an identification number, location data, an online identifier or to one or more factors specific to the physical, physiological, genetic, mental, economic, cultural or social identity of that natural person;
8 |
9 | > Note: the Personal Data definition comes from the General Data Protection Regulation (GDPR).
10 |
11 | ## Information Collection And Use
12 |
13 | The Service does not collect Personal Data.
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Black Candy for iOS
2 |
3 | [](https://github.com/blackcandy-org/ios/actions/workflows/ci.yml)
4 |
5 | 
6 |
7 | The official Black Candy iOS app. To use this app, you must have a Black Candy server set up. For more information, please visit https://github.com/blackcandy-org/blackcandy
8 |
9 | ## Get the app
10 |
11 | [
](https://apps.apple.com/app/blackcandy/id6444304071)
12 |
--------------------------------------------------------------------------------
/Scripts/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 |
4 | ENV_VARS=("APP_IDENTIFIER")
5 |
6 | # Set environment variables for project build configuration
7 | for var in "${ENV_VARS[@]}"; do
8 | echo "${var}=${!var}" >> BlackCandy.xcconfig
9 | done
10 |
--------------------------------------------------------------------------------
/fastlane/Appfile:
--------------------------------------------------------------------------------
1 | app_identifier ENV["APP_IDENTIFIER"] # The bundle identifier of your app
2 | apple_id ENV["APPLE_ID"] # Your Apple Developer Portal username
3 |
4 | itc_team_id ENV["CONNECT_TEAM_ID"] # App Store Connect Team ID
5 | team_id ENV["TEAM_ID"] # Developer Portal Team ID
6 |
7 | # For more information about the Appfile, see:
8 | # https://docs.fastlane.tools/advanced/#appfile
9 |
--------------------------------------------------------------------------------
/fastlane/Fastfile:
--------------------------------------------------------------------------------
1 | # This file contains the fastlane.tools configuration
2 | # You can find the documentation at https://docs.fastlane.tools
3 | #
4 | # For a list of all available actions, check out
5 | #
6 | # https://docs.fastlane.tools/actions
7 | #
8 | # For a list of all available plugins, check out
9 | #
10 | # https://docs.fastlane.tools/plugins/available-plugins
11 | #
12 |
13 | # Uncomment the line if you want fastlane to automatically update itself
14 | # update_fastlane
15 |
16 | skip_docs
17 | default_platform(:ios)
18 |
19 | platform :ios do
20 | desc "Push a new beta build to TestFlight"
21 | lane :beta do
22 | increment_build_number(
23 | build_number: latest_testflight_build_number + 1,
24 | xcodeproj: "BlackCandy.xcodeproj"
25 | )
26 |
27 | commit_version_bump(
28 | message: "Bump version to #{get_version_number}.beta#{get_build_number}",
29 | xcodeproj: "BlackCandy.xcodeproj",
30 | force: true
31 | )
32 |
33 | build_app(scheme: "BlackCandy")
34 | upload_to_testflight
35 |
36 | add_git_tag(tag: "v#{get_version_number}.beta#{get_build_number}")
37 | push_to_git_remote
38 | end
39 |
40 | desc "Frame screenshot"
41 | lane :frame do
42 | # Clear previous framed screenshots
43 | sh("find #{ENV['PWD']}/fastlane/screenshots -type f -name '*framed*' -delete")
44 |
45 | frameit(path: "./fastlane/screenshots")
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/fastlane/screenshots/Framefile.json:
--------------------------------------------------------------------------------
1 | {
2 | "device_frame_version": "latest",
3 | "default": {
4 | "title": {
5 | "font": "./fonts/GillSans.ttc",
6 | "font_size": 45,
7 | "font_weight": 300,
8 | "color": "#2c2c2e"
9 | },
10 |
11 | "background": "./background.jpg",
12 | "padding": "7%x7.5%"
13 | },
14 |
15 | "data": [
16 | {
17 | "filter": "iPhone 8 Plus",
18 | "title": {
19 | "font_size": 70
20 | },
21 | "padding": "6%x10%"
22 | },
23 | {
24 | "filter": "iPhone 14 Plus",
25 | "title": {
26 | "font_size": 80
27 | },
28 | "padding": "5%x15%"
29 | },
30 | {
31 | "filter": "iPhone 14 Pro Max",
32 | "title": {
33 | "font_size": 85
34 | },
35 | "padding": "5%x15%"
36 | },
37 | {
38 | "filter": "iPad Pro",
39 | "title": {
40 | "font_size": 75
41 | }
42 | }
43 | ]
44 | }
45 |
--------------------------------------------------------------------------------
/fastlane/screenshots/background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/fastlane/screenshots/background.jpg
--------------------------------------------------------------------------------
/fastlane/screenshots/en-US/title.strings:
--------------------------------------------------------------------------------
1 | "Home" = "The Home of Your Music"
2 | "Library" = "Access Your Library Easily"
3 | "Playback" = "Control Your Playback"
4 | "LightMode" = "Come With Light Theme"
5 | "Orientation" = "Fits Any Orientation"
6 |
--------------------------------------------------------------------------------
/fastlane/screenshots/fonts/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/fastlane/screenshots/fonts/.keep
--------------------------------------------------------------------------------
/images/appstore_badge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/images/appstore_badge.png
--------------------------------------------------------------------------------
/images/screenshot_main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackcandy-org/ios/1f0f1e53253b16ab9f40b93fdeb6ca3442092979/images/screenshot_main.png
--------------------------------------------------------------------------------