├── .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 | [![CI](https://github.com/blackcandy-org/ios/actions/workflows/ci.yml/badge.svg)](https://github.com/blackcandy-org/ios/actions/workflows/ci.yml) 4 | 5 | ![Screenshot](https://raw.githubusercontent.com/blackcandy-org/ios/master/images/screenshot_main.png) 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 | [Get it on App Store](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 --------------------------------------------------------------------------------