From db684fccde461bf09ca5932421c66e6542254e27 Mon Sep 17 00:00:00 2001 From: ErrorErrorError <16653389+ErrorErrorError@users.noreply.github.com> Date: Tue, 21 Nov 2023 20:02:33 -0800 Subject: [PATCH] wip: okay actually add all updates to commit lol --- Package.swift | 5 +- Package/Sources/Clients/PlayerClient.swift | 1 - .../Dependencies/ComposableArchitecture.swift | 2 +- Package/Sources/Features/ContentCore.swift | 1 + Package/Sources/Index.swift | 1 + Sources/Clients/BuildClient/BuildClient.swift | 4 +- Sources/Clients/DatabaseClient/Client.swift | 9 +- Sources/Clients/DatabaseClient/Live.swift | 18 + .../Supporting Files/Request.swift | 9 +- Sources/Clients/ModuleClient/Client.swift | 8 +- .../ModuleClient/Extensions/JSContext+.swift | 2 +- .../ModuleClient/Extensions/JSValue+.swift | 67 +- Sources/Clients/ModuleClient/Instance.swift | 33 +- .../JS+Bindings/JSContext+Console.swift | 32 +- .../JS+Bindings/JSContext+JSRuntime.swift | 181 +- .../JS+Bindings/JSContext+Request.swift | 154 +- Sources/Clients/ModuleClient/Live.swift | 14 +- Sources/Clients/PlayerClient/Client.swift | 22 +- Sources/Clients/PlayerClient/Live.swift | 12 +- Sources/Clients/PlayerClient/Models.swift | 1 + .../PlayerFeature/PlayerFeature.swift | 26 +- Sources/Clients/RepoClient/Client.swift | 30 +- Sources/Clients/RepoClient/Live.swift | 86 +- Sources/Clients/RepoClient/Models.swift | 2 +- Sources/Features/App/AppFeature+Reducer.swift | 50 +- .../ContentCore/ContentCore+View.swift | 337 +-- .../Features/ContentCore/ContentCore.swift | 312 +-- .../Discover/DiscoverFeature+Reducer.swift | 36 +- .../Features/Discover/DiscoverFeature.swift | 19 +- .../ModuleListsFeature+Reducer.swift | 13 +- .../ModuleLists/ModuleListsFeature+View.swift | 4 +- .../ModuleLists/ModuleListsFeature.swift | 3 +- .../PlaylistDetailsFeature+Reducer.swift | 68 +- .../PlaylistDetailsFeature.swift | 27 +- .../iOS/PlaylistDetailsFeature+View+iOS.swift | 244 +- .../RepoPackagesFeature+Reducer.swift | 50 +- .../RepoPackagesFeature+View.swift | 4 +- .../RepoPackages/RepoPackagesFeature.swift | 12 +- .../Features/Repos/ReposFeature+Reducer.swift | 50 +- .../Features/Repos/ReposFeature+View.swift | 4 +- Sources/Features/Repos/ReposFeature.swift | 7 +- .../Search/SearchFeature+Reducer.swift | 12 +- Sources/Features/Search/SearchFeature.swift | 6 +- .../VideoPlayerFeature+Reducer.swift | 785 +++---- .../VideoPlayer/VideoPlayerFeature.swift | 461 ++-- .../iOS/VideoPlayerFeature+iOS.swift | 2005 ++++++++--------- Sources/Shared/Architecture/Feature.swift | 2 - .../Shared/Architecture/TCA+Extensions.swift | 14 +- Sources/Shared/SharedModels/Meta.swift | 6 +- Sources/Shared/SharedModels/Playlist.swift | 87 +- .../SharedModels/Utilities/Paging.swift | 9 +- Sources/Shared/SharedModels/Video.swift | 68 +- Sources/Shared/ViewComponents/ChipView.swift | 6 +- 53 files changed, 2608 insertions(+), 2813 deletions(-) diff --git a/Package.swift b/Package.swift index b230f9d..b24f461 100644 --- a/Package.swift +++ b/Package.swift @@ -801,7 +801,6 @@ import Foundation struct PlayerClient: _Client { var dependencies: any Dependencies { Architecture() - DatabaseClient() ModuleClient() SharedModels() Styling() @@ -888,7 +887,7 @@ extension _Client { struct ComposableArchitecture: PackageDependency { var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.2.0") + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.4.2") } } // @@ -1075,6 +1074,7 @@ struct ContentCore: _Feature { LoggerClient() Tagged() ComposableArchitecture() + Styling() } } // @@ -1450,6 +1450,7 @@ let package = Package { Search() Settings() VideoPlayer() + ContentCore() MochiApp() } testTargets: { diff --git a/Package/Sources/Clients/PlayerClient.swift b/Package/Sources/Clients/PlayerClient.swift index 061bad9..fb22959 100644 --- a/Package/Sources/Clients/PlayerClient.swift +++ b/Package/Sources/Clients/PlayerClient.swift @@ -11,7 +11,6 @@ import Foundation struct PlayerClient: _Client { var dependencies: any Dependencies { Architecture() - DatabaseClient() ModuleClient() SharedModels() Styling() diff --git a/Package/Sources/Dependencies/ComposableArchitecture.swift b/Package/Sources/Dependencies/ComposableArchitecture.swift index 746adfd..7c56e2b 100644 --- a/Package/Sources/Dependencies/ComposableArchitecture.swift +++ b/Package/Sources/Dependencies/ComposableArchitecture.swift @@ -8,6 +8,6 @@ struct ComposableArchitecture: PackageDependency { var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.2.0") + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.4.2") } } diff --git a/Package/Sources/Features/ContentCore.swift b/Package/Sources/Features/ContentCore.swift index dcf7db4..d027582 100644 --- a/Package/Sources/Features/ContentCore.swift +++ b/Package/Sources/Features/ContentCore.swift @@ -16,5 +16,6 @@ struct ContentCore: _Feature { LoggerClient() Tagged() ComposableArchitecture() + Styling() } } diff --git a/Package/Sources/Index.swift b/Package/Sources/Index.swift index a39eff3..daf6fc2 100644 --- a/Package/Sources/Index.swift +++ b/Package/Sources/Index.swift @@ -19,6 +19,7 @@ let package = Package { Search() Settings() VideoPlayer() + ContentCore() MochiApp() } testTargets: { diff --git a/Sources/Clients/BuildClient/BuildClient.swift b/Sources/Clients/BuildClient/BuildClient.swift index 0b3fdd3..828f681 100644 --- a/Sources/Clients/BuildClient/BuildClient.swift +++ b/Sources/Clients/BuildClient/BuildClient.swift @@ -13,8 +13,8 @@ import Semver // MARK: - BuildClient public struct BuildClient { - public let version: Semver - public let buildNumber: Int + public var version: Semver + public var buildNumber: Int } // MARK: TestDependencyKey diff --git a/Sources/Clients/DatabaseClient/Client.swift b/Sources/Clients/DatabaseClient/Client.swift index 72a6aa6..a2ecdbf 100644 --- a/Sources/Clients/DatabaseClient/Client.swift +++ b/Sources/Clients/DatabaseClient/Client.swift @@ -17,13 +17,20 @@ public struct DatabaseClient: Sendable { public var insert: @Sendable (any Entity) async throws -> any Entity public var update: @Sendable (any Entity) async throws -> any Entity public var delete: @Sendable (any Entity) async throws -> Void - var fetch: @Sendable (any Entity.Type, Any) async throws -> [any Entity] + var fetch: @Sendable (any Entity.Type, any _Request) async throws -> [any Entity] + var observe: @Sendable (any Entity.Type, any _Request) -> AsyncStream<[any Entity]> } public extension DatabaseClient { func fetch(_ request: Request) async throws -> [T] { try await (fetch(T.self, request) as? [T]) ?? [] } + + func observe(_ request: Request) -> AsyncStream<[T]> { + self.observe(T.self, request) + .compactMap { ($0 as? [T]) ?? [] } + .eraseToStream() + } } // MARK: DatabaseClient.Error diff --git a/Sources/Clients/DatabaseClient/Live.swift b/Sources/Clients/DatabaseClient/Live.swift index 0bd90c1..a3b26ab 100644 --- a/Sources/Clients/DatabaseClient/Live.swift +++ b/Sources/Clients/DatabaseClient/Live.swift @@ -65,6 +65,24 @@ public extension DatabaseClient { try await persistence.schedule { context in try context.fetch(entityType, request).compactMap { try entityType.init(id: $0.objectID, context: context) } } + } observe: { entityType, request in + .init { continuation in + Task.detached { + let fetchValues = { + try? await persistence.schedule { ctx in + try ctx.fetch(entityType, request).compactMap { try entityType.init(id: $0.objectID, context: ctx) } + } + } + + await continuation.yield(fetchValues() ?? []) + + let observe = NotificationCenter.default.notifications(named: NSManagedObjectContext.didSaveObjectsNotification) + + for await _ in observe { + await continuation.yield(fetchValues() ?? []) + } + } + } } }() } diff --git a/Sources/Clients/DatabaseClient/Supporting Files/Request.swift b/Sources/Clients/DatabaseClient/Supporting Files/Request.swift index 1a07664..e4e77b0 100644 --- a/Sources/Clients/DatabaseClient/Supporting Files/Request.swift +++ b/Sources/Clients/DatabaseClient/Supporting Files/Request.swift @@ -8,9 +8,16 @@ import CoreData import Foundation +protocol _Request { + associatedtype SomeEntity: Entity + var fetchLimit: Int? { get set } + var predicate: NSPredicate? { get set } + var sortDescriptors: [SortDescriptor] { get set } +} + // MARK: - Request -public struct Request { +public struct Request: _Request { var fetchLimit: Int? var predicate: NSPredicate? var sortDescriptors: [SortDescriptor] = [] diff --git a/Sources/Clients/ModuleClient/Client.swift b/Sources/Clients/ModuleClient/Client.swift index e0a8b82..2fb4d2e 100644 --- a/Sources/Clients/ModuleClient/Client.swift +++ b/Sources/Clients/ModuleClient/Client.swift @@ -16,8 +16,9 @@ import XCTestDynamicOverlay public struct ModuleClient: Sendable { public var initialize: @Sendable () async throws -> Void - public var getModule: @Sendable (_ repoModuleID: RepoModuleID) async throws -> Self.Instance - public var removeModule: @Sendable (_ repoModuleID: RepoModuleID) async throws -> Void + public var getModule: @Sendable (_ repoModuleId: RepoModuleID) async throws -> Self.Instance + public var removeCachedModule: @Sendable (_ repoModuleId: RepoModuleID) async throws -> Void + public var removeCachedModules: @Sendable (_ repoID: Repo.ID) async throws -> Void } public extension ModuleClient { @@ -67,7 +68,8 @@ extension ModuleClient: TestDependencyKey { public static let testValue = Self( initialize: unimplemented(), getModule: unimplemented(), - removeModule: unimplemented() + removeCachedModule: unimplemented(), + removeCachedModules: unimplemented() ) } diff --git a/Sources/Clients/ModuleClient/Extensions/JSContext+.swift b/Sources/Clients/ModuleClient/Extensions/JSContext+.swift index 571082a..954a116 100644 --- a/Sources/Clients/ModuleClient/Extensions/JSContext+.swift +++ b/Sources/Clients/ModuleClient/Extensions/JSContext+.swift @@ -1,5 +1,5 @@ // -// File.swift +// JSContext+.swift // // // Created by ErrorErrorError on 11/17/23. diff --git a/Sources/Clients/ModuleClient/Extensions/JSValue+.swift b/Sources/Clients/ModuleClient/Extensions/JSValue+.swift index 571082a..ee5265e 100644 --- a/Sources/Clients/ModuleClient/Extensions/JSValue+.swift +++ b/Sources/Clients/ModuleClient/Extensions/JSValue+.swift @@ -1,5 +1,5 @@ // -// File.swift +// JSValue+.swift // // // Created by ErrorErrorError on 11/17/23. @@ -7,3 +7,68 @@ // import Foundation +import JavaScriptCore + +extension JSValue { + subscript(_ key: String) -> JSValue? { + guard !isOptional else { + return nil + } + guard let value = forProperty(key) else { + return nil + } + return !value.isOptional ? value : nil + } + + var isOptional: Bool { isNull || isUndefined } + + @discardableResult + func value(_ function: String) async throws -> JSValue { + try await withCheckedThrowingContinuation { continuation in + let onFufilled: @convention(block) (JSValue) -> Void = { value in + continuation.resume(returning: value) + } + + let onRejected: @convention(block) (JSValue) -> Void = { value in + continuation.resume(throwing: value.toError(function)) + } + + self.invokeMethod( + "then", + withArguments: [ + unsafeBitCast(onFufilled, to: JSValue.self), + unsafeBitCast(onRejected, to: JSValue.self) + ] + ) + } + } + + func toError(_ functionName: String? = nil, stackTrace: Bool = true) -> JSValueError { .init(self, functionName) } +} + +struct JSValueError: Error, LocalizedError, CustomStringConvertible { + var functionName: String? + var name: String? + var errorDescription: String? + var failureReason: String? + var stackTrace: String? + + init(_ value: JSValue, _ functionName: String? = nil, stackTrace: Bool = true) { + self.functionName = functionName + self.name = value["name"]?.toString() + self.errorDescription = value["message"]?.toString() + self.failureReason = value["cause"]?.toString() + if stackTrace { + self.stackTrace = value["stack"]?.toString() + } + } + + // TODO: Allow stack trace + var description: String { + """ + Instance\(functionName.flatMap { ".\($0)" } ?? "") => \ + \(name ?? "Error"): \(errorDescription ?? "No Message") + \(failureReason.flatMap { " \($0)" } ?? "\\") + """ + } +} diff --git a/Sources/Clients/ModuleClient/Instance.swift b/Sources/Clients/ModuleClient/Instance.swift index 60f7112..17099bc 100644 --- a/Sources/Clients/ModuleClient/Instance.swift +++ b/Sources/Clients/ModuleClient/Instance.swift @@ -10,7 +10,6 @@ import Foundation import JavaScriptCore import os import SharedModels -import WebKit public extension ModuleClient { struct Instance { @@ -39,7 +38,23 @@ public extension ModuleClient { } } +extension ModuleClient.Instance { + private func reportError(_ callback: @autoclosure @escaping () async throws -> R) async rethrows -> R { + do { + return try await callback() + } catch { + self.logger.error("\(error)") + throw error + } + } +} + +/// Available SourceModule Methods public extension ModuleClient.Instance { + func searchFilters() async throws -> [SearchFilter] { + try await reportError(await runtime.searchFilters()) + } + func search(_ query: SearchQuery) async throws -> Paging { try await reportError(await runtime.search(query)) } @@ -48,14 +63,13 @@ public extension ModuleClient.Instance { try await reportError(await runtime.discoverListings(request)) } - func searchFilters() async throws -> [SearchFilter] { - try await reportError(await runtime.searchFilters()) - } - func playlistDetails(_ id: Playlist.ID) async throws -> Playlist.Details { try await reportError(await runtime.playlistDetails(id)) } +} +/// Available VideoContent Methods +public extension ModuleClient.Instance { func playlistEpisodes(_ id: Playlist.ID, _ options: Playlist.ItemsRequestOptions?) async throws -> Playlist.ItemsResponse { try await reportError(await runtime.playlistEpisodes(id, options)) } @@ -67,13 +81,4 @@ public extension ModuleClient.Instance { func playlistEpisodeServer(_ request: Playlist.EpisodeServerRequest) async throws -> Playlist.EpisodeServerResponse { try await reportError(await runtime.playlistEpisodeServer(request)) } - - private func reportError(_ callback: @autoclosure @escaping () async throws -> R) async rethrows -> R { - do { - return try await callback() - } catch { - self.logger.error("\(error)") - throw error - } - } } diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift index 571082a..e20a007 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift @@ -1,5 +1,5 @@ // -// File.swift +// JSContext+Console.swift // // // Created by ErrorErrorError on 11/17/23. @@ -7,3 +7,33 @@ // import Foundation +import JavaScriptCore + +extension JSContext { + func setConsoleBinding(_ logger: @escaping (MessageLog, String) -> Void) { + exceptionHandler = { _, exception in + guard let exception else { + return + } + + logger(.error, exception.toError().description) + } + + let console = JSValue(newObjectIn: self) + + let logger = { (type: MessageLog) in { + guard let arguments = JSContext.currentArguments()?.compactMap({ $0 as? JSValue }) else { + return + } + + let msg = arguments.compactMap { $0.toString() } + .joined(separator: " ") + + logger(type, msg) + } as @convention(block) () -> Void } + + MessageLog.allCases.forEach { console?.setObject(logger($0), forKeyedSubscript: $0.rawValue) } + + setObject(console, forKeyedSubscript: "console" as NSString) + } +} diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift index 761a5f8..01aab5b 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift @@ -1,9 +1,9 @@ // // JSContext+JSRuntime.swift -// +// // // Created by ErrorErrorError on 11/4/23. -// +// // import Dependencies @@ -15,122 +15,18 @@ import os import SharedModels extension JSContext { - convenience init( _ module: Module, _ logger: @escaping (MessageLog, String) -> Void) throws { + convenience init(_ module: Module, _ logger: @escaping (MessageLog, String) -> Void) throws { self.init() - addConsoleBinding(logger) - addRequestBinding() - try loadModuleAndInitialize(module) - } - - private func addRequestBinding() { - enum RequestError: Error { - case invalidURL(for: String) - - var localizedDescription: String { - switch self { - case let .invalidURL(for: string): - "Invalid URL for \(string)" - } - } - } - - let request = JSValue(newObjectIn: self) - let session = URLSession(configuration: .ephemeral) - - let buildRequest: @convention(block) (String, String, JSValue) -> JSValue = { [weak self] urlString, httpMethodString, options in - guard let `self` = self else { - let error = JSValue(newErrorFromMessage: "JSContext is unavailable.", in: self) - return .init(newPromiseRejectedWithReason: error, in: self) - } - - guard let url = URL(string: urlString) else { - let error = JSValue(newErrorFromMessage: RequestError.invalidURL(for: urlString).localizedDescription, in: self) - return .init(newPromiseRejectedWithReason: error, in: self) - } - - var request = URLRequest(url: url) - request.httpMethod = httpMethodString.uppercased().trimmingCharacters(in: .whitespacesAndNewlines) - request.httpBody = options["body"]?.toString()?.data(using: .utf8) - - if let timeout = options["timeout"]?.toDouble() { - request.timeoutInterval = timeout - } - - if let headers = options["headers"]?.toDictionary() as? [String: String] { - headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } - } - - return .init(newPromiseIn: self) { resolved, rejected in - let task = session.dataTask(with: request) { data, response, error in - if let error { - rejected?.call(withArguments: [JSValue(newErrorFromMessage: error.localizedDescription, in: self) ?? .init()]) - } else { - guard let response = response as? HTTPURLResponse, let responseObject = JSValue(newObjectIn: self) else { - let error = JSValue(newErrorFromMessage: "Not a valid http response. url: \(url), method: \(httpMethodString)", in: self) - rejected?.call(withArguments: error.flatMap { [$0] }) - return - } - - let dataToText = data.flatMap { String(bytes: $0, encoding: .utf8) } - responseObject.setObject(data, forKeyedSubscript: "data") - responseObject.setObject(dataToText, forKeyedSubscript: "dataText") - responseObject.setObject(response.url?.absoluteString, forKeyedSubscript: "url") - responseObject.setObject(response.allHeaderFields, forKeyedSubscript: "headers") - responseObject.setObject(response.statusCode, forKeyedSubscript: "status") - responseObject.setObject(HTTPURLResponse.localizedString(forStatusCode: response.statusCode), forKeyedSubscript: "statusText") - responseObject.setObject(response.mimeType, forKeyedSubscript: "mimeType") - responseObject.setObject(response.expectedContentLength, forKeyedSubscript: "expectedContentLength") - responseObject.setObject(response.textEncodingName, forKeyedSubscript: "textEncodingName") - resolved?.call(withArguments: [responseObject]) - } - } - task.resume() - } - } - - request?.setObject(unsafeBitCast(buildRequest, to: JSValue.self), forKeyedSubscript: "buildRequest") - setObject(request, forKeyedSubscript: "__request__" as NSString) - } - - private func addConsoleBinding(_ logger: @escaping (MessageLog, String) -> Void) { - exceptionHandler = { [weak self] _, exception in - guard self != nil else { - return - } - let string = exception?.toString() ?? "" - logger(.error, "js error: \(string)") - } - - let console = JSValue(newObjectIn: self) - - let logger = { (type: MessageLog) in { [weak self] in - guard self != nil else { - return - } - - guard let arguments = JSContext.currentArguments()?.compactMap({ $0 as? JSValue }) else { - return - } - - let msg = arguments.compactMap { $0.toString() } - .joined(separator: " ") + setConsoleBinding(logger) + setRequestBinding() - logger(type, msg) - } as @convention(block) () -> Void } - - MessageLog.allCases.forEach { console?.setObject(logger($0), forKeyedSubscript: $0.rawValue) } - - setObject(console, forKeyedSubscript: "console" as NSString) - } - - private func loadModuleAndInitialize(_ module: Module) throws { @Dependency(\.fileClient) var fileClient let jsURL = try fileClient.retrieveModuleDirectory(module.mainJSFile) - try self.evaluateScript(String(contentsOf: jsURL)) - self.evaluateScript("const Instance = new source.default()") + try evaluateScript(String(contentsOf: jsURL)) + evaluateScript("const Instance = new source.default()") } } @@ -141,7 +37,7 @@ extension JSContext: JSRuntime { let encoder = JSValueEncoder() let decoder = JSValueDecoder() - guard let value = function.call(withArguments: try args.map { try encoder.encode($0, into: self) }) else { + guard let value = try function.call(withArguments: args.map { try encoder.encode($0, into: self) }) else { throw ModuleClient.Error.jsRuntime(.instanceCall(function: "Instance.\(functionName)", msg: "Failed to retrieve value from function")) } @@ -161,7 +57,7 @@ extension JSContext: JSRuntime { let encoder = JSValueEncoder() - guard let promise = try function.call(withArguments: args.map { try encoder.encode($0, into: self) })else { + guard let promise = try function.call(withArguments: args.map { try encoder.encode($0, into: self) }) else { throw ModuleClient.Error.jsRuntime(.promiseValueError) } @@ -174,7 +70,7 @@ extension JSContext: JSRuntime { let encoder = JSValueEncoder() let decoder = JSValueDecoder() - guard let promise = try function.call(withArguments: args.map { try encoder.encode($0, into: self)}) else { + guard let promise = try function.call(withArguments: args.map { try encoder.encode($0, into: self) }) else { throw ModuleClient.Error.jsRuntime(.instanceCall(function: "Instance.\(functionName)", msg: "Failed to retrieve value from function")) } @@ -182,7 +78,7 @@ extension JSContext: JSRuntime { } private func getInstance() throws -> JSValue { - guard let instance = self.evaluateScript("Instance"), !instance.isOptional, instance.isObject else { + guard let instance = evaluateScript("Instance"), !instance.isOptional, instance.isObject else { throw ModuleClient.Error.jsRuntime(.retrievingInstanceFailed) } return instance @@ -199,58 +95,3 @@ extension JSContext: JSRuntime { return function } } - -private extension JSValue { - subscript(_ key: String) -> JSValue? { - guard !isOptional else { - return nil - } - guard let value = self.forProperty(key) else { - return nil - } - return !value.isOptional ? value : nil - } - - var isOptional: Bool { self.isNull || self.isUndefined } - - @discardableResult - func value(_ function: String) async throws -> JSValue { - try await withCheckedThrowingContinuation { continuation in - let onFufilled: @convention(block) (JSValue) -> Void = { value in - continuation.resume(returning: value) - } - - let onRejected: @convention(block) (JSValue) -> Void = { value in - continuation.resume(throwing: value.toError(function)) - } - - self.invokeMethod( - "then", - withArguments: [ - unsafeBitCast(onFufilled, to: JSValue.self), - unsafeBitCast(onRejected, to: JSValue.self) - ] - ) - } - } - - func toError(_ functionName: String) -> JSValueError { .init(self, functionName) } -} - -private struct JSValueError: Error, LocalizedError, CustomStringConvertible { - var functionName: String - var name: String? - var errorDescription: String? - var failureReason: String? - - init(_ value: JSValue, _ functionName: String) { - self.functionName = functionName - self.name = value["name"]?.toString() - self.errorDescription = value["message"]?.toString() - self.failureReason = value["cause"]?.toString() - } - - var description: String { - "Instance.\(functionName) => \(name ?? "Unknown"): \(errorDescription ?? "No Message")" + ((failureReason != nil) ? "\n \(failureReason ?? "")": "") - } -} diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift index 571082a..a2417e5 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift @@ -1,9 +1,159 @@ // -// File.swift -// +// JSContext+Request.swift +// // // Created by ErrorErrorError on 11/17/23. // // import Foundation +import JavaScriptCore + +extension JSContext { + func setRequestBinding() { + enum RequestError: Error { + case invalidURL(for: String) + case invalidResponse(url: URL, method: HTTPMethods) + case contextUnavailable + + var localizedDescription: String { + switch self { + case let .invalidURL(for: string): + "Invalid URL for \(string)" + case .contextUnavailable: + "JSContext is unavailable" + case let .invalidResponse(url, method): + "Invalid http response using \(method.httpMethodName) for url: \(url)" + } + } + } + + let request = JSValue(newObjectIn: self) + let session = URLSession(configuration: .ephemeral) + + enum HTTPMethods: String, CaseIterable { + case get + case post + case put + case patch + + var httpMethodName: String { + rawValue.uppercased() + } + } + + let buildRequest = { (method: HTTPMethods) in { [weak self] urlString, options in + guard let self else { + let error = JSValue(newErrorFromMessage: RequestError.contextUnavailable.localizedDescription, in: JSContext.current()) + return .init(newPromiseRejectedWithReason: error, in: error?.context) + } + + guard let url = URL(string: urlString) else { + let error = JSValue(newErrorFromMessage: RequestError.invalidURL(for: urlString).localizedDescription, in: self) + return .init(newPromiseRejectedWithReason: error, in: self) + } + + var request = URLRequest(url: url) + request.httpMethod = method.httpMethodName + request.httpBody = options["body"]?.toString()?.data(using: .utf8) + + if let timeout = options["timeout"]?.toDouble() { + request.timeoutInterval = timeout + } + + if let headers = options["headers"]?.toDictionary() as? [String: String] { + headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } + } + + return .init(newPromiseIn: self) { resolved, rejected in + let task = session.dataTask(with: request) { data, response, error in + guard let resolved, let rejected else { + return + } + + if let error { + rejected.call(withArguments: [JSValue(newErrorFromMessage: error.localizedDescription, in: rejected.context) ?? .init()]) + } else { + guard let response = response as? HTTPURLResponse, + let responseObject = JSValue(newObjectIn: resolved.context), + let requestObject = JSValue(newObjectIn: resolved.context) + else { + let error = JSValue( + newErrorFromMessage: RequestError.invalidResponse(url: url, method: method).localizedDescription, + in: rejected.context + ) + rejected.call(withArguments: error.flatMap { [$0] }) + return + } + + // MochiRequestOptions + requestObject.setObject(url, forKeyedSubscript: "url") + requestObject.setObject(method.rawValue, forKeyedSubscript: "method") + requestObject.setObject(options, forKeyedSubscript: "options") + + // MochiResponse + responseObject.setObject(response.statusCode, forKeyedSubscript: "status") + responseObject.setObject(HTTPURLResponse.localizedString(forStatusCode: response.statusCode), forKeyedSubscript: "statusText") + responseObject.setObject(response.allHeaderFields, forKeyedSubscript: "headers") + responseObject.setObject(requestObject, forKeyedSubscript: "request") + + let data = data ?? .init() + let dataToText = String(bytes: data, encoding: .utf8) ?? "" + let dataFunction = { + let ctx = JSContext.current() + let buffer = UnsafeMutableRawPointer.allocate(byteCount: data.count, alignment: MemoryLayout.alignment) + data.copyBytes(to: .init(start: buffer, count: data.count)) + + var exception: JSValueRef? + + // swiftlint:disable opening_brace + guard let bufValue = JSObjectMakeArrayBufferWithBytesNoCopy( + ctx?.jsGlobalContextRef, + buffer, + data.count, + { buffer, _ in buffer?.deallocate() }, + nil, + &exception + ) else { + return JSValue(object: data, in: self) + } + // swiftlint:enable opening_brace + + if let exception { + return JSValue(jsValueRef: exception, in: ctx) + } else { + return JSValue(jsValueRef: bufValue, in: ctx) + } + } as @convention(block) () -> JSValue + + let jsonFunction = { + let ctx = JSContext.current() + let value = dataToText.withCString(JSStringCreateWithUTF8CString) + defer { JSStringRelease(value) } + return JSValue(jsValueRef: JSValueMakeFromJSONString(ctx?.jsGlobalContextRef, value), in: ctx) + } as @convention(block) () -> JSValue + + let textFunction = { + let ctx = JSContext.current() + let value = dataToText.withCString(JSStringCreateWithUTF8CString) + defer { JSStringRelease(value) } + return JSValue(jsValueRef: JSValueMakeString(ctx?.jsGlobalContextRef, value), in: ctx) + } as @convention(block) () -> JSValue + + responseObject.setObject(unsafeBitCast(dataFunction, to: JSValue.self), forKeyedSubscript: "data") + responseObject.setObject(unsafeBitCast(jsonFunction, to: JSValue.self), forKeyedSubscript: "json") + responseObject.setObject(unsafeBitCast(textFunction, to: JSValue.self), forKeyedSubscript: "text") + + resolved.call(withArguments: [responseObject]) + } + } + task.resume() + } + } as @convention(block) (String, JSValue) -> JSValue } + + HTTPMethods.allCases.forEach { method in + request?.setObject(unsafeBitCast(buildRequest(method), to: JSValue.self), forKeyedSubscript: method.rawValue) + } + setObject(request, forKeyedSubscript: "request" as NSString) + } +} diff --git a/Sources/Clients/ModuleClient/Live.swift b/Sources/Clients/ModuleClient/Live.swift index af6a8bb..8ba9dc0 100644 --- a/Sources/Clients/ModuleClient/Live.swift +++ b/Sources/Clients/ModuleClient/Live.swift @@ -21,7 +21,8 @@ extension ModuleClient: DependencyKey { return Self( initialize: cached.initialize, getModule: cached.getCached, - removeModule: cached.removeModule + removeCachedModule: cached.removeModule, + removeCachedModules: cached.removeModules ) }() } @@ -53,6 +54,7 @@ private actor ModulesCache { defer { semaphore.signal() } // TODO: Check if the module is cached already & validate version & file hash or reload + if let instance = cached[id] { return instance } @@ -74,4 +76,14 @@ private actor ModulesCache { defer { semaphore.signal() } self.cached[id] = nil } + + @Sendable + func removeModules(id: Repo.ID) async throws { + try await semaphore.waitUnlessCancelled() + defer { semaphore.signal() } + + for key in cached.keys where key.repoId == id { + cached[key] = nil + } + } } diff --git a/Sources/Clients/PlayerClient/Client.swift b/Sources/Clients/PlayerClient/Client.swift index 374b240..84d8042 100644 --- a/Sources/Clients/PlayerClient/Client.swift +++ b/Sources/Clients/PlayerClient/Client.swift @@ -15,15 +15,16 @@ import XCTestDynamicOverlay // MARK: - PlayerClient public struct PlayerClient: Sendable { - public let load: @Sendable (VideoCompositionItem) async throws -> Void - public let setRate: @Sendable (Float) async -> Void - public let play: @Sendable () async -> Void - public let pause: @Sendable () async -> Void - public let seek: @Sendable (_ progress: Double) async -> Void - public let volume: @Sendable (_ amount: Double) async -> Void - public let clear: @Sendable () async -> Void - public let status: @Sendable () async -> AsyncStream - let player: AVPlayer + public var load: @Sendable (VideoCompositionItem) async throws -> Void + public var setRate: @Sendable (Float) async -> Void + public var play: @Sendable () async -> Void + public var pause: @Sendable () async -> Void + public var seek: @Sendable (_ progress: Double) async -> Void + public var volume: @Sendable (_ amount: Double) async -> Void + public var clear: @Sendable () async -> Void + public var get: @Sendable () -> Status + public var observe: @Sendable () -> AsyncStream + var player: @Sendable () -> AVPlayer } // MARK: TestDependencyKey @@ -37,7 +38,8 @@ extension PlayerClient: TestDependencyKey { seek: unimplemented(), volume: unimplemented(), clear: unimplemented(), - status: unimplemented(), + get: unimplemented(), + observe: unimplemented(), player: unimplemented() ) } diff --git a/Sources/Clients/PlayerClient/Live.swift b/Sources/Clients/PlayerClient/Live.swift index e237ffa..5708731 100644 --- a/Sources/Clients/PlayerClient/Live.swift +++ b/Sources/Clients/PlayerClient/Live.swift @@ -29,8 +29,9 @@ extension PlayerClient: DependencyKey { seek: { @MainActor progress in await impl.seek(to: progress) }, volume: { @MainActor volume in await impl.volume(to: volume) }, clear: { @MainActor in await impl.clear() }, - status: { @MainActor in impl.status() }, - player: impl.player + get: { impl.status() }, + observe: { impl.observe() }, + player: { impl.player } ) }() } @@ -124,8 +125,11 @@ private class InternalPlayer { nowPlaying.clear() } - @MainActor - func status() -> AsyncStream { + func status() -> PlayerClient.Status { + .idle + } + + func observe() -> AsyncStream { .finished } } diff --git a/Sources/Clients/PlayerClient/Models.swift b/Sources/Clients/PlayerClient/Models.swift index 339d372..7647015 100644 --- a/Sources/Clients/PlayerClient/Models.swift +++ b/Sources/Clients/PlayerClient/Models.swift @@ -11,6 +11,7 @@ import Foundation // MARK: - PlayerClient.Status public extension PlayerClient { + // TODO: Add metadata in the status enum Status: Hashable, Sendable { case idle case loading diff --git a/Sources/Clients/PlayerClient/PlayerFeature/PlayerFeature.swift b/Sources/Clients/PlayerClient/PlayerFeature/PlayerFeature.swift index 418a65b..0cff9b2 100644 --- a/Sources/Clients/PlayerClient/PlayerFeature/PlayerFeature.swift +++ b/Sources/Clients/PlayerClient/PlayerFeature/PlayerFeature.swift @@ -145,57 +145,57 @@ public struct PlayerFeature: Feature { await withTaskCancellation(id: Cancellables.initialize) { await withTaskGroup(of: Void.self) { group in group.addTask { - for await rate in playerClient.player.valueStream(\.rate) { + for await rate in playerClient.player().valueStream(\.rate) { await send(.internal(.rate(rate))) } } group.addTask { - for await time in playerClient.player.periodicTimeStream() { + for await time in playerClient.player().periodicTimeStream() { await send(.internal(.progress(time))) } } group.addTask { - for await time in playerClient.player.valueStream(\.currentItem?.duration) { + for await time in playerClient.player().valueStream(\.currentItem?.duration) { await send(.internal(.duration(time ?? .zero))) } } group.addTask { - for await status in playerClient.player.valueStream(\.status) { + for await status in playerClient.player().valueStream(\.status) { await send(.internal(.status(status))) } } group.addTask { - for await status in playerClient.player.valueStream(\.timeControlStatus) { + for await status in playerClient.player().valueStream(\.timeControlStatus) { await send(.internal(.timeControlStatus(status))) } } group.addTask { - for await empty in playerClient.player.valueStream(\.currentItem?.isPlaybackBufferEmpty) { + for await empty in playerClient.player().valueStream(\.currentItem?.isPlaybackBufferEmpty) { await send(.internal(.playbackBufferEmpty(empty ?? true))) } } group.addTask { - for await full in playerClient.player.valueStream(\.currentItem?.isPlaybackBufferFull) { + for await full in playerClient.player().valueStream(\.currentItem?.isPlaybackBufferFull) { await send(.internal(.playbackBufferFull(full ?? false))) } } group.addTask { - for await canKeepUp in playerClient.player.valueStream(\.currentItem?.isPlaybackLikelyToKeepUp) { + for await canKeepUp in playerClient.player().valueStream(\.currentItem?.isPlaybackLikelyToKeepUp) { await send(.internal(.playbackLikelyToKeepUp(canKeepUp ?? false))) } } group.addTask { - for await selection in playerClient.player.valueStream(\.currentItem?.currentMediaSelection) { + for await selection in playerClient.player().valueStream(\.currentItem?.currentMediaSelection) { if let selection, - let group = playerClient.player.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible), + let group = playerClient.player().currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible), let option = selection.selectedMediaOption(in: group) { await send(.internal(.selectedSubtitle(option))) } else { @@ -205,7 +205,7 @@ public struct PlayerFeature: Feature { } group.addTask { - for await asset in playerClient.player.valueStream(\.currentItem?.asset) { + for await asset in playerClient.player().valueStream(\.currentItem?.asset) { if let asset, let group = asset.mediaSelectionGroup(forMediaCharacteristic: .legible) { await send(.internal(.subtitles(group))) } else { @@ -276,7 +276,7 @@ public struct PlayerFeature: Feature { case let .view(.didTapSubtitle(group, option)): return .run { _ in - await playerClient.player.currentItem?.select(option, in: group) + await playerClient.player().currentItem?.select(option, in: group) } case let .view(.didSetPiPActive(active)): @@ -370,7 +370,7 @@ public struct PlayerFeature: Feature { public var body: some SwiftUI.View { WithViewStore(store, observe: PlayerViewState.init) { viewStore in PlayerView( - player: player, + player: player(), gravity: viewStore.gravity, enablePIP: viewStore.enablePIP ) diff --git a/Sources/Clients/RepoClient/Client.swift b/Sources/Clients/RepoClient/Client.swift index d4f2fd5..3a2bf27 100644 --- a/Sources/Clients/RepoClient/Client.swift +++ b/Sources/Clients/RepoClient/Client.swift @@ -17,14 +17,19 @@ import XCTestDynamicOverlay // MARK: - RepoClient public struct RepoClient: Sendable { - public let validate: @Sendable (URL) async throws -> RepoPayload - public let addRepo: @Sendable (RepoPayload) async throws -> Void - public let removeRepo: @Sendable (Repo.ID) async throws -> Void - public let addModule: @Sendable (Repo.ID, Module.Manifest) async -> Void - public let removeModule: @Sendable (Repo.ID, Module) async throws -> Void - public let downloads: @Sendable () -> AsyncStream<[RepoModuleID: RepoModuleDownloadState]> - public let repos: @Sendable (Request) async throws -> [Repo] - public let fetchRemoteRepoModules: @Sendable (Repo.ID) async throws -> [Module.Manifest] + public var validate: @Sendable (URL) async throws -> RepoPayload + + public var addRepo: @Sendable (RepoPayload) async throws -> Void + public var updateRepo: @Sendable (Repo) async throws -> Void + public var deleteRepo: @Sendable (Repo.ID) async throws -> Void + + public var installModule: @Sendable (Repo.ID, Module.Manifest) -> Void + public var removeModule: @Sendable (RepoModuleID) async throws -> Void + + public var repos: @Sendable (Request) -> AsyncStream<[Repo]> + + public var downloads: @Sendable () -> AsyncStream<[RepoModuleID: RepoModuleDownloadState]> + public var fetchModulesMetadata: @Sendable (Repo.ID) async throws -> [Module.Manifest] } // MARK: TestDependencyKey @@ -33,12 +38,13 @@ extension RepoClient: TestDependencyKey { public static let testValue = Self( validate: unimplemented("\(Self.self).validateRepo"), addRepo: unimplemented("\(Self.self).addRepo"), - removeRepo: unimplemented("\(Self.self).removeRepo"), - addModule: unimplemented("\(Self.self).addModule"), + updateRepo: unimplemented("\(Self.self).updateRepo"), + deleteRepo: unimplemented("\(Self.self).deleteRepo"), + installModule: unimplemented("\(Self.self).addModule"), removeModule: unimplemented("\(Self.self).removeModule"), - downloads: unimplemented("\(Self.self).downloads"), repos: unimplemented("\(Self.self).repos"), - fetchRemoteRepoModules: unimplemented("\(Self.self).fetchRemoteRepoModules") + downloads: unimplemented("\(Self.self).downloads"), + fetchModulesMetadata: unimplemented("\(Self.self).fetchRemoteRepoModules") ) } diff --git a/Sources/Clients/RepoClient/Live.swift b/Sources/Clients/RepoClient/Live.swift index 403ca59..efdf08b 100644 --- a/Sources/Clients/RepoClient/Live.swift +++ b/Sources/Clients/RepoClient/Live.swift @@ -46,23 +46,29 @@ extension RepoClient: DependencyKey { _ = try await databaseClient.insert(repo) }, - removeRepo: { repoId in + updateRepo: { repo in + _ = try await databaseClient.update(repo) + }, + deleteRepo: { repoId in if let repo = try? await databaseClient.fetch(.all.where(\Repo.remoteURL == repoId.rawValue)).first { try await databaseClient.delete(repo) } await Self.downloadManager.cancelAllRepoDownloads(repoId) }, - addModule: { repoId, manifest in - let id = manifest.id(repoID: repoId) - await Self.downloadManager.download(id, module: manifest) + installModule: { repoId, manifest in + Self.downloadManager.addToQueue(manifest.id(repoID: repoId), module: manifest) }, - removeModule: { repoId, module in - let id = module.id(repoID: repoId) + removeModule: { id in + if let repo = try? await databaseClient.fetch(.all.where(\Repo.remoteURL == id.repoId.rawValue)).first, + let module = repo.modules.first(where: { $0.id == id.moduleId }) { + try await databaseClient.delete(module) + try Self.fileClient.remove(fileClient.retrieveModuleDirectory(module.directory)) + } + Self.downloadManager.cancelModuleDownload(id) - try await databaseClient.delete(module) - try Self.fileClient.remove(fileClient.retrieveModuleDirectory(module.directory)) }, + repos: { databaseClient.observe($0) }, downloads: { .init { continuation in let cancellation = Self.downloadManager.states.sink { _ in @@ -76,8 +82,7 @@ extension RepoClient: DependencyKey { } } }, - repos: { try await databaseClient.fetch($0) }, - fetchRemoteRepoModules: { repoId in + fetchModulesMetadata: { repoId in let url = repoId.rawValue.appendingPathComponent("Manifest.json", isDirectory: false) let request = URLRequest(url: url) let (data, response) = try await URLSession.shared.data(for: request) @@ -87,6 +92,7 @@ extension RepoClient: DependencyKey { } // MARK: - ModulesDownloadManager +// FIXME: Improve download manager for queue, ect private class ModulesDownloadManager { let states = CurrentValueSubject<[RepoModuleID: RepoClient.RepoModuleDownloadState], Never>([:]) @@ -100,37 +106,51 @@ private class ModulesDownloadManager { @Dependency(\.databaseClient) var databaseClient - func download(_ repoModuleID: RepoModuleID, module: Module.Manifest) async { - guard states.value[repoModuleID]?.canRestartDownload ?? true else { + func addToQueue(_ repoModuleId: RepoModuleID, module: Module.Manifest) { + Task.detached { [weak self] in + await self?.download(repoModuleId, module: module) + } + } + + private func download(_ repoModuleId: RepoModuleID, module: Module.Manifest) async { + guard states.value[repoModuleId]?.canRestartDownload ?? true else { return } - states.value[repoModuleID] = .pending + states.value[repoModuleId] = .pending await semaphore.wait() defer { semaphore.signal() } - let moduleFileURL = repoModuleID.repoId.rawValue.appendingPathComponent(module.file, isDirectory: false) + let moduleFileURL = repoModuleId.repoId.rawValue.appendingPathComponent(module.file, isDirectory: false) let request = URLRequest(url: moduleFileURL) let sequence = URLSession.shared.data(request) - states.value[repoModuleID] = .downloading(percent: 0) + states.value[repoModuleId] = .downloading(percent: 0) let task = Task { do { for try await value in sequence { switch value { case let .progress(progress): - states.value[repoModuleID] = .downloading(percent: progress) + states.value[repoModuleId] = .downloading(percent: progress) case let .value(data, response): guard let response = response as? HTTPURLResponse, - response.mimeType == "text/javascript", + response.mimeType == "text/javascript" || + response.mimeType == "application/javascript" || + response.mimeType == "application/zip" || + response.mimeType == "application/x-zip", (200..<300).contains(response.statusCode) else { throw RepoClient.Error.failedToDownloadModule } + guard response.mimeType == "text/javascript" || response.mimeType == "application/javascript" else { + print("Unknown mime type of file \(response.mimeType ?? "Unknown")") + throw RepoClient.Error.failedToDownloadModule + } + guard let directory = URL( - string: "\(repoModuleID.repoId.host ?? "Default")/\(repoModuleID.moduleId.rawValue)", + string: "\(repoModuleId.repoId.host ?? "Default")/\(repoModuleId.moduleId.rawValue)", relativeTo: nil ) else { throw RepoClient.Error.failedToInstallModule @@ -150,28 +170,28 @@ private class ModulesDownloadManager { } } catch { print(error) - states.value[repoModuleID] = .failed((error as? RepoClient.Error) ?? .failedToDownloadModule) + states.value[repoModuleId] = .failed((error as? RepoClient.Error) ?? .failedToDownloadModule) } return nil } - downloadTasks[repoModuleID] = task + downloadTasks[repoModuleId] = task guard let module = await task.value else { - states.value[repoModuleID] = .failed(.failedToDownloadModule) - downloadTasks[repoModuleID]?.cancel() - downloadTasks[repoModuleID] = nil + states.value[repoModuleId] = .failed(.failedToDownloadModule) + downloadTasks[repoModuleId]?.cancel() + downloadTasks[repoModuleId] = nil return } - states.value[repoModuleID] = .installing + states.value[repoModuleId] = .installing - guard var repo: Repo = try? await databaseClient.fetch(.all.where(\.remoteURL == repoModuleID.repoId.rawValue)).first else { - states.value[repoModuleID] = .failed(.failedToFindRepo) + guard var repo: Repo = try? await databaseClient.fetch(.all.where(\.remoteURL == repoModuleId.repoId.rawValue)).first else { + states.value[repoModuleId] = .failed(.failedToFindRepo) return } - if let index = repo.modules.firstIndex(where: { $0.id == repoModuleID.moduleId }) { + if let index = repo.modules.firstIndex(where: { $0.id == repoModuleId.moduleId }) { repo.modules.remove(at: index) } @@ -180,16 +200,16 @@ private class ModulesDownloadManager { do { _ = try await databaseClient.update(repo) } catch { - states.value[repoModuleID] = .failed(.failedToInstallModule) + states.value[repoModuleId] = .failed(.failedToInstallModule) } - states.value[repoModuleID] = .installed + states.value[repoModuleId] = .installed } - func cancelModuleDownload(_ repoModuleID: RepoModuleID) { - downloadTasks[repoModuleID]?.cancel() - downloadTasks[repoModuleID] = nil - states.value[repoModuleID] = nil + func cancelModuleDownload(_ repoModuleId: RepoModuleID) { + downloadTasks[repoModuleId]?.cancel() + downloadTasks[repoModuleId] = nil + states.value[repoModuleId] = nil } func cancelAllRepoDownloads(_ repoId: Repo.ID) async { diff --git a/Sources/Clients/RepoClient/Models.swift b/Sources/Clients/RepoClient/Models.swift index 243d96a..24ccc52 100644 --- a/Sources/Clients/RepoClient/Models.swift +++ b/Sources/Clients/RepoClient/Models.swift @@ -30,7 +30,7 @@ public extension RepoClient { var canRestartDownload: Bool { switch self { - case .failed: + case .failed, .installed: true default: false diff --git a/Sources/Features/App/AppFeature+Reducer.swift b/Sources/Features/App/AppFeature+Reducer.swift index aebb28b..127199a 100644 --- a/Sources/Features/App/AppFeature+Reducer.swift +++ b/Sources/Features/App/AppFeature+Reducer.swift @@ -35,6 +35,9 @@ extension AppFeature: Reducer { } else if state.discover.isSearchExpanded { return state.discover.collapseSearch() .map { .internal(.discover($0)) } + } else if !state.discover.search.query.isEmpty { + return state.discover.collapseAndClearSearch() + .map { .internal(.discover($0)) } } case .repos: state.repos.path.removeAll() @@ -48,29 +51,30 @@ extension AppFeature: Reducer { case .internal(.appDelegate): break -// case let .internal(.discover(.delegate(.playbackVideoItem(_, repoModuleID, playlist, group, paging, itemId)))): -// break -// let effect = state.videoPlayer?.clearForNewPlaylistIfNeeded( -// repoModuleID: repoModuleID, -// playlist: playlist, -// group: group, -// page: paging, -// episodeId: itemId -// ) -// .map { Action.internal(.videoPlayer(.presented($0))) } -// -// if let effect { -// return effect -// } else { -// state.videoPlayer = .init( -// repoModuleID: repoModuleID, -// playlist: playlist, -// contents: .init(), -// group: group, -// page: paging, -// episodeId: itemId -// ) -// } + case let .internal(.discover(.delegate(.playbackVideoItem(contents, repoModuleId, playlist, group, variant, paging, itemId)))): + let effect = state.videoPlayer?.clearForNewPlaylistIfNeeded( + repoModuleId: repoModuleId, + playlist: playlist, + groupId: group, + variantId: variant, + pageId: paging, + episodeId: itemId + ) + .map { Action.internal(.videoPlayer(.presented($0))) } + + if let effect { + return effect + } else { + state.videoPlayer = .init( + repoModuleId: repoModuleId, + playlist: playlist, +// contents: .init(contents: .init(groups: .loaded(contents))), + group: group, + variant: variant, + page: paging, + episodeId: itemId + ) + } case .internal(.discover): break diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index f73ab11..a0916f0 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -13,137 +13,118 @@ import Styling import SwiftUI import ViewComponents -// MARK: - ContentListingView +// MARK: - ContentCore+View public extension ContentCore { @MainActor struct View: FeatureView { public let store: StoreOf + @ObservedObject + private var viewStore: ViewStoreOf + private let contentType: Playlist.PlaylistType + @MainActor - public init(store: StoreOf) { + public init( + store: StoreOf, + contentType: Playlist.PlaylistType = .video, + selectedGroupId: Playlist.Group.ID? = nil, + selectedVariantId: Playlist.Group.Variant.ID? = nil, + selectedPageId: PagingID? = nil, + selectedItemId: Playlist.Item.ID? = nil + ) { self.store = store + self.contentType = contentType + self._viewStore = .init(wrappedValue: .init(store, observe: \.`self`)) + self.__selectedGroupId = .init(wrappedValue: selectedGroupId) + self.__selectedVariantId = .init(wrappedValue: selectedVariantId) + self.__selectedPagingId = .init(wrappedValue: selectedPageId) + self.selectedItemId = selectedItemId } @Environment(\.theme) var theme @SwiftUI.State - private var selectedGroupId: Playlist.Group.ID? + private var _selectedGroupId: Playlist.Group.ID? @SwiftUI.State - private var selectedVariantId: Playlist.Group.Variant.ID? + private var _selectedVariantId: Playlist.Group.Variant.ID? @SwiftUI.State - private var selectedPagingId: PagingID? - - private static let placeholderItems = [ - Playlist.Item( - id: "/1", - title: "Placeholder", - description: "Placeholder", - number: 1, - timestamp: "May 12, 2023", - tags: [] - ), - Playlist.Item( - id: "/2", - title: "Placeholder", - description: "Placeholder", - number: 2, - timestamp: "May 12, 2023", - tags: [] - ), - Playlist.Item( - id: "/3", - title: "Placeholder", - description: "Placeholder", - number: 3, - timestamp: "May 12, 2023", - tags: [] - ) - ] + private var _selectedPagingId: PagingID? - @MainActor - public var body: some SwiftUI.View { - WithViewStore(store, observe: \.`self`) { viewStore in - LoadableView(loadable: viewStore.state) { groups in - content(groups) - } failedView: { _ in - content([]) - } waitingView: { - content([]) - } - .shimmering(active: !viewStore.didFinish) - .disabled(!viewStore.didFinish) - .onChange(of: selectedGroupId) { _ in - selectedVariantId = nil - selectedPagingId = nil - } - .onChange(of: selectedVariantId) { _ in - selectedPagingId = nil - } - } + private let selectedItemId: Playlist.Item.ID? + + private var groupLoadable: Loadable { + viewStore.groups.map { groups in _selectedGroupId.flatMap { groups[id: $0] } ?? groups.first } + .flatMap(Loadable.init) + } + + private var variantLoadable: Loadable { + groupLoadable.flatMap(\.variants) + .map { variants in _selectedVariantId.flatMap { variants[id: $0] } ?? variants.first } + .flatMap(Loadable.init) + } + + private var pageLoadable: Loadable> { + variantLoadable.flatMap(\.pagings) + .map { pagings in _selectedPagingId.flatMap { pagings[id: $0] } ?? pagings.first } + .flatMap(Loadable.init) + } + + private var hasMultipleGroups: Bool { + (viewStore.groups.value?.count ?? 0) > 1 } @MainActor - @ViewBuilder - private func content(_ groups: [Playlist.Group]) -> some SwiftUI.View { - let defaultSelectedGroupId = selectedGroupId ?? groups.first?.id - let group = defaultSelectedGroupId.flatMap { groups[id: $0] } - let groupLoadable = groups.group(id: defaultSelectedGroupId) - - let defaultSelectedVariantId = selectedVariantId ?? group?.variants.value?.first?.id - let variant = defaultSelectedVariantId.flatMap { group?.variants.value?[id: $0] } - let variantLoadable = groupLoadable.flatMap { $0.variant(variantId: defaultSelectedVariantId) } - - let defaultSelectedPagingId = selectedPagingId ?? variant?.pagings.value?.first?.id - let page = defaultSelectedPagingId.flatMap { variant?.pagings.value?[id: $0] } - let pageLoadable = variantLoadable.flatMap { $0.page(pageId: defaultSelectedPagingId) } - - let hasMultipleGroups = groups.count > 1 - + public var body: some SwiftUI.View { HeaderWithContent { VStack { HStack(alignment: .center) { /// Groups Menu { if hasMultipleGroups { - ForEach(groups, id: \.id) { group in + ForEach(viewStore.groups.value ?? [], id: \.id) { group in Button { - selectedGroupId = group.id - // store.send(.view(.didTapContent(.group(group.id)))) + _selectedGroupId = group.id + store.send(.view(.didTapContent(.group(group.id)))) } label: { - Text(group.altTitle ?? "Season \(group.number.withoutTrailingZeroes)") + Text(group.altTitle ?? .init( + format: contentType.multiGroupsDefaultTitle, + group.number.withoutTrailingZeroes + )) } } } } label: { - if let group, hasMultipleGroups { + if let selectedGroup = groupLoadable.value, hasMultipleGroups { HStack { - Text(group.altTitle ?? "Season \(group.number.withoutTrailingZeroes)") + Text(selectedGroup.altTitle ?? .init( + format: contentType.multiGroupsDefaultTitle, + selectedGroup.number.withoutTrailingZeroes) + ) Image(systemName: "chevron.compact.down") Spacer() } - } else if let group { - Text(group.altTitle ?? "Episodes") } else { - Text("Episodes") + Text(groupLoadable.value?.altTitle ?? contentType.oneGroupDefaultTitle) } } - .animation(.easeInOut, value: defaultSelectedGroupId) - + .animation(.easeInOut, value: _selectedGroupId) + Spacer() - + // TODO: Add option to show/hide pagings with infinie scroll /// Pagings Menu { - if let pagings = variant?.pagings.value { + if let pagings = variantLoadable.value?.pagings.value { ForEach(Array(zip(pagings.indices, pagings)), id: \.1.id) { index, paging in Button { - selectedPagingId = paging.id - if let groupId = defaultSelectedGroupId, let variantId = defaultSelectedVariantId { - // store.send(.view(.didTapContent(.page(groupId, variantId, paging.id)))) + _selectedPagingId = paging.id + + if let groupId = groupLoadable.value?.id, let variantId = variantLoadable.value?.id { + store.send(.view(.didTapContent(.page(groupId, variantId, paging.id)))) } } label: { Text(paging.title ?? "Page \(index + 1)") @@ -157,47 +138,49 @@ public extension ContentCore { .padding(.vertical, 6) .background(.thinMaterial, in: Capsule()) } - - if let page, let index = variant?.pagings.value?.firstIndex(where: \.id == page.id) { - textView(page.title ?? "Page \(index + 1)") + + if let selectedPage = pageLoadable.value, let index = variantLoadable.value?.pagings.value?.firstIndex(where: \.id == selectedPage.id) { + textView(selectedPage.title ?? "Page \(index + 1)") } else { textView("Not Selected") } } .font(.footnote.weight(.semibold)) .shimmering(active: !variantLoadable.didFinish) - .animation(.easeInOut, value: defaultSelectedPagingId) + .animation(.easeInOut, value: _selectedPagingId) } .frame(maxWidth: .infinity) - + .padding(.horizontal) + // TODO: Allow variations to also be a menu ScrollView(.horizontal) { HStack(spacing: 6) { - if let variant { - ChipView(text: variant.title) + if let selectedVariant = variantLoadable.value { + ChipView(text: selectedVariant.title) .background(Color.blue) .foregroundColor(.white) } - - if let variants = group?.variants.value { + + if let variants = groupLoadable.value?.variants.value { ForEach(variants, id: \.id) { variant in - if variant.id != defaultSelectedVariantId { + if variant.id != variantLoadable.value?.id { ChipView(text: variant.title) .onTapGesture { - if let defaultSelectedGroupId { - selectedVariantId = variant.id - // store.send(.view(.didTapContent(.variant(defaultSelectedGroupId, variant.id)))) + if let groupId = groupLoadable.value?.id { + _selectedVariantId = variant.id + store.send(.view(.didTapContent(.variant(groupId, variant.id)))) } } } } } } + .padding(.horizontal) } .font(.footnote.weight(.semibold)) .frame(maxWidth: .infinity) .shimmering(active: !groupLoadable.didFinish) - .animation(.easeInOut, value: defaultSelectedVariantId) + .animation(.easeInOut, value: _selectedVariantId) } .frame(maxWidth: .infinity) .foregroundColor(theme.textColor) @@ -213,6 +196,16 @@ public extension ContentCore { .overlay { Text("There was an error loading content.") .font(.callout.weight(.semibold)) + + Button { +// viewStore.send(.view(.didTapRetry(items))) + } label: { + Text("Retry") + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) } } else if items.didFinish, (items.value?.count ?? 0) == 0 { RoundedRectangle(cornerRadius: 12) @@ -225,55 +218,98 @@ public extension ContentCore { .font(.callout.weight(.medium)) } } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 12) { - ForEach(items.value ?? Self.placeholderItems, id: \.id) { item in - VStack(alignment: .leading, spacing: 0) { - FillAspectImage(url: item.thumbnail) - .aspectRatio(16 / 9, contentMode: .fit) - .cornerRadius(12) - - Spacer() - .frame(height: 8) - - Text("Episode \(item.number.withoutTrailingZeroes)") - .font(.footnote.weight(.semibold)) - .foregroundColor(.init(white: 0.4)) - - Spacer() - .frame(height: 4) - - Text(item.title ?? "Episode \(item.number.withoutTrailingZeroes)") - .font(.body.weight(.semibold)) - } - .frame(width: 228) - .contentShape(Rectangle()) - .onTapGesture { - if let groupId = defaultSelectedGroupId, - let variantId = defaultSelectedVariantId, - let pageId = defaultSelectedPagingId { - // store.send(.view(.didTapVideoItem(groupId, variantId, pageId, item.id))) + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(items.value ?? Self.placeholderItems, id: \.id) { item in + VStack(alignment: .leading, spacing: 0) { + FillAspectImage(url: item.thumbnail ?? viewStore.playlist.posterImage) + .aspectRatio(16 / 9, contentMode: .fit) + .cornerRadius(12) + + Spacer() + .frame(height: 8) + + Text(String(format: contentType.itemTypeWithNumber, item.number.withoutTrailingZeroes)) + .font(.footnote.weight(.semibold)) + .foregroundColor(.init(white: 0.4)) + + Spacer() + .frame(height: 4) + + Text(item.title ?? String(format: contentType.itemTypeWithNumber, item.number.withoutTrailingZeroes)) + .font(.body.weight(.semibold)) } + .frame(width: 228) + .contentShape(Rectangle()) + .onTapGesture { + if let groupId = groupLoadable.value?.id, + let variantId = variantLoadable.value?.id, + let pageId = pageLoadable.value?.id { + store.send(.view(.didTapPlaylistItem(groupId, variantId, pageId, id: item.id))) + } + } + .id(item.id) } + .frame(maxHeight: .infinity, alignment: .top) } - .frame(maxHeight: .infinity, alignment: .top) + .frame(maxWidth: .infinity) + .padding(.horizontal) + } + .onAppear { + proxy.scrollTo(selectedItemId, anchor: .center) } - .frame(maxWidth: .infinity) - .padding(.horizontal) } .frame(maxWidth: .infinity) .shimmering(active: !items.didFinish) .disabled(!items.didFinish) } } - .animation(.easeInOut, value: defaultSelectedGroupId) - .animation(.easeInOut, value: defaultSelectedVariantId) - .animation(.easeInOut, value: defaultSelectedPagingId) + .animation(.easeInOut, value: items.didFinish) + .animation(.easeInOut, value: _selectedGroupId) + .animation(.easeInOut, value: _selectedVariantId) + .animation(.easeInOut, value: _selectedPagingId) + } + .onChange(of: _selectedGroupId) { _ in + _selectedVariantId = nil + _selectedPagingId = nil + } + .onChange(of: _selectedVariantId) { _ in + _selectedPagingId = nil } } } } +extension ContentCore.View { + static let placeholderItems = [ + Playlist.Item( + id: "/1", + title: "Placeholder", + description: "Placeholder", + number: 1, + timestamp: "May 12, 2023", + tags: [] + ), + Playlist.Item( + id: "/2", + title: "Placeholder", + description: "Placeholder", + number: 2, + timestamp: "May 12, 2023", + tags: [] + ), + Playlist.Item( + id: "/3", + title: "Placeholder", + description: "Placeholder", + number: 3, + timestamp: "May 12, 2023", + tags: [] + ) + ] +} + @MainActor private struct HeaderWithContent: View { let label: () -> Label @@ -284,7 +320,6 @@ private struct HeaderWithContent: View { LazyVStack(alignment: .leading, spacing: 12) { label() .font(.title3.bold()) - .padding(.horizontal) content() } .frame(maxWidth: .infinity) @@ -312,12 +347,46 @@ private struct HeaderWithContent: View { } } +// TODO: Move these to translatable content + +private extension Playlist.PlaylistType { + var multiGroupsDefaultTitle: String { + switch self { + case .video: + "Season %@" + case .image, .text: + "Volume %@" + } + } + + var oneGroupDefaultTitle: String { + switch self { + case .video: + "Episodes" + case .image, .text: + "Chapters" + } + } + + var itemTypeWithNumber: String { + switch self { + case .video: + "Episode %@" + case .image, .text: + "Chanpter %@" + } + } +} + // MARK: - ContentListingView_Previews #Preview { ContentCore.View( store: .init( - initialState: .pending, + initialState: .init( + repoModuleId: Repo().id(.init("")), + playlist: .empty + ), reducer: { EmptyReducer() } ) ) diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index a85090f..71d3936 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -24,17 +24,54 @@ private enum Cancellable: Hashable, CaseIterable { // MARK: - ContentCore -public struct ContentCore: Reducer { - public typealias State = Loadable<[Playlist.Group]> +public struct ContentCore: Feature { + public struct State: FeatureState { + public var repoModuleId: RepoModuleID + public var playlist: Playlist + public var groups: Loadable<[Playlist.Group]> - public enum Action: Equatable, Sendable { - case update(option: Playlist.ItemsRequestOptions?, Loadable) + public init( + repoModuleId: RepoModuleID, + playlist: Playlist, + groups: Loadable<[Playlist.Group]> = .pending + ) { + self.repoModuleId = repoModuleId + self.playlist = playlist + self.groups = groups + } + } + + public enum Action: FeatureAction { + public enum ViewAction: SendableAction { + case didTapContent(Playlist.ItemsRequestOptions) + case didTapPlaylistItem( + Playlist.Group.ID, + Playlist.Group.Variant.ID, + PagingID, + id: Playlist.Item.ID + ) + } + + public enum DelegateAction: SendableAction { + case didTapPlaylistItem( + Playlist.Group.ID, + Playlist.Group.Variant.ID, + PagingID, + id: Playlist.Item.ID + ) + } + + public enum InternalAction: SendableAction { + case update(option: Playlist.ItemsRequestOptions?, Loadable) + } + + case view(ViewAction) + case delegate(DelegateAction) + case `internal`(InternalAction) } public enum Error: Swift.Error, Equatable, Sendable { - case wrongResponseType(expected: String, got: String) case contentNotFound - case variantsNotFound(for: Playlist.Group.ID) } public init() {} @@ -42,42 +79,122 @@ public struct ContentCore: Reducer { public var body: some ReducerOf { Reduce { state, action in switch action { - case let .update(option, response): - state.update(option, response) + case let .view(.didTapContent(option)): + return state.fetchContent(option) + + case let .view(.didTapPlaylistItem(groupId, variantId, pageId, itemId)): + return .send(.delegate(.didTapPlaylistItem(groupId, variantId, pageId, id: itemId))) + + case let .internal(.update(option, response)): + guard case var .loaded(value) = state.groups, let option, var group = value[id: option.groupId] else { + state.groups = response.flatMap { .loaded($0) } + break + } + + let variantsResponse = response + .flatMap { .init(expected: $0[id: group.id]) } + .flatMap { .init(expected: $0.variants.value) } + + if case .group = option { + group = .init( + id: group.id, + number: group.number, + altTitle: group.altTitle, + variants: variantsResponse + ) + } else if let variantId = option.variantId { + let pagingsResponse = variantsResponse + .flatMap { .init(expected: $0[id: variantId]) } + .flatMap { .init(expected: $0.pagings.value) } + + if let pageId = option.pagingId { + // Update page's items + group = .init( + id: group.id, + number: group.number, + altTitle: group.altTitle, + variants: group.variants.map { variants in + var variants = variants + + variants[id: variantId] = variants[id: variantId].flatMap { variant in + .init( + id: variant.id, + title: variant.title, + pagings: variant.pagings.map { pagings in + var pagings = pagings + + pagings[id: pageId] = pagings[id: pageId].flatMap { page in + .init( + id: page.id, + previousPage: page.previousPage, + nextPage: page.nextPage, + title: page.title, + items: pagingsResponse + .flatMap { .init(expected: $0[id: page.id]) } + .flatMap { .init(expected: $0.items.value) } + ) + } + + return pagings + } + ) + } + return variants + } + ) + } else { + group = .init( + id: group.id, + number: group.number, + altTitle: group.altTitle, + variants: group.variants.map { variants in + var variants = variants + + variants[id: variantId] = variants[id: variantId] + .flatMap { .init(id: $0.id, title: $0.title, pagings: pagingsResponse) } + + return variants + } + ) + } + } + value[id: option.groupId] = group + state.groups = .loaded(value) + + case .view: + break + + case .delegate: + break } return .none } } } -// MARK: - ContentAction - -public protocol ContentAction { - static func content(_: ContentCore.Action) -> Self -} - public extension ContentCore.State { - mutating func clear() -> Effect where Action.InternalAction: ContentAction { - self = .pending + mutating func clear() -> Effect { + self.groups = .pending return .merge(.cancel(id: Cancellable.fetchContent)) } - mutating func fetchPlaylistContentIfNecessary( - _ repoModuleId: RepoModuleID, - _ playlistId: Playlist.ID, + mutating func fetchContent( _ option: Playlist.ItemsRequestOptions? = nil, forced: Bool = false - ) -> Effect where Action.InternalAction: ContentAction { + ) -> Effect { @Dependency(\.moduleClient) var moduleClient @Dependency(\.logger) var logger - // FIXME: Force should modify the respective group/variant/paging + let playlistId = self.playlist.id + let repoModuleId = self.repoModuleId - if forced || !hasInitialized { - self = .loading + // TODO: Force should modify the respective group/variant/paging + + if forced || !groups.hasInitialized { + groups = .loading } return .run { send in @@ -86,120 +203,57 @@ public extension ContentCore.State { try await module.playlistEpisodes(playlistId, option) } - await send(.internal(.content(.update(option: option, .loaded(value))))) + await send(.internal(.update(option: option, .loaded(value)))) } } catch: { error, send in logger.error("\(#function) - \(error)") - await send(.internal(.content(.update(option: option, .failed(error))))) + await send(.internal(.update(option: option, .failed(error)))) } } } -private extension ContentCore.State { - init(_ response: Playlist.ItemsResponse) { - if case let .groups(array) = response { - self = .loaded(array) - } else { - self = .failed( - ContentCore.Error.wrongResponseType( - expected: String(describing: Playlist.ItemsResponse.groups.self), - got: String(describing: response.self) - ) - ) - } - } +// MARK: Public methods for variants - mutating func update( - _ requested: Playlist.ItemsRequestOptions?, - _ response: Loadable - ) { - guard case var .loaded(state) = self, let requested, var group = state[id: requested.groupID] else { - self = response.flatMap { .init($0) } - return - } +public extension ContentCore.State { + func group(id: Playlist.Group.ID) -> Loadable { + groups.flatMap { .init(expected: $0[id: id]) } + } - if case .group = requested { - // Requested group content updates - group = .init( - id: group.id, - number: group.number, - altTitle: group.altTitle, - variants: response.map(/Playlist.ItemsResponse.groups) - .flatMap { groups in .init( - value: groups, - error: .wrongResponseType( - expected: "\(Playlist.ItemsRequestOptions.self).groups", - got: String(describing: groups.self)) - ) - } - .flatMap { .init(value: $0?[id: requested.groupID]) } - .flatMap(\.variants) - ) - } else if let variantID = requested.variantID { - // Can only be requested if a variantID is available - if var variant = group.variants.flatMap({ .init(value: $0[id: variantID]) }).value { - if let pagingID = requested.pagingID { - // Requested paging items update - variant = .init( - id: variant.id, - title: variant.title, - icon: variant.icon, - pagings: variant.pagings.map { pagings in - var pagings = pagings - var page = pagings[id: pagingID] - - page = page.flatMap { page in - .init( - id: page.id, - previousPage: page.previousPage, - nextPage: page.nextPage, - items: response.map(/Playlist.ItemsResponse.pagings) - .flatMap { pagings in .init( - value: pagings, - error: .wrongResponseType( - expected: "\(Playlist.ItemsRequestOptions.self).pagings", - got: String(describing: pagings.self)) - ) - } - .flatMap { .init(value: $0[id: pagingID]) } - .map(\.items) - ) - } + func variant( + groupId: Playlist.Group.ID, + variantId: Playlist.Group.Variant.ID + ) -> Loadable { + group(id: groupId) + .flatMap(\.variants) + .flatMap { .init(expected: $0[id: variantId]) } + } - pagings[id: pagingID] = page - return pagings - } - ) - } else { - // Requested variant pagings update - variant = .init( - id: variant.id, - title: variant.title, - icon: variant.icon, - pagings: response.map(/Playlist.ItemsResponse.variants) - .flatMap { variants in .init( - value: variants, - error: .wrongResponseType( - expected: "\(Playlist.ItemsRequestOptions.self).variants", - got: String(describing: variants.self)) - ) - } - .flatMap { .init(value: $0[id: variantID]) } - .flatMap(\.pagings) - ) - } - } - } else { - print("Ayoo wtf is this") - } + func page( + groupId: Playlist.Group.ID, + variantId: Playlist.Group.Variant.ID, + pageId: PagingID + ) -> Loadable { + variant(groupId: groupId, variantId: variantId) + .flatMap(\.pagings) + .flatMap { .init(expected: $0[id: pageId]) } + } - state[id: requested.groupID] = group - self = .loaded(state) + func item( + groupId: Playlist.Group.ID, + variantId: Playlist.Group.Variant.ID, + pageId: PagingID, + itemId: Playlist.Item.ID + ) -> Loadable { + page(groupId: groupId, variantId: variantId, pageId: pageId) + .flatMap(\.items) + .flatMap { .init(expected: $0[id: itemId]) } } } +// MARK: Helpers + private extension Playlist.ItemsRequestOptions { - var groupID: Playlist.Group.ID { + var groupId: Playlist.Group.ID { switch self { case let .group(id): id @@ -210,18 +264,18 @@ private extension Playlist.ItemsRequestOptions { } } - var variantID: Playlist.Group.Variant.ID? { + var variantId: Playlist.Group.Variant.ID? { switch self { + case .group: + nil case let .variant(_, id): id case let .page(_, id, _): id - default: - nil } } - var pagingID: PagingID? { + var pagingId: PagingID? { switch self { case let .page(_, _, id): id @@ -231,12 +285,12 @@ private extension Playlist.ItemsRequestOptions { } } -private extension Loadable { - init(value: T?, error: ContentCore.Error = .contentNotFound) { +extension Loadable { + init(expected value: T?) { if let value { self = .loaded(value) } else { - self = .failed(error) + self = .failed(ContentCore.Error.contentNotFound) } } } diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 844e80c..35b6845 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -37,8 +37,8 @@ extension DiscoverFeature { guard case let .module(moduleState) = state.selected else { break } - let repoModuleID = moduleState.module.id - state.screens.append(.playlistDetails(.init(repoModuleID: repoModuleID, playlist: playlist))) + let repoModuleId = moduleState.module.id + state.screens.append(.playlistDetails(.init(content: .init(repoModuleId: repoModuleId, playlist: playlist)))) case let .internal(.selectedModule(selection)): if let selection { @@ -58,10 +58,11 @@ extension DiscoverFeature { } case let .internal(.moduleLists(.presented(.delegate(.selectedModule(repoModule))))): + state.moduleLists = nil return .send(.internal(.selectedModule(repoModule))) - case let .internal(.search(.delegate(.playlistTapped(repoModuleID, playlist)))): - state.screens.append(.playlistDetails(.init(repoModuleID: repoModuleID, playlist: playlist))) + case let .internal(.search(.delegate(.playlistTapped(repoModuleId, playlist)))): + state.screens.append(.playlistDetails(.init(content: .init(repoModuleId: repoModuleId, playlist: playlist)))) case .internal(.search): break @@ -69,19 +70,20 @@ extension DiscoverFeature { case .internal(.moduleLists): break -// case let .internal(.screens(.element(_, .playlistDetails(.delegate(.playbackVideoItem(items, id, playlist, group, paging, itemId)))))): -// return .send( -// .delegate( -// .playbackVideoItem( -// items, -// repoModuleID: id, -// playlist: playlist, -// group: group, -// paging: paging, -// itemId: itemId -// ) -// ) -// ) + case let .internal(.screens(.element(_, .playlistDetails(.delegate(.playbackVideoItem(items, id, playlist, group, variant, paging, itemId)))))): + return .send( + .delegate( + .playbackVideoItem( + items, + repoModuleId: id, + playlist: playlist, + group: group, + variant: variant, + paging: paging, + itemId: itemId + ) + ) + ) case .internal(.screens): break diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index f562538..78e7221 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -47,21 +47,16 @@ public struct DiscoverFeature: Feature { public struct Screens: Reducer { public enum State: Equatable, Sendable { case playlistDetails(PlaylistDetailsFeature.State) -// case playlistItems } public enum Action: Equatable, Sendable { case playlistDetails(PlaylistDetailsFeature.Action) -// case playlistItems } public var body: some ReducerOf { Scope(state: /State.playlistDetails, action: /Action.playlistDetails) { PlaylistDetailsFeature() } - -// Scope(state: State.playlistItems, action: /Action.playlistItems) { -// } } } @@ -128,10 +123,11 @@ public struct DiscoverFeature: Feature { public enum DelegateAction: SendableAction { case playbackVideoItem( Playlist.ItemsResponse, - repoModuleID: RepoModuleID, + repoModuleId: RepoModuleID, playlist: Playlist, - group: Playlist.Group, - paging: Playlist.Group.Variant.ID, + group: Playlist.Group.ID, + variant: Playlist.Group.Variant.ID, + paging: PagingID, itemId: Playlist.Item.ID ) } @@ -178,4 +174,11 @@ public extension DiscoverFeature.State { mutating func collapseSearch() -> Effect { search.collapse().map { .internal(.search($0)) } } + + mutating func collapseAndClearSearch() -> Effect { + .concatenate( + search.collapse().map { .internal(.search($0)) }, + search.clearQuery().map { .internal(.search($0)) } + ) + } } diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift b/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift index fa5f82d..a9e038b 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift @@ -15,19 +15,18 @@ public extension ModuleListsFeature { var body: some ReducerOf { Reduce { state, action in switch action { - case .view(.didAppear): - return .run { - await .internal(.fetchRepos(.init { try await databaseClient.fetch(.all) })) + case .view(.onTask): + return .run { send in + for await items in databaseClient.observe(Request.all) { + await send(.internal(.fetchRepos(.success(items)))) + } } case let .view(.didSelectModule(repoId, moduleId)): guard let module = state.repos[id: repoId]?.modules[id: moduleId]?.manifest else { break } - return .concatenate( - .send(.delegate(.selectedModule(.init(repoId: repoId, module: module)))), - .run { _ in await self.dismiss() } - ) + return .concatenate(.send(.delegate(.selectedModule(.init(repoId: repoId, module: module))))) case let .internal(.fetchRepos(.success(repos))): state.repos = repos diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift index 148bca7..e8132e5 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift @@ -42,8 +42,8 @@ extension ModuleListsFeature.View: View { } } .frame(maxWidth: .infinity) - .onAppear { - viewStore.send(.didAppear) + .task { + await viewStore.send(.onTask).finish() } } .frame(maxWidth: .infinity) diff --git a/Sources/Features/ModuleLists/ModuleListsFeature.swift b/Sources/Features/ModuleLists/ModuleListsFeature.swift index 19dd01a..18f410c 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature.swift @@ -14,6 +14,7 @@ import SharedModels public struct ModuleListsFeature: Feature { public struct State: FeatureState { + // TODO: Set as loadable public var repos: [Repo] public var selected: ModuleSelectable? @@ -41,7 +42,7 @@ public struct ModuleListsFeature: Feature { public enum Action: FeatureAction { public enum ViewAction: SendableAction { - case didAppear + case onTask case didSelectModule(Repo.ID, Module.ID) } diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift index a1af4ea..bb8f02f 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift @@ -36,15 +36,11 @@ extension PlaylistDetailsFeature { Reduce { state, action in switch action { - case .view(.didAppear): + case .view(.onTask): return state.fetchPlaylistDetails() case .view(.didTappedBackButton): - return .concatenate( - state.content.clear(), - .merge(Cancellables.allCases.map { .cancel(id: $0) }), - .run { await self.dismiss() } - ) + return .run { await self.dismiss() } case .view(.didTapToRetryDetails): return state.fetchPlaylistDetails(forced: true) @@ -57,38 +53,6 @@ extension PlaylistDetailsFeature { ) ) - case let .view(.didTapVideoItem(groupID, variantID, itemId)): - guard state.content.value != nil else { - break - } - -// return .send( -// .delegate( -// .playbackVideoItem( -// .init(contents: [], allGroups: []), -// repoModuleID: state.repoModuleId, -// playlist: state.playlist, -// group: group, -// paging: page, -// itemId: itemId -// ) -// ) -// ) - - case let .view(.didTapContentGroup(id)): - return state.content.fetchPlaylistContentIfNecessary( - state.repoModuleId, - state.playlist.id, - .group(id) - ) - - case let .view(.didTapContentGroupPage(groupID, variantID)): - return state.content.fetchPlaylistContentIfNecessary( - state.repoModuleId, - state.playlist.id, - .variant(groupID, variantID) - ) - case .view(.binding): break @@ -98,6 +62,30 @@ extension PlaylistDetailsFeature { case let .internal(.playlistDetailsResponse(loadable)): state.details = loadable + case let .internal(.content(.delegate(.didTapPlaylistItem(groupId, variantId, pageId, itemId)))): + guard state.content.groups.value != nil else { + break + } + + switch state.content.playlist.type { + case .video: + return .send( + .delegate( + .playbackVideoItem( + .init(), + repoModuleId: state.content.repoModuleId, + playlist: state.content.playlist, + group: groupId, + variant: variantId, + paging: pageId, + itemId: itemId + ) + ) + ) + default: + break + } + case .internal(.content): break @@ -126,7 +114,7 @@ extension PlaylistDetailsFeature.State { var effects = [Effect]() let playlistId = playlist.id - let repoModuleId = repoModuleId + let repoModuleId = content.repoModuleId if forced || !details.hasInitialized { details = .loading @@ -147,7 +135,7 @@ extension PlaylistDetailsFeature.State { ) } - effects.append(content.fetchPlaylistContentIfNecessary(repoModuleId, playlistId, forced: forced)) + effects.append(content.fetchContent(forced: forced).map { .internal(.content($0)) }) return .merge(effects) } } diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift index ee9afc9..1a49ba5 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift @@ -55,10 +55,9 @@ public struct PlaylistDetailsFeature: Feature { } public struct State: FeatureState { - public let repoModuleId: RepoModuleID - public let playlist: Playlist - public var details: Loadable public var content: ContentCore.State + public var playlist: Playlist { content.playlist } + public var details: Loadable @PresentationState public var destination: Destination.State? @@ -68,20 +67,16 @@ public struct PlaylistDetailsFeature: Feature { } public var resumableState: Resumable { - content.didFinish ? (content.value == nil ? .unavailable : .start) : .loading + content.groups.didFinish ? (content.groups.value == nil ? .unavailable : .start) : .loading } public init( - repoModuleID: RepoModuleID, - playlist: Playlist, + content: ContentCore.State, details: Loadable = .pending, - content: ContentCore.State = .pending, destination: Destination.State? = nil ) { - self.repoModuleId = repoModuleID - self.playlist = playlist - self.details = details self.content = content + self.details = details self.destination = destination } @@ -112,28 +107,26 @@ public struct PlaylistDetailsFeature: Feature { public enum Action: FeatureAction { public enum ViewAction: SendableAction, BindableAction { - case didAppear + case onTask case didTappedBackButton case didTapToRetryDetails case didTapOnReadMore - case didTapContentGroup(Playlist.Group.ID) - case didTapContentGroupPage(Playlist.Group.ID, Playlist.Group.Variant.ID) - case didTapVideoItem(Playlist.Group.ID, Playlist.Group.Variant.ID, Playlist.Item.ID) case binding(BindingAction) } public enum DelegateAction: SendableAction { case playbackVideoItem( Playlist.ItemsResponse, - repoModuleID: RepoModuleID, + repoModuleId: RepoModuleID, playlist: Playlist, group: Playlist.Group.ID, - paging: Playlist.Group.Variant.ID, + variant: Playlist.Group.Variant.ID, + paging: PagingID, itemId: Playlist.Item.ID ) } - public enum InternalAction: SendableAction, ContentAction { + public enum InternalAction: SendableAction { case playlistDetailsResponse(Loadable) case content(ContentCore.Action) case destination(PresentationAction) diff --git a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift index 9347519..12c4b57 100644 --- a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift +++ b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift @@ -103,9 +103,7 @@ extension PlaylistDetailsFeature.View: View { } .menuStyle(.materialToolbarImage) } - .onAppear { - store.send(.view(.didAppear)) - } + .task { await store.send(.view(.onTask)).finish() } .sheet( store: store.scope( state: \.$destination, @@ -328,217 +326,13 @@ extension PlaylistDetailsFeature.View { } } - switch playlistInfo.type { - case .video: - EmptyView() -// PlaylistVideoContentView( -// store: store.scope( -// state: \.content, -// action: { $0 } -// ), -// playlistInfo: playlistInfo -// ) - case .image: - EmptyView() - case .text: - EmptyView() - } - } - } -} - -@MainActor -private struct PlaylistVideoContentView: View { - let store: Store - let playlistInfo: PlaylistInfo - - @State - private var selectedGroupID: Playlist.Group.ID? - - @State - private var selectedPage: Playlist.Group.Variant.ID? - - private static let placeholderItems = [ - Playlist.Item( - id: "/1", - title: "Placeholder", - description: "Placeholder", - number: 1, - timestamp: "May 12, 2023", - tags: [] - ), - Playlist.Item( - id: "/2", - title: "Placeholder", - description: "Placeholder", - number: 2, - timestamp: "May 12, 2023", - tags: [] - ), - Playlist.Item( - id: "/3", - title: "Placeholder", - description: "Placeholder", - number: 3, - timestamp: "May 12, 2023", - tags: [] - ) - ] - - @MainActor - var body: some View { - WithViewStore(store, observe: \.`self`) { viewStore in -// let defaultSelectedGroupID = selectedGroupID ?? viewStore.value.flatMap(\.first?.id) - -// let group = viewStore.state.map { groups in -// defaultSelectedGroupID.flatMap { groups[id: $0] } ?? groups.first ?? .failed(ContentCore.Error.contentNotFound) -// } - -// let defaultSelectedGroup = selectedGroup ?? viewStore.state.value.flatMap(\.keys.first) - -// let group = viewStore.state.flatMap { groups in -// defaultSelectedGroup.flatMap { groups[$0] } ?? groups.values.first ?? .loaded([:]) -// } - -// let defaultSelectedPage = selectedPage ?? group.value.flatMap(\.keys.first) -// -// let page = group.flatMap { pages in -// defaultSelectedPage.flatMap { pages[$0] } ?? pages.values.first ?? .loaded(.init(id: "")) -// } - -// HeaderWithContent { -// HStack { -// if let value = viewStore.state.value, value.keys.count > 1 { -// Menu { -// ForEach(value.keys, id: \.self) { group in -// Button { -// selectedGroup = group -// viewStore.send(.didTapContentGroup(group)) -// } label: { -// Text(group.altTitle ?? "Group \(group.id.withoutTrailingZeroes)") -// } -// } -// } label: { -// HStack { -// if let group = defaultSelectedGroup { -// Text(group.altTitle ?? "Group \(group.id.withoutTrailingZeroes)") -// } else { -// Text("Episodes") -// } -// -// if (viewStore.value?.count ?? 0) > 1 { -// Image(systemName: "chevron.down") -// .font(.footnote.weight(.bold)) -// } -// } -// .foregroundColor(.label) -// } -// } else { -// Text(defaultSelectedGroup?.altTitle ?? "Episodes") -// } -// -// Spacer() -// -// if let pages = group.value, pages.keys.count > 1 { -// Menu { -// ForEach(pages.keys, id: \.id) { page in -// Button { -// selectedPage = page -// if let defaultSelectedGroup { -// viewStore.send(.didTapContentGroupPage(defaultSelectedGroup, page)) -// } -// } label: { -// Text(page.displayName) -// } -// } -// } label: { -// HStack { -// Text(defaultSelectedPage?.displayName ?? "Unknown") -// .font(.system(size: 14)) -// Image(systemName: "chevron.down") -// .font(.footnote.weight(.semibold)) -// } -// .foregroundColor(.label) -// .padding(.horizontal, 6) -// .padding(.vertical, 4) -// .background { -// Capsule() -// .fill(Color.gray.opacity(0.24)) -// } -// } -// } -// } -// } content: { -// ZStack { -// if page.error != nil { -// RoundedRectangle(cornerRadius: 12) -// .fill(Color.red.opacity(0.16)) -// .padding(.horizontal) -// .frame(maxWidth: .infinity) -// .frame(height: 125) -// .overlay { -// Text("There was an error loading content.") -// .font(.callout.weight(.semibold)) -// } -// } else if page.didFinish, (page.value?.items.count ?? 0) == 0 { -// RoundedRectangle(cornerRadius: 12) -// .fill(Color.gray.opacity(0.12)) -// .padding(.horizontal) -// .frame(maxWidth: .infinity) -// .frame(height: 125) -// .overlay { -// Text("There is no content available.") -// .font(.callout.weight(.medium)) -// } -// } else { -// ScrollView(.horizontal, showsIndicators: false) { -// HStack(alignment: .top, spacing: 12) { -// ForEach(page.value?.items ?? Self.placeholderItems, id: \.id) { item in -// VStack(alignment: .leading, spacing: 0) { -// FillAspectImage(url: item.thumbnail ?? playlistInfo.posterImage) -// .aspectRatio(16 / 9, contentMode: .fit) -// .cornerRadius(12) -// -// Spacer() -// .frame(height: 8) -// -// Text("Episode \(item.number.withoutTrailingZeroes)") -// .font(.footnote.weight(.semibold)) -// .foregroundColor(.init(white: 0.4)) -// -// Spacer() -// .frame(height: 4) -// -// Text(item.title ?? "Episode \(item.number.withoutTrailingZeroes)") -// .font(.body.weight(.semibold)) -// } -// .frame(width: 228) -// .contentShape(Rectangle()) -// .onTapGesture { -// if let group = defaultSelectedGroup, let page = defaultSelectedPage { -// viewStore.send(.didTapVideoItem(group, page, item.id)) -// } -// } -// } -// .frame(maxHeight: .infinity, alignment: .top) -// } -// .frame(maxWidth: .infinity) -// .padding(.horizontal) -// } -// .frame(maxWidth: .infinity) -// .shimmering(active: !page.didFinish) -// .disabled(!page.didFinish) -// } -// } -// .animation(.easeInOut, value: viewStore.state) -// .animation(.easeInOut, value: selectedGroup) -// .animation(.easeInOut, value: selectedPage) -// } -// .shimmering(active: !viewStore.didFinish) -// .disabled(!viewStore.didFinish) -// .onChange(of: selectedGroup) { _ in -// selectedPage = nil -// } + ContentCore.View( + store: store.scope( + state: \.content, + action: Action.InternalAction.content + ), + contentType: playlistInfo.type + ) } } } @@ -546,9 +340,9 @@ private struct PlaylistVideoContentView: View { // MARK: - HeaderWithContent @MainActor -private struct HeaderWithContent: View { +private struct HeaderWithContent: View { let label: () -> Label - let content: () -> Variant + let content: () -> Content @MainActor var body: some View { @@ -564,7 +358,7 @@ private struct HeaderWithContent: View { @MainActor init( @ViewBuilder label: @escaping () -> Label, - @ViewBuilder content: @escaping () -> Variant + @ViewBuilder content: @escaping () -> Content ) { self.label = label self.content = content @@ -573,7 +367,7 @@ private struct HeaderWithContent: View { @MainActor init( title: String = "", - @ViewBuilder content: @escaping () -> Variant + @ViewBuilder content: @escaping () -> Content ) where Label == Text { self.init { Text(title) @@ -601,8 +395,10 @@ extension PlaylistDetailsFeature.View { PlaylistDetailsFeature.View( store: .init( initialState: .init( - repoModuleID: Module().id(repoID: "/"), - playlist: .placeholder(0), + content: .init( + repoModuleId: Module().id(repoID: "/"), + playlist: .placeholder(0) + ), details: .loaded( .init( genres: ["Action", "Thriller"], @@ -610,13 +406,7 @@ extension PlaylistDetailsFeature.View { previews: .init() ) ), - content: .loaded(.init()), - destination: .readMore( - .init( - title: "This is a title", - description: "This will not only elaborate on the description but also use as a screen demo." - ) - ) + destination: nil ), reducer: { EmptyReducer() } ) diff --git a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+Reducer.swift b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+Reducer.swift index 9e70863..9378246 100644 --- a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+Reducer.swift +++ b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+Reducer.swift @@ -19,7 +19,7 @@ extension RepoPackagesFeature { public var body: some ReducerOf { Reduce { state, action in switch action { - case .view(.didAppear): + case .view(.onTask): let repoId = state.repo.id return .merge( state.fetchRemoteModules(), @@ -30,6 +30,11 @@ extension RepoPackagesFeature { let mapped = Dictionary(uniqueKeysWithValues: filteredRepo.map { ($0.moduleId, $1) }) await send(.internal(.downloadStates(mapped))) } + }, + .run { send in + for await repos in repoClient.repos(.all) { + await send(.internal(.updateRepo(repos[id: repoId]))) + } } ) @@ -40,28 +45,22 @@ extension RepoPackagesFeature { guard let manifest = state.packages.value?.map(\.latestModule).first(where: \.id == moduleId) else { break } - let repoId = state.repo.id - return .run { await repoClient.addModule(repoId, manifest) } + let repoId = state.repo.id + repoClient.installModule(repoId, manifest) + return .run { try await moduleClient.removeCachedModule(manifest.id(repoID: repoId)) } case let .view(.didTapRemoveModule(moduleId)): - guard let module = state.repo.modules.first(where: \.id == moduleId) else { - break + return .run { send in + try await Task.sleep(nanoseconds: 1_000_000_000) + await send(.internal(.delayDeletingModule(id: moduleId))) } - state.repo.modules.remove(module) - - let repoId = state.repo.id - return .merge( - .run { try await repoClient.removeModule(repoId, module) }, - .send(.delegate(.removeModule(state.repo.id(moduleId)))) - ) - case .view(.didTapClose): - return .merge( - .cancel(id: Cancellable.fetchingModules), - .run { _ in await dismiss() } - ) + return .run { _ in await dismiss() } + + case let .internal(.updateRepo(repo)): + state.repo = repo ?? state.repo case let .internal(.repoModules(modules)): state.fetchedModules = modules @@ -75,6 +74,19 @@ extension RepoPackagesFeature { case let .internal(.downloadStates(modules)): state.downloadStates = modules + case let .internal(.delayDeletingModule(moduleID)): + guard let module = state.repo.modules[id: moduleID] else { + break + } + + state.repo.modules.remove(module) + + let repoId = state.repo.id + return .merge( + .run { try await repoClient.removeModule(module.id(repoID: repoId)) }, + .run { try await moduleClient.removeCachedModule(module.id(repoID: repoId)) } + ) + case .delegate: break } @@ -88,6 +100,8 @@ extension RepoPackagesFeature.State { @Dependency(\.repoClient) var repoClient + // TODO: Cache request + guard !fetchedModules.hasInitialized || forced else { return .none } @@ -98,7 +112,7 @@ extension RepoPackagesFeature.State { return .run { send in try await withTaskCancellation(id: RepoPackagesFeature.Cancellable.fetchingModules, cancelInFlight: true) { - let modules = try await repoClient.fetchRemoteRepoModules(id) + let modules = try await repoClient.fetchModulesMetadata(id) await send(.internal(.repoModules(.loaded(modules)))) } } catch: { error, send in diff --git a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift index f5fc63b..764ca96 100644 --- a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift +++ b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift @@ -102,8 +102,8 @@ extension RepoPackagesFeature.View: View { } .buttonStyle(.materialToolbarImage) } - .onAppear { - store.send(.view(.didAppear)) + .task { + await store.send(.view(.onTask)).finish() } .background(theme.backgroundColor.ignoresSafeArea().edgesIgnoringSafeArea(.all)) .transition(.move(edge: .trailing).combined(with: .opacity)) diff --git a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift index 0127731..dd9cb39 100644 --- a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift +++ b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift @@ -9,6 +9,7 @@ import Architecture import ComposableArchitecture import DatabaseClient +import ModuleClient import RepoClient import Semver import SharedModels @@ -23,6 +24,9 @@ public struct RepoPackagesFeature: Feature { @Dependency(\.dismiss) var dismiss + @Dependency(\.moduleClient) + var moduleClient + @Dependency(\.repoClient) var repoClient @@ -63,7 +67,7 @@ public extension RepoPackagesFeature { case delegate(DelegateAction) public enum ViewAction: SendableAction { - case didAppear + case onTask case didTapClose case didTapToRefreshRepo case didTapAddModule(Module.ID) @@ -71,13 +75,13 @@ public extension RepoPackagesFeature { } public enum InternalAction: SendableAction { + case delayDeletingModule(id: Module.ID) + case updateRepo(Repo?) case repoModules(Loadable<[Module.Manifest]>) case downloadStates([Module.ID: RepoClient.RepoModuleDownloadState]) } - public enum DelegateAction: SendableAction { - case removeModule(RepoModuleID) - } + public enum DelegateAction: SendableAction {} } } diff --git a/Sources/Features/Repos/ReposFeature+Reducer.swift b/Sources/Features/Repos/ReposFeature+Reducer.swift index 497903f..13b17a5 100644 --- a/Sources/Features/Repos/ReposFeature+Reducer.swift +++ b/Sources/Features/Repos/ReposFeature+Reducer.swift @@ -34,8 +34,12 @@ extension ReposFeature { Reduce { state, action in switch action { - case .view(.didAppear): - return state.refreshRepos() + case .view(.onTask): + return .run { send in + for await value in repoClient.repos(.all) { + await send(.internal(.loadRepos(value))) + } + } case .view(.didTapRefreshRepos): break @@ -44,20 +48,16 @@ extension ReposFeature { state.url = "" state.searchedRepo = .pending - return .concatenate( - .run { [repoPayload] in - try await repoClient.addRepo(repoPayload) - }, - state.refreshRepos() - ) + return .run { try await repoClient.addRepo(repoPayload) } case let .view(.didTapDeleteRepo(repoId)): return .merge( .run { _ in try await Task.sleep(nanoseconds: 1_000_000 * 500) - try await repoClient.removeRepo(repoId) + try await repoClient.deleteRepo(repoId) }, - .cancel(id: Cancellables.fetchRemoteRepoModules(repoId)) + .cancel(id: Cancellables.fetchRemoteRepoModules(repoId)), + .run { try await moduleClient.removeCachedModules(repoId) } ) case let .view(.didTapRepo(repoId)): @@ -77,15 +77,7 @@ extension ReposFeature { return .run { send in try await withTaskCancellation(id: Cancellables.repoURLDebounce, cancelInFlight: true) { try await Task.sleep(nanoseconds: 1_000_000 * 1_250) - try await send( - .internal( - .validateRepoURL( - .loaded( - repoClient.validate(url) - ) - ) - ) - ) + try await send(.internal(.validateRepoURL(.loaded(repoClient.validate(url))))) } } catch: { error, send in print(error) @@ -101,11 +93,8 @@ extension ReposFeature { case let .internal(.loadRepos(repos)): state.repos = .init(uniqueElements: repos) - case let .internal(.path(.element(_, .delegate(delegateAction)))): - switch delegateAction { - case let .removeModule(repoModuleID): - state.repos[id: repoModuleID.repoId]?.modules[id: repoModuleID.moduleId] = nil - } + case .internal(.path(.element(_, .delegate))): + break case .internal(.path): break @@ -120,16 +109,3 @@ extension ReposFeature { } } } - -extension ReposFeature.State { - func refreshRepos() -> Effect { - @Dependency(\.repoClient) - var repoClient - - return .run { send in - try await withTaskCancellation(id: ReposFeature.Cancellables.loadRepos, cancelInFlight: true) { - try await send(.internal(.loadRepos(repoClient.repos(.all)))) - } - } - } -} diff --git a/Sources/Features/Repos/ReposFeature+View.swift b/Sources/Features/Repos/ReposFeature+View.swift index bff2ecf..178891a 100644 --- a/Sources/Features/Repos/ReposFeature+View.swift +++ b/Sources/Features/Repos/ReposFeature+View.swift @@ -91,8 +91,8 @@ extension ReposFeature.View: View { maxWidth: .infinity, maxHeight: .infinity ) - .onAppear { - store.send(.view(.didAppear)) + .task { + store.send(.view(.onTask)) } } destination: { store in RepoPackagesFeature.View(store: store) diff --git a/Sources/Features/Repos/ReposFeature.swift b/Sources/Features/Repos/ReposFeature.swift index 7456770..3cc0eeb 100644 --- a/Sources/Features/Repos/ReposFeature.swift +++ b/Sources/Features/Repos/ReposFeature.swift @@ -9,6 +9,7 @@ import Architecture import ComposableArchitecture import Foundation +import ModuleClient import RepoClient import SharedModels import Styling @@ -20,6 +21,9 @@ public struct ReposFeature: Feature { @Dependency(\.repoClient) var repoClient + @Dependency(\.moduleClient) + var moduleClient + public init() {} public struct State: FeatureState { @@ -44,7 +48,7 @@ public struct ReposFeature: Feature { public enum Action: FeatureAction { public enum ViewAction: SendableAction, BindableAction { - case didAppear + case onTask case didTapRefreshRepos(Repo.ID? = nil) case didTapRepo(Repo.ID) case didTapAddNewRepo(RepoClient.RepoPayload) @@ -71,7 +75,6 @@ public struct ReposFeature: Feature { @Environment(\.theme) var theme -// @EnvironmentObject var theme: ThemeManager @Dependency(\.dateFormatter) var dateFormatter diff --git a/Sources/Features/Search/SearchFeature+Reducer.swift b/Sources/Features/Search/SearchFeature+Reducer.swift index 7d026e9..20317e6 100644 --- a/Sources/Features/Search/SearchFeature+Reducer.swift +++ b/Sources/Features/Search/SearchFeature+Reducer.swift @@ -37,7 +37,7 @@ extension SearchFeature: Reducer { break } - guard let selected = state.repoModuleID else { + guard let selected = state.repoModuleId else { break } @@ -80,13 +80,13 @@ extension SearchFeature: Reducer { return state.clearQuery() case let .view(.didTapPlaylist(playlist)): - if let repoModuleID = state.repoModuleID { + if let repoModuleId = state.repoModuleId { state.searchFieldFocused = false - return .send(.delegate(.playlistTapped(repoModuleID, playlist))) + return .send(.delegate(.playlistTapped(repoModuleId, playlist))) } case .view(.binding(\.$query)): - guard let selected = state.repoModuleID else { + guard let selected = state.repoModuleId else { state.items = .pending return .cancel(id: Cancellables.fetchingItemsDebounce) } @@ -168,8 +168,8 @@ public extension SearchFeature.State { return .cancel(id: Cancellables.fetchingItemsDebounce) } - mutating func updateModule(with repoModuleID: RepoModuleID?) -> Effect { - self.repoModuleID = repoModuleID + mutating func updateModule(with repoModuleId: RepoModuleID?) -> Effect { + self.repoModuleId = repoModuleId return clearQuery() } } diff --git a/Sources/Features/Search/SearchFeature.swift b/Sources/Features/Search/SearchFeature.swift index 127d714..3496eb7 100644 --- a/Sources/Features/Search/SearchFeature.swift +++ b/Sources/Features/Search/SearchFeature.swift @@ -28,19 +28,19 @@ public struct SearchFeature: Feature { @BindingState public var query: String - public var repoModuleID: RepoModuleID? + public var repoModuleId: RepoModuleID? public var filters: [SearchFilter] public var items: Loadable>>> public init( expandView: Bool = false, searchFieldFocused: Bool = false, - repoModuleID: RepoModuleID? = nil, + repoModuleId: RepoModuleID? = nil, query: String = "", filters: [SearchFilter] = [], items: Loadable>>> = .pending ) { - self.repoModuleID = repoModuleID + self.repoModuleId = repoModuleId self.expandView = expandView self.searchFieldFocused = searchFieldFocused self.query = query diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index 2056c84..cc3333e 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -30,402 +30,405 @@ extension VideoPlayerFeature: Reducer { PlayerFeature() } -// Scope(state: \.loadables.contents, action: /Action.InternalAction.content) { -// ContentCore() -// } + Scope(state: \.content, action: /Action.InternalAction.content) { + ContentCore() + } Reduce { state, action in -// switch action { -// case .view(.didAppear): -// return state.loadables.contents -// .fetchPlaylistContentIfNecessary( -// state.repoModuleID, -// state.playlist.id, -// state.selected.group, -// state.selected.page -// ) -// -// case .view(.didTapBackButton): + switch action { + case .view(.didAppear): + return state.content + .fetchContent(.page(state.selected.groupId, state.selected.variantId, state.selected.pageId)) + .map { .internal(.content($0)) } + + case .view(.didTapBackButton): + return state.dismiss() + + case .view(.didTapMoreButton): + state.overlay = .more(.episodes) + return .cancel(id: Cancellables.delayCloseTab) + + case let .view(.didSelectMoreTab(tab)): + state.overlay = .more(tab) + return .cancel(id: Cancellables.delayCloseTab) + + case .view(.didTapPlayer): + state.overlay = state.overlay == nil ? .tools : nil + return state.delayDismissOverlayIfNeeded() + + case .view(.didTapCloseMoreOverlay): + state.overlay = .tools + return state.delayDismissOverlayIfNeeded() + + case let .view(.didTapSource(sourceId)): + return state.clearForChangedSourceIfNeeded(sourceId) + + case let .view(.didTapServer(serverId)): + return state.clearForChangedServerIfNeeded(serverId) + + case let .view(.didTapLink(linkId)): + return state.clearForChangedLinkIfNeeded(linkId) + + case let .view(.didSkipTo(time)): + let fraction = max(state.player.duration.seconds, 1) + return .run { _ in + await playerClient.seek(time / fraction) + } + + case .internal(.hideToolsOverlay): + state.overlay = state.overlay == .tools ? nil : state.overlay + + case let .internal(.content(.delegate(.didTapPlaylistItem(group, variant, page, itemId)))): + state.overlay = .tools + return state.clearForNewEpisodeIfNeeded(group, variant, page, itemId) + + // TODO: Remove this warning + // swiftlint:disable tca_feature_reducer_actions + case .internal(.content(.internal(.update(_, .loaded)))): + // TODO: Decide if it should fetch sources or not + return state.fetchSourcesIfNecessary() + + case let .internal(.sourcesResponse(episodeId, .loaded(response))): + state.loadables.update(with: episodeId, response: .loaded(response)) + + // TODO: Select preferred quality or first link if available + if state.selected.sourceId == nil { + state.selected.sourceId = response.first?.id + } + if state.selected.serverId == nil { + state.selected.serverId = response.first?.servers.first?.id + } + return state.fetchServerIfNecessary() + + case let .internal(.sourcesResponse(episodeId, response)): + state.loadables.update(with: episodeId, response: response) + + case let .internal(.serverResponse(serverId, response)): + state.loadables.update(with: serverId, response: response) + + if case let .loaded(response) = response, state.selected.serverId == serverId { + // TODO: Select preferred quality or first link if available + if let id = response.links.first?.id { + return state.clearForChangedLinkIfNeeded(id) + } + } else if case let .failed(error) = response { + logger.warning("There was an error retrieving video server response: \(error.localizedDescription)") + } + + case .internal(.player(.delegate(.didStartedSeeking))): + return .cancel(id: Cancellables.delayCloseTab) + + case .internal(.player(.delegate(.didTapGoForwards))), + .internal(.player(.delegate(.didTapGoBackwards))), + .internal(.player(.delegate(.didTogglePlayButton))), + .internal(.player(.delegate(.didFinishedSeekingTo))): + return state.delayDismissOverlayIfNeeded() + + case .internal(.player(.delegate(.didTapClosePiP))): // return state.dismiss() -// -// case .view(.didTapMoreButton): -// state.overlay = .more(.episodes) -// return .cancel(id: Cancellables.delayCloseTab) -// -// case let .view(.didSelectMoreTab(tab)): -// state.overlay = .more(tab) -// return .cancel(id: Cancellables.delayCloseTab) -// -// case .view(.didTapPlayer): -// state.overlay = state.overlay == nil ? .tools : nil -// return state.delayDismissOverlayIfNeeded() -// -// case .view(.didTapCloseMoreOverlay): -// state.overlay = .tools -// return state.delayDismissOverlayIfNeeded() -// -// case let .view(.didTapContentGroup(groupId)): -// return state.loadables.contents.fetchPlaylistContentIfNecessary(state.repoModuleID, state.playlist.id, groupId) -// -// case let .view(.didTapContentGroupPage(groupId, pageId)): -// return state.loadables.contents.fetchPlaylistContentIfNecessary(state.repoModuleID, state.playlist.id, groupId, pageId) -// -// case let .view(.didTapPlayEpisode(group, page, itemId)): -// state.overlay = .tools -// return state.clearForNewEpisodeIfNeeded(group, page, itemId) -// -// case let .view(.didTapSource(sourceId)): -// return state.clearForChangedSourceIfNeeded(sourceId) -// -// case let .view(.didTapServer(serverId)): -// return state.clearForChangedServerIfNeeded(serverId) -// -// case let .view(.didTapLink(linkId)): -// return state.clearForChangedLinkIfNeeded(linkId) -// -// case let .view(.didSkipTo(time)): -// let fraction = max(state.player.duration.seconds, 1) -// return .run { _ in -// await playerClient.seek(time / fraction) -// } -// -// case .internal(.hideToolsOverlay): -// state.overlay = state.overlay == .tools ? nil : state.overlay -// -// case .internal(.content(.update(_, _, .loaded))): -// return state.fetchSourcesIfNecessary() -// -// case let .internal(.sourcesResponse(episodeId, .loaded(response))): -// state.loadables.update(with: episodeId, response: .loaded(response)) -// -// // TODO: Select preferred quality or first link if available -// if state.selected.sourceId == nil { -// state.selected.sourceId = response.first?.id -// } -// if state.selected.serverId == nil { -// state.selected.serverId = response.first?.servers.first?.id -// } -// return state.fetchServerIfNecessary() -// -// case let .internal(.sourcesResponse(episodeId, response)): -// state.loadables.update(with: episodeId, response: response) -// -// case let .internal(.serverResponse(serverId, response)): -// state.loadables.update(with: serverId, response: response) -// -// if case let .loaded(response) = response, state.selected.serverId == serverId { -// // TODO: Select preferred quality or first link if available -// if let id = response.links.first?.id { -// return state.clearForChangedLinkIfNeeded(id) -// } -// } else if case let .failed(error) = response { -// logger.warning("There was an error retrieving video server response: \(error)") -// } -// -// case .internal(.player(.delegate(.didStartedSeeking))): -// return .cancel(id: Cancellables.delayCloseTab) -// -// case .internal(.player(.delegate(.didTapGoForwards))), -// .internal(.player(.delegate(.didTapGoBackwards))), -// .internal(.player(.delegate(.didTogglePlayButton))), -// .internal(.player(.delegate(.didFinishedSeekingTo))): -// return state.delayDismissOverlayIfNeeded() -// -// case .internal(.player(.delegate(.didTapClosePiP))): -//// return state.dismiss() -// break -// -// case .internal(.player): -// break -// -// case .internal(.content): -// break -// -// case .delegate: -// break -// } + break + + case .internal(.player): + break + + case .internal(.content): + break + + case .delegate: + break + } return .none } } } -//extension VideoPlayerFeature.State { -// func delayDismissOverlayIfNeeded() -> Effect { -// if overlay == .tools { -// .run { send in -// try await withTaskCancellation(id: Cancellables.delayCloseTab, cancelInFlight: true) { -// try await Task.sleep(nanoseconds: 1_000_000_000 * 5) -// await send(.internal(.hideToolsOverlay)) -// } -// } -// } else { -// .cancel(id: Cancellables.delayCloseTab) -// } -// } -//} - -//public extension VideoPlayerFeature.State { -// func dismiss() -> Effect { -// @Dependency(\.playerClient) -// var playerClient -// -// @Dependency(\.dismiss) -// var dismiss -// -// return .merge( -// .merge(Cancellables.allCases.map { .cancel(id: $0) }), -// .run { _ in -// await playerClient.clear() -// await dismiss() -// } -// ) -// } -// -// mutating func clearForNewPlaylistIfNeeded( -// repoModuleID: RepoModuleID, -// playlist: Playlist, -// group: Playlist.Group, -// page: Playlist.Group.Variant.Page, -// episodeId: Playlist.Item.ID -// ) -> Effect { -// @Dependency(\.playerClient) -// var playerClient -// -// var shouldClearContents = false -// var shouldClearSources = false -// -// if repoModuleID != self.repoModuleID { -// self.repoModuleID = repoModuleID -// shouldClearSources = true -// shouldClearContents = true -// } -// -// if playlist.id != self.playlist.id { -// self.playlist = playlist -// shouldClearSources = true -// shouldClearContents = true -// } -// -// if group != selected.group { -// selected.group = group -// shouldClearSources = true -// } -// -// if page != selected.page { -// selected.page = page -// shouldClearSources = true -// } -// -// if episodeId != selected.episodeId { -// selected.episodeId = episodeId -// shouldClearSources = true -// } -// -// if shouldClearSources { -// selected.serverId = nil -// selected.linkId = nil -// selected.sourceId = nil -// -// loadables.serverResponseLoadables.removeAll() -// loadables.playlistItemSourcesLoadables.removeAll() -// -// return .merge( -// shouldClearContents ? loadables.contents.clear() : .none, -// loadables.contents.fetchPlaylistContentIfNecessary(repoModuleID, playlist.id), -// .run { await playerClient.clear() } -// ) -// } -// -// return .none -// } -// -// fileprivate mutating func clearForNewEpisodeIfNeeded( -// _ group: Playlist.Group, -// _ page: Playlist.Group.Variant.Page, -// _ episodeId: Playlist.Item.ID -// ) -> Effect { -// @Dependency(\.playerClient) -// var playerClient -// -// if selected.group != group || selected.page != page || selected.episodeId != episodeId { -// selected.group = group -// selected.page = page -// selected.episodeId = episodeId -// selected.sourceId = nil -// selected.serverId = nil -// selected.linkId = nil -// loadables.serverResponseLoadables.removeAll() -// loadables.playlistItemSourcesLoadables.removeAll() -// return .merge( -// fetchSourcesIfNecessary(), -// .run { -// await playerClient.clear() -// } -// ) -// } -// return .none -// } -// -// mutating func clearForChangedSourceIfNeeded(_ sourceId: Playlist.EpisodeSource.ID) -> Effect { -// @Dependency(\.playerClient) -// var playerClient -// -// if selected.sourceId != sourceId { -// selected.sourceId = sourceId -// -// if let sources = loadables[episodeId: selected.episodeId].value { -// selected.serverId = sources.first { $0.id == sourceId }?.servers.first?.id -// } else { -// selected.serverId = nil -// } -// selected.linkId = nil -// loadables.serverResponseLoadables.removeAll() -// -// return .merge( -// fetchSourcesIfNecessary(), -// .run { -// await playerClient.clear() -// } -// ) -// } -// return .none -// } -// -// mutating func clearForChangedServerIfNeeded(_ serverId: Playlist.EpisodeServer.ID) -> Effect { -// @Dependency(\.playerClient) -// var playerClient -// -// if serverId != selected.serverId { -// selected.serverId = serverId -// selected.linkId = nil -// selected.serverId.flatMap { loadables.serverResponseLoadables[$0] = nil } -// -// return .merge( -// fetchServerIfNecessary(), -// .run { -// await playerClient.clear() -// } -// ) -// } -// -// return .none -// } -// -// mutating func clearForChangedLinkIfNeeded(_ linkId: Playlist.EpisodeServer.Link.ID) -> Effect { -// @Dependency(\.playerClient) -// var playerClient -// -// if selected.linkId != linkId { -// selected.linkId = linkId -// -// if let server = selected.serverId.flatMap({ loadables[serverId: $0] })?.value, -// let link = server.links[id: linkId] { -// let playlist = playlist -// let episode = selectedItem.value.flatMap { $0 } -// let loadItem = PlayerClient.VideoCompositionItem( -// link: link.url, -// headers: server.headers, -// subtitles: server.subtitles.map { subtitle in -// .init( -// name: subtitle.name, -// default: subtitle.default, -// autoselect: subtitle.autoselect, -// forced: false, -// link: subtitle.url -// ) -// }, -// metadata: .init( -// title: episode.flatMap { $0.title ?? "Episode \($0.number.withoutTrailingZeroes)" }, -// artworkImage: episode?.thumbnail ?? playlist.posterImage, -// author: playlist.title -// ) -// ) -// -// return .run { _ in -// await playerClient.clear() -// try await playerClient.load(loadItem) -// await playerClient.play() -// } -// } else { -// return .run { -// await playerClient.clear() -// } -// } -// } -// return .none -// } -// -// mutating func fetchSourcesIfNecessary(forced: Bool = false) -> Effect { -// @Dependency(\.moduleClient) -// var moduleClient -// -// let repoModuleId = repoModuleID -// let playlist = playlist -// let episodeId = selected.episodeId -// -// if forced || !loadables[episodeId: episodeId].hasInitialized { -// loadables.update(with: episodeId, response: .loading) -// return .run { send in -// try await withTaskCancellation(id: Cancellables.fetchingSources, cancelInFlight: true) { -// let value = try await moduleClient.withModule(id: repoModuleId) { module in -// try await module.playlistEpisodeSources( -// .init( -// playlistId: playlist.id, -// episodeId: episodeId -// ) -// ) -// } -// -// await send(.internal(.sourcesResponse(episodeId: episodeId, .loaded(value)))) -// } -// } catch: { error, send in -// await send(.internal(.sourcesResponse(episodeId: episodeId, .failed(error)))) -// } -// } -// -// return fetchServerIfNecessary() -// } -// -// mutating func fetchServerIfNecessary(forced: Bool = false) -> Effect { -// @Dependency(\.moduleClient) -// var moduleClient -// -// let repoModuleId = repoModuleID -// let playlist = playlist -// let episodeId = selected.episodeId -// let sourceId = selected.sourceId -// let serverId = selected.serverId -// -// guard let sourceId else { -// return .none -// } -// -// guard let serverId else { -// return .none -// } -// -// if forced || !loadables[serverId: serverId].hasInitialized { -// loadables.update(with: serverId, response: .loading) -// return .run { send in -// try await withTaskCancellation(id: Cancellables.fetchingServer, cancelInFlight: true) { -// let value = try await moduleClient.withModule(id: repoModuleId) { module in -// try await module.playlistEpisodeServer( -// .init( -// playlistId: playlist.id, -// episodeId: episodeId, -// sourceId: sourceId, -// serverId: serverId -// ) -// ) -// } -// -// await send(.internal(.serverResponse(serverId: serverId, .loaded(value)))) -// } -// } catch: { error, send in -// await send(.internal(.serverResponse(serverId: serverId, .failed(error)))) -// } -// } -// -// return .none -// } -//} +extension VideoPlayerFeature.State { + func delayDismissOverlayIfNeeded() -> Effect { + if overlay == .tools { + .run { send in + try await withTaskCancellation(id: Cancellables.delayCloseTab, cancelInFlight: true) { + try await Task.sleep(nanoseconds: 1_000_000_000 * 5) + await send(.internal(.hideToolsOverlay)) + } + } + } else { + .cancel(id: Cancellables.delayCloseTab) + } + } +} + +public extension VideoPlayerFeature.State { + func dismiss() -> Effect { + @Dependency(\.playerClient) + var playerClient + + @Dependency(\.dismiss) + var dismiss + + return .merge( + .merge(Cancellables.allCases.map { .cancel(id: $0) }), + .run { _ in + await playerClient.clear() + await dismiss() + } + ) + } + + mutating func clearForNewPlaylistIfNeeded( + repoModuleId: RepoModuleID, + playlist: Playlist, + groupId: Playlist.Group.ID, + variantId: Playlist.Group.Variant.ID, + pageId: PagingID, + episodeId: Playlist.Item.ID + ) -> Effect { + @Dependency(\.playerClient) + var playerClient + + var shouldClearContents = false + var shouldClearSources = false + + if repoModuleId != content.repoModuleId { + content.repoModuleId = repoModuleId + shouldClearSources = true + shouldClearContents = true + } + + if playlist.id != self.playlist.id { + self.playlist = playlist + shouldClearSources = true + shouldClearContents = true + } + + if groupId != selected.groupId { + selected.groupId = groupId + shouldClearSources = true + } + + if variantId != selected.variantId { + selected.variantId = variantId + shouldClearSources = true + } + + if pageId != selected.pageId { + selected.pageId = pageId + shouldClearSources = true + } + + if episodeId != selected.itemId { + selected.itemId = episodeId + shouldClearSources = true + } + + if shouldClearSources { + selected.serverId = nil + selected.linkId = nil + selected.sourceId = nil + + loadables.serverResponseLoadables.removeAll() + loadables.playlistItemSourcesLoadables.removeAll() + + return .merge( + shouldClearContents ? content.clear() : .none, content.fetchContent().map { .internal(.content($0)) }, + .run { await playerClient.clear() } + ) + } + + return .none + } + + fileprivate mutating func clearForNewEpisodeIfNeeded( + _ groupId: Playlist.Group.ID, + _ variantId: Playlist.Group.Variant.ID, + _ pageId: PagingID, + _ episodeId: Playlist.Item.ID + ) -> Effect { + @Dependency(\.playerClient) + var playerClient + + if selected.groupId != groupId || + selected.variantId != variantId || + selected.pageId != pageId || + selected.itemId != episodeId { + selected.groupId = groupId + selected.variantId = variantId + selected.pageId = pageId + selected.itemId = episodeId + selected.sourceId = nil + selected.serverId = nil + selected.linkId = nil + loadables.serverResponseLoadables.removeAll() + loadables.playlistItemSourcesLoadables.removeAll() + return .merge( + fetchSourcesIfNecessary(), + .run { + await playerClient.clear() + } + ) + } + return .none + } + + mutating func clearForChangedSourceIfNeeded(_ sourceId: Playlist.EpisodeSource.ID) -> Effect { + @Dependency(\.playerClient) + var playerClient + + if selected.sourceId != sourceId { + selected.sourceId = sourceId + + if let sources = loadables[episodeId: selected.itemId].value { + selected.serverId = sources.first { $0.id == sourceId }?.servers.first?.id + } else { + selected.serverId = nil + } + selected.linkId = nil + loadables.serverResponseLoadables.removeAll() + + return .merge( + fetchSourcesIfNecessary(), + .run { + await playerClient.clear() + } + ) + } + return .none + } + + mutating func clearForChangedServerIfNeeded(_ serverId: Playlist.EpisodeServer.ID) -> Effect { + @Dependency(\.playerClient) + var playerClient + + if serverId != selected.serverId { + selected.serverId = serverId + selected.linkId = nil + selected.serverId.flatMap { loadables.serverResponseLoadables[$0] = nil } + + return .merge( + fetchServerIfNecessary(), + .run { + await playerClient.clear() + } + ) + } + + return .none + } + + mutating func clearForChangedLinkIfNeeded(_ linkId: Playlist.EpisodeServer.Link.ID) -> Effect { + @Dependency(\.playerClient) + var playerClient + + if selected.linkId != linkId { + selected.linkId = linkId + + if let server = selected.serverId.flatMap({ loadables[serverId: $0] })?.value, + let link = server.links[id: linkId] { + let playlist = playlist + let episode = selectedItem.value.flatMap { $0 } + let loadItem = PlayerClient.VideoCompositionItem( + link: link.url, + headers: server.headers, + subtitles: server.subtitles.map { subtitle in + .init( + name: subtitle.name, + default: subtitle.default, + autoselect: subtitle.autoselect, + forced: false, + link: subtitle.url + ) + }, + metadata: .init( + title: episode.flatMap { $0.title ?? "Episode \($0.number.withoutTrailingZeroes)" }, + artworkImage: episode?.thumbnail ?? playlist.posterImage, + author: playlist.title + ) + ) + + return .run { _ in + await playerClient.clear() + try await playerClient.load(loadItem) + await playerClient.play() + } + } else { + return .run { + await playerClient.clear() + } + } + } + return .none + } + + mutating func fetchSourcesIfNecessary(forced: Bool = false) -> Effect { + @Dependency(\.moduleClient) + var moduleClient + + let repoModuleId = content.repoModuleId + let playlist = playlist + let episodeId = selected.itemId + + if forced || !loadables[episodeId: episodeId].hasInitialized { + loadables.update(with: episodeId, response: .loading) + return .run { send in + try await withTaskCancellation(id: Cancellables.fetchingSources, cancelInFlight: true) { + let value = try await moduleClient.withModule(id: repoModuleId) { module in + try await module.playlistEpisodeSources( + .init( + playlistId: playlist.id, + episodeId: episodeId + ) + ) + } + + await send(.internal(.sourcesResponse(episodeId: episodeId, .loaded(value)))) + } + } catch: { error, send in + await send(.internal(.sourcesResponse(episodeId: episodeId, .failed(error)))) + } + } + + return fetchServerIfNecessary() + } + + mutating func fetchServerIfNecessary(forced: Bool = false) -> Effect { + @Dependency(\.moduleClient) + var moduleClient + + let repoModuleId = content.repoModuleId + let playlist = playlist + let episodeId = selected.itemId + let sourceId = selected.sourceId + let serverId = selected.serverId + + guard let sourceId else { + return .none + } + + guard let serverId else { + return .none + } + + if forced || !loadables[serverId: serverId].hasInitialized { + loadables.update(with: serverId, response: .loading) + return .run { send in + try await withTaskCancellation(id: Cancellables.fetchingServer, cancelInFlight: true) { + let value = try await moduleClient.withModule(id: repoModuleId) { module in + try await module.playlistEpisodeServer( + .init( + playlistId: playlist.id, + episodeId: episodeId, + sourceId: sourceId, + serverId: serverId + ) + ) + } + + await send(.internal(.serverResponse(serverId: serverId, .loaded(value)))) + } + } catch: { error, send in + await send(.internal(.serverResponse(serverId: serverId, .failed(error)))) + } + } + + return .none + } +} diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift index eb64e0a..14b0594 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift @@ -52,47 +52,39 @@ public struct VideoPlayerFeature: Feature { } } - public var repoModuleID: RepoModuleID - public var playlist: Playlist -// public var loadables: Loadables -// public var selected: SelectedContent + public var playlist: Playlist { + get { content.playlist } + set { content.playlist = newValue } + } + + public var content: ContentCore.State + public var loadables: Loadables + public var selected: SelectedContent public var overlay: Overlay? public var player: PlayerFeature.State public init( - repoModuleID: RepoModuleID, - playlist: Playlist, -// loadables: Loadables = .init(), -// selected: SelectedContent, - overlay: Overlay? = .tools, - player: PlayerFeature.State = .init() - ) { - self.repoModuleID = repoModuleID - self.playlist = playlist -// self.loadables = loadables -// self.selected = selected - self.overlay = overlay - self.player = player - } - - public init( - repoModuleID: RepoModuleID, + repoModuleId: RepoModuleID, playlist: Playlist, -// contents: Loadables = .init(), + loadables: Loadables = .init(), group: Playlist.Group.ID, - page: Playlist.Group.Variant.ID, + variant: Playlist.Group.Variant.ID, + page: PagingID, episodeId: Playlist.Item.ID, overlay: Overlay? = .tools, player: PlayerFeature.State = .init() ) { - self.repoModuleID = repoModuleID - self.playlist = playlist -// self.loadables = contents -// self.selected = .init( -// group: group, -// page: page, -// episodeId: episodeId -// ) + self.content = .init( + repoModuleId: repoModuleId, + playlist: playlist + ) + self.loadables = loadables + self.selected = .init( + groupId: group, + variantId: variant, + pageId: page, + itemId: episodeId + ) self.overlay = overlay self.player = player } @@ -107,9 +99,6 @@ public struct VideoPlayerFeature: Feature { case didSelectMoreTab(State.Overlay.MoreTab) case didTapCloseMoreOverlay case didSkipTo(time: CGFloat) - case didTapContentGroup(Playlist.Group) - case didTapContentGroupPage(Playlist.Group.ID, Playlist.Group.Variant.ID) - case didTapPlayEpisode(Playlist.Group.ID, Playlist.Group.Variant.ID, Playlist.Item.ID) case didTapSource(Playlist.EpisodeSource.ID) case didTapServer(Playlist.EpisodeServer.ID) case didTapLink(Playlist.EpisodeServer.Link.ID) @@ -117,7 +106,7 @@ public struct VideoPlayerFeature: Feature { public enum DelegateAction: SendableAction {} - public enum InternalAction: SendableAction, ContentAction { + public enum InternalAction: SendableAction { case hideToolsOverlay case sourcesResponse(episodeId: Playlist.Item.ID, _ response: Loadable<[Playlist.EpisodeSource]>) case serverResponse(serverId: Playlist.EpisodeServer.ID, _ response: Loadable) @@ -154,211 +143,217 @@ public struct VideoPlayerFeature: Feature { public init() {} } -//public extension VideoPlayerFeature.State { -// var selectedGroup: Loadable { -// loadables.contents.flatMap { groups in -// groups[selected.group] ?? .failed(VideoPlayerFeature.Error.contentNotFound) -// } -// } -// -// var selectedPage: Loadable> { -// selectedGroup.flatMap { pages in -// pages[selected.page] ?? .failed(VideoPlayerFeature.Error.contentNotFound) -// } -// } -// -// var selectedItem: Loadable { -// selectedPage.flatMap { page in -// page.items.first(where: \.id == selected.episodeId) -// .flatMap { .loaded($0) } ?? .failed(VideoPlayerFeature.Error.contentNotFound) -// } -// } -// -// var selectedSource: Loadable { -// selectedItem.flatMap { item in -// loadables[episodeId: item.id].flatMap { sources in -// selected.sourceId.flatMap { sourceId in -// sources[id: sourceId] -// } -// .flatMap { .loaded($0) } ?? .failed(VideoPlayerFeature.Error.contentNotFound) -// } -// } -// } -// -// var selectedServer: Loadable { -// selectedSource.flatMap { source in -// selected.serverId.flatMap { serverId in -// source.servers[id: serverId] -// } -// .flatMap { .loaded($0) } ?? .failed(VideoPlayerFeature.Error.contentNotFound) -// } -// } -// -// var selectedServerResponse: Loadable { -// selectedServer.flatMap { server in -// loadables[serverId: server.id].flatMap { .loaded($0) } -// } -// } -// -// var selectedLink: Loadable { -// selectedServerResponse.flatMap { serverResponse in -// selected.linkId.flatMap { linkId in -// serverResponse.links[id: linkId] -// } -// .flatMap { .loaded($0) } ?? .failed(VideoPlayerFeature.Error.contentNotFound) -// } -// } -// -// struct SelectedContent: Equatable, Sendable { -// public var group: Playlist.Group.ID -// public var page: Playlist.Group.Variant.ID -// public var episodeId: Playlist.Item.ID -// public var sourceId: Playlist.EpisodeSource.ID? -// public var serverId: Playlist.EpisodeServer.ID? -// public var linkId: Playlist.EpisodeServer.Link.ID? -// -// public init( -// group: Playlist.Group.ID, -// page: Playlist.Group.Variant.ID, -// episodeId: Playlist.Item.ID, -// sourceId: Playlist.EpisodeSource.ID? = nil, -// serverId: Playlist.EpisodeServer.ID? = nil, -// linkId: Playlist.EpisodeServer.Link.ID? = nil -// ) { -// self.group = group -// self.page = page -// self.episodeId = episodeId -// self.sourceId = sourceId -// self.serverId = serverId -// self.linkId = linkId -// } -// } -// -// struct Loadables: Equatable, Sendable { -// public var contents = ContentCore.State.pending -// public var playlistItemSourcesLoadables = [Playlist.Item.ID: Loadable<[Playlist.EpisodeSource]>]() -// public var serverResponseLoadables = [Playlist.EpisodeServer.ID: Loadable]() -// -// subscript(episodeId episodeId: Playlist.Item.ID) -> Loadable<[Playlist.EpisodeSource]> { -// get { playlistItemSourcesLoadables[episodeId] ?? .pending } -// set { playlistItemSourcesLoadables[episodeId] = newValue } -// } -// -// subscript(serverId serverId: Playlist.EpisodeServer.ID) -> Loadable { -// get { serverResponseLoadables[serverId] ?? .pending } -// set { serverResponseLoadables[serverId] = newValue } -// } -// -// public init( -// contents: ContentCore.State = .pending, -// playlistItemSourcesLoadables: [Playlist.Item.ID: Loadable<[Playlist.EpisodeSource]>] = [:], -// serverResponseLoadables: [Playlist.EpisodeServer.ID: Loadable] = [:] -// ) { -// self.contents = contents -// self.playlistItemSourcesLoadables = playlistItemSourcesLoadables -// self.serverResponseLoadables = serverResponseLoadables -// } -// -// public mutating func update( -// with episodeId: Playlist.Item.ID, -// response: Loadable<[Playlist.EpisodeSource]> -// ) { -// playlistItemSourcesLoadables[episodeId] = response -// } -// -// public mutating func update( -// with serverId: Playlist.EpisodeServer.ID, -// response: Loadable -// ) { -// serverResponseLoadables[serverId] = response -// } -// } -//} -// -//// MARK: - VideoPlayerFeature.View.SkipActionViewState -// -//extension VideoPlayerFeature.View { -// struct SkipActionViewState: Equatable { -// enum Action: Hashable, CustomStringConvertible { -// case times(Playlist.EpisodeServer.SkipTime) -// case next(Double, Playlist.Group, Playlist.Group.Variant.Page, Playlist.Item.ID) -// -// var isEnding: Bool { -// if case let .times(time) = self { -// return time.type == .ending -// } -// return false -// } -// -// var action: VideoPlayerFeature.Action { -// switch self { -// case let .next(_, group, paging, itemId): -// .view(.didTapPlayEpisode(group, paging, itemId)) -// case let .times(time): -// .view(.didSkipTo(time: time.endTime)) -// } -// } -// -// var description: String { -// switch self { -// case let .times(time): -// time.type.description -// case let .next(number, _, _, _): -// "Play E\(number.withoutTrailingZeroes)" -// } -// } -// -// var image: String { -// switch self { -// case .next: -// "play.fill" -// default: -// "forward.fill" -// } -// } -// -// var textColor: Color { -// if case .next = self { -// return .black -// } -// return .white -// } -// -// var background: Color { -// if case .next = self { -// return .white -// } -// return .init(white: 0.25) -// } -// } -// -// var actions: [Action] -// var canShowActions: Bool -// -// var visible: Bool { -// canShowActions && !actions.isEmpty -// } -// -// init(_ state: VideoPlayerFeature.State) { -// self.canShowActions = state.player.duration.isValid && state.player.duration > .zero -// self.actions = state.selectedServerResponse.value?.skipTimes -// .filter { $0.startTime <= state.player.progress.seconds && state.player.progress.seconds <= $0.endTime } -// .sorted(by: \.startTime) -// .compactMap { .times($0) } ?? [] -// +public extension VideoPlayerFeature.State { + var selectedGroup: Loadable { + content.group(id: selected.groupId) + } + + var selectedVariant: Loadable { + content.variant(groupId: selected.groupId, variantId: selected.variantId) + } + + var selectedPage: Loadable> { + content.page( + groupId: selected.groupId, + variantId: selected.variantId, + pageId: selected.pageId + ) + } + + var selectedItem: Loadable { + content.item( + groupId: selected.groupId, + variantId: selected.variantId, + pageId: selected.pageId, + itemId: selected.itemId + ) + } + + var selectedSource: Loadable { + selectedItem.flatMap { item in + loadables[episodeId: item.id].flatMap { sources in + selected.sourceId.flatMap { sourceId in + sources[id: sourceId] + } + .flatMap { .loaded($0) } ?? .failed(VideoPlayerFeature.Error.contentNotFound) + } + } + } + + var selectedServer: Loadable { + selectedSource.flatMap { source in + selected.serverId.flatMap { serverId in + source.servers[id: serverId] + } + .flatMap { .loaded($0) } ?? .failed(VideoPlayerFeature.Error.contentNotFound) + } + } + + var selectedServerResponse: Loadable { + selectedServer.flatMap { server in + loadables[serverId: server.id].flatMap { .loaded($0) } + } + } + + var selectedLink: Loadable { + selectedServerResponse.flatMap { serverResponse in + selected.linkId.flatMap { linkId in + serverResponse.links[id: linkId] + } + .flatMap { .loaded($0) } ?? .failed(VideoPlayerFeature.Error.contentNotFound) + } + } + + struct SelectedContent: Equatable, Sendable { + public var groupId: Playlist.Group.ID + public var variantId: Playlist.Group.Variant.ID + public var pageId: PagingID + public var itemId: Playlist.Item.ID + public var sourceId: Playlist.EpisodeSource.ID? + public var serverId: Playlist.EpisodeServer.ID? + public var linkId: Playlist.EpisodeServer.Link.ID? + + public init( + groupId: Playlist.Group.ID, + variantId: Playlist.Group.Variant.ID, + pageId: PagingID, + itemId: Playlist.Item.ID, + sourceId: Playlist.EpisodeSource.ID? = nil, + serverId: Playlist.EpisodeServer.ID? = nil, + linkId: Playlist.EpisodeServer.Link.ID? = nil + ) { + self.groupId = groupId + self.variantId = variantId + self.pageId = pageId + self.itemId = itemId + self.sourceId = sourceId + self.serverId = serverId + self.linkId = linkId + } + } + + struct Loadables: Equatable, Sendable { + public var playlistItemSourcesLoadables = [Playlist.Item.ID: Loadable<[Playlist.EpisodeSource]>]() + public var serverResponseLoadables = [Playlist.EpisodeServer.ID: Loadable]() + + subscript(episodeId episodeId: Playlist.Item.ID) -> Loadable<[Playlist.EpisodeSource]> { + get { playlistItemSourcesLoadables[episodeId] ?? .pending } + set { playlistItemSourcesLoadables[episodeId] = newValue } + } + + subscript(serverId serverId: Playlist.EpisodeServer.ID) -> Loadable { + get { serverResponseLoadables[serverId] ?? .pending } + set { serverResponseLoadables[serverId] = newValue } + } + + public init( + playlistItemSourcesLoadables: [Playlist.Item.ID: Loadable<[Playlist.EpisodeSource]>] = [:], + serverResponseLoadables: [Playlist.EpisodeServer.ID: Loadable] = [:] + ) { + self.playlistItemSourcesLoadables = playlistItemSourcesLoadables + self.serverResponseLoadables = serverResponseLoadables + } + + public mutating func update( + with episodeId: Playlist.Item.ID, + response: Loadable<[Playlist.EpisodeSource]> + ) { + playlistItemSourcesLoadables[episodeId] = response + } + + public mutating func update( + with serverId: Playlist.EpisodeServer.ID, + response: Loadable + ) { + serverResponseLoadables[serverId] = response + } + } +} + +// MARK: - VideoPlayerFeature.View.SkipActionViewState + +extension VideoPlayerFeature.View { + struct SkipActionViewState: Equatable { + enum Action: Hashable, CustomStringConvertible { + case times(Playlist.EpisodeServer.SkipTime) + case next(Double, Playlist.Group.ID, Playlist.Group.Variant.ID, PagingID, Playlist.Item.ID) + + var isEnding: Bool { + if case let .times(time) = self { + return time.type == .ending + } + return false + } + + var action: VideoPlayerFeature.Action { + switch self { + case let .next(_, group, variant, paging, itemId): + .internal(.content(.delegate(.didTapPlaylistItem(group, variant, paging, id: itemId)))) + case let .times(time): + .view(.didSkipTo(time: time.endTime)) + } + } + + var description: String { + switch self { + case let .times(time): + time.type.description + case let .next(number, _, _, _, _): + "Play E\(number.withoutTrailingZeroes)" + } + } + + var image: String { + switch self { + case .next: + "play.fill" + default: + "forward.fill" + } + } + + var textColor: Color { + if case .next = self { + return .black + } + return .white + } + + var background: Color { + if case .next = self { + return .white + } + return .init(white: 0.25) + } + } + + var actions: [Action] + var canShowActions: Bool + + var visible: Bool { + canShowActions && !actions.isEmpty + } + + init(_ state: VideoPlayerFeature.State) { + self.canShowActions = state.player.duration.isValid && state.player.duration > .zero + self.actions = state.selectedServerResponse.value?.skipTimes + .filter { $0.startTime <= state.player.progress.seconds && state.player.progress.seconds <= $0.endTime } + .sorted(by: \.startTime) + .compactMap { .times($0) } ?? [] + // if let currentEpisode = state.selectedItem.value, -// let episodes = state.selectedPage.value?.items, +// let episodes = state.selectedPage.value?.items.value, // let index = episodes.firstIndex(where: { $0.id == currentEpisode.id }), (index + 1) < episodes.endIndex { // let nextEpisode = episodes[index + 1] // // if let ending = actions.first(where: \.isEnding), case let .times(type) = ending { // if state.player.progress.seconds >= type.startTime { -// actions.append(.next(nextEpisode.number, state.selected.group, state.selected.page, nextEpisode.id)) +// actions.append(.next(nextEpisode.number, state.selected.groupId, state.selected.variantId, state.selected.pageId, nextEpisode.id)) // } // } else if state.player.progress.seconds >= (0.92 * state.player.duration.seconds) { -// actions.append(.next(nextEpisode.number, state.selected.group, state.selected.page, nextEpisode.id)) +// actions.append(.next(nextEpisode.number, state.selected.groupId, state.selected.variantId, state.selected.pageId, nextEpisode.id)) // } // } -// } -// } -//} + } + } +} diff --git a/Sources/Features/VideoPlayer/iOS/VideoPlayerFeature+iOS.swift b/Sources/Features/VideoPlayer/iOS/VideoPlayerFeature+iOS.swift index fe1c0eb..6bb6c25 100644 --- a/Sources/Features/VideoPlayer/iOS/VideoPlayerFeature+iOS.swift +++ b/Sources/Features/VideoPlayer/iOS/VideoPlayerFeature+iOS.swift @@ -23,55 +23,55 @@ extension VideoPlayerFeature.View: View { @MainActor public var body: some View { WithViewStore(store, observe: \.player.pipState.status.isInPiP) { viewStore in -// ZStack { -// PlayerFeature.View( -// store: store.scope( -// state: \.player, -// action: Action.InternalAction.player -// ) -// ) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .edgesIgnoringSafeArea(.all) -// .ignoresSafeArea(.all, edges: .all) -// .contentShape(Rectangle()) -// .onTapGesture { -// viewStore.send(.didTapPlayer) -// } -// } -// .overlay { -// WithViewStore(store, observe: \.overlay) { viewStore in -// ZStack { -// contentStatusView -// -// switch viewStore.state { -// case .none: -// skipButtons -// .padding() -// .frame( -// maxWidth: .infinity, -// maxHeight: .infinity, -// alignment: .bottomTrailing -// ) -// case .tools: -// toolsOverlay -// case let .more(tab): -// moreOverlay(tab) -// } -// } -// .animation(.easeInOut, value: viewStore.state) -// } -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .background { -// Color.black -// .edgesIgnoringSafeArea(.all) -// .ignoresSafeArea(.all, edges: .all) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// .blur(radius: viewStore.state ? 30 : 0) -// .opacity(viewStore.state ? 0.0 : 1.0) -// .animation(.easeInOut(duration: 0.35), value: viewStore.state) -// .prefersHomeIndicatorAutoHidden(viewStore.state ? false : true) + ZStack { + PlayerFeature.View( + store: store.scope( + state: \.player, + action: Action.InternalAction.player + ) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .edgesIgnoringSafeArea(.all) + .ignoresSafeArea(.all, edges: .all) + .contentShape(Rectangle()) + .onTapGesture { + viewStore.send(.didTapPlayer) + } + } + .overlay { + WithViewStore(store, observe: \.overlay) { viewStore in + ZStack { + contentStatusView + + switch viewStore.state { + case .none: + skipButtons + .padding() + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottomTrailing + ) + case .tools: + toolsOverlay + case let .more(tab): + moreOverlay(tab) + } + } + .animation(.easeInOut, value: viewStore.state) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + Color.black + .edgesIgnoringSafeArea(.all) + .ignoresSafeArea(.all, edges: .all) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .blur(radius: viewStore.state ? 30 : 0) + .opacity(viewStore.state ? 0.0 : 1.0) + .animation(.easeInOut(duration: 0.35), value: viewStore.state) + .prefersHomeIndicatorAutoHidden(viewStore.state ? false : true) } .progressViewStyle(CircularProgressViewStyle(tint: Color.white)) .onAppear { @@ -80,1064 +80,851 @@ extension VideoPlayerFeature.View: View { } } -//extension VideoPlayerFeature.View { -// @MainActor -// var toolsOverlay: some View { -// VStack { -// topBar -// Spacer() -// skipButtons -// bottomBar -// } -// .overlay { controlsBar } -// .padding() -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .background { -// Color.black -// .opacity(0.35) -// .ignoresSafeArea() -// .edgesIgnoringSafeArea(.all) -// .onTapGesture { -// store.send(.view(.didTapPlayer)) -// } -// } -// } -// -// @MainActor -// var skipButtons: some View { -// WithViewStore( -// store, -// observe: SkipActionViewState.init -// ) { viewState in -// ZStack { -// if viewState.visible { -// HStack { -// Spacer() -// ForEach(viewState.actions, id: \.self) { action in -// Button { -// viewState.send(action.action) -// } label: { -// HStack { -// Image(systemName: action.image) -// Text(action.description) -// } -// .font(.system(size: 13).weight(.heavy)) -// .foregroundColor(action.textColor) -// .padding(12) -// .background(action.background.opacity(0.8)) -// .cornerRadius(6) -// .contentShape(Rectangle()) -// } -// .buttonStyle(.plain) -// .shadow(color: Color.gray.opacity(0.25), radius: 6) -// .transition(.move(edge: .trailing).combined(with: .opacity)) -// .swipeable(.easeInOut(duration: 0.2)) -// } -// } -// .frame(maxWidth: .infinity) -// .transition(.move(edge: .trailing).combined(with: .opacity)) -// } -// } -// .animation( -// .easeInOut(duration: 0.25), -// value: viewState.state -// ) -// } -// .padding(.vertical, 4) -// } -// -// private struct PlaylistDisplayState: Equatable, Sendable { -// let playlist: Playlist -// let group: Loadable -// let episode: Loadable -// -// init(_ state: VideoPlayerFeature.State) { -// self.playlist = state.playlist -// self.group = state.selectedGroup.flatMap { _ in .loaded(state.selected.group) } -// self.episode = state.selectedItem -// } -// } -// -// @MainActor -// var topBar: some View { -// HStack(alignment: .top, spacing: 12) { -// Button { -// store.send(.view(.didTapBackButton)) -// } label: { -// Image(systemName: "chevron.left") -// .frame(width: 28, height: 28) -// .contentShape(Rectangle()) -// } -// .buttonStyle(.plain) -// -// WithViewStore(store, observe: PlaylistDisplayState.init) { viewStore in -// VStack(alignment: .leading, spacing: 0) { -// Group { -// switch viewStore.group { -// case .pending, .loading: -// EmptyView() -// case let .loaded(group): -// Group { -// switch viewStore.episode { -// case .pending, .loading: -// Text("Loading...") -// case let .loaded(item): -// Text(item.title ?? "Episode \(item.number.withoutTrailingZeroes)") -// case .failed: -// EmptyView() -// } -// } -// -// Group { -// Text(group.altTitle ?? "S\(group.id.withoutTrailingZeroes)") + -// Text("\u{2022}") + -// Text("E\(viewStore.episode.value?.number.withoutTrailingZeroes ?? "0")") -// } -// .font(.caption.weight(.semibold)) -// .foregroundColor(.init(white: 0.78)) -// case .failed: -// EmptyView() -// } -// } -// .font(.callout.weight(.semibold)) -// -// Spacer() -// .frame(height: 2) -// -//// Text(viewStore.playlist.title ?? "No title") -//// .font(.footnote) -// } -// } -// -// Spacer() -// -// WithViewStore( -// store.scope( -// state: \.player, -// action: Action.InternalAction.player -// ), -// observe: \.pipState -// ) { viewStore in -// if viewStore.isSupported { -// Button { -// viewStore.send(.view(.didTogglePictureInPicture)) -// } label: { -// Image(systemName: "rectangle.inset.bottomright.filled") -// .foregroundColor(.white) -// .frame(width: 28, height: 28) -// .contentShape(Rectangle()) -// } -// .buttonStyle(.plain) -// .disabled(!viewStore.isPossible) -// } -// } -// -// PlayerRoutePickerView() -// .scaleEffect(0.85) -// .frame(width: 28, height: 28) -// .fixedSize() -// -// Menu { -// ForEach(VideoPlayerFeature.State.Overlay.MoreTab.allCases, id: \.self) { tab in -// if tab == .speed { -// WithViewStore( -// store.scope( -// state: \.player, -// action: Action.InternalAction.player -// ), -// observe: \.rate -// ) { viewStore in -// Menu { -// Picker( -// tab.rawValue, -// selection: viewStore.binding(get: \.`self`, send: { .didSelectRate($0) }) -// ) { -// let values: [Float] = [0.25, 0.50, 0.75, 1.0, 1.25, 1.50, 1.75, 2.0] -// ForEach(values, id: \.self) { value in -// Text(String(format: "%.2f", value) + "x") -// .tag(value) -// } -// } -// } label: { -// tab.image -// Text(tab.rawValue) -// } -// } -// } else { -// Button { -// store.send(.view(.didSelectMoreTab(tab))) -// } label: { -// tab.image -// Text(tab.rawValue) -// } -// .buttonStyle(.plain) -// } -// } -// } label: { -// Image(systemName: "ellipsis") -// .frame(width: 28, height: 28) -// .contentShape(Rectangle()) -// .rotationEffect(.degrees(90)) -// } -// } -// .foregroundColor(.white) -// .font(.body.weight(.medium)) -// } -// -// private struct RateBufferingState: Equatable, Sendable { -// let isPlaying: Bool -// let isBuffering: Bool -// -// init(_ state: PlayerFeature.State) { -// self.isPlaying = state.rate != 0 -// self.isBuffering = state.isBuffering -// } -// } -// -// @MainActor -// var controlsBar: some View { -// WithViewStore(store, observe: \.videoPlayerStatus == nil) { canShowControls in -// if canShowControls.state { -// WithViewStore( -// store.scope( -// state: \.player, -// action: Action.InternalAction.player -// ), -// observe: RateBufferingState.init -// ) { rateBufferingState in -// HStack(spacing: 0) { -// Spacer() -// -// Button { -// rateBufferingState.send(.view(.didTapGoBackwards)) -// } label: { -// Image(systemName: "gobackward") -// .font(.title2.weight(.bold)) -// .padding(12) -// .contentShape(Rectangle()) -// } -// .buttonStyle(.plain) -// -// Group { -// if rateBufferingState.isBuffering { -// ProgressView() -// .scaleEffect(1.25) -// } else { -// Button { -// rateBufferingState.send(.view(.didTogglePlayButton)) -// } label: { -// Image(systemName: rateBufferingState.isPlaying ? "pause.fill" : "play.fill") -// .resizable() -// .aspectRatio(contentMode: .fit) -// .font(.largeTitle) -// .padding(12) -// .contentShape(Rectangle()) -// } -// .buttonStyle(.plain) -// } -// } -// .frame(width: 54, height: 54) -// -// Button { -// rateBufferingState.send(.view(.didTapGoForwards)) -// } label: { -// Image(systemName: "goforward") -// .font(.title2.weight(.bold)) -// .padding(12) -// .contentShape(Rectangle()) -// } -// .buttonStyle(.plain) -// -// Spacer() -// } -// } -// } -// } -// .foregroundColor(.white) -// } -// -// @MainActor -// var bottomBar: some View { -// ProgressBar( -// store.scope( -// state: \.player, -// action: Action.InternalAction.player -// ) -// ) -// .foregroundColor(.white) -// .frame(maxWidth: .infinity) -// } -// -// @MainActor -// var contentStatusView: some View { -// WithViewStore(store, observe: \.videoPlayerStatus) { viewStore in -// switch viewStore.state { -// case let .some(.loading(type)): -// ProgressView("Loading \(type.rawValue)...") -// -// case let .some(.needSelection(type)): -// VStack(alignment: .leading) { -// Text("Content Error") -// .font(.title2.bold()) -// Text("Please select a \(type.rawValue) to load.") -// } -// -// case let .some(.empty(type)): -// VStack(alignment: .leading) { -// Text("Content Error") -// .font(.title2.bold()) -// Text("There are no \(type.rawValue)s for this content.") -// } -// -// case let .some(.failed(type)): -// VStack(alignment: .leading) { -// Text("Content Error") -// .font(.title2.bold()) -// Text("There was an error loading \(type.rawValue). Please try again later.") -// .font(.callout) -// -// Button {} label: { -// Text("Retry") -// .padding(12) -// .background(Color(white: 0.16)) -// .cornerRadius(6) -// } -// } -// default: -// EmptyView() -// } -// } -// .foregroundColor(.white) -// } -//} -// -//extension VideoPlayerFeature.State { -// enum VideoPlayerState: Equatable { -// case pending(ContentType) -// case loading(ContentType) -// case empty(ContentType) -// case needSelection(ContentType) -// case failed(ContentType) -// -// enum ContentType: String, Equatable { -// case group -// case page -// case episode -// case source -// case server -// case link -// case playback -// } -// -// var action: VideoPlayerFeature.Action.ViewAction? { nil } -// } -// -// var videoPlayerStatus: VideoPlayerState? { -// if let content = selectedGroup.videoContentState(for: .group) { -// return content -// } else if let content = selectedPage.videoContentState(for: .page) { -// return content -// } else if let content = selectedItem.videoContentState(for: .episode) { -// return content -// } else if let content = selectedSource.videoContentState(for: .source) { -// return content -// } else if let content = selectedServer.videoContentState(for: .server) { -// return content -// } else if let content = selectedServerResponse.videoContentState(for: .server) { -// return content -// } else if let content = selectedLink.videoContentState(for: .link) { -// return content -// } else if player.status == .failed { -// return .failed(.playback) -// } -// -// return nil -// } -//} -// -//private extension Loadable { -// func videoContentState(for content: VideoPlayerFeature.State.VideoPlayerState.ContentType) -> VideoPlayerFeature.State.VideoPlayerState? { -// switch self { -// case .pending: -// return .pending(content) -// case .loading: -// return .loading(content) -// case let .loaded(t): -// if let t = t as? (any _OptionalProtocol) { -// if t.optional == nil { -// return .needSelection(content) -// } -// } -// return nil -// case .failed: -// return .failed(content) -// } -// } -//} -// -//extension VideoPlayerFeature.View { -// struct ProgressBar: View { -// @ObservedObject -// var viewStore: ViewStore -// -// private var progress: Double { -// if canUseControls { -// min(1.0, max(0, viewStore.progress.seconds / viewStore.duration.seconds)) -// } else { -// .zero -// } -// } -// -// @SwiftUI.State -// private var dragProgress: Double? -// -// private var isDragging: Bool { -// dragProgress != nil -// } -// -// private var canUseControls: Bool { -// viewStore.duration.isValid && !viewStore.duration.seconds.isNaN && viewStore.duration != .zero -// } -// -// init(_ store: StoreOf) { -// self.viewStore = .init(store, observe: \.`self`) -// } -// -// var body: some View { -// HStack(alignment: .center, spacing: 12) { -// GeometryReader { proxy in -// ZStack(alignment: .leading) { -// ZStack(alignment: .leading) { -// BlurView(.systemUltraThinMaterialLight) -// Color.white -// .frame( -// width: proxy.size.width * (isDragging ? dragProgress ?? progress : progress), -// height: proxy.size.height, -// alignment: .leading -// ) -// } -// .frame( -// width: proxy.size.width, -// height: isDragging ? 12 : 8 -// ) -// .clipShape(Capsule(style: .continuous)) -// } -// .frame( -// width: proxy.size.width, -// height: proxy.size.height -// ) -// .contentShape(Rectangle()) -// .gesture( -// DragGesture(minimumDistance: 0) -// .onChanged { value in -// if !isDragging { -// viewStore.send(.didStartedSeeking) -// dragProgress = progress -// } -// -// let locationX = value.location.x -// let percentage = locationX / proxy.size.width -// -// dragProgress = max(0, min(1.0, percentage)) -// } -// .onEnded { _ in -// if let dragProgress { -// viewStore.send(.didFinishedSeekingTo(dragProgress)) -// } -// dragProgress = nil -// } -// ) -// .animation(.spring(response: 0.3), value: isDragging) -// } -// .frame(maxWidth: .infinity) -// .frame(height: 24) -// -// Group { -// if canUseControls { -// Text(progressDisplayTime) + -// Text(" / ") + -// Text(viewStore.duration.displayTime ?? "--.--") -// } else { -// Text("--.-- / --.--") -// } -// } -// .font(.caption.monospacedDigit()) -// } -// .disabled(!canUseControls) -// } -// -// private var progressDisplayTime: String { -// if canUseControls { -// if isDragging { -// @Dependency(\.dateComponentsFormatter) -// var formatter -// -// formatter.unitsStyle = .positional -// formatter.zeroFormattingBehavior = .pad -// -// let time = (dragProgress ?? .zero) * viewStore.duration.seconds -// -// if time < 60 * 60 { -// formatter.allowedUnits = [.minute, .second] -// } else { -// formatter.allowedUnits = [.hour, .minute, .second] -// } -// -// return formatter.string(from: time) ?? "00:00" -// } else { -// return viewStore.progress.displayTime ?? "00:00" -// } -// } else { -// return "--:--" -// } -// } -// } -//} -// -//extension VideoPlayerFeature.View { -// @MainActor -// func moreOverlay(_ selected: VideoPlayerFeature.State.Overlay.MoreTab) -> some View { -// GeometryReader { proxy in -// DynamicStack(stackType: proxy.size.width < proxy.size.height ? .vstack() : .hstack()) { -// VStack(alignment: .trailing) { -// Button { -// store.send(.view(.didTapCloseMoreOverlay)) -// } label: { -// Image(systemName: "xmark") -// .font(.title3.weight(.semibold)) -// .padding([.top, .trailing], 20) -// .contentShape(Rectangle()) -// } -// .buttonStyle(.plain) -// .frame(maxWidth: .infinity, alignment: .trailing) -// .padding(.horizontal) -// -// Spacer() -// -// Group { -// switch selected { -// case .episodes: -// episodes -// case .sourcesAndServers: -// sourcesAndServers -// case .speed: -// EmptyView() -// case .qualityAndSubtitles: -// qualityAndSubtitles -// case .settings: -// settings -// } -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .background { -// Group { -// if selected == .episodes { -// BlurView(.systemThinMaterialDark) -// } else { -// Rectangle() -// .fill(Color.black.opacity(0.35)) -// } -// } -// .edgesIgnoringSafeArea(.all) -// .ignoresSafeArea(.all, edges: .all) -// } -// .foregroundColor(.white) -// } -// -// @MainActor -// private struct PlaylistVideoContentView: View { -// let store: Store -// let playlist: Playlist -// -// let selectedItem: Playlist.Item.ID? -// -// @SwiftUI.State -// var selectedGroup: Playlist.Group? -// -// @SwiftUI.State -// var selectedPage: Playlist.Group.Variant.Page? -// -// private static let placeholderItems = [ -// Playlist.Item( -// id: "/1", -// title: "Placeholder", -// description: "Placeholder", -// number: 1, -// timestamp: "May 12, 2023", -// tags: [] -// ), -// Playlist.Item( -// id: "/2", -// title: "Placeholder", -// description: "Placeholder", -// number: 2, -// timestamp: "May 12, 2023", -// tags: [] -// ), -// Playlist.Item( -// id: "/3", -// title: "Placeholder", -// description: "Placeholder", -// number: 3, -// timestamp: "May 12, 2023", -// tags: [] -// ) -// ] -// -// @MainActor -// var body: some View { -// WithViewStore(store, observe: \.`self`) { viewStore in -// let defaultSelectedGroup = selectedGroup ?? viewStore.state.value.flatMap(\.keys.first) -// -// let group = viewStore.state.flatMap { groups in -// defaultSelectedGroup.flatMap { groups[$0] } ?? groups.values.first ?? .loaded([:]) -// } -// -// let defaultSelectedPage = selectedPage ?? group.value.flatMap(\.keys.first) -// -// let page = group.flatMap { pages in -// defaultSelectedPage.flatMap { pages[$0] } ?? pages.values.first ?? .loaded(.init(id: "")) -// } -// -// VStack(spacing: 8) { -// HStack { -// if let value = viewStore.state.value, value.keys.count > 1 { -// Menu { -// ForEach(value.keys, id: \.self) { group in -// Button { -// selectedGroup = group -// viewStore.send(.didTapContentGroup(group)) -// } label: { -// Text(group.altTitle ?? "Group \(group.id.withoutTrailingZeroes)") -// } -// } -// } label: { -// HStack { -// if let group = defaultSelectedGroup { -// Text(group.altTitle ?? "Group \(group.id.withoutTrailingZeroes)") -// } else { -// Text("Episodes") -// } -// -// if (viewStore.value?.count ?? 0) > 1 { -// Image(systemName: "chevron.down") -// .font(.footnote.weight(.bold)) -// } -// } -// .foregroundColor(.label) -// } -// } else { -// if let group = defaultSelectedGroup { -// Text(group.altTitle ?? "Episodes") -// } -// } -// -// Spacer() -// -// if let pages = group.value, pages.keys.count > 1 { -// Menu { -// ForEach(pages.keys, id: \.id) { page in -// Button { -// selectedPage = page -// if let defaultSelectedGroup { -// viewStore.send(.didTapContentGroupPage(defaultSelectedGroup, page)) -// } -// } label: { -// Text(page.displayName) -// } -// } -// } label: { -// HStack { -// Text(defaultSelectedPage?.displayName ?? "Unknown") -// .font(.system(size: 14)) -// Image(systemName: "chevron.down") -// .font(.footnote.weight(.semibold)) -// } -// .foregroundColor(.label) -// .padding(.horizontal, 6) -// .padding(.vertical, 4) -// .background { -// Capsule() -// .fill(Color.gray.opacity(0.24)) -// } -// } -// } -// } -// -// ZStack { -// if page.error != nil { -// RoundedRectangle(cornerRadius: 12) -// .fill(Color.red.opacity(0.16)) -// .padding(.horizontal) -// .frame(maxWidth: .infinity) -// .frame(height: 125) -// .overlay { -// Text("There was an error loading content.") -// .font(.callout.weight(.semibold)) -// } -// } else if page.didFinish, (page.value?.items.count ?? 0) == 0 { -// RoundedRectangle(cornerRadius: 12) -// .fill(Color.gray.opacity(0.12)) -// .padding(.horizontal) -// .frame(maxWidth: .infinity) -// .frame(height: 125) -// .overlay { -// Text("There is no content available.") -// .font(.callout.weight(.medium)) -// } -// } else { -// ScrollViewReader { scrollReader in -// ScrollView(.vertical, showsIndicators: false) { -// Spacer() -// .frame(height: 24) -// -// LazyVGrid(columns: [.init(), .init(), .init()]) { -// ForEach(page.value?.items ?? Self.placeholderItems, id: \.id) { item in -// VStack(alignment: .leading, spacing: 0) { -// FillAspectImage(url: item.thumbnail ?? playlist.posterImage) -// .aspectRatio(16 / 9, contentMode: .fit) -// .cornerRadius(12) -// -// Spacer() -// .frame(height: 8) -// -// Text("Episode \(item.number.withoutTrailingZeroes)") -// .font(.footnote.weight(.semibold)) -// .foregroundColor(.init(white: 0.72)) -// -// Spacer() -// .frame(height: 4) -// -// Text(item.title ?? "Episode \(item.number.withoutTrailingZeroes)") -// .font(.callout.weight(.semibold)) -// } -// .overlay(alignment: .topTrailing) { -// if item.id == selectedItem { -// Text("Playing") -// .font(.footnote.weight(.bold)) -// .foregroundColor(.black) -// .padding(.horizontal, 8) -// .padding(.vertical, 4) -// .background(Capsule(style: .continuous).fill(Color.white)) -// .padding(8) -// } -// } -// .contentShape(Rectangle()) -// .onTapGesture { -// if let group = defaultSelectedGroup, let page = defaultSelectedPage { -// viewStore.send(.didTapPlayEpisode(group, page, item.id)) -// } -// } -// } -// } -// .onAppear { -// scrollReader.scrollTo(selectedItem) -// } -// } -// } -// .frame(maxWidth: .infinity) -// .shimmering(active: !page.didFinish) -// .disabled(!page.didFinish) -// } -// } -// .animation(.easeInOut, value: viewStore.state) -// .animation(.easeInOut, value: selectedGroup) -// .animation(.easeInOut, value: selectedPage) -// } -// .shimmering(active: !viewStore.didFinish) -// .disabled(!viewStore.didFinish) -// .onChange(of: selectedGroup) { _ in -// selectedPage = nil -// } -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .padding(.horizontal) -// } -// } -// -// struct EpisodesViewState: Equatable, Sendable { -// let playlist: Playlist -// let group: Playlist.Group? -// let page: Playlist.Group.Variant.Page? -// let itemID: Playlist.Item.ID? -// -// init(_ state: VideoPlayerFeature.State) { -// self.playlist = state.playlist -// self.group = state.selected.group -// self.page = state.selected.page -// self.itemID = state.selected.episodeId -// } -// } -// -// @MainActor -// var episodes: some View { -// WithViewStore(store, observe: EpisodesViewState.init) { viewStore in -// PlaylistVideoContentView( -// store: store.scope( -// state: \.loadables.groups, -// action: { $0 } -// ), -// playlist: viewStore.playlist, -// selectedItem: viewStore.itemID, -// selectedGroup: viewStore.group, -// selectedPage: viewStore.page -// ) -// } -// } -// -// @MainActor -// var sourcesAndServers: some View { -// WithViewStore(store) { state in -// state.loadables[episodeId: state.selected.episodeId] -// } content: { loadableSourcesStore in -// LoadableView(loadable: loadableSourcesStore.state) { playlistItemSourcesLoadables in -// VStack(alignment: .leading, spacing: 8) { -// WithViewStore(store, observe: \.selected.sourceId) { selected in -// MoreListingRow( -// title: "Sources", -// selected: selected.state, -// items: playlistItemSourcesLoadables, -// itemTitle: \.displayName, -// selectedCallback: { id in -// selected.send(.didTapSource(id)) -// } -// ) -// } -// -// Spacer() -// .frame(height: 2) -// -// WithViewStore(store, observe: \.selected.sourceId) { selectedSourceIdState in -// WithViewStore(store, observe: \.selected.serverId) { selectedServerIdState in -// MoreListingRow( -// title: "Servers", -// selected: selectedServerIdState.state, -// items: selectedSourceIdState.state.flatMap { playlistItemSourcesLoadables[id: $0] }?.servers ?? [], -// itemTitle: \.displayName, -// selectedCallback: { id in -// selectedServerIdState.send(.didTapServer(id)) -// } -// ) -// } -// } -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) -// } failedView: { _ in -// Text("Failed to load sources.") -// } waitingView: { -// ProgressView() -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// -// private struct SelectedSubtitle: Equatable { -// let selected: AVMediaSelectionOption? -// let group: AVMediaSelectionGroup? -// -// init(_ state: PlayerFeature.State) { -// self.selected = state.selectedSubtitle -// self.group = state.subtitles -// } -// } -// -// @MainActor -// var qualityAndSubtitles: some View { -// WithViewStore(store, observe: \.selectedServerResponse) { loadableServerResponseState in -// LoadableView(loadable: loadableServerResponseState.state) { response in -// VStack(alignment: .leading, spacing: 8) { -// WithViewStore(store, observe: \.selected.linkId) { selectedState in -// MoreListingRow( -// title: "Quality", -// selected: selectedState.state, -// items: response.links, -// itemTitle: \.quality.description, -// selectedCallback: { id in -// selectedState.send(.didTapLink(id)) -// } -// ) -// } -// -// Spacer() -// .frame(height: 2) -// -// WithViewStore( -// store.scope( -// state: \.player, -// action: Action.InternalAction.player -// ), -// observe: SelectedSubtitle.init -// ) { viewStore in -// MoreListingRow( -// title: "Subtitles", -// selected: { $0 == viewStore.selected }, -// items: viewStore.group?.options ?? [], -// itemTitle: \.displayName, -// noneCallback: viewStore.group.flatMap { group in -// group.allowsEmptySelection ? { viewStore.send(.didTapSubtitle(for: group, nil)) } : nil -// }, -// selectedCallback: { option in -// if let group = viewStore.group { -// viewStore.send(.didTapSubtitle(for: group, option)) -// } -// } -// ) -// } -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) -// } failedView: { _ in -// Text("Failed to load server contents.") -// } waitingView: { -// ProgressView() -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// } -// -// @MainActor -// var settings: some View { -// EmptyView() -// } -// -// @MainActor -// private struct MoreListingRow: View { -// var title: String -// var items: [T] -// var selected: (T) -> Bool -// let itemTitle: (T) -> String -// var noneCallback: (() -> Void)? -// var selectedCallback: ((T) -> Void)? -// -// init( -// title: String, -// selected: @escaping (T) -> Bool, -// items: [T], -// itemTitle: @escaping (T) -> String, -// noneCallback: (() -> Void)? = nil, -// selectedCallback: ((T) -> Void)? = nil -// ) { -// self.title = title -// self.selected = selected -// self.items = items -// self.itemTitle = itemTitle -// self.noneCallback = noneCallback -// self.selectedCallback = selectedCallback -// } -// -// init( -// title: String, -// selected: T.ID? = nil, -// items: [T], -// itemTitle: KeyPath, -// noneCallback: (() -> Void)? = nil, -// selectedCallback: ((T.ID) -> Void)? = nil -// ) where T: Identifiable { -// self.init( -// title: title, -// selected: { $0.id == selected }, -// items: items, -// itemTitle: { $0[keyPath: itemTitle] }, -// noneCallback: noneCallback, -// selectedCallback: selectedCallback.flatMap { callback in { callback($0.id) } } -// ) -// } -// -// @MainActor -// var body: some View { -// VStack(alignment: .leading, spacing: 8) { -// Text(title) -// .font(.headline.weight(.semibold)) -// .padding(.horizontal) -// -// ScrollView(.horizontal, showsIndicators: false) { -// HStack { -// if items.isEmpty || noneCallback != nil { -// makeTextButton( -// "None", -// isSelected: items.isEmpty || !items.contains(where: selected) -// ) { -// noneCallback?() -// } -// .disabled(noneCallback == nil) -// } -// -// if let item = items.first(where: selected) { -// makeTextButton( -// itemTitle(item), -// isSelected: true -// ) { -// selectedCallback?(item) -// } -// } -// -// ForEach(Array(zip(items.indices, items)), id: \.0) { _, item in -// if !selected(item) { -// makeTextButton( -// itemTitle(item), -// isSelected: selected(item) -// ) { -// withAnimation(.easeInOut) { -// selectedCallback?(item) -// } -// } -// } -// } -// } -// .padding(.horizontal) -// } -// } -// .frame(maxWidth: .infinity) -// } -// -// @MainActor -// func makeTextButton(_ text: String, isSelected: Bool, callback: @escaping () -> Void) -> some View { -// Button { -// callback() -// } label: { -// Text(text) -// .font(.callout.weight(.semibold)) -// .foregroundColor(isSelected ? .black : .white) -// .padding(12) -// .background(Color(white: isSelected ? 1.0 : 0.24)) -// .cornerRadius(6) -// .contentShape(Rectangle()) -// .fixedSize(horizontal: true, vertical: true) -// } -// } -// } -//} +extension VideoPlayerFeature.View { + @MainActor + var toolsOverlay: some View { + VStack { + topBar + Spacer() + skipButtons + bottomBar + } + .overlay { controlsBar } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + Color.black + .opacity(0.35) + .ignoresSafeArea() + .edgesIgnoringSafeArea(.all) + .onTapGesture { + store.send(.view(.didTapPlayer)) + } + } + } + + @MainActor + var skipButtons: some View { + WithViewStore( + store, + observe: SkipActionViewState.init + ) { viewState in + ZStack { + if viewState.visible { + HStack { + Spacer() + ForEach(viewState.actions, id: \.self) { action in + Button { + viewState.send(action.action) + } label: { + HStack { + Image(systemName: action.image) + Text(action.description) + } + .font(.system(size: 13).weight(.heavy)) + .foregroundColor(action.textColor) + .padding(12) + .background(action.background.opacity(0.8)) + .cornerRadius(6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .shadow(color: Color.gray.opacity(0.25), radius: 6) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .swipeable(.easeInOut(duration: 0.2)) + } + } + .frame(maxWidth: .infinity) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .animation( + .easeInOut(duration: 0.25), + value: viewState.state + ) + } + .padding(.vertical, 4) + } + + private struct PlaylistDisplayState: Equatable, Sendable { + let playlist: Playlist + let groupId: Loadable + let episode: Loadable + + init(_ state: VideoPlayerFeature.State) { + self.playlist = state.playlist + self.groupId = state.selectedGroup + self.episode = state.selectedItem + } + } + + @MainActor + var topBar: some View { + HStack(alignment: .top, spacing: 12) { + Button { + store.send(.view(.didTapBackButton)) + } label: { + Image(systemName: "chevron.left") + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + WithViewStore(store, observe: PlaylistDisplayState.init) { viewStore in + VStack(alignment: .leading, spacing: 0) { + Group { + switch viewStore.groupId { + case .pending, .loading: + EmptyView() + case let .loaded(groupId): + Group { + switch viewStore.episode { + case .pending, .loading: + Text("Loading...") + case let .loaded(item): + Text(item.title ?? "Episode \(item.number.withoutTrailingZeroes)") + case .failed: + EmptyView() + } + } + + Group { + Text(groupId.altTitle ?? "S\(groupId.number.withoutTrailingZeroes)") + + Text("\u{2022}") + + Text("E\(viewStore.episode.value?.number.withoutTrailingZeroes ?? "0")") + } + .font(.caption.weight(.semibold)) + .foregroundColor(.init(white: 0.78)) + case .failed: + EmptyView() + } + } + .font(.callout.weight(.semibold)) + + Spacer() + .frame(height: 2) + +// Text(viewStore.playlist.title ?? "No title") +// .font(.footnote) + } + } + + Spacer() + + WithViewStore( + store.scope( + state: \.player, + action: Action.InternalAction.player + ), + observe: \.pipState + ) { viewStore in + if viewStore.isSupported { + Button { + viewStore.send(.view(.didTogglePictureInPicture)) + } label: { + Image(systemName: "rectangle.inset.bottomright.filled") + .foregroundColor(.white) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!viewStore.isPossible) + } + } + + PlayerRoutePickerView() + .scaleEffect(0.85) + .frame(width: 28, height: 28) + .fixedSize() + + Menu { + ForEach(VideoPlayerFeature.State.Overlay.MoreTab.allCases, id: \.self) { tab in + if tab == .speed { + WithViewStore( + store.scope( + state: \.player, + action: Action.InternalAction.player + ), + observe: \.rate + ) { viewStore in + Menu { + Picker( + tab.rawValue, + selection: viewStore.binding(get: \.`self`, send: { .didSelectRate($0) }) + ) { + let values: [Float] = [0.25, 0.50, 0.75, 1.0, 1.25, 1.50, 1.75, 2.0] + ForEach(values, id: \.self) { value in + Text(String(format: "%.2f", value) + "x") + .tag(value) + } + } + } label: { + tab.image + Text(tab.rawValue) + } + } + } else { + Button { + store.send(.view(.didSelectMoreTab(tab))) + } label: { + tab.image + Text(tab.rawValue) + } + .buttonStyle(.plain) + } + } + } label: { + Image(systemName: "ellipsis") + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .rotationEffect(.degrees(90)) + } + } + .foregroundColor(.white) + .font(.body.weight(.medium)) + } + + private struct RateBufferingState: Equatable, Sendable { + let isPlaying: Bool + let isBuffering: Bool + + init(_ state: PlayerFeature.State) { + self.isPlaying = state.rate != 0 + self.isBuffering = state.isBuffering + } + } + + @MainActor + var controlsBar: some View { + WithViewStore(store, observe: \.videoPlayerStatus == nil) { canShowControls in + if canShowControls.state { + WithViewStore( + store.scope( + state: \.player, + action: Action.InternalAction.player + ), + observe: RateBufferingState.init + ) { rateBufferingState in + HStack(spacing: 0) { + Spacer() + + Button { + rateBufferingState.send(.view(.didTapGoBackwards)) + } label: { + Image(systemName: "gobackward") + .font(.title2.weight(.bold)) + .padding(12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Group { + if rateBufferingState.isBuffering { + ProgressView() + .scaleEffect(1.25) + } else { + Button { + rateBufferingState.send(.view(.didTogglePlayButton)) + } label: { + Image(systemName: rateBufferingState.isPlaying ? "pause.fill" : "play.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .font(.largeTitle) + .padding(12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .frame(width: 54, height: 54) + + Button { + rateBufferingState.send(.view(.didTapGoForwards)) + } label: { + Image(systemName: "goforward") + .font(.title2.weight(.bold)) + .padding(12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Spacer() + } + } + } + } + .foregroundColor(.white) + } + + @MainActor + var bottomBar: some View { + ProgressBar( + store.scope( + state: \.player, + action: Action.InternalAction.player + ) + ) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + } + + @MainActor + var contentStatusView: some View { + WithViewStore(store, observe: \.videoPlayerStatus) { viewStore in + switch viewStore.state { + case let .some(.loading(type)): + ProgressView("Loading \(type.rawValue)...") + + case let .some(.needSelection(type)): + VStack(alignment: .leading) { + Text("Content Error") + .font(.title2.bold()) + Text("Please select a \(type.rawValue) to load.") + } + + case let .some(.empty(type)): + VStack(alignment: .leading) { + Text("Content Error") + .font(.title2.bold()) + Text("There are no \(type.rawValue)s for this content.") + } + + case let .some(.failed(type)): + VStack(alignment: .leading) { + Text("Content Error") + .font(.title2.bold()) + Text("There was an error loading \(type.rawValue). Please try again later.") + .font(.callout) + + Button {} label: { + Text("Retry") + .padding(12) + .background(Color(white: 0.16)) + .cornerRadius(6) + } + } + default: + EmptyView() + } + } + .foregroundColor(.white) + } +} + +extension VideoPlayerFeature.State { + enum VideoPlayerState: Equatable { + case pending(ContentType) + case loading(ContentType) + case empty(ContentType) + case needSelection(ContentType) + case failed(ContentType) + + enum ContentType: String, Equatable { + case group + case variant + case page + case episode + case source + case server + case link + case playback + } + + var action: VideoPlayerFeature.Action.ViewAction? { nil } + } + + var videoPlayerStatus: VideoPlayerState? { + if let content = selectedGroup.videoContentState(for: .group) { + return content + } else if let content = selectedVariant.videoContentState(for: .variant) { + return content + } else if let content = selectedPage.videoContentState(for: .page) { + return content + } else if let content = selectedItem.videoContentState(for: .episode) { + return content + } else if let content = selectedSource.videoContentState(for: .source) { + return content + } else if let content = selectedServer.videoContentState(for: .server) { + return content + } else if let content = selectedServerResponse.videoContentState(for: .server) { + return content + } else if let content = selectedLink.videoContentState(for: .link) { + return content + } else if player.status == .failed { + return .failed(.playback) + } + + return nil + } +} + +private extension Loadable { + func videoContentState(for content: VideoPlayerFeature.State.VideoPlayerState.ContentType) -> VideoPlayerFeature.State.VideoPlayerState? { + switch self { + case .pending: + return .pending(content) + case .loading: + return .loading(content) + case let .loaded(t): + if let t = t as? (any _OptionalProtocol) { + if t.optional == nil { + return .needSelection(content) + } + } + return nil + case .failed: + return .failed(content) + } + } +} + +extension VideoPlayerFeature.View { + struct ProgressBar: View { + @ObservedObject + var viewStore: ViewStore + + private var progress: Double { + if canUseControls { + min(1.0, max(0, viewStore.progress.seconds / viewStore.duration.seconds)) + } else { + .zero + } + } + + @SwiftUI.State + private var dragProgress: Double? + + private var isDragging: Bool { + dragProgress != nil + } + + private var canUseControls: Bool { + viewStore.duration.isValid && !viewStore.duration.seconds.isNaN && viewStore.duration != .zero + } + + init(_ store: StoreOf) { + self.viewStore = .init(store, observe: \.`self`) + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + GeometryReader { proxy in + ZStack(alignment: .leading) { + ZStack(alignment: .leading) { + BlurView(.systemUltraThinMaterialLight) + Color.white + .frame( + width: proxy.size.width * (isDragging ? dragProgress ?? progress : progress), + height: proxy.size.height, + alignment: .leading + ) + } + .frame( + width: proxy.size.width, + height: isDragging ? 12 : 8 + ) + .clipShape(Capsule(style: .continuous)) + } + .frame( + width: proxy.size.width, + height: proxy.size.height + ) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if !isDragging { + viewStore.send(.didStartedSeeking) + dragProgress = progress + } + + let locationX = value.location.x + let percentage = locationX / proxy.size.width + + dragProgress = max(0, min(1.0, percentage)) + } + .onEnded { _ in + if let dragProgress { + viewStore.send(.didFinishedSeekingTo(dragProgress)) + } + dragProgress = nil + } + ) + .animation(.spring(response: 0.3), value: isDragging) + } + .frame(maxWidth: .infinity) + .frame(height: 24) + + Group { + if canUseControls { + Text(progressDisplayTime) + + Text(" / ") + + Text(viewStore.duration.displayTime ?? "--.--") + } else { + Text("--.-- / --.--") + } + } + .font(.caption.monospacedDigit()) + } + .disabled(!canUseControls) + } + + private var progressDisplayTime: String { + if canUseControls { + if isDragging { + @Dependency(\.dateComponentsFormatter) + var formatter + + formatter.unitsStyle = .positional + formatter.zeroFormattingBehavior = .pad + + let time = (dragProgress ?? .zero) * viewStore.duration.seconds + + if time < 60 * 60 { + formatter.allowedUnits = [.minute, .second] + } else { + formatter.allowedUnits = [.hour, .minute, .second] + } + + return formatter.string(from: time) ?? "00:00" + } else { + return viewStore.progress.displayTime ?? "00:00" + } + } else { + return "--:--" + } + } + } +} + +extension VideoPlayerFeature.View { + @MainActor + func moreOverlay(_ selected: VideoPlayerFeature.State.Overlay.MoreTab) -> some View { + GeometryReader { proxy in + DynamicStack(stackType: proxy.size.width < proxy.size.height ? .vstack() : .hstack()) { + VStack(alignment: .trailing) { + Button { + store.send(.view(.didTapCloseMoreOverlay)) + } label: { + Image(systemName: "xmark") + .font(.title3.weight(.semibold)) + .padding([.top, .trailing], 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.horizontal) + + Spacer() + + Group { + switch selected { + case .episodes: + episodes + case .sourcesAndServers: + sourcesAndServers + case .speed: + EmptyView() + case .qualityAndSubtitles: + qualityAndSubtitles + case .settings: + settings + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + Group { + if selected == .episodes { + BlurView(.systemThinMaterialDark) + } else { + Rectangle() + .fill(Color.black.opacity(0.35)) + } + } + .edgesIgnoringSafeArea(.all) + .ignoresSafeArea(.all, edges: .all) + } + .foregroundColor(.white) + } + + private struct EpisodeViewState: Equatable { + let groupId: Playlist.Group.ID + let variantId: Playlist.Group.Variant.ID + let pageId: PagingID + let itemId: Playlist.Item.ID + + init(_ state: VideoPlayerFeature.State) { + self.groupId = state.selected.groupId + self.variantId = state.selected.variantId + self.pageId = state.selected.pageId + self.itemId = state.selected.itemId + } + } + + @MainActor + var episodes: some View { + WithViewStore(store, observe: EpisodeViewState.init) { viewStore in + ContentCore.View( + store: store.scope( + state: \.content, + action: Action.InternalAction.content + ), + contentType: .video, + selectedGroupId: viewStore.groupId, + selectedVariantId: viewStore.variantId, + selectedPageId: viewStore.pageId, + selectedItemId: viewStore.itemId + ) + } + } + + @MainActor + var sourcesAndServers: some View { + WithViewStore(store) { state in + state.loadables[episodeId: state.selected.itemId] + } content: { loadableSourcesStore in + LoadableView(loadable: loadableSourcesStore.state) { playlistItemSourcesLoadables in + VStack(alignment: .leading, spacing: 8) { + WithViewStore(store, observe: \.selected.sourceId) { selected in + MoreListingRow( + title: "Sources", + selected: selected.state, + items: playlistItemSourcesLoadables, + itemTitle: \.displayName, + selectedCallback: { id in + selected.send(.didTapSource(id)) + } + ) + } + + Spacer() + .frame(height: 2) + + WithViewStore(store, observe: \.selected.sourceId) { selectedSourceIdState in + WithViewStore(store, observe: \.selected.serverId) { selectedServerIdState in + MoreListingRow( + title: "Servers", + selected: selectedServerIdState.state, + items: selectedSourceIdState.state.flatMap { playlistItemSourcesLoadables[id: $0] }?.servers ?? [], + itemTitle: \.displayName, + selectedCallback: { id in + selectedServerIdState.send(.didTapServer(id)) + } + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } failedView: { _ in + Text("Failed to load sources.") + } waitingView: { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private struct SelectedSubtitle: Equatable { + let selected: AVMediaSelectionOption? + let groupId: AVMediaSelectionGroup? + + init(_ state: PlayerFeature.State) { + self.selected = state.selectedSubtitle + self.groupId = state.subtitles + } + } + + @MainActor + var qualityAndSubtitles: some View { + WithViewStore(store, observe: \.selectedServerResponse) { loadableServerResponseState in + LoadableView(loadable: loadableServerResponseState.state) { response in + VStack(alignment: .leading, spacing: 8) { + WithViewStore(store, observe: \.selected.linkId) { selectedState in + MoreListingRow( + title: "Quality", + selected: selectedState.state, + items: response.links, + itemTitle: \.quality.description, + selectedCallback: { id in + selectedState.send(.didTapLink(id)) + } + ) + } + + Spacer() + .frame(height: 2) + + WithViewStore( + store.scope( + state: \.player, + action: Action.InternalAction.player + ), + observe: SelectedSubtitle.init + ) { viewStore in + MoreListingRow( + title: "Subtitles", + selected: { $0 == viewStore.selected }, + items: viewStore.groupId?.options ?? [], + itemTitle: \.displayName, + noneCallback: viewStore.groupId.flatMap { groupId in + groupId.allowsEmptySelection ? { viewStore.send(.didTapSubtitle(for: groupId, nil)) } : nil + }, + selectedCallback: { option in + if let groupId = viewStore.groupId { + viewStore.send(.didTapSubtitle(for: groupId, option)) + } + } + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } failedView: { _ in + Text("Failed to load server contents.") + } waitingView: { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @MainActor + var settings: some View { + EmptyView() + } + + @MainActor + private struct MoreListingRow: View { + var title: String + var items: [T] + var selected: (T) -> Bool + let itemTitle: (T) -> String + var noneCallback: (() -> Void)? + var selectedCallback: ((T) -> Void)? + + init( + title: String, + selected: @escaping (T) -> Bool, + items: [T], + itemTitle: @escaping (T) -> String, + noneCallback: (() -> Void)? = nil, + selectedCallback: ((T) -> Void)? = nil + ) { + self.title = title + self.selected = selected + self.items = items + self.itemTitle = itemTitle + self.noneCallback = noneCallback + self.selectedCallback = selectedCallback + } + + init( + title: String, + selected: T.ID? = nil, + items: [T], + itemTitle: KeyPath, + noneCallback: (() -> Void)? = nil, + selectedCallback: ((T.ID) -> Void)? = nil + ) where T: Identifiable { + self.init( + title: title, + selected: { $0.id == selected }, + items: items, + itemTitle: { $0[keyPath: itemTitle] }, + noneCallback: noneCallback, + selectedCallback: selectedCallback.flatMap { callback in { callback($0.id) } } + ) + } + + @MainActor + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline.weight(.semibold)) + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + if items.isEmpty || noneCallback != nil { + makeTextButton( + "None", + isSelected: items.isEmpty || !items.contains(where: selected) + ) { + noneCallback?() + } + .disabled(noneCallback == nil) + } + + if let item = items.first(where: selected) { + makeTextButton( + itemTitle(item), + isSelected: true + ) { + selectedCallback?(item) + } + } + + ForEach(Array(zip(items.indices, items)), id: \.0) { _, item in + if !selected(item) { + makeTextButton( + itemTitle(item), + isSelected: selected(item) + ) { + withAnimation(.easeInOut) { + selectedCallback?(item) + } + } + } + } + } + .padding(.horizontal) + } + } + .frame(maxWidth: .infinity) + } + + @MainActor + func makeTextButton(_ text: String, isSelected: Bool, callback: @escaping () -> Void) -> some View { + Button { + callback() + } label: { + Text(text) + .font(.callout.weight(.semibold)) + .foregroundColor(isSelected ? .black : .white) + .padding(12) + .background(Color(white: isSelected ? 1.0 : 0.24)) + .cornerRadius(6) + .contentShape(Rectangle()) + .fixedSize(horizontal: true, vertical: true) + } + } + } +} #endif -// -//#Preview { -// VideoPlayerFeature.View( -// store: .init( -// initialState: .init( -// repoModuleID: .init( -// repoId: .init( -// .init(string: "/").unsafelyUnwrapped -// ), -// moduleId: .init("") -// ), -// playlist: .empty, -//// loadables: .init(), -//// selected: .init( -//// group: .init(id: .init(0)), -//// page: .init(id: .init(""), displayName: ""), -//// episodeId: .init("") -//// ), -// overlay: .tools -// ), -// reducer: { EmptyReducer() } -// ) -// ) -// .previewInterfaceOrientation(.landscapeRight) -//} + +#Preview { + VideoPlayerFeature.View( + store: .init( + initialState: .init( + repoModuleId: Repo().id(.init("")), + playlist: .empty, + loadables: .init(), + group: .init(""), + variant: .init(""), + page: .init(""), + episodeId: .init(""), + overlay: .tools + ), + reducer: { EmptyReducer() } + ) + ) + .previewInterfaceOrientation(.landscapeRight) +} diff --git a/Sources/Shared/Architecture/Feature.swift b/Sources/Shared/Architecture/Feature.swift index 911f0b1..e212c60 100644 --- a/Sources/Shared/Architecture/Feature.swift +++ b/Sources/Shared/Architecture/Feature.swift @@ -53,6 +53,4 @@ public protocol FeatureView: View { associatedtype State: FeatureState associatedtype Action: FeatureAction var store: Store { get } - - init(store: Store) } diff --git a/Sources/Shared/Architecture/TCA+Extensions.swift b/Sources/Shared/Architecture/TCA+Extensions.swift index b91508b..15a79ff 100644 --- a/Sources/Shared/Architecture/TCA+Extensions.swift +++ b/Sources/Shared/Architecture/TCA+Extensions.swift @@ -28,8 +28,8 @@ public extension Store where Action: FeatureAction { public extension Scope where ParentAction: FeatureAction { init( - state toChildState: CasePath, - action toChildAction: CasePath, + state toChildState: AnyCasePath, + action toChildAction: AnyCasePath, @ReducerBuilder child: () -> Child ) where ChildState == Child.State, ChildAction == Child.Action { self.init( @@ -43,7 +43,7 @@ public extension Scope where ParentAction: FeatureAction { public extension Scope where ParentAction: FeatureAction { init( state toChildState: WritableKeyPath, - action toChildAction: CasePath, + action toChildAction: AnyCasePath, @ReducerBuilder child: () -> Child ) where ChildState == Child.State, ChildAction == Child.Action { self.init( @@ -58,7 +58,7 @@ public extension Scope where ParentAction: FeatureAction { public extension Reducer where Action: FeatureAction { func ifLet( _ toPresentationState: WritableKeyPath>, - action toPresentationAction: CasePath>, + action toPresentationAction: AnyCasePath>, @ReducerBuilder destination: () -> Destination ) -> _PresentationReducer where Destination.State == DestinationState, Destination.Action == DestinationAction { self.ifLet( @@ -70,7 +70,7 @@ public extension Reducer where Action: FeatureAction { func ifLet( _ toWrappedState: WritableKeyPath, - action toWrappedAction: CasePath, + action toWrappedAction: AnyCasePath, @ReducerBuilder then wrapped: () -> Wrapped ) -> _IfLetReducer where WrappedState == Wrapped.State, WrappedAction == Wrapped.Action { self.ifLet( @@ -103,13 +103,13 @@ public extension Effect { /// in a reducer, specifically pullback /// public struct Case: Reducer where Child.State == ParentState { - public let toChildAction: CasePath + public let toChildAction: AnyCasePath public let child: Child // swiftformat:disable opaqueGenericParameters @inlinable public init( - _ toChildAction: CasePath, + _ toChildAction: AnyCasePath, @ReducerBuilder _ child: () -> Child ) where ChildAction == Child.Action { self.toChildAction = toChildAction diff --git a/Sources/Shared/SharedModels/Meta.swift b/Sources/Shared/SharedModels/Meta.swift index aafc0ba..e183a47 100644 --- a/Sources/Shared/SharedModels/Meta.swift +++ b/Sources/Shared/SharedModels/Meta.swift @@ -50,14 +50,14 @@ extension DiscoverListing { public extension DiscoverListing { struct Request: Sendable, Codable { - public let listingID: DiscoverListing.ID + public let listingId: DiscoverListing.ID public let page: PagingID public init( - listingID: DiscoverListing.ID, + listingId: DiscoverListing.ID, page: PagingID ) { - self.listingID = listingID + self.listingId = listingId self.page = page } } diff --git a/Sources/Shared/SharedModels/Playlist.swift b/Sources/Shared/SharedModels/Playlist.swift index a2afdbb..837ba3f 100644 --- a/Sources/Shared/SharedModels/Playlist.swift +++ b/Sources/Shared/SharedModels/Playlist.swift @@ -7,8 +7,9 @@ // import Foundation -import Tagged import JSValueCoder +import OrderedCollections +import Tagged // MARK: - Playlist @@ -151,8 +152,7 @@ public extension Playlist { public extension Playlist { - // TODO: Write a codable that handles all the boilerplate when converting to JSValue - + // TODO: Write a codable that handles all the boilerplate when converting associated enum to JSValue enum ItemsRequestOptions: Sendable, Equatable, Encodable { case group(Playlist.Group.ID) case variant(Playlist.Group.ID, Playlist.Group.Variant.ID) @@ -171,86 +171,58 @@ public extension Playlist { enum GroupCodingKeys: JSValueEnumCodingKey { case type - case groupID + case groupId } enum VariantCodingKeys: JSValueEnumCodingKey { case type - case groupID - case variantID + case groupId + case variantId } enum PageCodingKeys: JSValueEnumCodingKey { case type - case groupID - case variantID - case pageID + case groupId + case variantId + case pageId } public func encode(to encoder: Encoder) throws { switch self { - case let .group(groupID): + case let .group(groupId): var container = encoder.container(keyedBy: GroupCodingKeys.self) try container.encode(type, forKey: .type) - try container.encode(groupID, forKey: .groupID) - case let .variant(groupID, variantID): + try container.encode(groupId, forKey: .groupId) + case let .variant(groupId, variantId): var container = encoder.container(keyedBy: VariantCodingKeys.self) try container.encode(type, forKey: .type) - try container.encode(groupID, forKey: .groupID) - try container.encode(variantID, forKey: .variantID) - case let .page(groupID, variantID, pageID): + try container.encode(groupId, forKey: .groupId) + try container.encode(variantId, forKey: .variantId) + case let .page(groupId, variantId, pageId): var container = encoder.container(keyedBy: PageCodingKeys.self) try container.encode(type, forKey: .type) - try container.encode(groupID, forKey: .groupID) - try container.encode(variantID, forKey: .variantID) - try container.encode(pageID, forKey: .pageID) + try container.encode(groupId, forKey: .groupId) + try container.encode(variantId, forKey: .variantId) + try container.encode(pageId, forKey: .pageId) } } } - enum ItemsResponse: Equatable, Sendable, Decodable { - case groups([Playlist.Group]) - case variants([Playlist.Group.Variant]) - case pagings([Paging]) - - enum CodingKeys: CodingKey { - case type - case items - } - - private enum ResponseType: String, Decodable { - case groups - case variants - case pagings - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - let type = try container.decode(ResponseType.self, forKey: .type) - - switch type { - case .groups: - self = try .groups(container.decode([Playlist.Group].self, forKey: .items)) - case .variants: - self = try .variants(container.decode([Playlist.Group.Variant].self, forKey: .items)) - case .pagings: - self = try .pagings(container.decode([Paging].self, forKey: .items)) - } - } - } + typealias ItemsResponse = [Playlist.Group] struct Group: Sendable, Equatable, Identifiable, Decodable { public let id: Tagged public let number: Double public let altTitle: String? - public let variants: Loadable<[Variant]> + public let variants: Loadable + + public typealias Variants = [Variant] public init( id: Self.ID, number: Double, altTitle: String? = nil, - variants: Loadable<[Variant]> = .pending + variants: Loadable = .pending ) { self.id = id self.number = number @@ -258,21 +230,20 @@ public extension Playlist { self.variants = variants } - public struct Variant: Equatable, Sendable, Decodable, Identifiable { + public struct Variant: Sendable, Equatable, Identifiable, Decodable { public let id: Tagged public let title: String - public let icon: URL? - public let pagings: Loadable<[LoadablePaging]> + public let pagings: Loadable + + public typealias Pagings = [LoadablePaging] public init( id: Self.ID, title: String, - icon: URL? = nil, - pagings: Loadable<[LoadablePaging]> = .pending + pagings: Loadable = .pending ) { self.id = id self.title = title - self.icon = icon self.pagings = pagings } } @@ -326,3 +297,5 @@ public extension Playlist { ) } } + +extension OrderedDictionary: @unchecked Sendable where Key: Sendable, Value: Sendable {} diff --git a/Sources/Shared/SharedModels/Utilities/Paging.swift b/Sources/Shared/SharedModels/Utilities/Paging.swift index f33810f..ca824b4 100644 --- a/Sources/Shared/SharedModels/Utilities/Paging.swift +++ b/Sources/Shared/SharedModels/Utilities/Paging.swift @@ -20,29 +20,36 @@ public struct BasePaging { public let id: PagingID public let previousPage: PagingID? public let nextPage: PagingID? + public let title: String? public let items: T public init( id: PagingID, previousPage: PagingID? = nil, nextPage: PagingID? = nil, + title: String? = nil, items: T ) { self.id = id self.previousPage = previousPage self.nextPage = nextPage + self.title = title self.items = items } } // MARK: - Paging -public typealias Paging = BasePaging<[Element]> +public typealias Paging = BasePaging<[T]> // MARK: - LoadablePaging public typealias LoadablePaging = BasePaging> +// MARK: - OptionalPaging + +public typealias OptionalPaging = BasePaging<[T]?> + // MARK: Identifable extension BasePaging: Identifiable {} diff --git a/Sources/Shared/SharedModels/Video.swift b/Sources/Shared/SharedModels/Video.swift index e77d1b1..8fe274a 100644 --- a/Sources/Shared/SharedModels/Video.swift +++ b/Sources/Shared/SharedModels/Video.swift @@ -10,7 +10,7 @@ import Foundation import Tagged public extension Playlist { - struct EpisodeSourcesRequest: Sendable, Equatable, Codable { + struct EpisodeSourcesRequest: Sendable, Equatable, Encodable { public let playlistId: Playlist.ID public let episodeId: Playlist.Item.ID @@ -23,7 +23,7 @@ public extension Playlist { } } - struct EpisodeServerRequest: Sendable, Equatable, Codable { + struct EpisodeServerRequest: Sendable, Equatable, Encodable { public let playlistId: Playlist.ID public let episodeId: Playlist.Item.ID public let sourceId: EpisodeSource.ID @@ -42,7 +42,7 @@ public extension Playlist { } } - struct EpisodeSource: Sendable, Equatable, Identifiable, Codable { + struct EpisodeSource: Sendable, Equatable, Identifiable, Decodable { public let id: Tagged public let displayName: String public let description: String? @@ -61,7 +61,7 @@ public extension Playlist { } } - struct EpisodeServer: Sendable, Equatable, Identifiable, Codable { + struct EpisodeServer: Sendable, Equatable, Identifiable, Decodable { public let id: Tagged public let displayName: String public let description: String? @@ -76,7 +76,7 @@ public extension Playlist { self.description = description } - public struct Link: Sendable, Equatable, Identifiable, Codable { + public struct Link: Sendable, Equatable, Identifiable, Decodable { public var id: Tagged { .init(url) } public let url: URL public let quality: Quality @@ -92,48 +92,12 @@ public extension Playlist { self.format = format } - public enum Quality: RawRepresentable, Sendable, Equatable, CustomStringConvertible, Codable { + public enum Quality: Int, Sendable, Equatable, CustomStringConvertible, Decodable { case auto - case q1080 - case q720 - case q480 case q360 - case custom(Int) - - public init?(rawValue: Int) { - if rawValue == Self.auto.rawValue { - self = .auto - } else if rawValue == Self.q1080.rawValue { - self = .q1080 - } else if rawValue == Self.q720.rawValue { - self = .q720 - } else if rawValue == Self.q480.rawValue { - self = .q480 - } else if rawValue == Self.q360.rawValue { - self = .q360 - } else if rawValue > 0 { - self = .custom(rawValue) - } else { - return nil - } - } - - public var rawValue: Int { - switch self { - case .auto: - Int.max - case .q1080: - 1_080 - case .q720: - 720 - case .q480: - 480 - case .q360: - 360 - case let .custom(res): - res - } - } + case q480 + case q720 + case q1080 public var description: String { switch self { @@ -147,19 +111,17 @@ public extension Playlist { "480p" case .q360: "360p" - case let .custom(resolution): - "\(resolution)p" } } } - public enum Format: Int32, Equatable, Sendable, Codable { + public enum Format: Int, Equatable, Sendable, Decodable { case hls case dash } } - public struct Subtitle: Sendable, Equatable, Identifiable, Codable { + public struct Subtitle: Sendable, Equatable, Identifiable, Decodable { public var id: Tagged { .init(url) } public let url: URL public let name: String @@ -181,14 +143,14 @@ public extension Playlist { self.autoselect = autoselect } - public enum Format: Int32, Sendable, Equatable, Codable { + public enum Format: Int32, Sendable, Equatable, Decodable { case vtt case ass case srt } } - public struct SkipTime: Hashable, Sendable, Codable { + public struct SkipTime: Hashable, Sendable, Decodable { public let startTime: Double public let endTime: Double public let type: SkipType @@ -203,7 +165,7 @@ public extension Playlist { self.type = type } - public enum SkipType: Int32, Equatable, Sendable, CustomStringConvertible, Codable { + public enum SkipType: Int32, Equatable, Sendable, CustomStringConvertible, Decodable { case opening case ending case recap @@ -222,7 +184,7 @@ public extension Playlist { } } - struct EpisodeServerResponse: Equatable, Sendable, Codable { + struct EpisodeServerResponse: Equatable, Sendable, Decodable { public let links: [Playlist.EpisodeServer.Link] public let subtitles: [Playlist.EpisodeServer.Subtitle] public let headers: [String: String] diff --git a/Sources/Shared/ViewComponents/ChipView.swift b/Sources/Shared/ViewComponents/ChipView.swift index 602bec4..a0c3f74 100644 --- a/Sources/Shared/ViewComponents/ChipView.swift +++ b/Sources/Shared/ViewComponents/ChipView.swift @@ -16,7 +16,7 @@ public struct ChipView: View { let background: () -> Background public init( - accessory: @escaping () -> Accessory, + @ViewBuilder accessory: @escaping () -> Accessory, background: @escaping () -> Background ) { self.accessory = accessory @@ -31,9 +31,7 @@ public struct ChipView: View { } public func background(_ style: S) -> ChipView { - .init(accessory: accessory) { - style - } + .init(accessory: accessory) { style } } }