From b2bcd50b6044bd9ec989768e90e505d35c6f3d38 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 4 Feb 2024 14:21:48 +0100 Subject: [PATCH 01/45] Added episode history --- App/Package.swift | 6 +- Package.swift | 1113 ++++++++++------- Package/Sources/Clients/AnalyticsClient.swift | 2 +- Package/Sources/Clients/BuildClient.swift | 4 +- Package/Sources/Clients/ClipboardClient.swift | 4 +- Package/Sources/Clients/DatabaseClient.swift | 4 +- Package/Sources/Clients/DeviceClient.swift | 4 +- Package/Sources/Clients/FileClient.swift | 4 +- Package/Sources/Clients/LoggerClient.swift | 4 +- Package/Sources/Clients/ModuleClient.swift | 8 +- Package/Sources/Clients/PlayerClient.swift | 4 +- .../Clients/PlaylistHistoryClient.swift | 18 + Package/Sources/Clients/RepoClient.swift | 4 +- .../Sources/Clients/UserDefaultsClient.swift | 4 +- .../Sources/Clients/UserSettingsClient.swift | 6 +- Package/Sources/Clients/_Client.swift | 8 +- .../Dependencies/ComposableArchitecture.swift | 4 +- Package/Sources/Dependencies/CustomDump.swift | 8 +- .../Sources/Dependencies/FluidGradient.swift | 4 +- Package/Sources/Dependencies/Nuke.swift | 8 +- Package/Sources/Dependencies/Parsing.swift | 2 +- Package/Sources/Dependencies/Semaphore.swift | 4 +- Package/Sources/Dependencies/Semver.swift | 4 +- Package/Sources/Dependencies/SwiftLog.swift | 10 +- Package/Sources/Dependencies/SwiftSoup.swift | 4 +- .../Sources/Dependencies/SwiftSyntax.swift | 11 +- .../Dependencies/SwiftUIBackports.swift | 4 +- Package/Sources/Dependencies/Tagged.swift | 6 +- Package/Sources/Dependencies/XMLCoder.swift | 3 +- Package/Sources/Features/ContentCore.swift | 4 +- Package/Sources/Features/Discover.swift | 4 +- Package/Sources/Features/MochiApp.swift | 4 +- Package/Sources/Features/ModuleLists.swift | 4 +- .../Sources/Features/PlaylistDetails.swift | 5 +- Package/Sources/Features/Repos.swift | 4 +- Package/Sources/Features/Search.swift | 4 +- Package/Sources/Features/Settings.swift | 4 +- Package/Sources/Features/VideoPlayer.swift | 4 +- Package/Sources/Features/_Feature.swift | 10 +- Package/Sources/Index.swift | 4 +- Package/Sources/Macros/CoreDBMacros.swift | 10 +- Package/Sources/Macros/_Macro.swift | 10 +- .../Sources/Platforms/MochiPlatforms.swift | 4 +- Package/Sources/Shared/Architecture.swift | 4 +- Package/Sources/Shared/CoreDB.swift | 20 +- .../Sources/Shared/FoundationHelpers.swift | 4 +- Package/Sources/Shared/JSValueCoder.swift | 6 +- Package/Sources/Shared/SharedModels.swift | 4 +- Package/Sources/Shared/Styling.swift | 6 +- Package/Sources/Shared/ViewComponents.swift | 6 +- Package/Sources/Shared/_Shared.swift | 10 +- Package/Support/Array+Depedencies.swift | 8 +- .../Support/Array+SupportedPlatforms.swift | 8 +- Package/Support/Array+TestTargets.swift | 8 +- Package/Support/CSettingsBuilder.swift | 16 +- Package/Support/Dependencies.swift | 6 +- Package/Support/Dependency.swift | 2 +- Package/Support/DependencyBuilder.swift | 12 +- Package/Support/LanguageTag.swift | 2 +- Package/Support/Macro.swift | 34 +- Package/Support/Package+Extensions.swift | 101 +- Package/Support/PackageDependency.swift | 54 +- Package/Support/PlatformSet.swift | 4 +- Package/Support/Product+Target.swift | 20 +- Package/Support/Product.swift | 12 +- Package/Support/ProductType.swift | 4 +- Package/Support/ProductsBuilder.swift | 12 +- Package/Support/ResourcesBuilder.swift | 12 +- Package/Support/String.swift | 6 +- .../Support/SupportedPlatformBuilder.swift | 42 +- Package/Support/SupportedPlatforms.swift | 6 +- Package/Support/SwiftSettingsBuilder.swift | 12 +- Package/Support/Target.swift | 46 +- Package/Support/TargetType.swift | 20 +- Package/Support/TestTarget.swift | 8 +- Package/Support/TestTargetBuilder.swift | 12 +- Package/Support/TestTargets.swift | 6 +- Package/Support/_Depending.swift | 28 +- Package/Support/_Named.swift | 10 +- .../Support/_PackageDescription_Product.swift | 16 +- .../Support/_PackageDescription_Target.swift | 98 +- Package/Support/_Path.swift | 10 +- .../Clients/DatabaseClient/MochiSchema.swift | 1 + .../Models/Extensions/PlaylistHistory+.swift | 15 + .../Models/PlaylistHistory.swift | 28 + .../Internal/PlayerItem+DASH.swift | 4 +- .../PlayerClient/Internal/PlayerItem.swift | 3 +- Sources/Clients/PlayerClient/Live.swift | 4 + Sources/Clients/PlayerClient/Models.swift | 7 +- .../PlaylistHistoryClient/Client.swift | 41 + .../Clients/PlaylistHistoryClient/Live.swift | 43 + .../PlaylistHistoryClient/Models.swift | 14 + .../ContentCore/ContentCore+View.swift | 52 +- .../Features/ContentCore/ContentCore.swift | 42 +- .../Discover/DiscoverFeature+Reducer.swift | 22 +- .../Discover/DiscoverFeature+View.swift | 2 + .../Features/Discover/DiscoverFeature.swift | 8 +- .../Features/Discover/ViewMoreListing.swift | 4 +- .../ModuleListsFeature+Reducer.swift | 5 + .../PlaylistDetailsFeature+Reducer.swift | 7 +- .../PlaylistDetailsFeature.swift | 51 +- .../iOS/PlaylistDetailsFeature+View+iOS.swift | 18 +- .../Features/Repos/ReposFeature+Reducer.swift | 8 +- .../VideoPlayer/Components/ProgressBar.swift | 9 +- .../VideoPlayerFeature+Reducer.swift | 29 +- .../VideoPlayer/VideoPlayerFeature.swift | 4 +- Sources/Macros/CoreDBMacros/EntityMacro.swift | 4 +- Sources/Shared/SharedModels/Playlist.swift | 5 +- .../Shared/ViewComponents/OnInitialTask.swift | 4 +- 109 files changed, 1545 insertions(+), 928 deletions(-) create mode 100644 Package/Sources/Clients/PlaylistHistoryClient.swift create mode 100644 Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift create mode 100644 Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift create mode 100644 Sources/Clients/PlaylistHistoryClient/Client.swift create mode 100644 Sources/Clients/PlaylistHistoryClient/Live.swift create mode 100644 Sources/Clients/PlaylistHistoryClient/Models.swift diff --git a/App/Package.swift b/App/Package.swift index ed80a13..38ade70 100644 --- a/App/Package.swift +++ b/App/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( - name: "app", - products: [], - targets: [] + name: "app", + products: [], + targets: [] ) diff --git a/Package.swift b/Package.swift index 2acfd9f..3d94cf5 100644 --- a/Package.swift +++ b/Package.swift @@ -6,51 +6,65 @@ // Licensed under MIT License // -extension Array: Dependencies where Element == Dependency { - func appending(_ dependencies: any Dependencies) -> [Dependency] { - self + dependencies - } +// MARK: - Array + Dependencies + +extension [Dependency]: Dependencies { + func appending(_ dependencies: any Dependencies) -> [Dependency] { + self + dependencies + } } + +// MARK: - Array + SupportedPlatforms + // // Array+SupportedPlatforms.swift // Copyright (c) 2023 BrightDigit. // Licensed under MIT License // -extension Array: SupportedPlatforms where Element == SupportedPlatform { - func appending(_ platforms: any SupportedPlatforms) -> Self { - self + .init(platforms) - } +extension [SupportedPlatform]: SupportedPlatforms { + func appending(_ platforms: any SupportedPlatforms) -> Self { + self + .init(platforms) + } } + +// MARK: - Array + TestTargets + // // Array+TestTargets.swift // Copyright (c) 2023 BrightDigit. // Licensed under MIT License // -extension Array: TestTargets where Element == TestTarget { - func appending(_ testTargets: any TestTargets) -> [TestTarget] { - self + testTargets - } +extension [TestTarget]: TestTargets { + func appending(_ testTargets: any TestTargets) -> [TestTarget] { + self + testTargets + } } + +// MARK: - CSettingsBuilder + // // CSettingsBuilder.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // @resultBuilder enum CSettingsBuilder { - static func buildPartialBlock(first: CSetting) -> [CSetting] { - [first] - } + static func buildPartialBlock(first: CSetting) -> [CSetting] { + [first] + } - static func buildPartialBlock(accumulated: [CSetting], next: CSetting) -> [CSetting] { - accumulated + [next] - } + static func buildPartialBlock(accumulated: [CSetting], next: CSetting) -> [CSetting] { + accumulated + [next] + } } + +// MARK: - Dependencies + // // Dependencies.swift // Copyright (c) 2023 BrightDigit. @@ -58,10 +72,13 @@ enum CSettingsBuilder { // protocol Dependencies: Sequence where Element == Dependency { - // swiftlint:disable:next identifier_name - init(_ s: S) where S.Element == Dependency, S: Sequence - func appending(_ dependencies: any Dependencies) -> Self + // swiftlint:disable:next identifier_name + init(_ s: S) where S.Element == Dependency, S: Sequence + func appending(_ dependencies: any Dependencies) -> Self } + +// MARK: - Dependency + // // Dependency.swift // Copyright (c) 2023 BrightDigit. @@ -69,8 +86,11 @@ protocol Dependencies: Sequence where Element == Dependency { // protocol Dependency { - var targetDepenency: _PackageDescription_TargetDependency { get } + var targetDepenency: _PackageDescription_TargetDependency { get } } + +// MARK: - DependencyBuilder + // // DependencyBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -79,14 +99,15 @@ protocol Dependency { @resultBuilder enum DependencyBuilder { - static func buildPartialBlock(first: Dependency) -> any Dependencies { - [first] - } + static func buildPartialBlock(first: Dependency) -> any Dependencies { + [first] + } - static func buildPartialBlock(accumulated: any Dependencies, next: Dependency) -> any Dependencies { - accumulated + [next] - } + static func buildPartialBlock(accumulated: any Dependencies, next: Dependency) -> any Dependencies { + accumulated + [next] + } } + // // LanguageTag.swift // Copyright (c) 2023 BrightDigit. @@ -94,42 +115,46 @@ enum DependencyBuilder { // extension LanguageTag { - static let english: LanguageTag = "en" + static let english: LanguageTag = "en" } + // // Macro.swift // // // Created by ErrorErrorError on 10/11/23. -// +// // import CompilerPluginSupport import Foundation +// MARK: - Macro + protocol Macro: Target {} extension Macro { - var targetType: TargetType { - .macro - } + var targetType: TargetType { + .macro + } - var targetDepenency: _PackageDescription_TargetDependency { - .target(name: self.name) - } + var targetDepenency: _PackageDescription_TargetDependency { + .target(name: name) + } - var cSettings: [CSetting] { - [] - } + var cSettings: [CSetting] { + [] + } - var swiftSettings: [SwiftSetting] { - [] - } + var swiftSettings: [SwiftSetting] { + [] + } - var resources: [Resource] { - [] - } + var resources: [Resource] { + [] + } } + // // Package+Extensions.swift // Copyright (c) 2023 BrightDigit. @@ -137,64 +162,66 @@ extension Macro { // extension Package { - convenience init( - name: String? = nil, - @ProductsBuilder entries: @escaping () -> [Product], - @TestTargetBuilder testTargets: @escaping () -> any TestTargets = { [TestTarget]() }, - @SwiftSettingsBuilder swiftSettings: @escaping () -> [SwiftSetting] = { [SwiftSetting]() } - ) { - let packageName: String - if let name { - packageName = name - } else { - var pathComponents = #filePath.split(separator: "/") - pathComponents.removeLast() - // swiftlint:disable:next force_unwrapping - packageName = String(pathComponents.last!) - } - let allTestTargets = testTargets() - let entries = entries() - let products = entries.map(_PackageDescription_Product.entry) - var targets = entries.flatMap(\.productTargets) - let allTargetsDependencies = targets.flatMap { $0.allDependencies() } - let allTestTargetsDependencies = allTestTargets.flatMap { $0.allDependencies() } - let dependencies = allTargetsDependencies + allTestTargetsDependencies - let targetDependencies = dependencies.compactMap { $0 as? Target } - let packageDependencies = dependencies.compactMap { $0 as? PackageDependency } - targets += targetDependencies - targets += allTestTargets.map { $0 as Target } -// assert(targetDependencies.count + packageDependencies.count == dependencies.count, "there was a miscount of target dependencies - target: \(targetDependencies.count), package: \(packageDependencies.count), expected: \(dependencies.count)") - - let packgeTargets = Dictionary( - grouping: targets, - by: { $0.name } - ) - .values - .compactMap(\.first) - .map { _PackageDescription_Target.entry($0, swiftSettings: swiftSettings()) } - - let packageDeps = Dictionary( - grouping: packageDependencies, - by: { $0.productName } - ).values.compactMap(\.first).map(\.dependency) - - self.init(name: packageName, products: products, dependencies: packageDeps, targets: packgeTargets) - } + convenience init( + name: String? = nil, + @ProductsBuilder entries: @escaping () -> [Product], + @TestTargetBuilder testTargets: @escaping () -> any TestTargets = { [TestTarget]() }, + @SwiftSettingsBuilder swiftSettings: @escaping () -> [SwiftSetting] = { [SwiftSetting]() } + ) { + let packageName: String + if let name { + packageName = name + } else { + var pathComponents = #filePath.split(separator: "/") + pathComponents.removeLast() + // swiftlint:disable:next force_unwrapping + packageName = String(pathComponents.last!) + } + let allTestTargets = testTargets() + let entries = entries() + let products = entries.map(_PackageDescription_Product.entry) + var targets = entries.flatMap(\.productTargets) + let allTargetsDependencies = targets.flatMap { $0.allDependencies() } + let allTestTargetsDependencies = allTestTargets.flatMap { $0.allDependencies() } + let dependencies = allTargetsDependencies + allTestTargetsDependencies + let targetDependencies = dependencies.compactMap { $0 as? Target } + let packageDependencies = dependencies.compactMap { $0 as? PackageDependency } + targets += targetDependencies + targets += allTestTargets.map { $0 as Target } +// assert(targetDependencies.count + packageDependencies.count == dependencies.count, "there was a miscount of target dependencies - target: \(targetDependencies.count), package: +// \(packageDependencies.count), expected: \(dependencies.count)") + + let packgeTargets = Dictionary( + grouping: targets, + by: { $0.name } + ) + .values + .compactMap(\.first) + .map { _PackageDescription_Target.entry($0, swiftSettings: swiftSettings()) } + + let packageDeps = Dictionary( + grouping: packageDependencies, + by: { $0.productName } + ).values.compactMap(\.first).map(\.dependency) + + self.init(name: packageName, products: products, dependencies: packageDeps, targets: packgeTargets) + } } extension Package { - func supportedPlatforms( - @SupportedPlatformBuilder supportedPlatforms: @escaping () -> any SupportedPlatforms - ) -> Package { - self.platforms = .init(supportedPlatforms()) - return self - } + func supportedPlatforms( + @SupportedPlatformBuilder supportedPlatforms: @escaping () -> any SupportedPlatforms + ) -> Package { + platforms = .init(supportedPlatforms()) + return self + } - func defaultLocalization(_ defaultLocalization: LanguageTag) -> Package { - self.defaultLocalization = defaultLocalization - return self - } + func defaultLocalization(_ defaultLocalization: LanguageTag) -> Package { + self.defaultLocalization = defaultLocalization + return self + } } + // // PackageDependency.swift // Copyright (c) 2023 BrightDigit. @@ -203,42 +230,45 @@ extension Package { import PackageDescription +// MARK: - PackageDependency + protocol PackageDependency: Dependency { - init() + init() - var packageName: String { get } - var dependency: _PackageDescription_PackageDependency { get } + var packageName: String { get } + var dependency: _PackageDescription_PackageDependency { get } } extension PackageDependency { - var productName: String { - "\(Self.self)" - } - - var packageName : String { - switch self.dependency.kind { - case let .sourceControl(name: name, location: location, requirement: _): - return name ?? location.packageName ?? productName - case let .fileSystem(name: name, path: path): - return name ?? path.packageName ?? productName - case let .registry(id: id, requirement: _): - return id - @unknown default: - return productName + var productName: String { + "\(Self.self)" + } + + var packageName: String { + switch dependency.kind { + case let .sourceControl(name: name, location: location, requirement: _): + return name ?? location.packageName ?? productName + case let .fileSystem(name: name, path: path): + return name ?? path.packageName ?? productName + case let .registry(id: id, requirement: _): + return id + @unknown default: + return productName + } } - } - var targetDepenency: _PackageDescription_TargetDependency { - switch self.dependency.kind { - case let .sourceControl(name: name, location: location, requirement: _): - let packageName = name ?? location.packageName - return .product(name: productName, package: packageName) + var targetDepenency: _PackageDescription_TargetDependency { + switch dependency.kind { + case let .sourceControl(name: name, location: location, requirement: _): + let packageName = name ?? location.packageName + return .product(name: productName, package: packageName) - default: - return .byName(name: productName) + default: + return .byName(name: productName) + } } - } } + // // PackageDescription.swift // Copyright (c) 2023 BrightDigit. @@ -253,6 +283,9 @@ typealias _PackageDescription_Product = PackageDescription.Product typealias _PackageDescription_Target = PackageDescription.Target typealias _PackageDescription_TargetDependency = PackageDescription.Target.Dependency typealias _PackageDescription_PackageDependency = PackageDescription.Package.Dependency + +// MARK: - PlatformSet + // // PlatformSet.swift // Copyright (c) 2023 BrightDigit. @@ -260,9 +293,10 @@ typealias _PackageDescription_PackageDependency = PackageDescription.Package.Dep // protocol PlatformSet { - @SupportedPlatformBuilder - var body: any SupportedPlatforms { get } + @SupportedPlatformBuilder + var body: any SupportedPlatforms { get } } + // // Product+Target.swift // Copyright (c) 2023 BrightDigit. @@ -270,20 +304,23 @@ protocol PlatformSet { // extension Product where Self: Target { - var productTargets: [Target] { - [self] - } + var productTargets: [Target] { + [self] + } - var targetType: TargetType { - switch self.productType { - case .library: - return .regular + var targetType: TargetType { + switch productType { + case .library: + .regular - case .executable: - return .executable + case .executable: + .executable + } } - } } + +// MARK: - Product + // // Product.swift // Copyright (c) 2023 BrightDigit. @@ -291,15 +328,18 @@ extension Product where Self: Target { // protocol Product: _Named { - var productTargets: [Target] { get } - var productType: ProductType { get } + var productTargets: [Target] { get } + var productType: ProductType { get } } extension Product { - var productType: ProductType { - .library - } + var productType: ProductType { + .library + } } + +// MARK: - ProductType + // // ProductType.swift // Copyright (c) 2023 BrightDigit. @@ -307,9 +347,12 @@ extension Product { // enum ProductType { - case library - case executable + case library + case executable } + +// MARK: - ProductsBuilder + // // ProductsBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -318,14 +361,17 @@ enum ProductType { @resultBuilder enum ProductsBuilder { - static func buildPartialBlock(first: Product) -> [Product] { - [first] - } + static func buildPartialBlock(first: Product) -> [Product] { + [first] + } - static func buildPartialBlock(accumulated: [Product], next: Product) -> [Product] { - accumulated + [next] - } + static func buildPartialBlock(accumulated: [Product], next: Product) -> [Product] { + accumulated + [next] + } } + +// MARK: - ResourcesBuilder + // // ResourcesBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -334,14 +380,15 @@ enum ProductsBuilder { @resultBuilder enum ResourcesBuilder { - static func buildPartialBlock(first: Resource) -> [Resource] { - [first] - } + static func buildPartialBlock(first: Resource) -> [Resource] { + [first] + } - static func buildPartialBlock(accumulated: [Resource], next: Resource) -> [Resource] { - accumulated + [next] - } + static func buildPartialBlock(accumulated: [Resource], next: Resource) -> [Resource] { + accumulated + [next] + } } + // // String.swift // Copyright (c) 2023 BrightDigit. @@ -349,10 +396,11 @@ enum ResourcesBuilder { // extension String { - var packageName: String? { - self.split(separator: "/").last?.split(separator: ".").first.map(String.init) - } + var packageName: String? { + split(separator: "/").last?.split(separator: ".").first.map(String.init) + } } + // // SupportedPlatformBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -361,34 +409,39 @@ extension String { import PackageDescription +// MARK: - SupportedPlatformBuilder + @resultBuilder enum SupportedPlatformBuilder { - static func buildPartialBlock(first: SupportedPlatform) -> any SupportedPlatforms { - [first] - } + static func buildPartialBlock(first: SupportedPlatform) -> any SupportedPlatforms { + [first] + } - static func buildPartialBlock(first: PlatformSet) -> any SupportedPlatforms { - first.body - } + static func buildPartialBlock(first: PlatformSet) -> any SupportedPlatforms { + first.body + } - static func buildPartialBlock(first: any SupportedPlatforms) -> any SupportedPlatforms { - first - } + static func buildPartialBlock(first: any SupportedPlatforms) -> any SupportedPlatforms { + first + } - static func buildPartialBlock( - accumulated: any SupportedPlatforms, - next: any SupportedPlatforms - ) -> any SupportedPlatforms { - accumulated.appending(next) - } + static func buildPartialBlock( + accumulated: any SupportedPlatforms, + next: any SupportedPlatforms + ) -> any SupportedPlatforms { + accumulated.appending(next) + } - static func buildPartialBlock( - accumulated: any SupportedPlatforms, - next: SupportedPlatform - ) -> any SupportedPlatforms { - accumulated.appending([next]) - } + static func buildPartialBlock( + accumulated: any SupportedPlatforms, + next: SupportedPlatform + ) -> any SupportedPlatforms { + accumulated.appending([next]) + } } + +// MARK: - SupportedPlatforms + // // SupportedPlatforms.swift // Copyright (c) 2023 BrightDigit. @@ -396,10 +449,13 @@ enum SupportedPlatformBuilder { // protocol SupportedPlatforms: Sequence where Element == SupportedPlatform { - // swiftlint:disable:next identifier_name - init(_ s: S) where S.Element == SupportedPlatform, S: Sequence - func appending(_ platforms: any SupportedPlatforms) -> Self + // swiftlint:disable:next identifier_name + init(_ s: S) where S.Element == SupportedPlatform, S: Sequence + func appending(_ platforms: any SupportedPlatforms) -> Self } + +// MARK: - SwiftSettingsBuilder + // // SwiftSettingsBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -408,14 +464,17 @@ protocol SupportedPlatforms: Sequence where Element == SupportedPlatform { @resultBuilder enum SwiftSettingsBuilder { - static func buildPartialBlock(first: SwiftSetting) -> [SwiftSetting] { - [first] - } + static func buildPartialBlock(first: SwiftSetting) -> [SwiftSetting] { + [first] + } - static func buildPartialBlock(accumulated: [SwiftSetting], next: SwiftSetting) -> [SwiftSetting] { - accumulated + [next] - } + static func buildPartialBlock(accumulated: [SwiftSetting], next: SwiftSetting) -> [SwiftSetting] { + accumulated + [next] + } } + +// MARK: - Target + // // Target.swift // Copyright (c) 2023 BrightDigit. @@ -423,59 +482,65 @@ enum SwiftSettingsBuilder { // protocol Target: _Depending, Dependency, _Named, _Path { - var targetType: TargetType { get } + var targetType: TargetType { get } - @CSettingsBuilder - var cSettings: [CSetting] { get } + @CSettingsBuilder + var cSettings: [CSetting] { get } - @SwiftSettingsBuilder - var swiftSettings: [SwiftSetting] { get } + @SwiftSettingsBuilder + var swiftSettings: [SwiftSetting] { get } - @ResourcesBuilder - var resources: [Resource] { get } + @ResourcesBuilder + var resources: [Resource] { get } } extension Target { - var targetType: TargetType { - .regular - } + var targetType: TargetType { + .regular + } - var targetDepenency: _PackageDescription_TargetDependency { - .target(name: self.name) - } + var targetDepenency: _PackageDescription_TargetDependency { + .target(name: name) + } - var cSettings: [CSetting] { - [] - } + var cSettings: [CSetting] { + [] + } - var swiftSettings: [SwiftSetting] { - [] - } + var swiftSettings: [SwiftSetting] { + [] + } - var resources: [Resource] { - [] - } + var resources: [Resource] { + [] + } } + +// MARK: - TargetType + // // TargetType.swift // Copyright (c) 2023 BrightDigit. // Licensed under MIT License // -//typealias TargetType = Target.TargetType +// typealias TargetType = Target.TargetType enum TargetType { - case regular - case executable - case test - case binary(BinaryTarget) - case macro + case regular + case executable + case test + case binary(BinaryTarget) + case macro - enum BinaryTarget { - case path(String) - case remote(url: String, checksum: String) - } + enum BinaryTarget { + case path(String) + case remote(url: String, checksum: String) + } } + +// MARK: - TestTarget + // // TestTarget.swift // Copyright (c) 2023 BrightDigit. @@ -485,10 +550,13 @@ enum TargetType { protocol TestTarget: Target {} extension TestTarget { - var targetType: TargetType { - .test - } + var targetType: TargetType { + .test + } } + +// MARK: - TestTargetBuilder + // // TestTargetBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -497,14 +565,17 @@ extension TestTarget { @resultBuilder enum TestTargetBuilder { - static func buildPartialBlock(first: TestTarget) -> any TestTargets { - [first] - } + static func buildPartialBlock(first: TestTarget) -> any TestTargets { + [first] + } - static func buildPartialBlock(accumulated: any TestTargets, next: TestTarget) -> any TestTargets { - accumulated + [next] - } + static func buildPartialBlock(accumulated: any TestTargets, next: TestTarget) -> any TestTargets { + accumulated + [next] + } } + +// MARK: - TestTargets + // // TestTargets.swift // Copyright (c) 2023 BrightDigit. @@ -512,10 +583,11 @@ enum TestTargetBuilder { // protocol TestTargets: Sequence where Element == TestTarget { - // swiftlint:disable:next identifier_name - init(_ s: S) where S.Element == TestTarget, S: Sequence - func appending(_ testTargets: any TestTargets) -> Self + // swiftlint:disable:next identifier_name + init(_ s: S) where S.Element == TestTarget, S: Sequence + func appending(_ testTargets: any TestTargets) -> Self } + // // Testable.swift // @@ -526,9 +598,14 @@ protocol TestTargets: Sequence where Element == TestTarget { import Foundation +// MARK: - Testable + protocol Testable { associatedtype Tests: TestTarget } + +// MARK: - _Depending + // // _Depending.swift // Copyright (c) 2023 BrightDigit. @@ -536,27 +613,30 @@ protocol Testable { // protocol _Depending { - @DependencyBuilder - var dependencies: any Dependencies { get } + @DependencyBuilder + var dependencies: any Dependencies { get } } extension _Depending { - var dependencies: any Dependencies { - [Dependency]() - } + var dependencies: any Dependencies { + [Dependency]() + } } extension _Depending { - func allDependencies() -> [Dependency] { - self.dependencies.compactMap { - $0 as? _Depending - } - .flatMap { - $0.allDependencies() + func allDependencies() -> [Dependency] { + dependencies.compactMap { + $0 as? _Depending + } + .flatMap { + $0.allDependencies() + } + .appending(dependencies) } - .appending(self.dependencies) - } } + +// MARK: - _Named + // // _Named.swift // Copyright (c) 2023 BrightDigit. @@ -564,14 +644,15 @@ extension _Depending { // protocol _Named { - var name: String { get } + var name: String { get } } extension _Named { - var name: String { - "\(Self.self)" - } + var name: String { + "\(Self.self)" + } } + // // _PackageDescription_Product.swift // Copyright (c) 2023 BrightDigit. @@ -579,18 +660,19 @@ extension _Named { // extension _PackageDescription_Product { - static func entry(_ entry: Product) -> _PackageDescription_Product { - let targets = entry.productTargets.map(\.name) + static func entry(_ entry: Product) -> _PackageDescription_Product { + let targets = entry.productTargets.map(\.name) - switch entry.productType { - case .executable: - return Self.executable(name: entry.name, targets: targets) + switch entry.productType { + case .executable: + return Self.executable(name: entry.name, targets: targets) - case .library: - return Self.library(name: entry.name, targets: targets) + case .library: + return Self.library(name: entry.name, targets: targets) + } } - } } + // // _PackageDescription_Target.swift // Copyright (c) 2023 BrightDigit. @@ -598,135 +680,150 @@ extension _PackageDescription_Product { // extension _PackageDescription_Target { - static func entry(_ entry: Target, swiftSettings: [SwiftSetting] = []) -> _PackageDescription_Target { - let dependencies = entry.dependencies.map(\.targetDepenency) - switch entry.targetType { - case .executable: - return .executableTarget( - name: entry.name, - dependencies: dependencies, - path: entry.path, - resources: entry.resources, - cSettings: entry.cSettings, - swiftSettings: swiftSettings + entry.swiftSettings - ) - - case .regular: - return .target( - name: entry.name, - dependencies: dependencies, - path: entry.path, - resources: entry.resources, - cSettings: entry.cSettings, - swiftSettings: swiftSettings + entry.swiftSettings - ) - - case .test: - return .testTarget( - name: entry.name, - dependencies: dependencies, - path: entry.path, - resources: entry.resources, - cSettings: entry.cSettings, - swiftSettings: swiftSettings + entry.swiftSettings - ) - - case .binary(.path(let path)): - return .binaryTarget( - name: entry.name, - path: path - ) - - case .binary(.remote(let url, let checksum)): - return .binaryTarget( - name: entry.name, - url: url, - checksum: checksum - ) - - case .macro: - return .macro( - name: entry.name, - dependencies: dependencies, - path: entry.path, - swiftSettings: swiftSettings + entry.swiftSettings - ) - } - } + static func entry(_ entry: Target, swiftSettings: [SwiftSetting] = []) -> _PackageDescription_Target { + let dependencies = entry.dependencies.map(\.targetDepenency) + switch entry.targetType { + case .executable: + return .executableTarget( + name: entry.name, + dependencies: dependencies, + path: entry.path, + resources: entry.resources, + cSettings: entry.cSettings, + swiftSettings: swiftSettings + entry.swiftSettings + ) + + case .regular: + return .target( + name: entry.name, + dependencies: dependencies, + path: entry.path, + resources: entry.resources, + cSettings: entry.cSettings, + swiftSettings: swiftSettings + entry.swiftSettings + ) + + case .test: + return .testTarget( + name: entry.name, + dependencies: dependencies, + path: entry.path, + resources: entry.resources, + cSettings: entry.cSettings, + swiftSettings: swiftSettings + entry.swiftSettings + ) + + case let .binary(.path(path)): + return .binaryTarget( + name: entry.name, + path: path + ) + + case let .binary(.remote(url, checksum)): + return .binaryTarget( + name: entry.name, + url: url, + checksum: checksum + ) + + case .macro: + return .macro( + name: entry.name, + dependencies: dependencies, + path: entry.path, + swiftSettings: swiftSettings + entry.swiftSettings + ) + } + } } + // // _Path.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - _Path + protocol _Path { - var path: String? { get } + var path: String? { get } } extension _Path { - var path: String? { nil } + var path: String? { nil } } + // // AnalyticsClient.swift // // // Created by ErrorErrorError on 10/4/23. -// +// // import Foundation +// MARK: - AnalyticsClient + struct AnalyticsClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() } } + // // BuildClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - BuildClient + struct BuildClient: _Client { var dependencies: any Dependencies { Semver() ComposableArchitecture() } } + // // ClipboardClient.swift -// +// // // Created by ErrorErrorError on 12/15/23. -// +// // import Foundation +// MARK: - ClipboardClient + struct ClipboardClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() } } + // // DatabaseClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - DatabaseClient + struct DatabaseClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() @@ -739,12 +836,15 @@ struct DatabaseClient: _Client { Resource.copy("Resources/MochiSchema.xcdatamodeld") } } + +// MARK: - DeviceClient + // // DeviceClient.swift -// +// // // Created by ErrorErrorError on 11/29/23. -// +// // struct DeviceClient: _Client { @@ -752,12 +852,15 @@ struct DeviceClient: _Client { ComposableArchitecture() } } + +// MARK: - FileClient + // // FileClient.swift -// +// // // Created by ErrorErrorError on 10/6/23. -// +// // struct FileClient: _Client { @@ -765,6 +868,7 @@ struct FileClient: _Client { ComposableArchitecture() } } + // // LocalizableClient.swift // @@ -775,6 +879,8 @@ struct FileClient: _Client { import Foundation +// MARK: - LocalizableClient + struct LocalizableClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() @@ -784,32 +890,38 @@ struct LocalizableClient: _Client { Resource.process("Resources") } } + // // LoggerClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - LoggerClient + struct LoggerClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() Logging() } } + // // ModuleClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - ModuleClient + struct ModuleClient: _Client { var dependencies: any Dependencies { DatabaseClient() @@ -825,6 +937,8 @@ struct ModuleClient: _Client { } } +// MARK: Testable + extension ModuleClient: Testable { struct Tests: TestTarget { var name: String { "ModuleClientTests" } @@ -838,16 +952,19 @@ extension ModuleClient: Testable { } } } + // // PlayerClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - PlayerClient + struct PlayerClient: _Client { var dependencies: any Dependencies { Architecture() @@ -860,16 +977,40 @@ struct PlayerClient: _Client { XMLCoder() } } + +// +// PlaylistHistoryClient.swift +// +// +// Created by DeNeRr on 29.01.2024. +// + +import Foundation + +// MARK: - PlaylistHistoryClient + +struct PlaylistHistoryClient: _Client { + var dependencies: any Dependencies { + DatabaseClient() + SharedModels() + Semaphore() + Tagged() + ComposableArchitecture() + } +} + // // RepoClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - RepoClient + struct RepoClient: _Client { var dependencies: any Dependencies { DatabaseClient() @@ -880,31 +1021,37 @@ struct RepoClient: _Client { ComposableArchitecture() } } + // // UserDefaultsClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - UserDefaultsClient + struct UserDefaultsClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() } } + // // File.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - UserSettingsClient + struct UserSettingsClient: _Client { var dependencies: any Dependencies { UserDefaultsClient() @@ -912,29 +1059,35 @@ struct UserSettingsClient: _Client { ViewComponents() } } + // // _Client.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - _Client + protocol _Client: Product, Target {} extension _Client { var path: String? { - "Sources/Clients/\(self.name)" + "Sources/Clients/\(name)" } } + +// MARK: - ComposableArchitecture + // // ComposableArchitecture.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct ComposableArchitecture: PackageDependency { @@ -942,42 +1095,51 @@ struct ComposableArchitecture: PackageDependency { .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.5.6") } } + // // CustomDump.swift // // // Created by ErrorErrorError on 1/1/24. -// +// // import Foundation +// MARK: - CustomDump + struct CustomDump: PackageDependency { - var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") - } + var dependency: Package.Dependency { + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") + } } + // // FluidGradient.swift -// +// // // Created by ErrorErrorError on 10/11/23. -// +// // import Foundation +// MARK: - FluidGradient + struct FluidGradient: PackageDependency { var dependency: Package.Dependency { .package(url: "https://github.com/Cindori/FluidGradient.git", exact: "1.0.0") } } + +// MARK: - Nuke + // // Nuke.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct Nuke: PackageDependency { @@ -989,17 +1151,22 @@ struct Nuke: PackageDependency { } } +// MARK: - NukeUI + struct NukeUI: PackageDependency { var dependency: Package.Dependency { .package(url: Nuke.nukeURL, exact: Nuke.nukeVersion) } } + +// MARK: - Parsing + // // Parsing.swift // // // Created by ErrorErrorError on 12/17/23. -// +// // struct Parsing: PackageDependency { @@ -1007,12 +1174,15 @@ struct Parsing: PackageDependency { .package(url: "https://github.com/pointfreeco/swift-parsing", exact: "0.13.0") } } + +// MARK: - Semaphore + // // Semaphore.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct Semaphore: PackageDependency { @@ -1020,12 +1190,15 @@ struct Semaphore: PackageDependency { .package(url: "https://github.com/groue/Semaphore", exact: "0.0.8") } } + +// MARK: - Semver + // // Semver.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct Semver: PackageDependency { @@ -1033,12 +1206,15 @@ struct Semver: PackageDependency { .package(url: "https://github.com/kutchie-pelaez/Semver.git", exact: "1.0.0") } } + +// MARK: - SwiftLog + // // File.swift -// +// // // Created by ErrorErrorError on 11/9/23. -// +// // struct SwiftLog: PackageDependency { @@ -1050,6 +1226,8 @@ struct SwiftLog: PackageDependency { } } +// MARK: - Logging + struct Logging: _Depending, Dependency { var targetDepenency: _PackageDescription_TargetDependency { .product(name: "\(Self.self)", package: SwiftLog().packageName) @@ -1059,12 +1237,15 @@ struct Logging: _Depending, Dependency { SwiftLog() } } + +// MARK: - SwiftSoup + // // SwiftSoup.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct SwiftSoup: PackageDependency { @@ -1072,22 +1253,27 @@ struct SwiftSoup: PackageDependency { .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0") } } + // // SwiftSyntax.swift -// +// // // Created by ErrorErrorError on 10/11/23. -// +// // import Foundation +// MARK: - SwiftSyntax + struct SwiftSyntax: PackageDependency { var dependency: Package.Dependency { .package(url: "https://github.com/apple/swift-syntax", from: "509.0.1") } } +// MARK: - SwiftSyntaxMacros + struct SwiftSyntaxMacros: _Depending, Dependency { var targetDepenency: _PackageDescription_TargetDependency { .product(name: "\(Self.self)", package: SwiftSyntax().packageName) @@ -1098,6 +1284,8 @@ struct SwiftSyntaxMacros: _Depending, Dependency { } } +// MARK: - SwiftCompilerPlugin + struct SwiftCompilerPlugin: _Depending, Dependency { var targetDepenency: _PackageDescription_TargetDependency { .product(name: "\(Self.self)", package: SwiftSyntax().packageName) @@ -1108,12 +1296,14 @@ struct SwiftCompilerPlugin: _Depending, Dependency { } } +// MARK: - SwiftUIBackports + // // SwiftUIBackports.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct SwiftUIBackports: PackageDependency { @@ -1121,27 +1311,33 @@ struct SwiftUIBackports: PackageDependency { .package(url: "https://github.com/shaps80/SwiftUIBackports.git", .upToNextMajor(from: "2.0.0")) } } + // // File.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - Tagged + struct Tagged: PackageDependency { var dependency: Package.Dependency { .package(url: "https://github.com/pointfreeco/swift-tagged", exact: "0.10.0") } } + +// MARK: - XMLCoder + // // XMLCoder.swift // // // Created by ErrorErrorError on 12/27/23. -// +// // struct XMLCoder: PackageDependency { @@ -1152,14 +1348,16 @@ struct XMLCoder: PackageDependency { // // ContentCore.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - ContentCore + struct ContentCore: _Feature { var dependencies: any Dependencies { Architecture() @@ -1171,16 +1369,19 @@ struct ContentCore: _Feature { Styling() } } + // // Discover.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - Discover + struct Discover: _Feature { var dependencies: any Dependencies { Architecture() @@ -1196,16 +1397,19 @@ struct Discover: _Feature { NukeUI() } } + // // MochiApp.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // import Foundation +// MARK: - MochiApp + struct MochiApp: _Feature { var name: String { "App" } @@ -1223,16 +1427,19 @@ struct MochiApp: _Feature { NukeUI() } } + // // ModuleLists.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - ModuleLists + struct ModuleLists: _Feature { var dependencies: any Dependencies { Architecture() @@ -1243,16 +1450,19 @@ struct ModuleLists: _Feature { ComposableArchitecture() } } + // // PlaylistDetails.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - PlaylistDetails + struct PlaylistDetails: _Feature { var dependencies: any Dependencies { Architecture() @@ -1260,6 +1470,7 @@ struct PlaylistDetails: _Feature { LoggerClient() ModuleClient() RepoClient() + PlaylistHistoryClient() Styling() SharedModels() ViewComponents() @@ -1267,16 +1478,19 @@ struct PlaylistDetails: _Feature { NukeUI() } } + // // Repos.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - Repos + struct Repos: _Feature { var dependencies: any Dependencies { Architecture() @@ -1290,16 +1504,19 @@ struct Repos: _Feature { NukeUI() } } + // // Search.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - Search + struct Search: _Feature { var dependencies: any Dependencies { Architecture() @@ -1315,12 +1532,15 @@ struct Search: _Feature { NukeUI() } } + +// MARK: - Settings + // // Settings.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // struct Settings: _Feature { @@ -1338,12 +1558,15 @@ struct Settings: _Feature { NukeUI() } } + +// MARK: - VideoPlayer + // // VideoPlayer.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // struct VideoPlayer: _Feature { @@ -1360,76 +1583,91 @@ struct VideoPlayer: _Feature { NukeUI() } } + // // File.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - _Feature + protocol _Feature: Product, Target {} extension _Feature { var path: String? { - "Sources/Features/\(self.name)" + "Sources/Features/\(name)" } } + +// MARK: - CoreDBMacros + // // CoreDBMacros.swift // // // Created by ErrorErrorError on 12/28/23. -// +// // struct CoreDBMacros: _Macro { - var dependencies: any Dependencies { - SwiftSyntaxMacros() - SwiftCompilerPlugin() - } + var dependencies: any Dependencies { + SwiftSyntaxMacros() + SwiftCompilerPlugin() + } } + // // File.swift -// +// // // Created by ErrorErrorError on 10/27/23. -// +// // import Foundation +// MARK: - _Macro + protocol _Macro: Macro {} extension _Macro { var path: String? { - "Sources/Macros/\(self.name)" + "Sources/Macros/\(name)" } } + // // MochiPlatforms.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // import Foundation +// MARK: - MochiPlatforms + struct MochiPlatforms: PlatformSet { var body: any SupportedPlatforms { SupportedPlatform.macOS(.v12) SupportedPlatform.iOS(.v15) } } + +// MARK: - Architecture + // // Architecture.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // struct Architecture: _Shared { @@ -1440,36 +1678,44 @@ struct Architecture: _Shared { LoggerClient() } } + +// MARK: - CoreDB + // // CoreDB.swift // // // Created by ErrorErrorError on 12/28/23. -// +// // struct CoreDB: _Shared { var dependencies: any Dependencies { - CoreDBMacros() + CoreDBMacros() } } +// MARK: Testable + extension CoreDB: Testable { - struct Tests: TestTarget { - var name: String { "CoreDBTests" } + struct Tests: TestTarget { + var name: String { "CoreDBTests" } - var dependencies: any Dependencies { - CoreDB() - CustomDump() + var dependencies: any Dependencies { + CoreDB() + CustomDump() + } } - } } + +// MARK: - FoundationHelpers + // // FoundationHelpers.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // struct FoundationHelpers: _Shared {} @@ -1478,13 +1724,17 @@ struct FoundationHelpers: _Shared {} // // // Created by ErrorErrorError on 11/6/23. -// +// // import Foundation +// MARK: - JSValueCoder + struct JSValueCoder: _Shared {} +// MARK: Testable + extension JSValueCoder: Testable { struct Tests: TestTarget { var name: String { "JSValueCoderTests" } @@ -1494,16 +1744,19 @@ extension JSValueCoder: Testable { } } } + // // SharedModels.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - SharedModels + struct SharedModels: _Shared { var dependencies: any Dependencies { DatabaseClient() @@ -1513,16 +1766,19 @@ struct SharedModels: _Shared { JSValueCoder() } } + // // File.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - Styling + struct Styling: _Shared { var dependencies: any Dependencies { ViewComponents() @@ -1532,16 +1788,19 @@ struct Styling: _Shared { UserSettingsClient() } } + // // File.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - ViewComponents + struct ViewComponents: _Shared { var dependencies: any Dependencies { SharedModels() @@ -1549,29 +1808,33 @@ struct ViewComponents: _Shared { NukeUI() } } + // // Shared.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - _Shared + protocol _Shared: Product, Target {} extension _Shared { var path: String? { - "Sources/Shared/\(self.name)" + "Sources/Shared/\(name)" } } + // // Index.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/AnalyticsClient.swift b/Package/Sources/Clients/AnalyticsClient.swift index f50714a..255b907 100644 --- a/Package/Sources/Clients/AnalyticsClient.swift +++ b/Package/Sources/Clients/AnalyticsClient.swift @@ -3,7 +3,7 @@ // // // Created by ErrorErrorError on 10/4/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/BuildClient.swift b/Package/Sources/Clients/BuildClient.swift index 203f85b..57c5e26 100644 --- a/Package/Sources/Clients/BuildClient.swift +++ b/Package/Sources/Clients/BuildClient.swift @@ -1,9 +1,9 @@ // // BuildClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/ClipboardClient.swift b/Package/Sources/Clients/ClipboardClient.swift index c63d1c2..4217b3f 100644 --- a/Package/Sources/Clients/ClipboardClient.swift +++ b/Package/Sources/Clients/ClipboardClient.swift @@ -1,9 +1,9 @@ // // ClipboardClient.swift -// +// // // Created by ErrorErrorError on 12/15/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/DatabaseClient.swift b/Package/Sources/Clients/DatabaseClient.swift index b83597c..2f14ffc 100644 --- a/Package/Sources/Clients/DatabaseClient.swift +++ b/Package/Sources/Clients/DatabaseClient.swift @@ -1,9 +1,9 @@ // // DatabaseClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/DeviceClient.swift b/Package/Sources/Clients/DeviceClient.swift index fc8afc0..2e5fb6b 100644 --- a/Package/Sources/Clients/DeviceClient.swift +++ b/Package/Sources/Clients/DeviceClient.swift @@ -1,9 +1,9 @@ // // DeviceClient.swift -// +// // // Created by ErrorErrorError on 11/29/23. -// +// // struct DeviceClient: _Client { diff --git a/Package/Sources/Clients/FileClient.swift b/Package/Sources/Clients/FileClient.swift index 040b72e..94015e2 100644 --- a/Package/Sources/Clients/FileClient.swift +++ b/Package/Sources/Clients/FileClient.swift @@ -1,9 +1,9 @@ // // FileClient.swift -// +// // // Created by ErrorErrorError on 10/6/23. -// +// // struct FileClient: _Client { diff --git a/Package/Sources/Clients/LoggerClient.swift b/Package/Sources/Clients/LoggerClient.swift index c918261..29c121c 100644 --- a/Package/Sources/Clients/LoggerClient.swift +++ b/Package/Sources/Clients/LoggerClient.swift @@ -1,9 +1,9 @@ // // LoggerClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/ModuleClient.swift b/Package/Sources/Clients/ModuleClient.swift index eddf07d..57ff9ab 100644 --- a/Package/Sources/Clients/ModuleClient.swift +++ b/Package/Sources/Clients/ModuleClient.swift @@ -1,13 +1,15 @@ // // ModuleClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - ModuleClient + struct ModuleClient: _Client { var dependencies: any Dependencies { DatabaseClient() @@ -23,6 +25,8 @@ struct ModuleClient: _Client { } } +// MARK: Testable + extension ModuleClient: Testable { struct Tests: TestTarget { var name: String { "ModuleClientTests" } diff --git a/Package/Sources/Clients/PlayerClient.swift b/Package/Sources/Clients/PlayerClient.swift index 6c27ecb..cb3de95 100644 --- a/Package/Sources/Clients/PlayerClient.swift +++ b/Package/Sources/Clients/PlayerClient.swift @@ -1,9 +1,9 @@ // // PlayerClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/PlaylistHistoryClient.swift b/Package/Sources/Clients/PlaylistHistoryClient.swift new file mode 100644 index 0000000..b6f0b5e --- /dev/null +++ b/Package/Sources/Clients/PlaylistHistoryClient.swift @@ -0,0 +1,18 @@ +// +// PlaylistHistoryClient.swift +// +// +// Created by DeNeRr on 29.01.2024. +// + +import Foundation + +struct PlaylistHistoryClient: _Client { + var dependencies: any Dependencies { + DatabaseClient() + SharedModels() + Semaphore() + Tagged() + ComposableArchitecture() + } +} diff --git a/Package/Sources/Clients/RepoClient.swift b/Package/Sources/Clients/RepoClient.swift index a499ee5..6a97f80 100644 --- a/Package/Sources/Clients/RepoClient.swift +++ b/Package/Sources/Clients/RepoClient.swift @@ -1,9 +1,9 @@ // // RepoClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/UserDefaultsClient.swift b/Package/Sources/Clients/UserDefaultsClient.swift index a4eb81e..af4a0ee 100644 --- a/Package/Sources/Clients/UserDefaultsClient.swift +++ b/Package/Sources/Clients/UserDefaultsClient.swift @@ -1,9 +1,9 @@ // // UserDefaultsClient.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/UserSettingsClient.swift b/Package/Sources/Clients/UserSettingsClient.swift index 2200e2c..fcd4b52 100644 --- a/Package/Sources/Clients/UserSettingsClient.swift +++ b/Package/Sources/Clients/UserSettingsClient.swift @@ -1,9 +1,9 @@ // -// File.swift -// +// UserSettingsClient.swift +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Clients/_Client.swift b/Package/Sources/Clients/_Client.swift index 760ed84..43b2d7e 100644 --- a/Package/Sources/Clients/_Client.swift +++ b/Package/Sources/Clients/_Client.swift @@ -1,17 +1,19 @@ // // _Client.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - _Client + protocol _Client: Product, Target {} extension _Client { var path: String? { - "Sources/Clients/\(self.name)" + "Sources/Clients/\(name)" } } diff --git a/Package/Sources/Dependencies/ComposableArchitecture.swift b/Package/Sources/Dependencies/ComposableArchitecture.swift index 5d9daf4..c8f4861 100644 --- a/Package/Sources/Dependencies/ComposableArchitecture.swift +++ b/Package/Sources/Dependencies/ComposableArchitecture.swift @@ -1,9 +1,9 @@ // // ComposableArchitecture.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct ComposableArchitecture: PackageDependency { diff --git a/Package/Sources/Dependencies/CustomDump.swift b/Package/Sources/Dependencies/CustomDump.swift index 0985141..b6ef44f 100644 --- a/Package/Sources/Dependencies/CustomDump.swift +++ b/Package/Sources/Dependencies/CustomDump.swift @@ -3,13 +3,13 @@ // // // Created by ErrorErrorError on 1/1/24. -// +// // import Foundation struct CustomDump: PackageDependency { - var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") - } + var dependency: Package.Dependency { + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") + } } diff --git a/Package/Sources/Dependencies/FluidGradient.swift b/Package/Sources/Dependencies/FluidGradient.swift index 01aec0e..78f32ec 100644 --- a/Package/Sources/Dependencies/FluidGradient.swift +++ b/Package/Sources/Dependencies/FluidGradient.swift @@ -1,9 +1,9 @@ // // FluidGradient.swift -// +// // // Created by ErrorErrorError on 10/11/23. -// +// // import Foundation diff --git a/Package/Sources/Dependencies/Nuke.swift b/Package/Sources/Dependencies/Nuke.swift index 0a791a0..0c1d9b0 100644 --- a/Package/Sources/Dependencies/Nuke.swift +++ b/Package/Sources/Dependencies/Nuke.swift @@ -1,11 +1,13 @@ // // Nuke.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // +// MARK: - Nuke + struct Nuke: PackageDependency { static let nukeURL = "https://github.com/kean/Nuke.git" static let nukeVersion: Version = "12.1.6" @@ -15,6 +17,8 @@ struct Nuke: PackageDependency { } } +// MARK: - NukeUI + struct NukeUI: PackageDependency { var dependency: Package.Dependency { .package(url: Nuke.nukeURL, exact: Nuke.nukeVersion) diff --git a/Package/Sources/Dependencies/Parsing.swift b/Package/Sources/Dependencies/Parsing.swift index 28e36b1..71203f1 100644 --- a/Package/Sources/Dependencies/Parsing.swift +++ b/Package/Sources/Dependencies/Parsing.swift @@ -3,7 +3,7 @@ // // // Created by ErrorErrorError on 12/17/23. -// +// // struct Parsing: PackageDependency { diff --git a/Package/Sources/Dependencies/Semaphore.swift b/Package/Sources/Dependencies/Semaphore.swift index 02fd61d..3f2d66a 100644 --- a/Package/Sources/Dependencies/Semaphore.swift +++ b/Package/Sources/Dependencies/Semaphore.swift @@ -1,9 +1,9 @@ // // Semaphore.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct Semaphore: PackageDependency { diff --git a/Package/Sources/Dependencies/Semver.swift b/Package/Sources/Dependencies/Semver.swift index 9fd49d5..5b100b0 100644 --- a/Package/Sources/Dependencies/Semver.swift +++ b/Package/Sources/Dependencies/Semver.swift @@ -1,9 +1,9 @@ // // Semver.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct Semver: PackageDependency { diff --git a/Package/Sources/Dependencies/SwiftLog.swift b/Package/Sources/Dependencies/SwiftLog.swift index 8191bed..7045e92 100644 --- a/Package/Sources/Dependencies/SwiftLog.swift +++ b/Package/Sources/Dependencies/SwiftLog.swift @@ -1,11 +1,13 @@ // -// File.swift -// +// SwiftLog.swift +// // // Created by ErrorErrorError on 11/9/23. -// +// // +// MARK: - SwiftLog + struct SwiftLog: PackageDependency { var name: String { "swift-log" } var productName: String { "swift-log" } @@ -15,6 +17,8 @@ struct SwiftLog: PackageDependency { } } +// MARK: - Logging + struct Logging: _Depending, Dependency { var targetDepenency: _PackageDescription_TargetDependency { .product(name: "\(Self.self)", package: SwiftLog().packageName) diff --git a/Package/Sources/Dependencies/SwiftSoup.swift b/Package/Sources/Dependencies/SwiftSoup.swift index 217e280..a351f96 100644 --- a/Package/Sources/Dependencies/SwiftSoup.swift +++ b/Package/Sources/Dependencies/SwiftSoup.swift @@ -1,9 +1,9 @@ // // SwiftSoup.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct SwiftSoup: PackageDependency { diff --git a/Package/Sources/Dependencies/SwiftSyntax.swift b/Package/Sources/Dependencies/SwiftSyntax.swift index 3b4ecdb..a228801 100644 --- a/Package/Sources/Dependencies/SwiftSyntax.swift +++ b/Package/Sources/Dependencies/SwiftSyntax.swift @@ -1,19 +1,23 @@ // // SwiftSyntax.swift -// +// // // Created by ErrorErrorError on 10/11/23. -// +// // import Foundation +// MARK: - SwiftSyntax + struct SwiftSyntax: PackageDependency { var dependency: Package.Dependency { .package(url: "https://github.com/apple/swift-syntax", from: "509.0.1") } } +// MARK: - SwiftSyntaxMacros + struct SwiftSyntaxMacros: _Depending, Dependency { var targetDepenency: _PackageDescription_TargetDependency { .product(name: "\(Self.self)", package: SwiftSyntax().packageName) @@ -24,6 +28,8 @@ struct SwiftSyntaxMacros: _Depending, Dependency { } } +// MARK: - SwiftCompilerPlugin + struct SwiftCompilerPlugin: _Depending, Dependency { var targetDepenency: _PackageDescription_TargetDependency { .product(name: "\(Self.self)", package: SwiftSyntax().packageName) @@ -33,4 +39,3 @@ struct SwiftCompilerPlugin: _Depending, Dependency { SwiftSyntax() } } - diff --git a/Package/Sources/Dependencies/SwiftUIBackports.swift b/Package/Sources/Dependencies/SwiftUIBackports.swift index ed9378a..a370293 100644 --- a/Package/Sources/Dependencies/SwiftUIBackports.swift +++ b/Package/Sources/Dependencies/SwiftUIBackports.swift @@ -1,9 +1,9 @@ // // SwiftUIBackports.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // struct SwiftUIBackports: PackageDependency { diff --git a/Package/Sources/Dependencies/Tagged.swift b/Package/Sources/Dependencies/Tagged.swift index e015231..234113d 100644 --- a/Package/Sources/Dependencies/Tagged.swift +++ b/Package/Sources/Dependencies/Tagged.swift @@ -1,9 +1,9 @@ // -// File.swift -// +// Tagged.swift +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Dependencies/XMLCoder.swift b/Package/Sources/Dependencies/XMLCoder.swift index 78e57f7..abb2888 100644 --- a/Package/Sources/Dependencies/XMLCoder.swift +++ b/Package/Sources/Dependencies/XMLCoder.swift @@ -3,7 +3,7 @@ // // // Created by ErrorErrorError on 12/27/23. -// +// // struct XMLCoder: PackageDependency { @@ -11,4 +11,3 @@ struct XMLCoder: PackageDependency { .package(url: "https://github.com/CoreOffice/XMLCoder.git", exact: "0.17.1") } } - diff --git a/Package/Sources/Features/ContentCore.swift b/Package/Sources/Features/ContentCore.swift index d027582..2a8495e 100644 --- a/Package/Sources/Features/ContentCore.swift +++ b/Package/Sources/Features/ContentCore.swift @@ -1,9 +1,9 @@ // // ContentCore.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Features/Discover.swift b/Package/Sources/Features/Discover.swift index 8947e46..4d3ba1b 100644 --- a/Package/Sources/Features/Discover.swift +++ b/Package/Sources/Features/Discover.swift @@ -1,9 +1,9 @@ // // Discover.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Features/MochiApp.swift b/Package/Sources/Features/MochiApp.swift index c29471c..964b5c9 100644 --- a/Package/Sources/Features/MochiApp.swift +++ b/Package/Sources/Features/MochiApp.swift @@ -1,9 +1,9 @@ // // MochiApp.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // import Foundation diff --git a/Package/Sources/Features/ModuleLists.swift b/Package/Sources/Features/ModuleLists.swift index 2b28370..2327b76 100644 --- a/Package/Sources/Features/ModuleLists.swift +++ b/Package/Sources/Features/ModuleLists.swift @@ -1,9 +1,9 @@ // // ModuleLists.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Features/PlaylistDetails.swift b/Package/Sources/Features/PlaylistDetails.swift index 0d9f795..c733c9d 100644 --- a/Package/Sources/Features/PlaylistDetails.swift +++ b/Package/Sources/Features/PlaylistDetails.swift @@ -1,9 +1,9 @@ // // PlaylistDetails.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation @@ -15,6 +15,7 @@ struct PlaylistDetails: _Feature { LoggerClient() ModuleClient() RepoClient() + PlaylistHistoryClient() Styling() SharedModels() ViewComponents() diff --git a/Package/Sources/Features/Repos.swift b/Package/Sources/Features/Repos.swift index e9e19f8..a436061 100644 --- a/Package/Sources/Features/Repos.swift +++ b/Package/Sources/Features/Repos.swift @@ -1,9 +1,9 @@ // // Repos.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Features/Search.swift b/Package/Sources/Features/Search.swift index 4dfca9c..4e980f7 100644 --- a/Package/Sources/Features/Search.swift +++ b/Package/Sources/Features/Search.swift @@ -1,9 +1,9 @@ // // Search.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Features/Settings.swift b/Package/Sources/Features/Settings.swift index c96ee80..000ed64 100644 --- a/Package/Sources/Features/Settings.swift +++ b/Package/Sources/Features/Settings.swift @@ -1,9 +1,9 @@ // // Settings.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // struct Settings: _Feature { diff --git a/Package/Sources/Features/VideoPlayer.swift b/Package/Sources/Features/VideoPlayer.swift index ccb11d9..b862fa1 100644 --- a/Package/Sources/Features/VideoPlayer.swift +++ b/Package/Sources/Features/VideoPlayer.swift @@ -1,9 +1,9 @@ // // VideoPlayer.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // struct VideoPlayer: _Feature { diff --git a/Package/Sources/Features/_Feature.swift b/Package/Sources/Features/_Feature.swift index ca747df..8988146 100644 --- a/Package/Sources/Features/_Feature.swift +++ b/Package/Sources/Features/_Feature.swift @@ -1,17 +1,19 @@ // -// File.swift -// +// _Feature.swift +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - _Feature + protocol _Feature: Product, Target {} extension _Feature { var path: String? { - "Sources/Features/\(self.name)" + "Sources/Features/\(name)" } } diff --git a/Package/Sources/Index.swift b/Package/Sources/Index.swift index 2064333..83c0522 100644 --- a/Package/Sources/Index.swift +++ b/Package/Sources/Index.swift @@ -1,9 +1,9 @@ // // Index.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // import Foundation diff --git a/Package/Sources/Macros/CoreDBMacros.swift b/Package/Sources/Macros/CoreDBMacros.swift index ce4d8a6..04bb554 100644 --- a/Package/Sources/Macros/CoreDBMacros.swift +++ b/Package/Sources/Macros/CoreDBMacros.swift @@ -3,12 +3,12 @@ // // // Created by ErrorErrorError on 12/28/23. -// +// // struct CoreDBMacros: _Macro { - var dependencies: any Dependencies { - SwiftSyntaxMacros() - SwiftCompilerPlugin() - } + var dependencies: any Dependencies { + SwiftSyntaxMacros() + SwiftCompilerPlugin() + } } diff --git a/Package/Sources/Macros/_Macro.swift b/Package/Sources/Macros/_Macro.swift index 59aeec4..a510cd4 100644 --- a/Package/Sources/Macros/_Macro.swift +++ b/Package/Sources/Macros/_Macro.swift @@ -1,17 +1,19 @@ // -// File.swift -// +// _Macro.swift +// // // Created by ErrorErrorError on 10/27/23. -// +// // import Foundation +// MARK: - _Macro + protocol _Macro: Macro {} extension _Macro { var path: String? { - "Sources/Macros/\(self.name)" + "Sources/Macros/\(name)" } } diff --git a/Package/Sources/Platforms/MochiPlatforms.swift b/Package/Sources/Platforms/MochiPlatforms.swift index d53de79..d3fa63e 100644 --- a/Package/Sources/Platforms/MochiPlatforms.swift +++ b/Package/Sources/Platforms/MochiPlatforms.swift @@ -1,9 +1,9 @@ // // MochiPlatforms.swift -// +// // // Created by ErrorErrorError on 10/4/23. -// +// // import Foundation diff --git a/Package/Sources/Shared/Architecture.swift b/Package/Sources/Shared/Architecture.swift index 433c0fa..9af7860 100644 --- a/Package/Sources/Shared/Architecture.swift +++ b/Package/Sources/Shared/Architecture.swift @@ -1,9 +1,9 @@ // // Architecture.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // struct Architecture: _Shared { diff --git a/Package/Sources/Shared/CoreDB.swift b/Package/Sources/Shared/CoreDB.swift index 42f4a8c..8b0297e 100644 --- a/Package/Sources/Shared/CoreDB.swift +++ b/Package/Sources/Shared/CoreDB.swift @@ -3,22 +3,26 @@ // // // Created by ErrorErrorError on 12/28/23. -// // +// + +// MARK: - CoreDB struct CoreDB: _Shared { var dependencies: any Dependencies { - CoreDBMacros() + CoreDBMacros() } } +// MARK: Testable + extension CoreDB: Testable { - struct Tests: TestTarget { - var name: String { "CoreDBTests" } + struct Tests: TestTarget { + var name: String { "CoreDBTests" } - var dependencies: any Dependencies { - CoreDB() - CustomDump() + var dependencies: any Dependencies { + CoreDB() + CustomDump() + } } - } } diff --git a/Package/Sources/Shared/FoundationHelpers.swift b/Package/Sources/Shared/FoundationHelpers.swift index 0def3af..2ca879a 100644 --- a/Package/Sources/Shared/FoundationHelpers.swift +++ b/Package/Sources/Shared/FoundationHelpers.swift @@ -1,9 +1,9 @@ // // FoundationHelpers.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // struct FoundationHelpers: _Shared {} diff --git a/Package/Sources/Shared/JSValueCoder.swift b/Package/Sources/Shared/JSValueCoder.swift index ff6f519..f7d42d1 100644 --- a/Package/Sources/Shared/JSValueCoder.swift +++ b/Package/Sources/Shared/JSValueCoder.swift @@ -3,13 +3,17 @@ // // // Created by ErrorErrorError on 11/6/23. -// +// // import Foundation +// MARK: - JSValueCoder + struct JSValueCoder: _Shared {} +// MARK: Testable + extension JSValueCoder: Testable { struct Tests: TestTarget { var name: String { "JSValueCoderTests" } diff --git a/Package/Sources/Shared/SharedModels.swift b/Package/Sources/Shared/SharedModels.swift index be697bc..4599c13 100644 --- a/Package/Sources/Shared/SharedModels.swift +++ b/Package/Sources/Shared/SharedModels.swift @@ -1,9 +1,9 @@ // // SharedModels.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Shared/Styling.swift b/Package/Sources/Shared/Styling.swift index 1e32e4f..b0c22b8 100644 --- a/Package/Sources/Shared/Styling.swift +++ b/Package/Sources/Shared/Styling.swift @@ -1,9 +1,9 @@ // -// File.swift -// +// Styling.swift +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Shared/ViewComponents.swift b/Package/Sources/Shared/ViewComponents.swift index ad15d79..e72364a 100644 --- a/Package/Sources/Shared/ViewComponents.swift +++ b/Package/Sources/Shared/ViewComponents.swift @@ -1,9 +1,9 @@ // -// File.swift -// +// ViewComponents.swift +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation diff --git a/Package/Sources/Shared/_Shared.swift b/Package/Sources/Shared/_Shared.swift index 92e2f96..dab1af1 100644 --- a/Package/Sources/Shared/_Shared.swift +++ b/Package/Sources/Shared/_Shared.swift @@ -1,17 +1,19 @@ // -// Shared.swift -// +// _Shared.swift +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - _Shared + protocol _Shared: Product, Target {} extension _Shared { var path: String? { - "Sources/Shared/\(self.name)" + "Sources/Shared/\(name)" } } diff --git a/Package/Support/Array+Depedencies.swift b/Package/Support/Array+Depedencies.swift index e74cf90..7305e73 100644 --- a/Package/Support/Array+Depedencies.swift +++ b/Package/Support/Array+Depedencies.swift @@ -4,8 +4,8 @@ // Licensed under MIT License // -extension Array: Dependencies where Element == Dependency { - func appending(_ dependencies: any Dependencies) -> [Dependency] { - self + dependencies - } +extension [Dependency]: Dependencies { + func appending(_ dependencies: any Dependencies) -> [Dependency] { + self + dependencies + } } diff --git a/Package/Support/Array+SupportedPlatforms.swift b/Package/Support/Array+SupportedPlatforms.swift index d8d30ea..7df3498 100644 --- a/Package/Support/Array+SupportedPlatforms.swift +++ b/Package/Support/Array+SupportedPlatforms.swift @@ -4,8 +4,8 @@ // Licensed under MIT License // -extension Array: SupportedPlatforms where Element == SupportedPlatform { - func appending(_ platforms: any SupportedPlatforms) -> Self { - self + .init(platforms) - } +extension [SupportedPlatform]: SupportedPlatforms { + func appending(_ platforms: any SupportedPlatforms) -> Self { + self + .init(platforms) + } } diff --git a/Package/Support/Array+TestTargets.swift b/Package/Support/Array+TestTargets.swift index 627b66c..eb2f9ba 100644 --- a/Package/Support/Array+TestTargets.swift +++ b/Package/Support/Array+TestTargets.swift @@ -4,8 +4,8 @@ // Licensed under MIT License // -extension Array: TestTargets where Element == TestTarget { - func appending(_ testTargets: any TestTargets) -> [TestTarget] { - self + testTargets - } +extension [TestTarget]: TestTargets { + func appending(_ testTargets: any TestTargets) -> [TestTarget] { + self + testTargets + } } diff --git a/Package/Support/CSettingsBuilder.swift b/Package/Support/CSettingsBuilder.swift index e26ef94..f992f92 100644 --- a/Package/Support/CSettingsBuilder.swift +++ b/Package/Support/CSettingsBuilder.swift @@ -1,18 +1,18 @@ // // CSettingsBuilder.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // @resultBuilder enum CSettingsBuilder { - static func buildPartialBlock(first: CSetting) -> [CSetting] { - [first] - } + static func buildPartialBlock(first: CSetting) -> [CSetting] { + [first] + } - static func buildPartialBlock(accumulated: [CSetting], next: CSetting) -> [CSetting] { - accumulated + [next] - } + static func buildPartialBlock(accumulated: [CSetting], next: CSetting) -> [CSetting] { + accumulated + [next] + } } diff --git a/Package/Support/Dependencies.swift b/Package/Support/Dependencies.swift index 495d533..f8c7cf4 100644 --- a/Package/Support/Dependencies.swift +++ b/Package/Support/Dependencies.swift @@ -5,7 +5,7 @@ // protocol Dependencies: Sequence where Element == Dependency { - // swiftlint:disable:next identifier_name - init(_ s: S) where S.Element == Dependency, S: Sequence - func appending(_ dependencies: any Dependencies) -> Self + // swiftlint:disable:next identifier_name + init(_ s: S) where S.Element == Dependency, S: Sequence + func appending(_ dependencies: any Dependencies) -> Self } diff --git a/Package/Support/Dependency.swift b/Package/Support/Dependency.swift index 7084e96..28f8daa 100644 --- a/Package/Support/Dependency.swift +++ b/Package/Support/Dependency.swift @@ -5,5 +5,5 @@ // protocol Dependency { - var targetDepenency: _PackageDescription_TargetDependency { get } + var targetDepenency: _PackageDescription_TargetDependency { get } } diff --git a/Package/Support/DependencyBuilder.swift b/Package/Support/DependencyBuilder.swift index fb93ba0..4831acd 100644 --- a/Package/Support/DependencyBuilder.swift +++ b/Package/Support/DependencyBuilder.swift @@ -6,11 +6,11 @@ @resultBuilder enum DependencyBuilder { - static func buildPartialBlock(first: Dependency) -> any Dependencies { - [first] - } + static func buildPartialBlock(first: Dependency) -> any Dependencies { + [first] + } - static func buildPartialBlock(accumulated: any Dependencies, next: Dependency) -> any Dependencies { - accumulated + [next] - } + static func buildPartialBlock(accumulated: any Dependencies, next: Dependency) -> any Dependencies { + accumulated + [next] + } } diff --git a/Package/Support/LanguageTag.swift b/Package/Support/LanguageTag.swift index d169327..1c34cba 100644 --- a/Package/Support/LanguageTag.swift +++ b/Package/Support/LanguageTag.swift @@ -5,5 +5,5 @@ // extension LanguageTag { - static let english: LanguageTag = "en" + static let english: LanguageTag = "en" } diff --git a/Package/Support/Macro.swift b/Package/Support/Macro.swift index b30b220..37e6669 100644 --- a/Package/Support/Macro.swift +++ b/Package/Support/Macro.swift @@ -3,32 +3,34 @@ // // // Created by ErrorErrorError on 10/11/23. -// +// // import CompilerPluginSupport import Foundation +// MARK: - Macro + protocol Macro: Target {} extension Macro { - var targetType: TargetType { - .macro - } + var targetType: TargetType { + .macro + } - var targetDepenency: _PackageDescription_TargetDependency { - .target(name: self.name) - } + var targetDepenency: _PackageDescription_TargetDependency { + .target(name: name) + } - var cSettings: [CSetting] { - [] - } + var cSettings: [CSetting] { + [] + } - var swiftSettings: [SwiftSetting] { - [] - } + var swiftSettings: [SwiftSetting] { + [] + } - var resources: [Resource] { - [] - } + var resources: [Resource] { + [] + } } diff --git a/Package/Support/Package+Extensions.swift b/Package/Support/Package+Extensions.swift index 51e15d6..8275def 100644 --- a/Package/Support/Package+Extensions.swift +++ b/Package/Support/Package+Extensions.swift @@ -5,61 +5,62 @@ // extension Package { - convenience init( - name: String? = nil, - @ProductsBuilder entries: @escaping () -> [Product], - @TestTargetBuilder testTargets: @escaping () -> any TestTargets = { [TestTarget]() }, - @SwiftSettingsBuilder swiftSettings: @escaping () -> [SwiftSetting] = { [SwiftSetting]() } - ) { - let packageName: String - if let name { - packageName = name - } else { - var pathComponents = #filePath.split(separator: "/") - pathComponents.removeLast() - // swiftlint:disable:next force_unwrapping - packageName = String(pathComponents.last!) - } - let allTestTargets = testTargets() - let entries = entries() - let products = entries.map(_PackageDescription_Product.entry) - var targets = entries.flatMap(\.productTargets) - let allTargetsDependencies = targets.flatMap { $0.allDependencies() } - let allTestTargetsDependencies = allTestTargets.flatMap { $0.allDependencies() } - let dependencies = allTargetsDependencies + allTestTargetsDependencies - let targetDependencies = dependencies.compactMap { $0 as? Target } - let packageDependencies = dependencies.compactMap { $0 as? PackageDependency } - targets += targetDependencies - targets += allTestTargets.map { $0 as Target } -// assert(targetDependencies.count + packageDependencies.count == dependencies.count, "there was a miscount of target dependencies - target: \(targetDependencies.count), package: \(packageDependencies.count), expected: \(dependencies.count)") + convenience init( + name: String? = nil, + @ProductsBuilder entries: @escaping () -> [Product], + @TestTargetBuilder testTargets: @escaping () -> any TestTargets = { [TestTarget]() }, + @SwiftSettingsBuilder swiftSettings: @escaping () -> [SwiftSetting] = { [SwiftSetting]() } + ) { + let packageName: String + if let name { + packageName = name + } else { + var pathComponents = #filePath.split(separator: "/") + pathComponents.removeLast() + // swiftlint:disable:next force_unwrapping + packageName = String(pathComponents.last!) + } + let allTestTargets = testTargets() + let entries = entries() + let products = entries.map(_PackageDescription_Product.entry) + var targets = entries.flatMap(\.productTargets) + let allTargetsDependencies = targets.flatMap { $0.allDependencies() } + let allTestTargetsDependencies = allTestTargets.flatMap { $0.allDependencies() } + let dependencies = allTargetsDependencies + allTestTargetsDependencies + let targetDependencies = dependencies.compactMap { $0 as? Target } + let packageDependencies = dependencies.compactMap { $0 as? PackageDependency } + targets += targetDependencies + targets += allTestTargets.map { $0 as Target } +// assert(targetDependencies.count + packageDependencies.count == dependencies.count, "there was a miscount of target dependencies - target: \(targetDependencies.count), package: +// \(packageDependencies.count), expected: \(dependencies.count)") - let packgeTargets = Dictionary( - grouping: targets, - by: { $0.name } - ) - .values - .compactMap(\.first) - .map { _PackageDescription_Target.entry($0, swiftSettings: swiftSettings()) } + let packgeTargets = Dictionary( + grouping: targets, + by: { $0.name } + ) + .values + .compactMap(\.first) + .map { _PackageDescription_Target.entry($0, swiftSettings: swiftSettings()) } - let packageDeps = Dictionary( - grouping: packageDependencies, - by: { $0.productName } - ).values.compactMap(\.first).map(\.dependency) + let packageDeps = Dictionary( + grouping: packageDependencies, + by: { $0.productName } + ).values.compactMap(\.first).map(\.dependency) - self.init(name: packageName, products: products, dependencies: packageDeps, targets: packgeTargets) - } + self.init(name: packageName, products: products, dependencies: packageDeps, targets: packgeTargets) + } } extension Package { - func supportedPlatforms( - @SupportedPlatformBuilder supportedPlatforms: @escaping () -> any SupportedPlatforms - ) -> Package { - self.platforms = .init(supportedPlatforms()) - return self - } + func supportedPlatforms( + @SupportedPlatformBuilder supportedPlatforms: @escaping () -> any SupportedPlatforms + ) -> Package { + platforms = .init(supportedPlatforms()) + return self + } - func defaultLocalization(_ defaultLocalization: LanguageTag) -> Package { - self.defaultLocalization = defaultLocalization - return self - } + func defaultLocalization(_ defaultLocalization: LanguageTag) -> Package { + self.defaultLocalization = defaultLocalization + return self + } } diff --git a/Package/Support/PackageDependency.swift b/Package/Support/PackageDependency.swift index 21828e0..8b62aa3 100644 --- a/Package/Support/PackageDependency.swift +++ b/Package/Support/PackageDependency.swift @@ -6,39 +6,41 @@ import PackageDescription +// MARK: - PackageDependency + protocol PackageDependency: Dependency { - init() + init() - var packageName: String { get } - var dependency: _PackageDescription_PackageDependency { get } + var packageName: String { get } + var dependency: _PackageDescription_PackageDependency { get } } extension PackageDependency { - var productName: String { - "\(Self.self)" - } - - var packageName : String { - switch self.dependency.kind { - case let .sourceControl(name: name, location: location, requirement: _): - return name ?? location.packageName ?? productName - case let .fileSystem(name: name, path: path): - return name ?? path.packageName ?? productName - case let .registry(id: id, requirement: _): - return id - @unknown default: - return productName + var productName: String { + "\(Self.self)" + } + + var packageName: String { + switch dependency.kind { + case let .sourceControl(name: name, location: location, requirement: _): + return name ?? location.packageName ?? productName + case let .fileSystem(name: name, path: path): + return name ?? path.packageName ?? productName + case let .registry(id: id, requirement: _): + return id + @unknown default: + return productName + } } - } - var targetDepenency: _PackageDescription_TargetDependency { - switch self.dependency.kind { - case let .sourceControl(name: name, location: location, requirement: _): - let packageName = name ?? location.packageName - return .product(name: productName, package: packageName) + var targetDepenency: _PackageDescription_TargetDependency { + switch dependency.kind { + case let .sourceControl(name: name, location: location, requirement: _): + let packageName = name ?? location.packageName + return .product(name: productName, package: packageName) - default: - return .byName(name: productName) + default: + return .byName(name: productName) + } } - } } diff --git a/Package/Support/PlatformSet.swift b/Package/Support/PlatformSet.swift index 62c9290..3c76ddc 100644 --- a/Package/Support/PlatformSet.swift +++ b/Package/Support/PlatformSet.swift @@ -5,6 +5,6 @@ // protocol PlatformSet { - @SupportedPlatformBuilder - var body: any SupportedPlatforms { get } + @SupportedPlatformBuilder + var body: any SupportedPlatforms { get } } diff --git a/Package/Support/Product+Target.swift b/Package/Support/Product+Target.swift index f3ebe62..31301a5 100644 --- a/Package/Support/Product+Target.swift +++ b/Package/Support/Product+Target.swift @@ -5,17 +5,17 @@ // extension Product where Self: Target { - var productTargets: [Target] { - [self] - } + var productTargets: [Target] { + [self] + } - var targetType: TargetType { - switch self.productType { - case .library: - return .regular + var targetType: TargetType { + switch productType { + case .library: + .regular - case .executable: - return .executable + case .executable: + .executable + } } - } } diff --git a/Package/Support/Product.swift b/Package/Support/Product.swift index ac3c910..11dcba9 100644 --- a/Package/Support/Product.swift +++ b/Package/Support/Product.swift @@ -4,13 +4,15 @@ // Licensed under MIT License // +// MARK: - Product + protocol Product: _Named { - var productTargets: [Target] { get } - var productType: ProductType { get } + var productTargets: [Target] { get } + var productType: ProductType { get } } extension Product { - var productType: ProductType { - .library - } + var productType: ProductType { + .library + } } diff --git a/Package/Support/ProductType.swift b/Package/Support/ProductType.swift index c8df09f..210bad1 100644 --- a/Package/Support/ProductType.swift +++ b/Package/Support/ProductType.swift @@ -5,6 +5,6 @@ // enum ProductType { - case library - case executable + case library + case executable } diff --git a/Package/Support/ProductsBuilder.swift b/Package/Support/ProductsBuilder.swift index 5805924..23eeed4 100644 --- a/Package/Support/ProductsBuilder.swift +++ b/Package/Support/ProductsBuilder.swift @@ -6,11 +6,11 @@ @resultBuilder enum ProductsBuilder { - static func buildPartialBlock(first: Product) -> [Product] { - [first] - } + static func buildPartialBlock(first: Product) -> [Product] { + [first] + } - static func buildPartialBlock(accumulated: [Product], next: Product) -> [Product] { - accumulated + [next] - } + static func buildPartialBlock(accumulated: [Product], next: Product) -> [Product] { + accumulated + [next] + } } diff --git a/Package/Support/ResourcesBuilder.swift b/Package/Support/ResourcesBuilder.swift index 8b77c66..8572e81 100644 --- a/Package/Support/ResourcesBuilder.swift +++ b/Package/Support/ResourcesBuilder.swift @@ -6,11 +6,11 @@ @resultBuilder enum ResourcesBuilder { - static func buildPartialBlock(first: Resource) -> [Resource] { - [first] - } + static func buildPartialBlock(first: Resource) -> [Resource] { + [first] + } - static func buildPartialBlock(accumulated: [Resource], next: Resource) -> [Resource] { - accumulated + [next] - } + static func buildPartialBlock(accumulated: [Resource], next: Resource) -> [Resource] { + accumulated + [next] + } } diff --git a/Package/Support/String.swift b/Package/Support/String.swift index 0e2e62c..de22bef 100644 --- a/Package/Support/String.swift +++ b/Package/Support/String.swift @@ -5,7 +5,7 @@ // extension String { - var packageName: String? { - self.split(separator: "/").last?.split(separator: ".").first.map(String.init) - } + var packageName: String? { + split(separator: "/").last?.split(separator: ".").first.map(String.init) + } } diff --git a/Package/Support/SupportedPlatformBuilder.swift b/Package/Support/SupportedPlatformBuilder.swift index d3be56c..c4c9ac1 100644 --- a/Package/Support/SupportedPlatformBuilder.swift +++ b/Package/Support/SupportedPlatformBuilder.swift @@ -8,29 +8,29 @@ import PackageDescription @resultBuilder enum SupportedPlatformBuilder { - static func buildPartialBlock(first: SupportedPlatform) -> any SupportedPlatforms { - [first] - } + static func buildPartialBlock(first: SupportedPlatform) -> any SupportedPlatforms { + [first] + } - static func buildPartialBlock(first: PlatformSet) -> any SupportedPlatforms { - first.body - } + static func buildPartialBlock(first: PlatformSet) -> any SupportedPlatforms { + first.body + } - static func buildPartialBlock(first: any SupportedPlatforms) -> any SupportedPlatforms { - first - } + static func buildPartialBlock(first: any SupportedPlatforms) -> any SupportedPlatforms { + first + } - static func buildPartialBlock( - accumulated: any SupportedPlatforms, - next: any SupportedPlatforms - ) -> any SupportedPlatforms { - accumulated.appending(next) - } + static func buildPartialBlock( + accumulated: any SupportedPlatforms, + next: any SupportedPlatforms + ) -> any SupportedPlatforms { + accumulated.appending(next) + } - static func buildPartialBlock( - accumulated: any SupportedPlatforms, - next: SupportedPlatform - ) -> any SupportedPlatforms { - accumulated.appending([next]) - } + static func buildPartialBlock( + accumulated: any SupportedPlatforms, + next: SupportedPlatform + ) -> any SupportedPlatforms { + accumulated.appending([next]) + } } diff --git a/Package/Support/SupportedPlatforms.swift b/Package/Support/SupportedPlatforms.swift index 1388f4e..e4e8629 100644 --- a/Package/Support/SupportedPlatforms.swift +++ b/Package/Support/SupportedPlatforms.swift @@ -5,7 +5,7 @@ // protocol SupportedPlatforms: Sequence where Element == SupportedPlatform { - // swiftlint:disable:next identifier_name - init(_ s: S) where S.Element == SupportedPlatform, S: Sequence - func appending(_ platforms: any SupportedPlatforms) -> Self + // swiftlint:disable:next identifier_name + init(_ s: S) where S.Element == SupportedPlatform, S: Sequence + func appending(_ platforms: any SupportedPlatforms) -> Self } diff --git a/Package/Support/SwiftSettingsBuilder.swift b/Package/Support/SwiftSettingsBuilder.swift index c8c7bf7..3321575 100644 --- a/Package/Support/SwiftSettingsBuilder.swift +++ b/Package/Support/SwiftSettingsBuilder.swift @@ -6,11 +6,11 @@ @resultBuilder enum SwiftSettingsBuilder { - static func buildPartialBlock(first: SwiftSetting) -> [SwiftSetting] { - [first] - } + static func buildPartialBlock(first: SwiftSetting) -> [SwiftSetting] { + [first] + } - static func buildPartialBlock(accumulated: [SwiftSetting], next: SwiftSetting) -> [SwiftSetting] { - accumulated + [next] - } + static func buildPartialBlock(accumulated: [SwiftSetting], next: SwiftSetting) -> [SwiftSetting] { + accumulated + [next] + } } diff --git a/Package/Support/Target.swift b/Package/Support/Target.swift index 8e33ba1..1cfd62d 100644 --- a/Package/Support/Target.swift +++ b/Package/Support/Target.swift @@ -4,37 +4,39 @@ // Licensed under MIT License // +// MARK: - Target + protocol Target: _Depending, Dependency, _Named, _Path { - var targetType: TargetType { get } + var targetType: TargetType { get } - @CSettingsBuilder - var cSettings: [CSetting] { get } + @CSettingsBuilder + var cSettings: [CSetting] { get } - @SwiftSettingsBuilder - var swiftSettings: [SwiftSetting] { get } + @SwiftSettingsBuilder + var swiftSettings: [SwiftSetting] { get } - @ResourcesBuilder - var resources: [Resource] { get } + @ResourcesBuilder + var resources: [Resource] { get } } extension Target { - var targetType: TargetType { - .regular - } + var targetType: TargetType { + .regular + } - var targetDepenency: _PackageDescription_TargetDependency { - .target(name: self.name) - } + var targetDepenency: _PackageDescription_TargetDependency { + .target(name: name) + } - var cSettings: [CSetting] { - [] - } + var cSettings: [CSetting] { + [] + } - var swiftSettings: [SwiftSetting] { - [] - } + var swiftSettings: [SwiftSetting] { + [] + } - var resources: [Resource] { - [] - } + var resources: [Resource] { + [] + } } diff --git a/Package/Support/TargetType.swift b/Package/Support/TargetType.swift index 21514b6..ecd066f 100644 --- a/Package/Support/TargetType.swift +++ b/Package/Support/TargetType.swift @@ -4,17 +4,17 @@ // Licensed under MIT License // -//typealias TargetType = Target.TargetType +// typealias TargetType = Target.TargetType enum TargetType { - case regular - case executable - case test - case binary(BinaryTarget) - case macro + case regular + case executable + case test + case binary(BinaryTarget) + case macro - enum BinaryTarget { - case path(String) - case remote(url: String, checksum: String) - } + enum BinaryTarget { + case path(String) + case remote(url: String, checksum: String) + } } diff --git a/Package/Support/TestTarget.swift b/Package/Support/TestTarget.swift index 1205a17..64faa89 100644 --- a/Package/Support/TestTarget.swift +++ b/Package/Support/TestTarget.swift @@ -4,10 +4,12 @@ // Licensed under MIT License // +// MARK: - TestTarget + protocol TestTarget: Target {} extension TestTarget { - var targetType: TargetType { - .test - } + var targetType: TargetType { + .test + } } diff --git a/Package/Support/TestTargetBuilder.swift b/Package/Support/TestTargetBuilder.swift index 5068a12..1356ce0 100644 --- a/Package/Support/TestTargetBuilder.swift +++ b/Package/Support/TestTargetBuilder.swift @@ -6,11 +6,11 @@ @resultBuilder enum TestTargetBuilder { - static func buildPartialBlock(first: TestTarget) -> any TestTargets { - [first] - } + static func buildPartialBlock(first: TestTarget) -> any TestTargets { + [first] + } - static func buildPartialBlock(accumulated: any TestTargets, next: TestTarget) -> any TestTargets { - accumulated + [next] - } + static func buildPartialBlock(accumulated: any TestTargets, next: TestTarget) -> any TestTargets { + accumulated + [next] + } } diff --git a/Package/Support/TestTargets.swift b/Package/Support/TestTargets.swift index 16dffbb..3c94a59 100644 --- a/Package/Support/TestTargets.swift +++ b/Package/Support/TestTargets.swift @@ -5,7 +5,7 @@ // protocol TestTargets: Sequence where Element == TestTarget { - // swiftlint:disable:next identifier_name - init(_ s: S) where S.Element == TestTarget, S: Sequence - func appending(_ testTargets: any TestTargets) -> Self + // swiftlint:disable:next identifier_name + init(_ s: S) where S.Element == TestTarget, S: Sequence + func appending(_ testTargets: any TestTargets) -> Self } diff --git a/Package/Support/_Depending.swift b/Package/Support/_Depending.swift index 118927e..2d47da3 100644 --- a/Package/Support/_Depending.swift +++ b/Package/Support/_Depending.swift @@ -4,25 +4,27 @@ // Licensed under MIT License // +// MARK: - _Depending + protocol _Depending { - @DependencyBuilder - var dependencies: any Dependencies { get } + @DependencyBuilder + var dependencies: any Dependencies { get } } extension _Depending { - var dependencies: any Dependencies { - [Dependency]() - } + var dependencies: any Dependencies { + [Dependency]() + } } extension _Depending { - func allDependencies() -> [Dependency] { - self.dependencies.compactMap { - $0 as? _Depending - } - .flatMap { - $0.allDependencies() + func allDependencies() -> [Dependency] { + dependencies.compactMap { + $0 as? _Depending + } + .flatMap { + $0.allDependencies() + } + .appending(dependencies) } - .appending(self.dependencies) - } } diff --git a/Package/Support/_Named.swift b/Package/Support/_Named.swift index 5da3ce0..384c2eb 100644 --- a/Package/Support/_Named.swift +++ b/Package/Support/_Named.swift @@ -4,12 +4,14 @@ // Licensed under MIT License // +// MARK: - _Named + protocol _Named { - var name: String { get } + var name: String { get } } extension _Named { - var name: String { - "\(Self.self)" - } + var name: String { + "\(Self.self)" + } } diff --git a/Package/Support/_PackageDescription_Product.swift b/Package/Support/_PackageDescription_Product.swift index 8620077..b92d660 100644 --- a/Package/Support/_PackageDescription_Product.swift +++ b/Package/Support/_PackageDescription_Product.swift @@ -5,15 +5,15 @@ // extension _PackageDescription_Product { - static func entry(_ entry: Product) -> _PackageDescription_Product { - let targets = entry.productTargets.map(\.name) + static func entry(_ entry: Product) -> _PackageDescription_Product { + let targets = entry.productTargets.map(\.name) - switch entry.productType { - case .executable: - return Self.executable(name: entry.name, targets: targets) + switch entry.productType { + case .executable: + return Self.executable(name: entry.name, targets: targets) - case .library: - return Self.library(name: entry.name, targets: targets) + case .library: + return Self.library(name: entry.name, targets: targets) + } } - } } diff --git a/Package/Support/_PackageDescription_Target.swift b/Package/Support/_PackageDescription_Target.swift index 5c75bfa..e12895b 100644 --- a/Package/Support/_PackageDescription_Target.swift +++ b/Package/Support/_PackageDescription_Target.swift @@ -5,59 +5,59 @@ // extension _PackageDescription_Target { - static func entry(_ entry: Target, swiftSettings: [SwiftSetting] = []) -> _PackageDescription_Target { - let dependencies = entry.dependencies.map(\.targetDepenency) - switch entry.targetType { - case .executable: - return .executableTarget( - name: entry.name, - dependencies: dependencies, - path: entry.path, - resources: entry.resources, - cSettings: entry.cSettings, - swiftSettings: swiftSettings + entry.swiftSettings - ) + static func entry(_ entry: Target, swiftSettings: [SwiftSetting] = []) -> _PackageDescription_Target { + let dependencies = entry.dependencies.map(\.targetDepenency) + switch entry.targetType { + case .executable: + return .executableTarget( + name: entry.name, + dependencies: dependencies, + path: entry.path, + resources: entry.resources, + cSettings: entry.cSettings, + swiftSettings: swiftSettings + entry.swiftSettings + ) - case .regular: - return .target( - name: entry.name, - dependencies: dependencies, - path: entry.path, - resources: entry.resources, - cSettings: entry.cSettings, - swiftSettings: swiftSettings + entry.swiftSettings - ) + case .regular: + return .target( + name: entry.name, + dependencies: dependencies, + path: entry.path, + resources: entry.resources, + cSettings: entry.cSettings, + swiftSettings: swiftSettings + entry.swiftSettings + ) - case .test: - return .testTarget( - name: entry.name, - dependencies: dependencies, - path: entry.path, - resources: entry.resources, - cSettings: entry.cSettings, - swiftSettings: swiftSettings + entry.swiftSettings - ) + case .test: + return .testTarget( + name: entry.name, + dependencies: dependencies, + path: entry.path, + resources: entry.resources, + cSettings: entry.cSettings, + swiftSettings: swiftSettings + entry.swiftSettings + ) - case .binary(.path(let path)): - return .binaryTarget( - name: entry.name, - path: path - ) + case let .binary(.path(path)): + return .binaryTarget( + name: entry.name, + path: path + ) - case .binary(.remote(let url, let checksum)): - return .binaryTarget( - name: entry.name, - url: url, - checksum: checksum - ) + case let .binary(.remote(url, checksum)): + return .binaryTarget( + name: entry.name, + url: url, + checksum: checksum + ) - case .macro: - return .macro( - name: entry.name, - dependencies: dependencies, - path: entry.path, - swiftSettings: swiftSettings + entry.swiftSettings - ) + case .macro: + return .macro( + name: entry.name, + dependencies: dependencies, + path: entry.path, + swiftSettings: swiftSettings + entry.swiftSettings + ) + } } - } } diff --git a/Package/Support/_Path.swift b/Package/Support/_Path.swift index 085c8bf..628e737 100644 --- a/Package/Support/_Path.swift +++ b/Package/Support/_Path.swift @@ -1,17 +1,19 @@ // // _Path.swift -// +// // // Created by ErrorErrorError on 10/5/23. -// +// // import Foundation +// MARK: - _Path + protocol _Path { - var path: String? { get } + var path: String? { get } } extension _Path { - var path: String? { nil } + var path: String? { nil } } diff --git a/Sources/Clients/DatabaseClient/MochiSchema.swift b/Sources/Clients/DatabaseClient/MochiSchema.swift index 4dc5a17..fc9b5db 100644 --- a/Sources/Clients/DatabaseClient/MochiSchema.swift +++ b/Sources/Clients/DatabaseClient/MochiSchema.swift @@ -13,6 +13,7 @@ struct MochiSchema: Schema { static var entities: Entities { Repo.self Module.self + PlaylistHistory.self } enum Migrations: Migratable { diff --git a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift new file mode 100644 index 0000000..fe9f478 --- /dev/null +++ b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift @@ -0,0 +1,15 @@ +// +// PlaylistHistory+.swift +// +// +// Created by DeNeRr on 31.01.2024. +// + +import Foundation +import Tagged + +// MARK: - PlaylistHistory + Identifiable + +extension PlaylistHistory: Identifiable { + public var id: Tagged { .init(playlistID) } +} diff --git a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift new file mode 100644 index 0000000..6477bff --- /dev/null +++ b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift @@ -0,0 +1,28 @@ +// +// PlaylistHistory.swift +// +// +// Created by DeNeRr on 27.01.2024. +// + +import CoreDB +import Foundation + +// MARK: PlaylistHistory + +@Entity +public struct PlaylistHistory: Equatable, Sendable { + @Attribute public var playlistID = String?.none + @Attribute public var lastWatchedEpisode = 1.0 + @Attribute public var timestamp: Double = 0.0 + + public init( + playlistID: String?, + timestamp: Double = 0.0, + lastWatchedEpisode: Double + ) { + self.playlistID = playlistID + self.timestamp = timestamp + self.lastWatchedEpisode = lastWatchedEpisode + } +} diff --git a/Sources/Clients/PlayerClient/Internal/PlayerItem+DASH.swift b/Sources/Clients/PlayerClient/Internal/PlayerItem+DASH.swift index 7b32e54..cdbfb35 100644 --- a/Sources/Clients/PlayerClient/Internal/PlayerItem+DASH.swift +++ b/Sources/Clients/PlayerClient/Internal/PlayerItem+DASH.swift @@ -148,7 +148,9 @@ extension PlayerItem { static let dashCustomPlaylistScheme = "mochi-mpd" func handleDASHRequest(_ loadingRequest: AVAssetResourceLoadingRequest) -> Bool { - guard let url = loadingRequest.request.url else { return false } + guard let url = loadingRequest.request.url else { + return false + } if url.pathExtension == "ts" { loadingRequest.redirect = URLRequest(url: url.recoveryScheme) loadingRequest.response = HTTPURLResponse( diff --git a/Sources/Clients/PlayerClient/Internal/PlayerItem.swift b/Sources/Clients/PlayerClient/Internal/PlayerItem.swift index 9434920..686fc39 100644 --- a/Sources/Clients/PlayerClient/Internal/PlayerItem.swift +++ b/Sources/Clients/PlayerClient/Internal/PlayerItem.swift @@ -88,7 +88,8 @@ extension URL { var recoveryScheme: URL { var component = URLComponents(url: self, resolvingAgainstBaseURL: false) - component?.scheme = "https" + let isDataScheme = component?.path.starts(with: "application/x-mpegURL") ?? false + component?.scheme = isDataScheme ? "data" : "https" return component?.url ?? self } } diff --git a/Sources/Clients/PlayerClient/Live.swift b/Sources/Clients/PlayerClient/Live.swift index fed7a00..557231d 100644 --- a/Sources/Clients/PlayerClient/Live.swift +++ b/Sources/Clients/PlayerClient/Live.swift @@ -113,6 +113,10 @@ private class InternalPlayer { let playerItem = PlayerItem(item) player.replaceCurrentItem(with: playerItem) + if let progress = item.progress { + let time = CMTime(seconds: progress * player.totalDuration, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + player.seek(to: time) + } #if os(iOS) try session.setActive(true) diff --git a/Sources/Clients/PlayerClient/Models.swift b/Sources/Clients/PlayerClient/Models.swift index 2562312..ba4faba 100644 --- a/Sources/Clients/PlayerClient/Models.swift +++ b/Sources/Clients/PlayerClient/Models.swift @@ -60,6 +60,7 @@ extension PlayerClient { let subtitles: [Subtitle] let metadata: SourceMetadata let format: Format + let progress: Double? public enum Format { case hls @@ -71,13 +72,15 @@ extension PlayerClient { headers: [String: String] = [:], subtitles: [Subtitle] = [], metadata: SourceMetadata, - format: Format + format: Format, + progress: Double? ) { self.link = link self.headers = headers self.subtitles = subtitles self.metadata = metadata self.format = format + self.progress = progress } public struct Subtitle { @@ -89,7 +92,7 @@ extension PlayerClient { public init( name: String, - default: Bool, + default: Bool = false, autoselect: Bool, forced: Bool = false, link: URL diff --git a/Sources/Clients/PlaylistHistoryClient/Client.swift b/Sources/Clients/PlaylistHistoryClient/Client.swift new file mode 100644 index 0000000..c370c2e --- /dev/null +++ b/Sources/Clients/PlaylistHistoryClient/Client.swift @@ -0,0 +1,41 @@ +// +// Client.swift +// +// +// Created by DeNeRr on 28.01.2024. +// + +import DatabaseClient +import Dependencies +@_exported +import Foundation +import SharedModels +import Tagged +import XCTestDynamicOverlay + +// MARK: - PlaylistHistoryClient + +public struct PlaylistHistoryClient: Sendable { + public var updateLastWatchedEpisode: @Sendable (String, Double?) async throws -> Void + public var fetch: @Sendable (String) async throws -> PlaylistHistory + public var updateTimestamp: @Sendable (String, Double) async throws -> Void + public var observe: @Sendable (String) -> AsyncStream<[PlaylistHistory]> +} + +// MARK: TestDependencyKey + +extension PlaylistHistoryClient: TestDependencyKey { + public static let testValue = Self( + updateLastWatchedEpisode: unimplemented("\(Self.self).updateLastWatchedEpisode"), + fetch: unimplemented("\(Self.self).fetch"), + updateTimestamp: unimplemented("\(Self.self).updateTimestamp"), + observe: unimplemented("\(Self.self).observe") + ) +} + +extension DependencyValues { + public var playlistHistoryClient: PlaylistHistoryClient { + get { self[PlaylistHistoryClient.self] } + set { self[PlaylistHistoryClient.self] = newValue } + } +} diff --git a/Sources/Clients/PlaylistHistoryClient/Live.swift b/Sources/Clients/PlaylistHistoryClient/Live.swift new file mode 100644 index 0000000..83294cb --- /dev/null +++ b/Sources/Clients/PlaylistHistoryClient/Live.swift @@ -0,0 +1,43 @@ +// +// Live.swift +// +// +// Created by DeNeRr on 28.01.2024. +// + +import DatabaseClient +import Dependencies +import Foundation +import Semaphore + +// MARK: - PlaylistHistoryClient + DependencyKey + +extension PlaylistHistoryClient: DependencyKey { + @Dependency(\.databaseClient) private static var databaseClient + + public static let liveValue = Self( + updateLastWatchedEpisode: { playlistID, epNumber in + if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == playlistID)).first { + playlist.lastWatchedEpisode = epNumber ?? 1 + _ = try await databaseClient.update(playlist) + } else { + _ = try await databaseClient.insert(PlaylistHistory(playlistID: playlistID, lastWatchedEpisode: epNumber ?? 1)) + } + }, + fetch: { playlistID in + guard let playlistHistory = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == playlistID)).first else { + throw PlaylistHistoryClient.Error.failedToFindPlaylisthistory + } + return playlistHistory + }, + updateTimestamp: { playlistID, timestamp in + if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == playlistID)).first { + playlist.timestamp = timestamp + _ = try await databaseClient.update(playlist) + } + }, + observe: { playlistID in + databaseClient.observe(.all.where(\PlaylistHistory.playlistID == playlistID)) + } + ) +} diff --git a/Sources/Clients/PlaylistHistoryClient/Models.swift b/Sources/Clients/PlaylistHistoryClient/Models.swift new file mode 100644 index 0000000..dbf0e4d --- /dev/null +++ b/Sources/Clients/PlaylistHistoryClient/Models.swift @@ -0,0 +1,14 @@ +// +// Models.swift +// +// +// Created by DeNeRr on 29.01.2024. +// + +import Foundation + +extension PlaylistHistoryClient { + public enum Error: Swift.Error, Equatable, Sendable { + case failedToFindPlaylisthistory + } +} diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index d179be4..9f35647 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -50,7 +50,7 @@ extension ContentCore { private let selectedItemId: Playlist.Item.ID? private var groupLoadable: Loadable { - viewStore.groups.map { groups in _selectedGroupId.flatMap { groups[id: $0] } ?? groups.first } + viewStore.groups.map { groups in _selectedGroupId.flatMap { groups[id: $0] } ?? groups.first { $0.default } ?? groups.first } .flatMap(Loadable.init) } @@ -152,34 +152,36 @@ extension ContentCore { .padding(.horizontal) // TODO: Allow variations to also be a menu? - ScrollView(.horizontal) { - HStack(spacing: 6) { - if let selectedVariant = variantLoadable.value { - ChipView(text: selectedVariant.title) - .background(Color.blue) - .foregroundColor(.white) - } + if groupLoadable.value?.variants.value?.count != 1 { + ScrollView(.horizontal) { + HStack(spacing: 6) { + if let selectedVariant = variantLoadable.value { + ChipView(text: selectedVariant.title) + .background(Color.blue) + .foregroundColor(.white) + } - if let variants = groupLoadable.value?.variants.value { - ForEach(variants, id: \.id) { variant in - if variant.id != variantLoadable.value?.id { - ChipView(text: variant.title) - .onTapGesture { - if let groupId = groupLoadable.value?.id { - _selectedVariantId = variant.id - store.send(.didTapContent(.variant(groupId, variant.id))) + if let variants = groupLoadable.value?.variants.value { + ForEach(variants, id: \.id) { variant in + if variant.id != variantLoadable.value?.id { + ChipView(text: variant.title) + .onTapGesture { + if let groupId = groupLoadable.value?.id { + _selectedVariantId = variant.id + store.send(.didTapContent(.variant(groupId, variant.id))) + } } - } + } } } } + .padding(.horizontal) } - .padding(.horizontal) + .font(.footnote.weight(.semibold)) + .frame(maxWidth: .infinity) + .shimmering(active: !groupLoadable.didFinish) + .animation(.easeInOut, value: _selectedVariantId) } - .font(.footnote.weight(.semibold)) - .frame(maxWidth: .infinity) - .shimmering(active: !groupLoadable.didFinish) - .animation(.easeInOut, value: _selectedVariantId) } .frame(maxWidth: .infinity) .foregroundColor(theme.textColor) @@ -226,7 +228,7 @@ extension ContentCore { if let groupId = groupLoadable.value?.id, let variantId = variantLoadable.value?.id, let pageId = pageLoadable.value?.id { - store.send(.didTapPlaylistItem(groupId, variantId, pageId, id: item.id)) + store.send(.didTapPlaylistItem(groupId, variantId, pageId, id: item.id, shouldReset: true)) } } .id(item.id) @@ -240,7 +242,9 @@ extension ContentCore { .onAppear { proxy.scrollTo(selectedItemId, anchor: .center) if items == .pending { - guard let groupId = groupLoadable.value?.id else { return } + guard let groupId = groupLoadable.value?.id else { + return + } guard let variantId = variantLoadable.value?.id else { viewStore.send(.didRequestLoadingPendingContent(.group(groupId))) diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index ad455db..c9004f3 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -13,6 +13,7 @@ import FoundationHelpers import LoggerClient import ModuleClient import OrderedCollections +import PlaylistHistoryClient import SharedModels import Tagged @@ -29,15 +30,18 @@ public struct ContentCore: Reducer { public var repoModuleId: RepoModuleID public var playlist: Playlist public var groups: Loadable<[Playlist.Group]> + public var playlistHistory: Loadable public init( repoModuleId: RepoModuleID, playlist: Playlist, - groups: Loadable<[Playlist.Group]> = .pending + groups: Loadable<[Playlist.Group]> = .pending, + playlistHistory: Loadable = .pending ) { self.repoModuleId = repoModuleId self.playlist = playlist self.groups = groups + self.playlistHistory = playlistHistory } } @@ -47,11 +51,13 @@ public struct ContentCore: Reducer { case update(option: Playlist.ItemsRequestOptions?, Loadable) case didRequestLoadingPendingContent(Playlist.ItemsRequestOptions?) case didTapContent(Playlist.ItemsRequestOptions) + case playlistHistoryResponse(Loadable) case didTapPlaylistItem( Playlist.Group.ID, Playlist.Group.Variant.ID, PagingID, - id: Playlist.Item.ID + id: Playlist.Item.ID, + shouldReset: Bool = false ) } @@ -67,8 +73,21 @@ public struct ContentCore: Reducer { case let .didTapContent(option): return state.fetchContent(option) - case .didTapPlaylistItem: - break + case let .didTapPlaylistItem(groupId, variantId, pageId, itemId, shouldReset): + @Dependency(\.playlistHistoryClient) var playlistHistoryClient + let playlistId = state.playlist.id + let item = state.item(groupId: groupId, variantId: variantId, pageId: pageId, itemId: itemId).value + return .run { _ in + if let epNumber = item?.number { + try? await playlistHistoryClient.updateLastWatchedEpisode(playlistId.rawValue, epNumber) + if shouldReset { + try? await playlistHistoryClient.updateTimestamp(playlistId.rawValue, 0) + } + } + } + + case let .playlistHistoryResponse(response): + state.playlistHistory = response case let .didRequestLoadingPendingContent(options): return state.fetchContent(options) @@ -92,6 +111,7 @@ extension ContentCore.State { forced: Bool = false ) -> Effect { @Dependency(\.moduleClient) var moduleClient + @Dependency(\.playlistHistoryClient) var playlistHistoryClient let playlistId = playlist.id let repoModuleId = repoModuleId @@ -126,6 +146,11 @@ extension ContentCore.State { } await send(.update(option: option, .loaded(value))) + for await playlistHistoryItems in playlistHistoryClient.observe(playlistId.rawValue) { + if let playlistHistory = playlistHistoryItems.first { + await send(.playlistHistoryResponse(.loaded(playlistHistory))) + } + } } } catch: { error, send in logger.error("\(#function) - \(error)") @@ -186,7 +211,8 @@ extension ContentCore.State { id: group.id, number: group.number, altTitle: group.altTitle, - variants: variantsResponse + variants: variantsResponse, + default: group.default ) } else if let variantId = option.variantId { let pagingsResponse = variantsResponse @@ -226,7 +252,8 @@ extension ContentCore.State { ) } return variants - } + }, + default: group.default ) } else { group = .init( @@ -240,7 +267,8 @@ extension ContentCore.State { .flatMap { .init(id: $0.id, title: $0.title, pagings: pagingsResponse) } return variants - } + }, + default: group.default ) } } diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 542fd14..a24809f 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -8,6 +8,8 @@ import Architecture import ComposableArchitecture +import DatabaseClient +import Foundation import LoggerClient import ModuleClient import ModuleLists @@ -15,6 +17,9 @@ import PlaylistDetails import RepoClient import Search import SharedModels +import Tagged + +let defaults = UserDefaults.standard // MARK: - DiscoverFeature @@ -27,8 +32,21 @@ extension DiscoverFeature { Reduce { state, action in switch action { case .view(.didAppear): - // TODO: Set default module to load or show home. - break + if state.section.module != nil { + break + } + guard let moduleId = defaults.string(forKey: "LastSelectedModuleId"), + let repoId = defaults.url(forKey: "LastSelectedRepoId") else { + state.section = .home() + break + } + return .run { send in + try await Task.sleep(nanoseconds: 50_000_000) + if let repo = try? await databaseClient.fetch(.all.where(\Repo.remoteURL == repoId)).first { + let module = repo.modules[id: Module.Manifest.ID(moduleId)]?.manifest + await send(.internal(.selectedModule(module == nil ? nil : .init(repoId: Tagged(repoId), module: module!)))) + } + } case .view(.didTapOpenModules): state.moduleLists = .init() diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index b727bb2..15055dc 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -31,6 +31,8 @@ extension DiscoverFeature.View: View { WithViewStore(store, observe: \.section) { viewStore in ZStack { switch viewStore.state { + case .empty: + VStack {} case .home: // TODO: Create home listing VStack { diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index 4fb5d1a..60fd07f 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -82,9 +82,12 @@ public struct DiscoverFeature: Feature { public enum Section: Equatable, Sendable { case home(HomeState = .init()) case module(ModuleListingState) + case empty var title: String { switch self { + case .empty: + .init(localizable: "Loading...") case .home: .init(localizable: "Home") case let .module(moduleState): @@ -94,6 +97,8 @@ public struct DiscoverFeature: Feature { var icon: URL? { switch self { + case .empty: + nil case .home: nil case let .module(moduleState): @@ -126,7 +131,7 @@ public struct DiscoverFeature: Feature { @PresentationState public var moduleLists: ModuleListsFeature.State? public init( - section: DiscoverFeature.Section = .home(), + section: DiscoverFeature.Section = .empty, path: StackState = .init(), moduleLists: ModuleListsFeature.State? = nil ) { @@ -190,6 +195,7 @@ public struct DiscoverFeature: Feature { } @Dependency(\.repoClient) var repoClient + @Dependency(\.databaseClient) var databaseClient @Dependency(\.moduleClient) var moduleClient public init() {} diff --git a/Sources/Features/Discover/ViewMoreListing.swift b/Sources/Features/Discover/ViewMoreListing.swift index 39e2e61..5425ff5 100644 --- a/Sources/Features/Discover/ViewMoreListing.swift +++ b/Sources/Features/Discover/ViewMoreListing.swift @@ -91,7 +91,9 @@ public struct ViewMoreListing: Reducer { return .run { await dismiss() } case let .didShowNextPageIndicator(pageId): - guard !(state.items[pageId]?.hasInitialized ?? false) else { break } + guard !(state.items[pageId]?.hasInitialized ?? false) else { + break + } return state.fetchPage(pageId: pageId) case let .didTapRetryLoadingPage(pageId): diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift b/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift index decc12a..c53e14a 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift @@ -9,8 +9,11 @@ import Architecture import ComposableArchitecture import DatabaseClient +import Foundation import RepoClient +let defaults = UserDefaults.standard + extension ModuleListsFeature { public var body: some ReducerOf { Reduce { state, action in @@ -31,6 +34,8 @@ extension ModuleListsFeature { guard let module = state.repos[id: repoId]?.modules[id: moduleId]?.manifest else { break } + defaults.set(moduleId.rawValue, forKey: "LastSelectedModuleId") + defaults.set(repoId.rawValue, forKey: "LastSelectedRepoId") return .concatenate(.send(.delegate(.selectedModule(.init(repoId: repoId, module: module))))) case let .internal(.fetchRepos(.success(repos))): diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift index af3cab2..d1d05fc 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift @@ -13,6 +13,7 @@ import DatabaseClient import Foundation import LoggerClient import ModuleClient +import PlaylistHistoryClient import RepoClient import SharedModels import Tagged @@ -61,8 +62,10 @@ extension PlaylistDetailsFeature { case let .internal(.playlistDetailsResponse(loadable)): state.details = loadable - case let .internal(.content(.didTapPlaylistItem(groupId, variantId, pageId, itemId))): - guard state.content.groups.value != nil else { break } + case let .internal(.content(.didTapPlaylistItem(groupId, variantId, pageId, itemId, _))): + guard state.content.groups.value != nil else { + break + } switch state.content.playlist.type { case .video: diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift index eb77ac2..2632c96 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift @@ -61,19 +61,32 @@ public struct PlaylistDetailsFeature: Feature { @PresentationState public var destination: Destination.State? + public var playlistHistory: Loadable { content.playlistHistory } + var playlistInfo: Loadable { details.map { .init(playlist: playlist, details: $0) } } public var resumableState: Resumable { // TODO: Show start based on last resumed or selected content? - if let group = content.groups.value?.first, - let variant = group.variants.value?.first, - let page = variant.pagings.value?.first, - let item = page.items.value?.first { - .start(group.id, variant.id, page.id, item.id) + if playlist.status == .upcoming { + return .upcoming + } + if let group = content.groups.value?.first(where: { $0.default }) ?? content.groups.value?.first, + let variant = group.variants.value?.first { + if let epNumber = playlistHistory.value?.lastWatchedEpisode { + if let page = variant.pagings.value?.first(where: { $0.items.value!.contains(where: { $0.number == epNumber }) }), + let item = page.items.value?.first(where: { $0.number == epNumber }) { + return .resume(group.id, variant.id, page.id, item.id, item.title ?? "", playlistHistory.value?.timestamp ?? 0.0) + } + } + if let page = variant.pagings.value?.first, + let item = page.items.value?.first { + return .start(group.id, variant.id, page.id, item.id) + } + return content.groups.didFinish ? .unavailable : .loading } else { - content.groups.didFinish ? .unavailable : .loading + return content.groups.didFinish ? .unavailable : .loading } } @@ -88,21 +101,33 @@ public struct PlaylistDetailsFeature: Feature { } public enum Resumable: Equatable, Sendable { + case upcoming case loading case start(Playlist.Group.ID, Playlist.Group.Variant.ID, PagingID, Playlist.Item.ID) - case `continue`(String, Double) + case resume(Playlist.Group.ID, Playlist.Group.Variant.ID, PagingID, Playlist.Item.ID, String, Double) case unavailable var image: Image? { - self != .unavailable ? .init(systemName: "play.fill") : nil + switch self { + case .upcoming: + .init(systemName: "calendar") + case .loading: + nil + case .start: + .init(systemName: "play.fill") + case .resume: + .init(systemName: "play.fill") + case .unavailable: + nil + } } var action: Action? { switch self { case let .start(groupId, variantId, pagingId, itemId): .internal(.content(.didTapPlaylistItem(groupId, variantId, pagingId, id: itemId))) - case .continue: - nil + case let .resume(groupId, variantId, pagingId, itemId, _, _): + .internal(.content(.didTapPlaylistItem(groupId, variantId, pagingId, id: itemId))) default: nil } @@ -110,12 +135,14 @@ public struct PlaylistDetailsFeature: Feature { var description: String { switch self { + case .upcoming: + "Upcoming" case .loading: "Loading..." case .start: "Start" - case .continue: - "Continue" + case .resume: + "Resume" case .unavailable: "Unavailable" } diff --git a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift index 1dcc419..88e6c0b 100644 --- a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift +++ b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift @@ -228,7 +228,7 @@ extension PlaylistDetailsFeature.View { .frame(height: 16) WithViewStore(store, observe: \.resumableState) { viewStore in - if case let .continue(title, progress) = viewStore.state { + if case let .resume(_, _, _, _, title, progress) = viewStore.state { VStack(spacing: 4) { HStack { Text(title) @@ -360,13 +360,15 @@ extension PlaylistDetailsFeature.View { } } - ContentCore.View( - store: store.scope( - state: \.content, - action: \.internal.content - ), - contentType: playlistInfo.type - ) + if playlistInfo.status != .upcoming { + ContentCore.View( + store: store.scope( + state: \.content, + action: \.internal.content + ), + contentType: playlistInfo.type + ) + } } } } diff --git a/Sources/Features/Repos/ReposFeature+Reducer.swift b/Sources/Features/Repos/ReposFeature+Reducer.swift index 2d194f0..81dd17a 100644 --- a/Sources/Features/Repos/ReposFeature+Reducer.swift +++ b/Sources/Features/Repos/ReposFeature+Reducer.swift @@ -60,7 +60,9 @@ extension ReposFeature { ) case let .view(.didTapRepo(repoId)): - guard let repo = state.repos[id: repoId] else { break } + guard let repo = state.repos[id: repoId] else { + break + } state.path.append(RepoPackagesFeature.State(repo: repo)) case .view(.binding(\.$url)): @@ -116,7 +118,9 @@ extension URL { // Everything else is case sensitive, so check if there's a foward slash. If not, add it. - guard var string = components?.string else { return nil } + guard var string = components?.string else { + return nil + } // Remove trailing slash if string.hasSuffix("/") { diff --git a/Sources/Features/VideoPlayer/Components/ProgressBar.swift b/Sources/Features/VideoPlayer/Components/ProgressBar.swift index 116c0f6..4c87aa2 100644 --- a/Sources/Features/VideoPlayer/Components/ProgressBar.swift +++ b/Sources/Features/VideoPlayer/Components/ProgressBar.swift @@ -19,7 +19,7 @@ private struct DragOffset: Equatable { private let initialProgress: Double private var initialDrag: Double private var lastDrag: Double - private var offset: Double { lastDrag - initialDrag } + var offset: Double { lastDrag - initialDrag } var progress: Double { initialProgress + offset } @@ -102,7 +102,12 @@ struct ProgressBar: View { viewState.send(.didSeekTo(time: dragged?.progress ?? .zero)) dragged?(percentageX) } - .onEnded { _ in + .onEnded { value in + if dragged?.offset == 0 { + let percentageX = value.location.x / proxy.size.width + + viewState.send(.didSeekTo(time: percentageX)) + } dragged = nil } ) diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index cdfd97c..22a0ffe 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -12,6 +12,7 @@ import ContentCore import LoggerClient import ModuleClient import PlayerClient +import PlaylistHistoryClient import SharedModels // MARK: - Cancellables @@ -33,11 +34,17 @@ extension VideoPlayerFeature: Reducer { Reduce { state, action in switch action { case .view(.didAppear): + @Dependency(\.playlistHistoryClient) var playlistHistoryClient + + let playlistID = state.playlist.id.rawValue return .merge( state.content.fetchContent(.page(state.selected.groupId, state.selected.variantId, state.selected.pageId)) .map { .internal(.content($0)) }, .run { send in for await status in playerClient.observe() { + if let progress = status.playback?.progress { + try? await playlistHistoryClient.updateTimestamp(playlistID, progress) + } await send(.internal(.playerStatusUpdate(status))) } } @@ -139,7 +146,7 @@ extension VideoPlayerFeature: Reducer { case .internal(.hideToolsOverlay): state.overlay = state.overlay == .tools ? nil : state.overlay - case let .internal(.content(.didTapPlaylistItem(group, variant, page, itemId))): + case let .internal(.content(.didTapPlaylistItem(group, variant, page, itemId, _))): state.overlay = .tools return state.clearForNewEpisodeIfNeeded(group, variant, page, itemId) @@ -284,6 +291,9 @@ extension VideoPlayerFeature.State { _ episodeId: Playlist.Item.ID ) -> Effect { @Dependency(\.playerClient) var playerClient + @Dependency(\.playlistHistoryClient) var playlistHistoryClient + + let playlistID = playlist.id.rawValue if selected.groupId != groupId || selected.variantId != variantId || @@ -300,7 +310,10 @@ extension VideoPlayerFeature.State { loadables.playlistItemSourcesLoadables.removeAll() return .merge( fetchSourcesIfNecessary(), - .run { await playerClient.clear() } + .run { _ in + await playerClient.clear() + try? await playlistHistoryClient.updateTimestamp(playlistID, 0) + } ) } return .none @@ -357,6 +370,7 @@ extension VideoPlayerFeature.State { if let server = selected.serverId.flatMap({ loadables[serverId: $0] })?.value, let link = server.links[id: linkId] { + let progress = playlistHistory.value?.timestamp let playlist = playlist let episode = selectedItem.value.flatMap { $0 } let loadItem = PlayerClient.VideoCompositionItem( @@ -376,7 +390,8 @@ extension VideoPlayerFeature.State { artworkImage: episode?.thumbnail ?? playlist.posterImage, author: playlist.title ), - format: link.format == .hls ? .hls : .dash + format: link.format == .hls ? .hls : .dash, + progress: progress ) return .run { _ in @@ -432,8 +447,12 @@ extension VideoPlayerFeature.State { let sourceId = selected.sourceId let serverId = selected.serverId - guard let sourceId else { return .none } - guard let serverId else { return .none } + guard let sourceId else { + return .none + } + guard let serverId else { + return .none + } if forced || !loadables[serverId: serverId].hasInitialized { loadables.update(with: serverId, response: .loading) diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift index 4275de6..e54994a 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift @@ -59,6 +59,8 @@ public struct VideoPlayerFeature: Feature { set { content.playlist = newValue } } + public var playlistHistory: Loadable { content.playlistHistory } + public var content: ContentCore.State public var loadables: Loadables public var selected: SelectedContent @@ -330,7 +332,7 @@ extension VideoPlayerFeature.View { var action: VideoPlayerFeature.Action { switch self { case let .next(_, group, variant, paging, itemId): - .internal(.content(.didTapPlaylistItem(group, variant, paging, id: itemId))) + .internal(.content(.didTapPlaylistItem(group, variant, paging, id: itemId, shouldReset: true))) case let .times(time): .view(.didSkipTo(time: time.endTime)) } diff --git a/Sources/Macros/CoreDBMacros/EntityMacro.swift b/Sources/Macros/CoreDBMacros/EntityMacro.swift index aa4f3da..4343506 100644 --- a/Sources/Macros/CoreDBMacros/EntityMacro.swift +++ b/Sources/Macros/CoreDBMacros/EntityMacro.swift @@ -38,7 +38,9 @@ extension EntityMacro: ExtensionMacro { in _: some SwiftSyntaxMacros.MacroExpansionContext ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { let inherited = declaration.inheritanceClause?.inheritedTypes ?? [] - guard !inherited.contains(where: { [entityType, qualifiedEntityName].contains($0.type.trimmedDescription) }) else { return [] } + guard !inherited.contains(where: { [entityType, qualifiedEntityName].contains($0.type.trimmedDescription) }) else { + return [] + } let ext: DeclSyntax = """ extension \(raw: type.trimmed): \(raw: qualifiedEntityName) {} diff --git a/Sources/Shared/SharedModels/Playlist.swift b/Sources/Shared/SharedModels/Playlist.swift index 6d1e951..ca05274 100644 --- a/Sources/Shared/SharedModels/Playlist.swift +++ b/Sources/Shared/SharedModels/Playlist.swift @@ -214,6 +214,7 @@ extension Playlist { public let number: Double public let altTitle: String? public let variants: Loadable + public let `default`: Bool public typealias Variants = [Variant] @@ -221,12 +222,14 @@ extension Playlist { id: Self.ID, number: Double, altTitle: String? = nil, - variants: Loadable = .pending + variants: Loadable = .pending, + default: Bool = false ) { self.id = id self.number = number self.altTitle = altTitle self.variants = variants + self.default = `default` } public struct Variant: Sendable, Equatable, Identifiable, Decodable { diff --git a/Sources/Shared/ViewComponents/OnInitialTask.swift b/Sources/Shared/ViewComponents/OnInitialTask.swift index 80b884b..943c8ec 100644 --- a/Sources/Shared/ViewComponents/OnInitialTask.swift +++ b/Sources/Shared/ViewComponents/OnInitialTask.swift @@ -21,7 +21,9 @@ private struct OnInitialTask: ViewModifier { @MainActor func body(content: Content) -> some View { content.task(priority: priority) { - guard !appeared else { return } + guard !appeared else { + return + } appeared = true await callback() } From e83feb155b2d01c7c05b3b751502100c591e52f0 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 4 Feb 2024 22:16:55 +0100 Subject: [PATCH 02/45] Seasons are now being saved --- .../Features/ContentCore/ContentCore.swift | 5 +- .../VideoPlayerFeature+Reducer.swift | 48 ++++++++++--------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index c9004f3..34651f9 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -75,13 +75,12 @@ public struct ContentCore: Reducer { case let .didTapPlaylistItem(groupId, variantId, pageId, itemId, shouldReset): @Dependency(\.playlistHistoryClient) var playlistHistoryClient - let playlistId = state.playlist.id let item = state.item(groupId: groupId, variantId: variantId, pageId: pageId, itemId: itemId).value return .run { _ in if let epNumber = item?.number { - try? await playlistHistoryClient.updateLastWatchedEpisode(playlistId.rawValue, epNumber) + try? await playlistHistoryClient.updateLastWatchedEpisode(groupId.rawValue, epNumber) if shouldReset { - try? await playlistHistoryClient.updateTimestamp(playlistId.rawValue, 0) + try? await playlistHistoryClient.updateTimestamp(groupId.rawValue, 0) } } } diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index 22a0ffe..caf6316 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -36,14 +36,14 @@ extension VideoPlayerFeature: Reducer { case .view(.didAppear): @Dependency(\.playlistHistoryClient) var playlistHistoryClient - let playlistID = state.playlist.id.rawValue + let groupId = state.selected.groupId.rawValue return .merge( state.content.fetchContent(.page(state.selected.groupId, state.selected.variantId, state.selected.pageId)) .map { .internal(.content($0)) }, .run { send in for await status in playerClient.observe() { if let progress = status.playback?.progress { - try? await playlistHistoryClient.updateTimestamp(playlistID, progress) + try? await playlistHistoryClient.updateTimestamp(groupId, progress) } await send(.internal(.playerStatusUpdate(status))) } @@ -364,38 +364,40 @@ extension VideoPlayerFeature.State { public mutating func clearForChangedLinkIfNeeded(_ linkId: Playlist.EpisodeServer.Link.ID) -> Effect { @Dependency(\.playerClient) var playerClient + @Dependency(\.playlistHistoryClient) var playlistHistoryClient if selected.linkId != linkId { selected.linkId = linkId if let server = selected.serverId.flatMap({ loadables[serverId: $0] })?.value, let link = server.links[id: linkId] { - let progress = playlistHistory.value?.timestamp 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 - ), - format: link.format == .hls ? .hls : .dash, - progress: progress - ) + let groupId = selected.groupId.rawValue return .run { _ in await playerClient.clear() + let playlistHistory = try? await playlistHistoryClient.fetch(groupId) + 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 + ), + format: link.format == .hls ? .hls : .dash, + progress: playlistHistory?.timestamp ?? nil + ) try await playerClient.load(loadItem) await playerClient.play() } From 50f48023692cdddd8289ec39b7e30fa8652e7158 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Thu, 22 Feb 2024 21:29:39 +0100 Subject: [PATCH 03/45] Added ddos webview, cookies and bugfixes --- Sources/Clients/ModuleClient/Client.swift | 1 + .../ModuleClient/Extensions/JSValue+.swift | 6 +++ Sources/Clients/ModuleClient/Instance.swift | 4 ++ .../JS+Bindings/JSContext+Request.swift | 3 ++ .../ContentCore/ContentCore+View.swift | 2 +- .../Features/ContentCore/ContentCore.swift | 6 +-- .../Discover/DiscoverFeature+Reducer.swift | 13 ++++++ .../Discover/DiscoverFeature+View.swift | 12 ++++++ .../Features/Discover/DiscoverFeature.swift | 41 ++++++++++++++++++- Sources/Features/Discover/WebView.swift | 26 ++++++++++++ .../PlaylistDetailsFeature.swift | 2 +- .../VideoPlayerFeature+Reducer.swift | 4 +- Sources/Shared/SharedModels/Playlist.swift | 4 +- 13 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 Sources/Features/Discover/WebView.swift diff --git a/Sources/Clients/ModuleClient/Client.swift b/Sources/Clients/ModuleClient/Client.swift index 1b08ccb..2aadc5b 100644 --- a/Sources/Clients/ModuleClient/Client.swift +++ b/Sources/Clients/ModuleClient/Client.swift @@ -52,6 +52,7 @@ extension ModuleClient.Error { case retrievingInstanceFailed case instanceCreationFailed case instanceCall(function: String, msg: String) + case requestForbidden(data: String, hostname: String) } } diff --git a/Sources/Clients/ModuleClient/Extensions/JSValue+.swift b/Sources/Clients/ModuleClient/Extensions/JSValue+.swift index 312b29f..f52abea 100644 --- a/Sources/Clients/ModuleClient/Extensions/JSValue+.swift +++ b/Sources/Clients/ModuleClient/Extensions/JSValue+.swift @@ -54,6 +54,9 @@ struct JSValueError: Error, LocalizedError, CustomStringConvertible { var errorDescription: String? var failureReason: String? var stackTrace: String? + var data: String? + var status: Double? + var hostname: String? init(_ value: JSValue, _ functionName: String? = nil, stackTrace: Bool = true) { self.functionName = functionName @@ -63,6 +66,9 @@ struct JSValueError: Error, LocalizedError, CustomStringConvertible { if stackTrace { self.stackTrace = value["stack"]?.toString() } + self.data = value["data"]?.toString() + self.status = value["status"]?.toDouble() + self.hostname = value["hostname"]?.toString() } // TODO: Allow stack trace diff --git a/Sources/Clients/ModuleClient/Instance.swift b/Sources/Clients/ModuleClient/Instance.swift index 3f57a5e..58fe6b4 100644 --- a/Sources/Clients/ModuleClient/Instance.swift +++ b/Sources/Clients/ModuleClient/Instance.swift @@ -86,7 +86,11 @@ extension ModuleClient.Instance { do { return try await callback() } catch { + let err = error as? JSValueError logger.error("\(error)") + if err?.status == 403, let data = err?.data, let hostname = err?.hostname { + throw ModuleClient.Error.jsRuntime(.requestForbidden(data: data, hostname: hostname)) + } throw error } } diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift index 466ea78..cc2492d 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift @@ -64,6 +64,9 @@ extension JSContext { if let headers = options["headers"]?.toDictionary() as? [String: String] { headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } } + + let cookies = HTTPCookieStorage.shared.cookies(for: url).map({ $0.map({ "\($0.name)=\($0.value)" }) })?.joined(separator: "; ") + request.setValue(cookies, forHTTPHeaderField: "Cookie") return .init(newPromiseIn: self) { resolved, rejected in let task = session.dataTask(with: request) { data, response, error in diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index 9f35647..3eaff03 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -50,7 +50,7 @@ extension ContentCore { private let selectedItemId: Playlist.Item.ID? private var groupLoadable: Loadable { - viewStore.groups.map { groups in _selectedGroupId.flatMap { groups[id: $0] } ?? groups.first { $0.default } ?? groups.first } + viewStore.groups.map { groups in _selectedGroupId.flatMap { groups[id: $0] } ?? groups.first { $0.default ?? false } ?? groups.first } .flatMap(Loadable.init) } diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index 34651f9..edd0b87 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -211,7 +211,7 @@ extension ContentCore.State { number: group.number, altTitle: group.altTitle, variants: variantsResponse, - default: group.default + default: group.default ?? false ) } else if let variantId = option.variantId { let pagingsResponse = variantsResponse @@ -252,7 +252,7 @@ extension ContentCore.State { } return variants }, - default: group.default + default: group.default ?? false ) } else { group = .init( @@ -267,7 +267,7 @@ extension ContentCore.State { return variants }, - default: group.default + default: group.default ?? false ) } } diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index a24809f..2ff1da4 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -119,6 +119,13 @@ extension DiscoverFeature { case .internal(.moduleLists): break + + case let .internal(.showCaptcha(html, hostname)): + state.solveCaptcha = .solveCaptcha(.init(html: html, hostname: hostname)) + break + + case .internal(.solveCaptcha): + break case .internal(.path): break @@ -131,6 +138,9 @@ extension DiscoverFeature { .ifLet(\.$moduleLists, action: \.internal.moduleLists) { ModuleListsFeature() } + .ifLet(\.$solveCaptcha, action: \.internal.solveCaptcha) { + DiscoverFeature.Captcha() + } .forEach(\.path, action: \.internal.path) { Path() } @@ -160,6 +170,9 @@ extension DiscoverFeature.State { } } catch: { error, send in if let error = error as? ModuleClient.Error { + if case let .jsRuntime(.requestForbidden(data, hostmame)) = error { + await send(.internal(.showCaptcha(data, hostmame))) + } await send(.internal(.loadedListings(id, .failed(DiscoverFeature.Error.module(error))))) } else { await send(.internal(.loadedListings(id, .failed(DiscoverFeature.Error.system(.unknown))))) diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index 15055dc..2e74c48 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -142,6 +142,18 @@ extension DiscoverFeature.View: View { } } } + .sheet( + store: store.scope( + state: \.$solveCaptcha, + action: \.internal.solveCaptcha + ), + state: /DiscoverFeature.Captcha.State.solveCaptcha, + action: DiscoverFeature.Captcha.Action.solveCaptcha + ) { store in + WithViewStore(store, observe: \.`self`) { viewStore in + WebView(html: viewStore.html, hostname: viewStore.hostname) + } + } } } diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index 60fd07f..57123d3 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -24,6 +24,40 @@ import ViewComponents // MARK: - DiscoverFeature public struct DiscoverFeature: Feature { + public struct Captcha: ComposableArchitecture.Reducer { + public enum State: Equatable, Sendable { + case solveCaptcha(SolveCaptcha.State) + } + + public enum Action: Equatable, Sendable { + case solveCaptcha(SolveCaptcha.Action) + } + + public var body: some ReducerOf { + Scope(state: /State.solveCaptcha, action: /Action.solveCaptcha) { + SolveCaptcha() + } + } + + public struct SolveCaptcha: ComposableArchitecture.Reducer { + public struct State: Equatable, Sendable { + public let html: String + public let hostname: String + + public init( + html: String, + hostname: String + ) { + self.html = html + self.hostname = hostname + } + } + + public enum Action: Equatable, Sendable {} + + public var body: some ReducerOf { EmptyReducer() } + } + } public enum Error: Swift.Error, Equatable, Sendable, Localizable { case system(System) case module(ModuleClient.Error) @@ -129,15 +163,18 @@ public struct DiscoverFeature: Feature { public var path: StackState @PresentationState public var moduleLists: ModuleListsFeature.State? + @PresentationState public var solveCaptcha: DiscoverFeature.Captcha.State? public init( section: DiscoverFeature.Section = .empty, path: StackState = .init(), - moduleLists: ModuleListsFeature.State? = nil + moduleLists: ModuleListsFeature.State? = nil, + solveCaptcha: DiscoverFeature.Captcha.State? = nil ) { self.section = section self.path = path self.moduleLists = moduleLists + self.solveCaptcha = solveCaptcha } } @@ -173,6 +210,8 @@ public struct DiscoverFeature: Feature { case selectedModule(RepoClient.SelectedModule?) case loadedListings(RepoModuleID, Loadable<[DiscoverListing]>) case moduleLists(PresentationAction) + case solveCaptcha(PresentationAction) + case showCaptcha(String, String) case path(StackAction) } diff --git a/Sources/Features/Discover/WebView.swift b/Sources/Features/Discover/WebView.swift new file mode 100644 index 0000000..3693136 --- /dev/null +++ b/Sources/Features/Discover/WebView.swift @@ -0,0 +1,26 @@ +// +// WebView.swift +// +// +// Created by DeNeRr on 22.02.2024. +// + +import SwiftUI +import WebKit + +struct WebView: UIViewRepresentable { + var html: String + var hostname: String + + func makeUIView(context: Context) -> WKWebView { + return WKWebView() + } + + func updateUIView(_ webView: WKWebView, context: Context) { + let url = URL(string: hostname)! + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in + HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: url) + } + webView.loadHTMLString(html, baseURL: URL(string: hostname)) + } +} diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift index 2632c96..c4ae772 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift @@ -72,7 +72,7 @@ public struct PlaylistDetailsFeature: Feature { if playlist.status == .upcoming { return .upcoming } - if let group = content.groups.value?.first(where: { $0.default }) ?? content.groups.value?.first, + if let group = content.groups.value?.first(where: { $0.default ?? false }) ?? content.groups.value?.first, let variant = group.variants.value?.first { if let epNumber = playlistHistory.value?.lastWatchedEpisode { if let page = variant.pagings.value?.first(where: { $0.items.value!.contains(where: { $0.number == epNumber }) }), diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index caf6316..8eac009 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -293,8 +293,6 @@ extension VideoPlayerFeature.State { @Dependency(\.playerClient) var playerClient @Dependency(\.playlistHistoryClient) var playlistHistoryClient - let playlistID = playlist.id.rawValue - if selected.groupId != groupId || selected.variantId != variantId || selected.pageId != pageId || @@ -312,7 +310,7 @@ extension VideoPlayerFeature.State { fetchSourcesIfNecessary(), .run { _ in await playerClient.clear() - try? await playlistHistoryClient.updateTimestamp(playlistID, 0) + try? await playlistHistoryClient.updateTimestamp(groupId.rawValue, 0) } ) } diff --git a/Sources/Shared/SharedModels/Playlist.swift b/Sources/Shared/SharedModels/Playlist.swift index ca05274..67852e4 100644 --- a/Sources/Shared/SharedModels/Playlist.swift +++ b/Sources/Shared/SharedModels/Playlist.swift @@ -214,7 +214,7 @@ extension Playlist { public let number: Double public let altTitle: String? public let variants: Loadable - public let `default`: Bool + public let `default`: Bool? public typealias Variants = [Variant] @@ -223,7 +223,7 @@ extension Playlist { number: Double, altTitle: String? = nil, variants: Loadable = .pending, - default: Bool = false + default: Bool? = nil ) { self.id = id self.number = number From ecfc6f5b3a35f80124607bc56710a2603a2107a5 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 25 Feb 2024 20:15:51 +0100 Subject: [PATCH 04/45] Last Watched items are now shown and play the correct video --- Package.swift | 243 ++---------------- .../Models/PlaylistHistory.swift | 47 +++- .../PlaylistHistoryClient/Client.swift | 6 +- .../Clients/PlaylistHistoryClient/Live.swift | 21 +- .../PlaylistHistoryClient/Models.swift | 23 ++ .../Features/ContentCore/ContentCore.swift | 21 +- .../Discover/DiscoverFeature+Reducer.swift | 38 +++ .../Discover/DiscoverFeature+View.swift | 91 +++++++ .../Features/Discover/DiscoverFeature.swift | 11 +- .../PlaylistDetailsFeature.swift | 6 +- Sources/Shared/SharedModels/Meta.swift | 1 + .../Shared/SharedModels/RepoModuleID.swift | 5 + 12 files changed, 270 insertions(+), 243 deletions(-) diff --git a/Package.swift b/Package.swift index 3d94cf5..9f537b2 100644 --- a/Package.swift +++ b/Package.swift @@ -6,16 +6,11 @@ // Licensed under MIT License // -// MARK: - Array + Dependencies - extension [Dependency]: Dependencies { func appending(_ dependencies: any Dependencies) -> [Dependency] { self + dependencies } } - -// MARK: - Array + SupportedPlatforms - // // Array+SupportedPlatforms.swift // Copyright (c) 2023 BrightDigit. @@ -27,9 +22,6 @@ extension [SupportedPlatform]: SupportedPlatforms { self + .init(platforms) } } - -// MARK: - Array + TestTargets - // // Array+TestTargets.swift // Copyright (c) 2023 BrightDigit. @@ -41,9 +33,6 @@ extension [TestTarget]: TestTargets { self + testTargets } } - -// MARK: - CSettingsBuilder - // // CSettingsBuilder.swift // @@ -62,9 +51,6 @@ enum CSettingsBuilder { accumulated + [next] } } - -// MARK: - Dependencies - // // Dependencies.swift // Copyright (c) 2023 BrightDigit. @@ -76,9 +62,6 @@ protocol Dependencies: Sequence where Element == Dependency { init(_ s: S) where S.Element == Dependency, S: Sequence func appending(_ dependencies: any Dependencies) -> Self } - -// MARK: - Dependency - // // Dependency.swift // Copyright (c) 2023 BrightDigit. @@ -88,9 +71,6 @@ protocol Dependencies: Sequence where Element == Dependency { protocol Dependency { var targetDepenency: _PackageDescription_TargetDependency { get } } - -// MARK: - DependencyBuilder - // // DependencyBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -107,7 +87,6 @@ enum DependencyBuilder { accumulated + [next] } } - // // LanguageTag.swift // Copyright (c) 2023 BrightDigit. @@ -117,7 +96,6 @@ enum DependencyBuilder { extension LanguageTag { static let english: LanguageTag = "en" } - // // Macro.swift // @@ -154,7 +132,6 @@ extension Macro { [] } } - // // Package+Extensions.swift // Copyright (c) 2023 BrightDigit. @@ -221,7 +198,6 @@ extension Package { return self } } - // // PackageDependency.swift // Copyright (c) 2023 BrightDigit. @@ -268,7 +244,6 @@ extension PackageDependency { } } } - // // PackageDescription.swift // Copyright (c) 2023 BrightDigit. @@ -283,9 +258,6 @@ typealias _PackageDescription_Product = PackageDescription.Product typealias _PackageDescription_Target = PackageDescription.Target typealias _PackageDescription_TargetDependency = PackageDescription.Target.Dependency typealias _PackageDescription_PackageDependency = PackageDescription.Package.Dependency - -// MARK: - PlatformSet - // // PlatformSet.swift // Copyright (c) 2023 BrightDigit. @@ -296,7 +268,6 @@ protocol PlatformSet { @SupportedPlatformBuilder var body: any SupportedPlatforms { get } } - // // Product+Target.swift // Copyright (c) 2023 BrightDigit. @@ -318,15 +289,14 @@ extension Product where Self: Target { } } } - -// MARK: - Product - // // Product.swift // Copyright (c) 2023 BrightDigit. // Licensed under MIT License // +// MARK: - Product + protocol Product: _Named { var productTargets: [Target] { get } var productType: ProductType { get } @@ -337,9 +307,6 @@ extension Product { .library } } - -// MARK: - ProductType - // // ProductType.swift // Copyright (c) 2023 BrightDigit. @@ -350,9 +317,6 @@ enum ProductType { case library case executable } - -// MARK: - ProductsBuilder - // // ProductsBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -369,9 +333,6 @@ enum ProductsBuilder { accumulated + [next] } } - -// MARK: - ResourcesBuilder - // // ResourcesBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -388,7 +349,6 @@ enum ResourcesBuilder { accumulated + [next] } } - // // String.swift // Copyright (c) 2023 BrightDigit. @@ -400,7 +360,6 @@ extension String { split(separator: "/").last?.split(separator: ".").first.map(String.init) } } - // // SupportedPlatformBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -409,8 +368,6 @@ extension String { import PackageDescription -// MARK: - SupportedPlatformBuilder - @resultBuilder enum SupportedPlatformBuilder { static func buildPartialBlock(first: SupportedPlatform) -> any SupportedPlatforms { @@ -439,9 +396,6 @@ enum SupportedPlatformBuilder { accumulated.appending([next]) } } - -// MARK: - SupportedPlatforms - // // SupportedPlatforms.swift // Copyright (c) 2023 BrightDigit. @@ -453,9 +407,6 @@ protocol SupportedPlatforms: Sequence where Element == SupportedPlatform { init(_ s: S) where S.Element == SupportedPlatform, S: Sequence func appending(_ platforms: any SupportedPlatforms) -> Self } - -// MARK: - SwiftSettingsBuilder - // // SwiftSettingsBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -472,15 +423,14 @@ enum SwiftSettingsBuilder { accumulated + [next] } } - -// MARK: - Target - // // Target.swift // Copyright (c) 2023 BrightDigit. // Licensed under MIT License // +// MARK: - Target + protocol Target: _Depending, Dependency, _Named, _Path { var targetType: TargetType { get } @@ -515,9 +465,6 @@ extension Target { [] } } - -// MARK: - TargetType - // // TargetType.swift // Copyright (c) 2023 BrightDigit. @@ -538,15 +485,14 @@ enum TargetType { case remote(url: String, checksum: String) } } - -// MARK: - TestTarget - // // TestTarget.swift // Copyright (c) 2023 BrightDigit. // Licensed under MIT License // +// MARK: - TestTarget + protocol TestTarget: Target {} extension TestTarget { @@ -554,9 +500,6 @@ extension TestTarget { .test } } - -// MARK: - TestTargetBuilder - // // TestTargetBuilder.swift // Copyright (c) 2023 BrightDigit. @@ -573,9 +516,6 @@ enum TestTargetBuilder { accumulated + [next] } } - -// MARK: - TestTargets - // // TestTargets.swift // Copyright (c) 2023 BrightDigit. @@ -587,7 +527,6 @@ protocol TestTargets: Sequence where Element == TestTarget { init(_ s: S) where S.Element == TestTarget, S: Sequence func appending(_ testTargets: any TestTargets) -> Self } - // // Testable.swift // @@ -598,20 +537,17 @@ protocol TestTargets: Sequence where Element == TestTarget { import Foundation -// MARK: - Testable - protocol Testable { associatedtype Tests: TestTarget } - -// MARK: - _Depending - // // _Depending.swift // Copyright (c) 2023 BrightDigit. // Licensed under MIT License // +// MARK: - _Depending + protocol _Depending { @DependencyBuilder var dependencies: any Dependencies { get } @@ -634,15 +570,14 @@ extension _Depending { .appending(dependencies) } } - -// MARK: - _Named - // // _Named.swift // Copyright (c) 2023 BrightDigit. // Licensed under MIT License // +// MARK: - _Named + protocol _Named { var name: String { get } } @@ -652,7 +587,6 @@ extension _Named { "\(Self.self)" } } - // // _PackageDescription_Product.swift // Copyright (c) 2023 BrightDigit. @@ -672,7 +606,6 @@ extension _PackageDescription_Product { } } } - // // _PackageDescription_Target.swift // Copyright (c) 2023 BrightDigit. @@ -736,7 +669,6 @@ extension _PackageDescription_Target { } } } - // // _Path.swift // @@ -756,7 +688,6 @@ protocol _Path { extension _Path { var path: String? { nil } } - // // AnalyticsClient.swift // @@ -767,14 +698,11 @@ extension _Path { import Foundation -// MARK: - AnalyticsClient - struct AnalyticsClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() } } - // // BuildClient.swift // @@ -785,15 +713,12 @@ struct AnalyticsClient: _Client { import Foundation -// MARK: - BuildClient - struct BuildClient: _Client { var dependencies: any Dependencies { Semver() ComposableArchitecture() } } - // // ClipboardClient.swift // @@ -804,14 +729,11 @@ struct BuildClient: _Client { import Foundation -// MARK: - ClipboardClient - struct ClipboardClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() } } - // // DatabaseClient.swift // @@ -822,8 +744,6 @@ struct ClipboardClient: _Client { import Foundation -// MARK: - DatabaseClient - struct DatabaseClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() @@ -836,9 +756,6 @@ struct DatabaseClient: _Client { Resource.copy("Resources/MochiSchema.xcdatamodeld") } } - -// MARK: - DeviceClient - // // DeviceClient.swift // @@ -852,9 +769,6 @@ struct DeviceClient: _Client { ComposableArchitecture() } } - -// MARK: - FileClient - // // FileClient.swift // @@ -868,7 +782,6 @@ struct FileClient: _Client { ComposableArchitecture() } } - // // LocalizableClient.swift // @@ -879,8 +792,6 @@ struct FileClient: _Client { import Foundation -// MARK: - LocalizableClient - struct LocalizableClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() @@ -890,7 +801,6 @@ struct LocalizableClient: _Client { Resource.process("Resources") } } - // // LoggerClient.swift // @@ -901,15 +811,12 @@ struct LocalizableClient: _Client { import Foundation -// MARK: - LoggerClient - struct LoggerClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() Logging() } } - // // ModuleClient.swift // @@ -952,7 +859,6 @@ extension ModuleClient: Testable { } } } - // // PlayerClient.swift // @@ -963,8 +869,6 @@ extension ModuleClient: Testable { import Foundation -// MARK: - PlayerClient - struct PlayerClient: _Client { var dependencies: any Dependencies { Architecture() @@ -977,7 +881,6 @@ struct PlayerClient: _Client { XMLCoder() } } - // // PlaylistHistoryClient.swift // @@ -987,8 +890,6 @@ struct PlayerClient: _Client { import Foundation -// MARK: - PlaylistHistoryClient - struct PlaylistHistoryClient: _Client { var dependencies: any Dependencies { DatabaseClient() @@ -998,7 +899,6 @@ struct PlaylistHistoryClient: _Client { ComposableArchitecture() } } - // // RepoClient.swift // @@ -1009,8 +909,6 @@ struct PlaylistHistoryClient: _Client { import Foundation -// MARK: - RepoClient - struct RepoClient: _Client { var dependencies: any Dependencies { DatabaseClient() @@ -1021,7 +919,6 @@ struct RepoClient: _Client { ComposableArchitecture() } } - // // UserDefaultsClient.swift // @@ -1032,16 +929,13 @@ struct RepoClient: _Client { import Foundation -// MARK: - UserDefaultsClient - struct UserDefaultsClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() } } - // -// File.swift +// UserSettingsClient.swift // // // Created by ErrorErrorError on 10/5/23. @@ -1050,8 +944,6 @@ struct UserDefaultsClient: _Client { import Foundation -// MARK: - UserSettingsClient - struct UserSettingsClient: _Client { var dependencies: any Dependencies { UserDefaultsClient() @@ -1059,7 +951,6 @@ struct UserSettingsClient: _Client { ViewComponents() } } - // // _Client.swift // @@ -1079,9 +970,6 @@ extension _Client { "Sources/Clients/\(name)" } } - -// MARK: - ComposableArchitecture - // // ComposableArchitecture.swift // @@ -1095,7 +983,6 @@ struct ComposableArchitecture: PackageDependency { .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.5.6") } } - // // CustomDump.swift // @@ -1106,14 +993,11 @@ struct ComposableArchitecture: PackageDependency { import Foundation -// MARK: - CustomDump - struct CustomDump: PackageDependency { var dependency: Package.Dependency { .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") } } - // // FluidGradient.swift // @@ -1124,16 +1008,11 @@ struct CustomDump: PackageDependency { import Foundation -// MARK: - FluidGradient - struct FluidGradient: PackageDependency { var dependency: Package.Dependency { .package(url: "https://github.com/Cindori/FluidGradient.git", exact: "1.0.0") } } - -// MARK: - Nuke - // // Nuke.swift // @@ -1142,6 +1021,8 @@ struct FluidGradient: PackageDependency { // // +// MARK: - Nuke + struct Nuke: PackageDependency { static let nukeURL = "https://github.com/kean/Nuke.git" static let nukeVersion: Version = "12.1.6" @@ -1158,9 +1039,6 @@ struct NukeUI: PackageDependency { .package(url: Nuke.nukeURL, exact: Nuke.nukeVersion) } } - -// MARK: - Parsing - // // Parsing.swift // @@ -1174,9 +1052,6 @@ struct Parsing: PackageDependency { .package(url: "https://github.com/pointfreeco/swift-parsing", exact: "0.13.0") } } - -// MARK: - Semaphore - // // Semaphore.swift // @@ -1190,9 +1065,6 @@ struct Semaphore: PackageDependency { .package(url: "https://github.com/groue/Semaphore", exact: "0.0.8") } } - -// MARK: - Semver - // // Semver.swift // @@ -1206,17 +1078,16 @@ struct Semver: PackageDependency { .package(url: "https://github.com/kutchie-pelaez/Semver.git", exact: "1.0.0") } } - -// MARK: - SwiftLog - // -// File.swift +// SwiftLog.swift // // // Created by ErrorErrorError on 11/9/23. // // +// MARK: - SwiftLog + struct SwiftLog: PackageDependency { var name: String { "swift-log" } var productName: String { "swift-log" } @@ -1237,9 +1108,6 @@ struct Logging: _Depending, Dependency { SwiftLog() } } - -// MARK: - SwiftSoup - // // SwiftSoup.swift // @@ -1253,7 +1121,6 @@ struct SwiftSoup: PackageDependency { .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0") } } - // // SwiftSyntax.swift // @@ -1295,9 +1162,6 @@ struct SwiftCompilerPlugin: _Depending, Dependency { SwiftSyntax() } } - -// MARK: - SwiftUIBackports - // // SwiftUIBackports.swift // @@ -1311,9 +1175,8 @@ struct SwiftUIBackports: PackageDependency { .package(url: "https://github.com/shaps80/SwiftUIBackports.git", .upToNextMajor(from: "2.0.0")) } } - // -// File.swift +// Tagged.swift // // // Created by ErrorErrorError on 10/5/23. @@ -1322,16 +1185,11 @@ struct SwiftUIBackports: PackageDependency { import Foundation -// MARK: - Tagged - struct Tagged: PackageDependency { var dependency: Package.Dependency { .package(url: "https://github.com/pointfreeco/swift-tagged", exact: "0.10.0") } } - -// MARK: - XMLCoder - // // XMLCoder.swift // @@ -1345,7 +1203,6 @@ struct XMLCoder: PackageDependency { .package(url: "https://github.com/CoreOffice/XMLCoder.git", exact: "0.17.1") } } - // // ContentCore.swift // @@ -1356,8 +1213,6 @@ struct XMLCoder: PackageDependency { import Foundation -// MARK: - ContentCore - struct ContentCore: _Feature { var dependencies: any Dependencies { Architecture() @@ -1369,7 +1224,6 @@ struct ContentCore: _Feature { Styling() } } - // // Discover.swift // @@ -1380,8 +1234,6 @@ struct ContentCore: _Feature { import Foundation -// MARK: - Discover - struct Discover: _Feature { var dependencies: any Dependencies { Architecture() @@ -1397,7 +1249,6 @@ struct Discover: _Feature { NukeUI() } } - // // MochiApp.swift // @@ -1408,8 +1259,6 @@ struct Discover: _Feature { import Foundation -// MARK: - MochiApp - struct MochiApp: _Feature { var name: String { "App" } @@ -1427,7 +1276,6 @@ struct MochiApp: _Feature { NukeUI() } } - // // ModuleLists.swift // @@ -1438,8 +1286,6 @@ struct MochiApp: _Feature { import Foundation -// MARK: - ModuleLists - struct ModuleLists: _Feature { var dependencies: any Dependencies { Architecture() @@ -1450,7 +1296,6 @@ struct ModuleLists: _Feature { ComposableArchitecture() } } - // // PlaylistDetails.swift // @@ -1461,8 +1306,6 @@ struct ModuleLists: _Feature { import Foundation -// MARK: - PlaylistDetails - struct PlaylistDetails: _Feature { var dependencies: any Dependencies { Architecture() @@ -1478,7 +1321,6 @@ struct PlaylistDetails: _Feature { NukeUI() } } - // // Repos.swift // @@ -1489,8 +1331,6 @@ struct PlaylistDetails: _Feature { import Foundation -// MARK: - Repos - struct Repos: _Feature { var dependencies: any Dependencies { Architecture() @@ -1504,7 +1344,6 @@ struct Repos: _Feature { NukeUI() } } - // // Search.swift // @@ -1515,8 +1354,6 @@ struct Repos: _Feature { import Foundation -// MARK: - Search - struct Search: _Feature { var dependencies: any Dependencies { Architecture() @@ -1532,9 +1369,6 @@ struct Search: _Feature { NukeUI() } } - -// MARK: - Settings - // // Settings.swift // @@ -1558,9 +1392,6 @@ struct Settings: _Feature { NukeUI() } } - -// MARK: - VideoPlayer - // // VideoPlayer.swift // @@ -1583,9 +1414,8 @@ struct VideoPlayer: _Feature { NukeUI() } } - // -// File.swift +// _Feature.swift // // // Created by ErrorErrorError on 10/5/23. @@ -1603,9 +1433,6 @@ extension _Feature { "Sources/Features/\(name)" } } - -// MARK: - CoreDBMacros - // // CoreDBMacros.swift // @@ -1620,9 +1447,8 @@ struct CoreDBMacros: _Macro { SwiftCompilerPlugin() } } - // -// File.swift +// _Macro.swift // // // Created by ErrorErrorError on 10/27/23. @@ -1640,7 +1466,6 @@ extension _Macro { "Sources/Macros/\(name)" } } - // // MochiPlatforms.swift // @@ -1651,17 +1476,12 @@ extension _Macro { import Foundation -// MARK: - MochiPlatforms - struct MochiPlatforms: PlatformSet { var body: any SupportedPlatforms { SupportedPlatform.macOS(.v12) SupportedPlatform.iOS(.v15) } } - -// MARK: - Architecture - // // Architecture.swift // @@ -1678,9 +1498,6 @@ struct Architecture: _Shared { LoggerClient() } } - -// MARK: - CoreDB - // // CoreDB.swift // @@ -1689,6 +1506,8 @@ struct Architecture: _Shared { // // +// MARK: - CoreDB + struct CoreDB: _Shared { var dependencies: any Dependencies { CoreDBMacros() @@ -1707,9 +1526,6 @@ extension CoreDB: Testable { } } } - -// MARK: - FoundationHelpers - // // FoundationHelpers.swift // @@ -1744,7 +1560,6 @@ extension JSValueCoder: Testable { } } } - // // SharedModels.swift // @@ -1755,8 +1570,6 @@ extension JSValueCoder: Testable { import Foundation -// MARK: - SharedModels - struct SharedModels: _Shared { var dependencies: any Dependencies { DatabaseClient() @@ -1766,9 +1579,8 @@ struct SharedModels: _Shared { JSValueCoder() } } - // -// File.swift +// Styling.swift // // // Created by ErrorErrorError on 10/5/23. @@ -1777,8 +1589,6 @@ struct SharedModels: _Shared { import Foundation -// MARK: - Styling - struct Styling: _Shared { var dependencies: any Dependencies { ViewComponents() @@ -1788,9 +1598,8 @@ struct Styling: _Shared { UserSettingsClient() } } - // -// File.swift +// ViewComponents.swift // // // Created by ErrorErrorError on 10/5/23. @@ -1799,8 +1608,6 @@ struct Styling: _Shared { import Foundation -// MARK: - ViewComponents - struct ViewComponents: _Shared { var dependencies: any Dependencies { SharedModels() @@ -1808,9 +1615,8 @@ struct ViewComponents: _Shared { NukeUI() } } - // -// Shared.swift +// _Shared.swift // // // Created by ErrorErrorError on 10/5/23. @@ -1828,7 +1634,6 @@ extension _Shared { "Sources/Shared/\(name)" } } - // // Index.swift // diff --git a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift index 6477bff..e9b7d2b 100644 --- a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift +++ b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift @@ -11,18 +11,51 @@ import Foundation // MARK: PlaylistHistory @Entity -public struct PlaylistHistory: Equatable, Sendable { - @Attribute public var playlistID = String?.none - @Attribute public var lastWatchedEpisode = 1.0 - @Attribute public var timestamp: Double = 0.0 +public struct PlaylistHistory: Equatable, Sendable, Hashable { + @Attribute public var playlistID = "" + @Attribute public var playlistName = String?.none + + @Attribute public var dateWatched = Date.now + @Attribute public var playlist = Date.now + @Attribute public var lastModuleId = "" + @Attribute public var lastRepoId = "" + @Attribute public var timestamp = 0.0 + + @Attribute public var thumbnail = URL?.none + @Attribute public var epId = "" + @Attribute public var epName = String?.none + + @Attribute public var pageId = "" + @Attribute public var groupId = "" + @Attribute public var variantId = "" public init( - playlistID: String?, + playlistID: String, timestamp: Double = 0.0, - lastWatchedEpisode: Double + epId: String, + playlistName: String?, + lastModuleId: String, + lastRepoId: String, + thumbnail: URL? = nil, + epName: String?, + pageId: String, + groupId: String, + variantId: String ) { self.playlistID = playlistID self.timestamp = timestamp - self.lastWatchedEpisode = lastWatchedEpisode + self.epId = epId + self.playlistName = playlistName + self.thumbnail = thumbnail + self.dateWatched = Date.now + self.lastModuleId = lastModuleId + self.lastRepoId = lastRepoId + self.epName = epName + self.pageId = pageId + self.groupId = groupId + self.variantId = variantId } } + + + diff --git a/Sources/Clients/PlaylistHistoryClient/Client.swift b/Sources/Clients/PlaylistHistoryClient/Client.swift index c370c2e..8ba7a4c 100644 --- a/Sources/Clients/PlaylistHistoryClient/Client.swift +++ b/Sources/Clients/PlaylistHistoryClient/Client.swift @@ -16,8 +16,9 @@ import XCTestDynamicOverlay // MARK: - PlaylistHistoryClient public struct PlaylistHistoryClient: Sendable { - public var updateLastWatchedEpisode: @Sendable (String, Double?) async throws -> Void + public var updateEpId: @Sendable (EpIdPayload) async throws -> Void public var fetch: @Sendable (String) async throws -> PlaylistHistory + public var observeModule: @Sendable (String) -> AsyncStream<[PlaylistHistory]> public var updateTimestamp: @Sendable (String, Double) async throws -> Void public var observe: @Sendable (String) -> AsyncStream<[PlaylistHistory]> } @@ -26,8 +27,9 @@ public struct PlaylistHistoryClient: Sendable { extension PlaylistHistoryClient: TestDependencyKey { public static let testValue = Self( - updateLastWatchedEpisode: unimplemented("\(Self.self).updateLastWatchedEpisode"), + updateEpId: unimplemented("\(Self.self).updateEpId"), fetch: unimplemented("\(Self.self).fetch"), + observeModule: unimplemented("\(Self.self).observeModule"), updateTimestamp: unimplemented("\(Self.self).updateTimestamp"), observe: unimplemented("\(Self.self).observe") ) diff --git a/Sources/Clients/PlaylistHistoryClient/Live.swift b/Sources/Clients/PlaylistHistoryClient/Live.swift index 83294cb..1fd05de 100644 --- a/Sources/Clients/PlaylistHistoryClient/Live.swift +++ b/Sources/Clients/PlaylistHistoryClient/Live.swift @@ -16,12 +16,20 @@ extension PlaylistHistoryClient: DependencyKey { @Dependency(\.databaseClient) private static var databaseClient public static let liveValue = Self( - updateLastWatchedEpisode: { playlistID, epNumber in - if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == playlistID)).first { - playlist.lastWatchedEpisode = epNumber ?? 1 - _ = try await databaseClient.update(playlist) + updateEpId: { payload in + if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == payload.playlistID)).first { + playlist.epId = payload.episode.id.rawValue + playlist.lastModuleId = payload.moduleId.rawValue + playlist.dateWatched = Date.now + playlist.epName = payload.episode.title + playlist.lastRepoId = payload.repoId.absoluteString + playlist.groupId = payload.groupId + playlist.variantId = payload.variantId + playlist.pageId = payload.pageId + playlist.thumbnail = payload.episode.thumbnail + try await databaseClient.update(playlist) } else { - _ = try await databaseClient.insert(PlaylistHistory(playlistID: playlistID, lastWatchedEpisode: epNumber ?? 1)) + try await databaseClient.insert(PlaylistHistory(playlistID: payload.playlistID, epId: payload.episode.id.rawValue, playlistName: payload.playlistName, lastModuleId: payload.moduleId.rawValue, lastRepoId: payload.repoId.absoluteString, thumbnail: payload.episode.thumbnail, epName: payload.episode.title, pageId: payload.pageId, groupId: payload.groupId, variantId: payload.variantId)) } }, fetch: { playlistID in @@ -30,6 +38,9 @@ extension PlaylistHistoryClient: DependencyKey { } return playlistHistory }, + observeModule: { moduleId in + databaseClient.observe(.all.where(\PlaylistHistory.lastModuleId == moduleId)) + }, updateTimestamp: { playlistID, timestamp in if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == playlistID)).first { playlist.timestamp = timestamp diff --git a/Sources/Clients/PlaylistHistoryClient/Models.swift b/Sources/Clients/PlaylistHistoryClient/Models.swift index dbf0e4d..3c4d1c1 100644 --- a/Sources/Clients/PlaylistHistoryClient/Models.swift +++ b/Sources/Clients/PlaylistHistoryClient/Models.swift @@ -6,9 +6,32 @@ // import Foundation +import SharedModels extension PlaylistHistoryClient { public enum Error: Swift.Error, Equatable, Sendable { case failedToFindPlaylisthistory } + + public struct EpIdPayload: Equatable, Sendable { + public let playlistID: String + public let episode: Playlist.Item + public let playlistName: String? + public let moduleId: Module.ID + public let repoId: Repo.ID + public let pageId: String + public let groupId: String + public let variantId: String + + public init(playlistID: String, episode: Playlist.Item, playlistName: String?, moduleId: Module.ID, repoId: Repo.ID, pageId: String, groupId: String, variantId: String) { + self.playlistID = playlistID + self.episode = episode + self.playlistName = playlistName + self.moduleId = moduleId + self.repoId = repoId + self.pageId = pageId + self.groupId = groupId + self.variantId = variantId + } + } } diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index edd0b87..7f1464a 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -75,13 +75,24 @@ public struct ContentCore: Reducer { case let .didTapPlaylistItem(groupId, variantId, pageId, itemId, shouldReset): @Dependency(\.playlistHistoryClient) var playlistHistoryClient + let playlist = state.playlist + let repoModuleId = state.repoModuleId let item = state.item(groupId: groupId, variantId: variantId, pageId: pageId, itemId: itemId).value return .run { _ in - if let epNumber = item?.number { - try? await playlistHistoryClient.updateLastWatchedEpisode(groupId.rawValue, epNumber) - if shouldReset { - try? await playlistHistoryClient.updateTimestamp(groupId.rawValue, 0) - } + if let item = item { + try? await playlistHistoryClient.updateEpId(.init( + playlistID: playlist.id.rawValue, + episode: item, + playlistName: playlist.title, + moduleId: repoModuleId.moduleId, + repoId: repoModuleId.repoId, + pageId: pageId.rawValue, + groupId: groupId.rawValue, + variantId: variantId.rawValue + )) + if shouldReset { + try? await playlistHistoryClient.updateTimestamp(groupId.rawValue, 0) + } } } diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 2ff1da4..97b8b98 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -47,6 +47,39 @@ extension DiscoverFeature { await send(.internal(.selectedModule(module == nil ? nil : .init(repoId: Tagged(repoId), module: module!)))) } } + + case .view(.onLastWatchedAppear): + guard let id = state.section.module?.module.id.moduleId else { + break + } + return .run { send in + for await history in playlistHistoryClient.observeModule(id.rawValue) { + await send(.internal(.updateLastWatched(history.sorted(by: { $0.dateWatched > $1.dateWatched } )))) + } + } + + case let .view(.didTapContinueWatching(item)): + let blankUrl = URL(string: "_blank")! + return .run { send in + try? await moduleClient.withModule(id: .init(repoId: Repo.ID(URL(string: item.lastRepoId)!), moduleId: Module.ID(item.lastModuleId))) { module in + let options = Playlist.ItemsRequestOptions.page(.init(item.groupId), .init(item.variantId), .init(item.pageId)) + let eps = try? await module.playlistEpisodes(Playlist.ID(rawValue: item.playlistID), options) + let playlist = Playlist(id: Playlist.ID(rawValue: item.playlistID), title: item.playlistName, posterImage: nil, bannerImage: nil, url: blankUrl, status: .unknown, type: .video) + await send( + .delegate( + .playbackVideoItem( + eps ?? [], + repoModuleId: .init(repoId: Repo.ID(URL(string: item.lastRepoId)!), moduleId: Module.ID(item.lastModuleId)), + playlist: playlist, + group: .init(item.groupId), + variant: .init(item.variantId), + paging: .init(item.pageId), + itemId: .init(item.epId) + ) + ) + ) + } + } case .view(.didTapOpenModules): state.moduleLists = .init() @@ -116,6 +149,9 @@ extension DiscoverFeature { ) ) ) + + case let .internal(.updateLastWatched(history)): + state.lastWatched = history case .internal(.moduleLists): break @@ -165,6 +201,8 @@ extension DiscoverFeature.State { let value = try await moduleClient.withModule(id: id) { module in try await module.discoverListings() } + + await send(.view(.onLastWatchedAppear)) await send(.internal(.loadedListings(id, .loaded(value)))) } diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index 2e74c48..be5d285 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -265,6 +265,8 @@ extension DiscoverFeature.View { rankListing(listing) case .featured: featuredListing(listing) + case .lastWatched: + lastWatchedListing() } } } @@ -273,6 +275,95 @@ extension DiscoverFeature.View { } extension DiscoverFeature.View { + @MainActor + func lastWatchedListing() -> some View { + LazyVStack(alignment: .leading) { + HStack { + Text("Last Watched") + .font(.title3.weight(.semibold)) + + Spacer() + +// if listing.paging.nextPage != nil { +// Button { +// store.send(.view(.didTapViewMoreListing(listing.id))) +// } label: { +// Text(localizable: "View More") +// .font(.footnote.weight(.bold)) +// .foregroundColor(.gray) +// .opacity(listing.items.isEmpty ? 0 : 1.0) +// } +// .buttonStyle(.plain) +// } + } + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + WithViewStore(store, observe: \.`self`) { state in + ForEach(state.lastWatched ?? [], id: \.self) { item in + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .bottom) { + FillAspectImage(url: item.thumbnail ?? URL(string: "")) + .aspectRatio(16 / 10, contentMode: .fit) + .overlay { + LinearGradient( + gradient: .init( + colors: [ + .black.opacity(0), + .black.opacity(0.8) + ], + easing: .easeIn + ), + startPoint: .top, + endPoint: .bottom + ) + } + + VStack(alignment: .leading, spacing: 5) { + Text(item.playlistName ?? "No Title") + .lineLimit(3) + .font(.subheadline.weight(.medium)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.white) + .padding(.horizontal) + + GeometryReader { proxy in + Color(.white) + .opacity(0.8) + .frame(maxWidth: proxy.size.width * item.timestamp) + } + .clipShape(Capsule(style: .continuous)) + .frame(maxWidth: .infinity) + .frame(height: 6) + } + } + .cornerRadius(12) + + Text(item.epName ?? "No Title") + .lineLimit(3) + .font(.subheadline.weight(.medium)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: 248) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.view(.didTapContinueWatching(item))) + } + } + } + } + .padding(.horizontal) + } + .frame(maxWidth: .infinity) + } + .onAppear { +// store.send(.view(.onLastWatchedAppear)) + } + } + @MainActor func rowListing(_ listing: DiscoverListing) -> some View { listingViewContainer(listing) { diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index 57123d3..79470d4 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -161,7 +161,8 @@ public struct DiscoverFeature: Feature { public struct State: FeatureState { public var section: Section public var path: StackState - + + @PresentationState public var lastWatched: [PlaylistHistory]? @PresentationState public var moduleLists: ModuleListsFeature.State? @PresentationState public var solveCaptcha: DiscoverFeature.Captcha.State? @@ -169,12 +170,14 @@ public struct DiscoverFeature: Feature { section: DiscoverFeature.Section = .empty, path: StackState = .init(), moduleLists: ModuleListsFeature.State? = nil, - solveCaptcha: DiscoverFeature.Captcha.State? = nil + solveCaptcha: DiscoverFeature.Captcha.State? = nil, + lastWatched: [PlaylistHistory]? = [] ) { self.section = section self.path = path self.moduleLists = moduleLists self.solveCaptcha = solveCaptcha + self.lastWatched = lastWatched } } @@ -186,6 +189,8 @@ public struct DiscoverFeature: Feature { public enum ViewAction: SendableAction { case didAppear case didTapOpenModules + case didTapContinueWatching(PlaylistHistory) + case onLastWatchedAppear case didTapPlaylist(Playlist) case didTapSearchButton case didTapViewMoreListing(DiscoverListing.ID) @@ -213,6 +218,7 @@ public struct DiscoverFeature: Feature { case solveCaptcha(PresentationAction) case showCaptcha(String, String) case path(StackAction) + case updateLastWatched([PlaylistHistory]) } case view(ViewAction) @@ -236,6 +242,7 @@ public struct DiscoverFeature: Feature { @Dependency(\.repoClient) var repoClient @Dependency(\.databaseClient) var databaseClient @Dependency(\.moduleClient) var moduleClient + @Dependency(\.playlistHistoryClient) var playlistHistoryClient public init() {} } diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift index c4ae772..12ea655 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift @@ -74,9 +74,9 @@ public struct PlaylistDetailsFeature: Feature { } if let group = content.groups.value?.first(where: { $0.default ?? false }) ?? content.groups.value?.first, let variant = group.variants.value?.first { - if let epNumber = playlistHistory.value?.lastWatchedEpisode { - if let page = variant.pagings.value?.first(where: { $0.items.value!.contains(where: { $0.number == epNumber }) }), - let item = page.items.value?.first(where: { $0.number == epNumber }) { + if let epId = playlistHistory.value?.epId { + if let page = variant.pagings.value?.first(where: { $0.items.value!.contains(where: { $0.id.rawValue == epId }) }), + let item = page.items.value?.first(where: { $0.id.rawValue == epId }) { return .resume(group.id, variant.id, page.id, item.id, item.title ?? "", playlistHistory.value?.timestamp ?? 0.0) } } diff --git a/Sources/Shared/SharedModels/Meta.swift b/Sources/Shared/SharedModels/Meta.swift index 540b5bb..a136ea6 100644 --- a/Sources/Shared/SharedModels/Meta.swift +++ b/Sources/Shared/SharedModels/Meta.swift @@ -22,6 +22,7 @@ public struct DiscoverListing: Sendable, Hashable, Identifiable, Codable { case `default` case rank case featured + case lastWatched } public enum OrientationType: Int, Sendable, Hashable, Codable { diff --git a/Sources/Shared/SharedModels/RepoModuleID.swift b/Sources/Shared/SharedModels/RepoModuleID.swift index 2babb85..af32727 100644 --- a/Sources/Shared/SharedModels/RepoModuleID.swift +++ b/Sources/Shared/SharedModels/RepoModuleID.swift @@ -16,6 +16,11 @@ import Tagged public struct RepoModuleID: Hashable, Sendable { public let repoId: Repo.ID public let moduleId: Module.ID + + public init(repoId: Repo.ID, moduleId: Module.ID) { + self.repoId = repoId + self.moduleId = moduleId + } } extension Repo.ID { From 42c5e2527864c9b45eb171789ad8f9a214e83d32 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 25 Feb 2024 21:10:05 +0100 Subject: [PATCH 05/45] Switch action to internal --- .../Discover/DiscoverFeature+Reducer.swift | 22 +++++++++---------- .../Discover/DiscoverFeature+View.swift | 3 --- .../Features/Discover/DiscoverFeature.swift | 2 +- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 97b8b98..1bccc35 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -48,16 +48,6 @@ extension DiscoverFeature { } } - case .view(.onLastWatchedAppear): - guard let id = state.section.module?.module.id.moduleId else { - break - } - return .run { send in - for await history in playlistHistoryClient.observeModule(id.rawValue) { - await send(.internal(.updateLastWatched(history.sorted(by: { $0.dateWatched > $1.dateWatched } )))) - } - } - case let .view(.didTapContinueWatching(item)): let blankUrl = URL(string: "_blank")! return .run { send in @@ -150,6 +140,16 @@ extension DiscoverFeature { ) ) + case .internal(.onLastWatchedAppear): + guard let id = state.section.module?.module.id.moduleId else { + break + } + return .run { send in + for await history in playlistHistoryClient.observeModule(id.rawValue) { + await send(.internal(.updateLastWatched(history.sorted(by: { $0.dateWatched > $1.dateWatched } )))) + } + } + case let .internal(.updateLastWatched(history)): state.lastWatched = history @@ -202,7 +202,7 @@ extension DiscoverFeature.State { try await module.discoverListings() } - await send(.view(.onLastWatchedAppear)) + await send(.internal(.onLastWatchedAppear)) await send(.internal(.loadedListings(id, .loaded(value)))) } diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index be5d285..42898fe 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -359,9 +359,6 @@ extension DiscoverFeature.View { } .frame(maxWidth: .infinity) } - .onAppear { -// store.send(.view(.onLastWatchedAppear)) - } } @MainActor diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index 79470d4..6d7d70a 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -190,7 +190,6 @@ public struct DiscoverFeature: Feature { case didAppear case didTapOpenModules case didTapContinueWatching(PlaylistHistory) - case onLastWatchedAppear case didTapPlaylist(Playlist) case didTapSearchButton case didTapViewMoreListing(DiscoverListing.ID) @@ -219,6 +218,7 @@ public struct DiscoverFeature: Feature { case showCaptcha(String, String) case path(StackAction) case updateLastWatched([PlaylistHistory]) + case onLastWatchedAppear } case view(ViewAction) From c1f63209ec1cb81bf69eb47870c7730bdbaaf2ce Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Tue, 27 Feb 2024 23:43:26 +0100 Subject: [PATCH 06/45] Refactoring, bugfixes, Date is now updated when last watched item is pressed. --- .../Models/Extensions/PlaylistHistory+.swift | 12 ++++++++ .../Models/PlaylistHistory.swift | 12 ++++---- .../PlaylistHistoryClient/Client.swift | 12 ++++---- .../Clients/PlaylistHistoryClient/Live.swift | 30 +++++++++++-------- .../PlaylistHistoryClient/Models.swift | 22 +++++++++----- Sources/Features/App/AppFeature+Reducer.swift | 5 +++- .../Features/ContentCore/ContentCore.swift | 8 ++--- .../Discover/DiscoverFeature+Reducer.swift | 26 +++++++++++----- .../Features/Discover/DiscoverFeature.swift | 1 + .../VideoPlayerFeature+Reducer.swift | 9 ++++-- 10 files changed, 90 insertions(+), 47 deletions(-) diff --git a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift index fe9f478..63ed314 100644 --- a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift +++ b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift @@ -12,4 +12,16 @@ import Tagged extension PlaylistHistory: Identifiable { public var id: Tagged { .init(playlistID) } + + public struct RMP: Equatable, Sendable { + public let repoId: String + public let moduleId: String + public let playlistId: String + + public init(repoId: String, moduleId: String, playlistId: String) { + self.repoId = repoId + self.moduleId = moduleId + self.playlistId = playlistId + } + } } diff --git a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift index e9b7d2b..3745c63 100644 --- a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift +++ b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift @@ -17,8 +17,8 @@ public struct PlaylistHistory: Equatable, Sendable, Hashable { @Attribute public var dateWatched = Date.now @Attribute public var playlist = Date.now - @Attribute public var lastModuleId = "" - @Attribute public var lastRepoId = "" + @Attribute public var moduleId = "" + @Attribute public var repoId = "" @Attribute public var timestamp = 0.0 @Attribute public var thumbnail = URL?.none @@ -34,8 +34,8 @@ public struct PlaylistHistory: Equatable, Sendable, Hashable { timestamp: Double = 0.0, epId: String, playlistName: String?, - lastModuleId: String, - lastRepoId: String, + moduleId: String, + repoId: String, thumbnail: URL? = nil, epName: String?, pageId: String, @@ -48,8 +48,8 @@ public struct PlaylistHistory: Equatable, Sendable, Hashable { self.playlistName = playlistName self.thumbnail = thumbnail self.dateWatched = Date.now - self.lastModuleId = lastModuleId - self.lastRepoId = lastRepoId + self.moduleId = moduleId + self.repoId = repoId self.epName = epName self.pageId = pageId self.groupId = groupId diff --git a/Sources/Clients/PlaylistHistoryClient/Client.swift b/Sources/Clients/PlaylistHistoryClient/Client.swift index 8ba7a4c..fd871e5 100644 --- a/Sources/Clients/PlaylistHistoryClient/Client.swift +++ b/Sources/Clients/PlaylistHistoryClient/Client.swift @@ -17,10 +17,11 @@ import XCTestDynamicOverlay public struct PlaylistHistoryClient: Sendable { public var updateEpId: @Sendable (EpIdPayload) async throws -> Void - public var fetch: @Sendable (String) async throws -> PlaylistHistory - public var observeModule: @Sendable (String) -> AsyncStream<[PlaylistHistory]> - public var updateTimestamp: @Sendable (String, Double) async throws -> Void - public var observe: @Sendable (String) -> AsyncStream<[PlaylistHistory]> + public var fetch: @Sendable (RMP) async throws -> PlaylistHistory + public var fetchForModule: @Sendable (String, String) async throws -> [PlaylistHistory] + public var updateTimestamp: @Sendable (RMP, Double) async throws -> Void + public var updateDateWatched: @Sendable (RMP) async throws -> Void + public var observe: @Sendable (RMP) -> AsyncStream<[PlaylistHistory]> } // MARK: TestDependencyKey @@ -29,8 +30,9 @@ extension PlaylistHistoryClient: TestDependencyKey { public static let testValue = Self( updateEpId: unimplemented("\(Self.self).updateEpId"), fetch: unimplemented("\(Self.self).fetch"), - observeModule: unimplemented("\(Self.self).observeModule"), + fetchForModule: unimplemented("\(Self.self).fetchForModule"), updateTimestamp: unimplemented("\(Self.self).updateTimestamp"), + updateDateWatched: unimplemented("\(Self.self).updateDateWatched"), observe: unimplemented("\(Self.self).observe") ) } diff --git a/Sources/Clients/PlaylistHistoryClient/Live.swift b/Sources/Clients/PlaylistHistoryClient/Live.swift index 1fd05de..4d438ba 100644 --- a/Sources/Clients/PlaylistHistoryClient/Live.swift +++ b/Sources/Clients/PlaylistHistoryClient/Live.swift @@ -17,38 +17,44 @@ extension PlaylistHistoryClient: DependencyKey { public static let liveValue = Self( updateEpId: { payload in - if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == payload.playlistID)).first { + if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == payload.rmp.repoId).where(\PlaylistHistory.moduleId == payload.rmp.moduleId).where(\PlaylistHistory.playlistID == payload.rmp.playlistId)).first { playlist.epId = payload.episode.id.rawValue - playlist.lastModuleId = payload.moduleId.rawValue playlist.dateWatched = Date.now playlist.epName = payload.episode.title - playlist.lastRepoId = payload.repoId.absoluteString playlist.groupId = payload.groupId playlist.variantId = payload.variantId playlist.pageId = payload.pageId playlist.thumbnail = payload.episode.thumbnail try await databaseClient.update(playlist) } else { - try await databaseClient.insert(PlaylistHistory(playlistID: payload.playlistID, epId: payload.episode.id.rawValue, playlistName: payload.playlistName, lastModuleId: payload.moduleId.rawValue, lastRepoId: payload.repoId.absoluteString, thumbnail: payload.episode.thumbnail, epName: payload.episode.title, pageId: payload.pageId, groupId: payload.groupId, variantId: payload.variantId)) + try await databaseClient.insert(PlaylistHistory(playlistID: payload.rmp.playlistId, epId: payload.episode.id.rawValue, playlistName: payload.playlistName, moduleId: payload.rmp.moduleId, repoId: payload.rmp.repoId, thumbnail: payload.episode.thumbnail, epName: payload.episode.title, pageId: payload.pageId, groupId: payload.groupId, variantId: payload.variantId)) } }, - fetch: { playlistID in - guard let playlistHistory = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == playlistID)).first else { + fetch: { rmp in + guard let playlistHistory = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first else { throw PlaylistHistoryClient.Error.failedToFindPlaylisthistory } return playlistHistory }, - observeModule: { moduleId in - databaseClient.observe(.all.where(\PlaylistHistory.lastModuleId == moduleId)) + fetchForModule: { repoId, moduleId in + let history = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == repoId).where(\PlaylistHistory.moduleId == moduleId)) + + return history?.sorted(by: { $0.dateWatched > $1.dateWatched } ) ?? [] }, - updateTimestamp: { playlistID, timestamp in - if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID == playlistID)).first { + updateTimestamp: { rmp, timestamp in + if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first { playlist.timestamp = timestamp _ = try await databaseClient.update(playlist) } }, - observe: { playlistID in - databaseClient.observe(.all.where(\PlaylistHistory.playlistID == playlistID)) + updateDateWatched: { rmp in + if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first { + playlist.dateWatched = Date.now + _ = try await databaseClient.update(playlist) + } + }, + observe: { rmp in + databaseClient.observe(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)) } ) } diff --git a/Sources/Clients/PlaylistHistoryClient/Models.swift b/Sources/Clients/PlaylistHistoryClient/Models.swift index 3c4d1c1..6afdf07 100644 --- a/Sources/Clients/PlaylistHistoryClient/Models.swift +++ b/Sources/Clients/PlaylistHistoryClient/Models.swift @@ -13,22 +13,30 @@ extension PlaylistHistoryClient { case failedToFindPlaylisthistory } + public struct RMP: Equatable, Sendable { + public let repoId: String + public let moduleId: String + public let playlistId: String + + public init(repoId: String, moduleId: String, playlistId: String) { + self.repoId = repoId + self.moduleId = moduleId + self.playlistId = playlistId + } + } + public struct EpIdPayload: Equatable, Sendable { - public let playlistID: String + public let rmp: RMP public let episode: Playlist.Item public let playlistName: String? - public let moduleId: Module.ID - public let repoId: Repo.ID public let pageId: String public let groupId: String public let variantId: String - public init(playlistID: String, episode: Playlist.Item, playlistName: String?, moduleId: Module.ID, repoId: Repo.ID, pageId: String, groupId: String, variantId: String) { - self.playlistID = playlistID + public init(rmp: RMP, episode: Playlist.Item, playlistName: String?, pageId: String, groupId: String, variantId: String) { + self.rmp = rmp self.episode = episode self.playlistName = playlistName - self.moduleId = moduleId - self.repoId = repoId self.pageId = pageId self.groupId = groupId self.variantId = variantId diff --git a/Sources/Features/App/AppFeature+Reducer.swift b/Sources/Features/App/AppFeature+Reducer.swift index 158a8a1..6061f71 100644 --- a/Sources/Features/App/AppFeature+Reducer.swift +++ b/Sources/Features/App/AppFeature+Reducer.swift @@ -77,7 +77,10 @@ extension AppFeature: Reducer { break case .internal(.videoPlayer(.dismiss)): - return .run { _ in await playerClient.clear() } + return .run { send in + await send(.internal(.discover(.delegate(.playbackDismissed)))) + await playerClient.clear() + } case .internal(.videoPlayer): break diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index 7f1464a..d4eee79 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -81,17 +81,15 @@ public struct ContentCore: Reducer { return .run { _ in if let item = item { try? await playlistHistoryClient.updateEpId(.init( - playlistID: playlist.id.rawValue, + rmp: .init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), episode: item, playlistName: playlist.title, - moduleId: repoModuleId.moduleId, - repoId: repoModuleId.repoId, pageId: pageId.rawValue, groupId: groupId.rawValue, variantId: variantId.rawValue )) if shouldReset { - try? await playlistHistoryClient.updateTimestamp(groupId.rawValue, 0) + try? await playlistHistoryClient.updateTimestamp(.init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), 0) } } } @@ -156,7 +154,7 @@ extension ContentCore.State { } await send(.update(option: option, .loaded(value))) - for await playlistHistoryItems in playlistHistoryClient.observe(playlistId.rawValue) { + for await playlistHistoryItems in playlistHistoryClient.observe(.init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlistId.rawValue)) { if let playlistHistory = playlistHistoryItems.first { await send(.playlistHistoryResponse(.loaded(playlistHistory))) } diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 1bccc35..c8d1d22 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -42,24 +42,30 @@ extension DiscoverFeature { } return .run { send in try await Task.sleep(nanoseconds: 50_000_000) - if let repo = try? await databaseClient.fetch(.all.where(\Repo.remoteURL == repoId)).first { - let module = repo.modules[id: Module.Manifest.ID(moduleId)]?.manifest - await send(.internal(.selectedModule(module == nil ? nil : .init(repoId: Tagged(repoId), module: module!)))) + if let repo = try? await databaseClient.fetch(.all.where(\Repo.remoteURL == repoId)).first, let module = repo.modules[id: Module.Manifest.ID(moduleId)]?.manifest { + await send(.internal(.selectedModule(.init(repoId: Tagged(repoId), module: module)))) + } else { + await send(.internal(.selectedModule(nil))) } } case let .view(.didTapContinueWatching(item)): let blankUrl = URL(string: "_blank")! return .run { send in - try? await moduleClient.withModule(id: .init(repoId: Repo.ID(URL(string: item.lastRepoId)!), moduleId: Module.ID(item.lastModuleId))) { module in + try? await moduleClient.withModule(id: .init(repoId: Repo.ID(URL(string: item.repoId)!), moduleId: Module.ID(item.moduleId))) { module in + let options = Playlist.ItemsRequestOptions.page(.init(item.groupId), .init(item.variantId), .init(item.pageId)) + let eps = try? await module.playlistEpisodes(Playlist.ID(rawValue: item.playlistID), options) + let playlist = Playlist(id: Playlist.ID(rawValue: item.playlistID), title: item.playlistName, posterImage: nil, bannerImage: nil, url: blankUrl, status: .unknown, type: .video) + + try? await playlistHistoryClient.updateDateWatched(.init(repoId: item.repoId, moduleId: item.moduleId, playlistId: playlist.id.rawValue)) await send( .delegate( .playbackVideoItem( eps ?? [], - repoModuleId: .init(repoId: Repo.ID(URL(string: item.lastRepoId)!), moduleId: Module.ID(item.lastModuleId)), + repoModuleId: .init(repoId: Repo.ID(URL(string: item.repoId)!), moduleId: Module.ID(item.moduleId)), playlist: playlist, group: .init(item.groupId), variant: .init(item.variantId), @@ -141,12 +147,13 @@ extension DiscoverFeature { ) case .internal(.onLastWatchedAppear): - guard let id = state.section.module?.module.id.moduleId else { + guard let repoModule = state.section.module?.module.id else { break } + return .run { send in - for await history in playlistHistoryClient.observeModule(id.rawValue) { - await send(.internal(.updateLastWatched(history.sorted(by: { $0.dateWatched > $1.dateWatched } )))) + if let history = try? await playlistHistoryClient.fetchForModule(repoModule.repoId.absoluteString, repoModule.moduleId.rawValue) { + await send(.internal(.updateLastWatched(history))) } } @@ -165,6 +172,9 @@ extension DiscoverFeature { case .internal(.path): break + + case .delegate(.playbackDismissed): + return .send(.internal(.onLastWatchedAppear)) case .delegate: break diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index 6d7d70a..a0b2053 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -207,6 +207,7 @@ public struct DiscoverFeature: Feature { paging: PagingID, itemId: Playlist.Item.ID ) + case playbackDismissed } @CasePathable diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index 8eac009..b6c67af 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -37,13 +37,14 @@ extension VideoPlayerFeature: Reducer { @Dependency(\.playlistHistoryClient) var playlistHistoryClient let groupId = state.selected.groupId.rawValue + let repoModule = state.content.repoModuleId return .merge( state.content.fetchContent(.page(state.selected.groupId, state.selected.variantId, state.selected.pageId)) .map { .internal(.content($0)) }, .run { send in for await status in playerClient.observe() { if let progress = status.playback?.progress { - try? await playlistHistoryClient.updateTimestamp(groupId, progress) + try? await playlistHistoryClient.updateTimestamp(.init(repoId: repoModule.repoId.absoluteString, moduleId: repoModule.moduleId.rawValue, playlistId: groupId), progress) } await send(.internal(.playerStatusUpdate(status))) } @@ -293,6 +294,7 @@ extension VideoPlayerFeature.State { @Dependency(\.playerClient) var playerClient @Dependency(\.playlistHistoryClient) var playlistHistoryClient + let repoModule = content.repoModuleId if selected.groupId != groupId || selected.variantId != variantId || selected.pageId != pageId || @@ -310,7 +312,7 @@ extension VideoPlayerFeature.State { fetchSourcesIfNecessary(), .run { _ in await playerClient.clear() - try? await playlistHistoryClient.updateTimestamp(groupId.rawValue, 0) + try? await playlistHistoryClient.updateTimestamp(.init(repoId: repoModule.repoId.absoluteString, moduleId: repoModule.moduleId.rawValue, playlistId: groupId.rawValue), 0) } ) } @@ -372,10 +374,11 @@ extension VideoPlayerFeature.State { let playlist = playlist let episode = selectedItem.value.flatMap { $0 } let groupId = selected.groupId.rawValue + let repoModule = content.repoModuleId return .run { _ in await playerClient.clear() - let playlistHistory = try? await playlistHistoryClient.fetch(groupId) + let playlistHistory = try? await playlistHistoryClient.fetch(.init(repoId: repoModule.repoId.absoluteString, moduleId: repoModule.moduleId.rawValue, playlistId: groupId)) let loadItem = PlayerClient.VideoCompositionItem( link: link.url, headers: server.headers, From 1f2c1ea35d651413481b6345a79e5a4ffb82561b Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Thu, 29 Feb 2024 23:20:32 +0100 Subject: [PATCH 07/45] Added Clear watch history button into settings --- .../PlaylistHistoryClient/Client.swift | 6 ++-- .../Clients/PlaylistHistoryClient/Live.swift | 5 +++ .../Platforms/SettingsFeature+iOS.swift | 2 ++ .../Settings/SettingsFeature+Reducer.swift | 6 ++++ .../Settings/SettingsFeature+View.swift | 34 +++++++++++++++++++ .../Features/Settings/SettingsFeature.swift | 4 +++ 6 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Sources/Clients/PlaylistHistoryClient/Client.swift b/Sources/Clients/PlaylistHistoryClient/Client.swift index fd871e5..0470d3b 100644 --- a/Sources/Clients/PlaylistHistoryClient/Client.swift +++ b/Sources/Clients/PlaylistHistoryClient/Client.swift @@ -22,6 +22,7 @@ public struct PlaylistHistoryClient: Sendable { public var updateTimestamp: @Sendable (RMP, Double) async throws -> Void public var updateDateWatched: @Sendable (RMP) async throws -> Void public var observe: @Sendable (RMP) -> AsyncStream<[PlaylistHistory]> + public var clearHistory: @Sendable () async throws -> Void } // MARK: TestDependencyKey @@ -32,8 +33,9 @@ extension PlaylistHistoryClient: TestDependencyKey { fetch: unimplemented("\(Self.self).fetch"), fetchForModule: unimplemented("\(Self.self).fetchForModule"), updateTimestamp: unimplemented("\(Self.self).updateTimestamp"), - updateDateWatched: unimplemented("\(Self.self).updateDateWatched"), - observe: unimplemented("\(Self.self).observe") + updateDateWatched: unimplemented("\(Self.self).clearHistory"), + observe: unimplemented("\(Self.self).updateDateWatched"), + clearHistory: unimplemented("\(Self.self).observe") ) } diff --git a/Sources/Clients/PlaylistHistoryClient/Live.swift b/Sources/Clients/PlaylistHistoryClient/Live.swift index 4d438ba..23564c4 100644 --- a/Sources/Clients/PlaylistHistoryClient/Live.swift +++ b/Sources/Clients/PlaylistHistoryClient/Live.swift @@ -55,6 +55,11 @@ extension PlaylistHistoryClient: DependencyKey { }, observe: { rmp in databaseClient.observe(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)) + }, + clearHistory: { + for playlistHistory in try await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID != nil)) { + try await databaseClient.delete(playlistHistory) + }; } ) } diff --git a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift index a3559f0..f719d44 100644 --- a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift +++ b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift @@ -24,6 +24,8 @@ extension SettingsFeature.View { AppearanceView(store: store) case .developer: DeveloperView(store: store) + case .history: + HistoryView(store: store) } } diff --git a/Sources/Features/Settings/SettingsFeature+Reducer.swift b/Sources/Features/Settings/SettingsFeature+Reducer.swift index e14861d..8aee611 100644 --- a/Sources/Features/Settings/SettingsFeature+Reducer.swift +++ b/Sources/Features/Settings/SettingsFeature+Reducer.swift @@ -9,6 +9,7 @@ import Architecture import ComposableArchitecture import UserSettingsClient +import PlaylistHistoryClient extension SettingsFeature { @ReducerBuilder public var body: some ReducerOf { @@ -25,6 +26,11 @@ extension SettingsFeature { switch action { case .view(.didTapViewLogs): state.path.append(.logs(.init())) + case .view(.clearHistory): + @Dependency(\.playlistHistoryClient) var playlistHistoryClient + return .run { + try? await playlistHistoryClient.clearHistory() + } case .view(.onTask): break case .view(.binding): diff --git a/Sources/Features/Settings/SettingsFeature+View.swift b/Sources/Features/Settings/SettingsFeature+View.swift index 393ca7b..6c7c88d 100644 --- a/Sources/Features/Settings/SettingsFeature+View.swift +++ b/Sources/Features/Settings/SettingsFeature+View.swift @@ -189,6 +189,40 @@ struct ThemePicker: View { } } +// MARK: - HistoryView + +@MainActor +struct HistoryView: View { + var showTitle = true + let store: StoreOf + + @SwiftUI.State private var confirmRemoval: Bool = false + + @Environment(\.theme) var theme + + var body: some View { + WithViewStore(store, observe: \.`self`) { viewStore in + SettingsGroup(title: showTitle ? SettingsFeature.Section.history.localized : "") { + Button { + confirmRemoval.toggle() + } label: { + Text("Clear Watch History").foregroundColor(.red) + .frame(maxWidth: .infinity) + } + .confirmationDialog("Are you sure?", + isPresented: $confirmRemoval) { + Button("Remove all watch history", role: .destructive) { + viewStore.send(.view(.clearHistory)) + } + } message: { + Text("You cannot undo this action") + } + .padding() + } + } + } +} + // MARK: - SettingsFeatureView_Previews #Preview { diff --git a/Sources/Features/Settings/SettingsFeature.swift b/Sources/Features/Settings/SettingsFeature.swift index 8db906b..fd7bc0e 100644 --- a/Sources/Features/Settings/SettingsFeature.swift +++ b/Sources/Features/Settings/SettingsFeature.swift @@ -18,10 +18,13 @@ public struct SettingsFeature: Feature { public enum Section: String, Sendable, Hashable, Localizable, CaseIterable { case general = "General" case appearance = "Appearance" + case history = "History" case developer = "Developer" var systemImage: String { switch self { + case .history: + "clock.arrow.circlepath" case .general: "gearshape.fill" case .appearance: @@ -68,6 +71,7 @@ public struct SettingsFeature: Feature { public enum ViewAction: SendableAction, BindableAction { case onTask case didTapViewLogs + case clearHistory case binding(BindingAction) } From 55e3b266416698fd9766078851a25959bef90b90 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Tue, 5 Mar 2024 23:23:51 +0100 Subject: [PATCH 08/45] Cleanup. Last watched playlists can now be removed without removing the whole history. --- .../Models/Extensions/PlaylistHistory+.swift | 12 ------------ Sources/Clients/PlaylistHistoryClient/Client.swift | 8 +++++--- Sources/Clients/PlaylistHistoryClient/Live.swift | 7 +++++++ .../Features/Discover/DiscoverFeature+Reducer.swift | 10 ++++++++++ .../Features/Discover/DiscoverFeature+View.swift | 13 +++++++++++-- Sources/Features/Discover/DiscoverFeature.swift | 2 ++ 6 files changed, 35 insertions(+), 17 deletions(-) diff --git a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift index 63ed314..fe9f478 100644 --- a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift +++ b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift @@ -12,16 +12,4 @@ import Tagged extension PlaylistHistory: Identifiable { public var id: Tagged { .init(playlistID) } - - public struct RMP: Equatable, Sendable { - public let repoId: String - public let moduleId: String - public let playlistId: String - - public init(repoId: String, moduleId: String, playlistId: String) { - self.repoId = repoId - self.moduleId = moduleId - self.playlistId = playlistId - } - } } diff --git a/Sources/Clients/PlaylistHistoryClient/Client.swift b/Sources/Clients/PlaylistHistoryClient/Client.swift index 0470d3b..8c077cf 100644 --- a/Sources/Clients/PlaylistHistoryClient/Client.swift +++ b/Sources/Clients/PlaylistHistoryClient/Client.swift @@ -23,6 +23,7 @@ public struct PlaylistHistoryClient: Sendable { public var updateDateWatched: @Sendable (RMP) async throws -> Void public var observe: @Sendable (RMP) -> AsyncStream<[PlaylistHistory]> public var clearHistory: @Sendable () async throws -> Void + public var removePlaylistHistory: @Sendable (RMP) async throws -> Void } // MARK: TestDependencyKey @@ -33,9 +34,10 @@ extension PlaylistHistoryClient: TestDependencyKey { fetch: unimplemented("\(Self.self).fetch"), fetchForModule: unimplemented("\(Self.self).fetchForModule"), updateTimestamp: unimplemented("\(Self.self).updateTimestamp"), - updateDateWatched: unimplemented("\(Self.self).clearHistory"), - observe: unimplemented("\(Self.self).updateDateWatched"), - clearHistory: unimplemented("\(Self.self).observe") + updateDateWatched: unimplemented("\(Self.self).updateDateWatched"), + observe: unimplemented("\(Self.self).observe"), + clearHistory: unimplemented("\(Self.self).clearHistory"), + removePlaylistHistory: unimplemented("\(Self.self).removePlaylistHistory") ) } diff --git a/Sources/Clients/PlaylistHistoryClient/Live.swift b/Sources/Clients/PlaylistHistoryClient/Live.swift index 23564c4..bc13187 100644 --- a/Sources/Clients/PlaylistHistoryClient/Live.swift +++ b/Sources/Clients/PlaylistHistoryClient/Live.swift @@ -60,6 +60,13 @@ extension PlaylistHistoryClient: DependencyKey { for playlistHistory in try await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID != nil)) { try await databaseClient.delete(playlistHistory) }; + }, + removePlaylistHistory: { rmp in + guard let playlistHistory = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first else { + throw PlaylistHistoryClient.Error.failedToFindPlaylisthistory + } + + try await databaseClient.delete(playlistHistory) } ) } diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index c8d1d22..077abab 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -76,6 +76,13 @@ extension DiscoverFeature { ) } } + + case let .view(.didTapRemovePlaylistHistory(repoId, moduleId, playlistId)): + return .run { send in + if let _ = try? await playlistHistoryClient.removePlaylistHistory(.init(repoId: repoId, moduleId: moduleId, playlistId: playlistId)) { + await send(.internal(.removeLastWatchedPlaylist(playlistId))) + } + } case .view(.didTapOpenModules): state.moduleLists = .init() @@ -157,6 +164,9 @@ extension DiscoverFeature { } } + case let .internal(.removeLastWatchedPlaylist(playlistId)): + state.lastWatched?.removeAll { $0.playlistID == playlistId } + case let .internal(.updateLastWatched(history)): state.lastWatched = history diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index 42898fe..3790da9 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -300,8 +300,8 @@ extension DiscoverFeature.View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 12) { - WithViewStore(store, observe: \.`self`) { state in - ForEach(state.lastWatched ?? [], id: \.self) { item in + WithViewStore(store, observe: \.`self`) { viewStore in + ForEach(viewStore.lastWatched ?? [], id: \.self) { item in VStack(alignment: .leading, spacing: 8) { ZStack(alignment: .bottom) { FillAspectImage(url: item.thumbnail ?? URL(string: "")) @@ -340,6 +340,14 @@ extension DiscoverFeature.View { } } .cornerRadius(12) + .contextMenu { + Button(role: .destructive) { + viewStore.send(.view(.didTapRemovePlaylistHistory(item.repoId, item.moduleId, item.playlistID))) + } label: { + Label("Remove from history", systemImage: "trash.fill") + } + .buttonStyle(.plain) + } Text(item.epName ?? "No Title") .lineLimit(3) @@ -352,6 +360,7 @@ extension DiscoverFeature.View { .onTapGesture { store.send(.view(.didTapContinueWatching(item))) } + .animation(.easeInOut, value: viewStore.lastWatched) } } } diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index a0b2053..1748d88 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -190,6 +190,7 @@ public struct DiscoverFeature: Feature { case didAppear case didTapOpenModules case didTapContinueWatching(PlaylistHistory) + case didTapRemovePlaylistHistory(String, String, String) case didTapPlaylist(Playlist) case didTapSearchButton case didTapViewMoreListing(DiscoverListing.ID) @@ -219,6 +220,7 @@ public struct DiscoverFeature: Feature { case showCaptcha(String, String) case path(StackAction) case updateLastWatched([PlaylistHistory]) + case removeLastWatchedPlaylist(String) case onLastWatchedAppear } From be5ff39e843a1fc9a06a54ffa18924951e40748f Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Thu, 7 Mar 2024 21:57:22 +0100 Subject: [PATCH 09/45] CF now passes --- Sources/Features/App/AppFeature+Reducer.swift | 12 ++++++++++++ .../Features/Discover/DiscoverFeature+Reducer.swift | 6 ++---- Sources/Features/Discover/WebView.swift | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Sources/Features/App/AppFeature+Reducer.swift b/Sources/Features/App/AppFeature+Reducer.swift index 6061f71..4d47af2 100644 --- a/Sources/Features/App/AppFeature+Reducer.swift +++ b/Sources/Features/App/AppFeature+Reducer.swift @@ -14,6 +14,8 @@ import ModuleLists import Repos import Settings import VideoPlayer +import Darwin +import Foundation extension AppFeature: Reducer { public var body: some ReducerOf { @@ -24,6 +26,16 @@ extension AppFeature: Reducer { Reduce { state, action in switch action { case .view(.didAppear): + var sysinfo = utsname() + uname(&sysinfo) + let dv = String(bytes: Data(bytes: &sysinfo.release, count: Int(_SYS_NAMELEN)), encoding: .ascii)!.trimmingCharacters(in: .controlCharacters) + let dictionary = Bundle(identifier: "com.apple.CFNetwork")?.infoDictionary! + let cfVersion = dictionary?["CFBundleVersion"] as! String + if let infoDict = Bundle.main.infoDictionary { + let version = infoDict["CFBundleVersion"] as! String + let name = infoDict["CFBundleName"] as! String + UserDefaults.standard.setValue("\(name)/\(version) CFNetwork/\(cfVersion) Darwin/\(dv)", forKey: "userAgent") + } break case let .view(.didSelectTab(tab)): diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 077abab..78e133e 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -19,8 +19,6 @@ import Search import SharedModels import Tagged -let defaults = UserDefaults.standard - // MARK: - DiscoverFeature extension DiscoverFeature { @@ -35,8 +33,8 @@ extension DiscoverFeature { if state.section.module != nil { break } - guard let moduleId = defaults.string(forKey: "LastSelectedModuleId"), - let repoId = defaults.url(forKey: "LastSelectedRepoId") else { + guard let moduleId = UserDefaults.standard.string(forKey: "LastSelectedModuleId"), + let repoId = UserDefaults.standard.url(forKey: "LastSelectedRepoId") else { state.section = .home() break } diff --git a/Sources/Features/Discover/WebView.swift b/Sources/Features/Discover/WebView.swift index 3693136..cdeb5d1 100644 --- a/Sources/Features/Discover/WebView.swift +++ b/Sources/Features/Discover/WebView.swift @@ -21,6 +21,9 @@ struct WebView: UIViewRepresentable { WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: url) } + if let ua = UserDefaults.standard.string(forKey: "userAgent") { + webView.customUserAgent = ua + } webView.loadHTMLString(html, baseURL: URL(string: hostname)) } } From 1e8a6c442cc5472de47fa1af2a76f3cbb6c785cc Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sat, 9 Mar 2024 21:40:17 +0100 Subject: [PATCH 10/45] Cookies are now set when changed. Sheet is now easier to close --- .../Discover/DiscoverFeature+View.swift | 11 +++++++++-- Sources/Features/Discover/WebView.swift | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index 3790da9..60958f5 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -150,8 +150,15 @@ extension DiscoverFeature.View: View { state: /DiscoverFeature.Captcha.State.solveCaptcha, action: DiscoverFeature.Captcha.Action.solveCaptcha ) { store in - WithViewStore(store, observe: \.`self`) { viewStore in - WebView(html: viewStore.html, hostname: viewStore.hostname) + VStack { + Capsule() + .frame(width: 48, height: 4) + .foregroundColor(.gray.opacity(0.26)) + .padding(.top, 8) + + WithViewStore(store, observe: \.`self`) { viewStore in + WebView(html: viewStore.html, hostname: viewStore.hostname) + } } } } diff --git a/Sources/Features/Discover/WebView.swift b/Sources/Features/Discover/WebView.swift index cdeb5d1..412e010 100644 --- a/Sources/Features/Discover/WebView.swift +++ b/Sources/Features/Discover/WebView.swift @@ -11,19 +11,28 @@ import WebKit struct WebView: UIViewRepresentable { var html: String var hostname: String + let observer = wkobserver() func makeUIView(context: Context) -> WKWebView { + WKWebsiteDataStore.default().httpCookieStore.add(observer) return WKWebView() } func updateUIView(_ webView: WKWebView, context: Context) { - let url = URL(string: hostname)! - WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in - HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: url) - } if let ua = UserDefaults.standard.string(forKey: "userAgent") { webView.customUserAgent = ua } webView.loadHTMLString(html, baseURL: URL(string: hostname)) } } + +extension WebView { + class wkobserver: NSObject, WKHTTPCookieStoreObserver { + func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { + cookieStore.getAllCookies { cookies in + let url = URL(string: "http\(cookies[0].isSecure ? "s" : "")://\(cookies[0].domain)")! + HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: url) + } + } + } + } From 46d4f965f767bbc93c674b022214e8f5dd45784f Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 10 Mar 2024 10:58:58 +0100 Subject: [PATCH 11/45] SwiftFormat. Failed module fetching can now be retried with a button --- .../Models/PlaylistHistory.swift | 9 ++-- .../JS+Bindings/JSContext+Request.swift | 4 +- .../Clients/PlaylistHistoryClient/Live.swift | 38 +++++++++---- .../PlaylistHistoryClient/Models.swift | 8 +-- Sources/Features/App/AppFeature+Reducer.swift | 5 +- .../Features/ContentCore/ContentCore.swift | 24 ++++----- .../Discover/DiscoverFeature+Reducer.swift | 32 +++++------ .../Discover/DiscoverFeature+View.swift | 52 +++++++++--------- .../Features/Discover/DiscoverFeature.swift | 54 ++++++++++--------- Sources/Features/Discover/WebView.swift | 21 +++++--- .../Settings/SettingsFeature+Reducer.swift | 8 +-- .../Settings/SettingsFeature+View.swift | 12 +++-- .../Shared/SharedModels/RepoModuleID.swift | 2 +- 13 files changed, 146 insertions(+), 123 deletions(-) diff --git a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift index 3745c63..4c1d75a 100644 --- a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift +++ b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift @@ -14,17 +14,17 @@ import Foundation public struct PlaylistHistory: Equatable, Sendable, Hashable { @Attribute public var playlistID = "" @Attribute public var playlistName = String?.none - + @Attribute public var dateWatched = Date.now @Attribute public var playlist = Date.now @Attribute public var moduleId = "" @Attribute public var repoId = "" @Attribute public var timestamp = 0.0 - + @Attribute public var thumbnail = URL?.none @Attribute public var epId = "" @Attribute public var epName = String?.none - + @Attribute public var pageId = "" @Attribute public var groupId = "" @Attribute public var variantId = "" @@ -56,6 +56,3 @@ public struct PlaylistHistory: Equatable, Sendable, Hashable { self.variantId = variantId } } - - - diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift index cc2492d..3058f97 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift @@ -64,8 +64,8 @@ extension JSContext { if let headers = options["headers"]?.toDictionary() as? [String: String] { headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } } - - let cookies = HTTPCookieStorage.shared.cookies(for: url).map({ $0.map({ "\($0.name)=\($0.value)" }) })?.joined(separator: "; ") + + let cookies = HTTPCookieStorage.shared.cookies(for: url).map { $0.map { "\($0.name)=\($0.value)" } }?.joined(separator: "; ") request.setValue(cookies, forHTTPHeaderField: "Cookie") return .init(newPromiseIn: self) { resolved, rejected in diff --git a/Sources/Clients/PlaylistHistoryClient/Live.swift b/Sources/Clients/PlaylistHistoryClient/Live.swift index bc13187..692c85a 100644 --- a/Sources/Clients/PlaylistHistoryClient/Live.swift +++ b/Sources/Clients/PlaylistHistoryClient/Live.swift @@ -17,7 +17,8 @@ extension PlaylistHistoryClient: DependencyKey { public static let liveValue = Self( updateEpId: { payload in - if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == payload.rmp.repoId).where(\PlaylistHistory.moduleId == payload.rmp.moduleId).where(\PlaylistHistory.playlistID == payload.rmp.playlistId)).first { + if var playlist = try? await databaseClient + .fetch(.all.where(\PlaylistHistory.repoId == payload.rmp.repoId).where(\PlaylistHistory.moduleId == payload.rmp.moduleId).where(\PlaylistHistory.playlistID == payload.rmp.playlistId)).first { playlist.epId = payload.episode.id.rawValue playlist.dateWatched = Date.now playlist.epName = payload.episode.title @@ -25,30 +26,44 @@ extension PlaylistHistoryClient: DependencyKey { playlist.variantId = payload.variantId playlist.pageId = payload.pageId playlist.thumbnail = payload.episode.thumbnail - try await databaseClient.update(playlist) + _ = try await databaseClient.update(playlist) } else { - try await databaseClient.insert(PlaylistHistory(playlistID: payload.rmp.playlistId, epId: payload.episode.id.rawValue, playlistName: payload.playlistName, moduleId: payload.rmp.moduleId, repoId: payload.rmp.repoId, thumbnail: payload.episode.thumbnail, epName: payload.episode.title, pageId: payload.pageId, groupId: payload.groupId, variantId: payload.variantId)) + _ = try await databaseClient.insert(PlaylistHistory( + playlistID: payload.rmp.playlistId, + epId: payload.episode.id.rawValue, + playlistName: payload.playlistName, + moduleId: payload.rmp.moduleId, + repoId: payload.rmp.repoId, + thumbnail: payload.episode.thumbnail, + epName: payload.episode.title, + pageId: payload.pageId, + groupId: payload.groupId, + variantId: payload.variantId + )) } }, fetch: { rmp in - guard let playlistHistory = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first else { + guard let playlistHistory = try? await databaseClient + .fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first else { throw PlaylistHistoryClient.Error.failedToFindPlaylisthistory } return playlistHistory }, fetchForModule: { repoId, moduleId in let history = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == repoId).where(\PlaylistHistory.moduleId == moduleId)) - - return history?.sorted(by: { $0.dateWatched > $1.dateWatched } ) ?? [] + + return history?.sorted(by: { $0.dateWatched > $1.dateWatched }) ?? [] }, updateTimestamp: { rmp, timestamp in - if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first { + if var playlist = try? await databaseClient + .fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first { playlist.timestamp = timestamp _ = try await databaseClient.update(playlist) } }, updateDateWatched: { rmp in - if var playlist = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first { + if var playlist = try? await databaseClient + .fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first { playlist.dateWatched = Date.now _ = try await databaseClient.update(playlist) } @@ -59,13 +74,14 @@ extension PlaylistHistoryClient: DependencyKey { clearHistory: { for playlistHistory in try await databaseClient.fetch(.all.where(\PlaylistHistory.playlistID != nil)) { try await databaseClient.delete(playlistHistory) - }; + } }, removePlaylistHistory: { rmp in - guard let playlistHistory = try? await databaseClient.fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first else { + guard let playlistHistory = try? await databaseClient + .fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first else { throw PlaylistHistoryClient.Error.failedToFindPlaylisthistory } - + try await databaseClient.delete(playlistHistory) } ) diff --git a/Sources/Clients/PlaylistHistoryClient/Models.swift b/Sources/Clients/PlaylistHistoryClient/Models.swift index 6afdf07..4ba7e36 100644 --- a/Sources/Clients/PlaylistHistoryClient/Models.swift +++ b/Sources/Clients/PlaylistHistoryClient/Models.swift @@ -12,19 +12,19 @@ extension PlaylistHistoryClient { public enum Error: Swift.Error, Equatable, Sendable { case failedToFindPlaylisthistory } - + public struct RMP: Equatable, Sendable { public let repoId: String public let moduleId: String public let playlistId: String - + public init(repoId: String, moduleId: String, playlistId: String) { self.repoId = repoId self.moduleId = moduleId self.playlistId = playlistId } } - + public struct EpIdPayload: Equatable, Sendable { public let rmp: RMP public let episode: Playlist.Item @@ -32,7 +32,7 @@ extension PlaylistHistoryClient { public let pageId: String public let groupId: String public let variantId: String - + public init(rmp: RMP, episode: Playlist.Item, playlistName: String?, pageId: String, groupId: String, variantId: String) { self.rmp = rmp self.episode = episode diff --git a/Sources/Features/App/AppFeature+Reducer.swift b/Sources/Features/App/AppFeature+Reducer.swift index 4d47af2..8a15219 100644 --- a/Sources/Features/App/AppFeature+Reducer.swift +++ b/Sources/Features/App/AppFeature+Reducer.swift @@ -8,14 +8,14 @@ import Architecture import ComposableArchitecture +import Darwin import DatabaseClient import Discover +import Foundation import ModuleLists import Repos import Settings import VideoPlayer -import Darwin -import Foundation extension AppFeature: Reducer { public var body: some ReducerOf { @@ -36,7 +36,6 @@ extension AppFeature: Reducer { let name = infoDict["CFBundleName"] as! String UserDefaults.standard.setValue("\(name)/\(version) CFNetwork/\(cfVersion) Darwin/\(dv)", forKey: "userAgent") } - break case let .view(.didSelectTab(tab)): if state.selected == tab { diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index d4eee79..3be5186 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -79,18 +79,18 @@ public struct ContentCore: Reducer { let repoModuleId = state.repoModuleId let item = state.item(groupId: groupId, variantId: variantId, pageId: pageId, itemId: itemId).value return .run { _ in - if let item = item { - try? await playlistHistoryClient.updateEpId(.init( - rmp: .init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), - episode: item, - playlistName: playlist.title, - pageId: pageId.rawValue, - groupId: groupId.rawValue, - variantId: variantId.rawValue - )) - if shouldReset { - try? await playlistHistoryClient.updateTimestamp(.init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), 0) - } + if let item { + try? await playlistHistoryClient.updateEpId(.init( + rmp: .init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), + episode: item, + playlistName: playlist.title, + pageId: pageId.rawValue, + groupId: groupId.rawValue, + variantId: variantId.rawValue + )) + if shouldReset { + try? await playlistHistoryClient.updateTimestamp(.init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), 0) + } } } diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 78e133e..17b3786 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -46,18 +46,18 @@ extension DiscoverFeature { await send(.internal(.selectedModule(nil))) } } - + case let .view(.didTapContinueWatching(item)): let blankUrl = URL(string: "_blank")! return .run { send in try? await moduleClient.withModule(id: .init(repoId: Repo.ID(URL(string: item.repoId)!), moduleId: Module.ID(item.moduleId))) { module in - + let options = Playlist.ItemsRequestOptions.page(.init(item.groupId), .init(item.variantId), .init(item.pageId)) - + let eps = try? await module.playlistEpisodes(Playlist.ID(rawValue: item.playlistID), options) - + let playlist = Playlist(id: Playlist.ID(rawValue: item.playlistID), title: item.playlistName, posterImage: nil, bannerImage: nil, url: blankUrl, status: .unknown, type: .video) - + try? await playlistHistoryClient.updateDateWatched(.init(repoId: item.repoId, moduleId: item.moduleId, playlistId: playlist.id.rawValue)) await send( .delegate( @@ -74,13 +74,16 @@ extension DiscoverFeature { ) } } - + case let .view(.didTapRemovePlaylistHistory(repoId, moduleId, playlistId)): return .run { send in if let _ = try? await playlistHistoryClient.removePlaylistHistory(.init(repoId: repoId, moduleId: moduleId, playlistId: playlistId)) { await send(.internal(.removeLastWatchedPlaylist(playlistId))) } } + + case .view(.didTapRetryLoadingModule): + return state.fetchLatestListings(state.section.module?.module) case .view(.didTapOpenModules): state.moduleLists = .init() @@ -150,37 +153,36 @@ extension DiscoverFeature { ) ) ) - + case .internal(.onLastWatchedAppear): guard let repoModule = state.section.module?.module.id else { break } - + return .run { send in if let history = try? await playlistHistoryClient.fetchForModule(repoModule.repoId.absoluteString, repoModule.moduleId.rawValue) { await send(.internal(.updateLastWatched(history))) } } - + case let .internal(.removeLastWatchedPlaylist(playlistId)): state.lastWatched?.removeAll { $0.playlistID == playlistId } - + case let .internal(.updateLastWatched(history)): state.lastWatched = history case .internal(.moduleLists): break - + case let .internal(.showCaptcha(html, hostname)): state.solveCaptcha = .solveCaptcha(.init(html: html, hostname: hostname)) - break - + case .internal(.solveCaptcha): break case .internal(.path): break - + case .delegate(.playbackDismissed): return .send(.internal(.onLastWatchedAppear)) @@ -219,7 +221,7 @@ extension DiscoverFeature.State { let value = try await moduleClient.withModule(id: id) { module in try await module.discoverListings() } - + await send(.internal(.onLastWatchedAppear)) await send(.internal(.loadedListings(id, .loaded(value)))) diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index 60958f5..5b0d510 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -143,24 +143,24 @@ extension DiscoverFeature.View: View { } } .sheet( - store: store.scope( - state: \.$solveCaptcha, - action: \.internal.solveCaptcha - ), - state: /DiscoverFeature.Captcha.State.solveCaptcha, - action: DiscoverFeature.Captcha.Action.solveCaptcha - ) { store in - VStack { - Capsule() - .frame(width: 48, height: 4) - .foregroundColor(.gray.opacity(0.26)) - .padding(.top, 8) - - WithViewStore(store, observe: \.`self`) { viewStore in - WebView(html: viewStore.html, hostname: viewStore.hostname) - } + store: store.scope( + state: \.$solveCaptcha, + action: \.internal.solveCaptcha + ), + state: /DiscoverFeature.Captcha.State.solveCaptcha, + action: DiscoverFeature.Captcha.Action.solveCaptcha + ) { store in + VStack { + Capsule() + .frame(width: 48, height: 4) + .foregroundColor(.gray.opacity(0.26)) + .padding(.top, 8) + + WithViewStore(store, observe: \.`self`) { viewStore in + WebView(html: viewStore.html, hostname: viewStore.hostname) } } + } } } @@ -191,7 +191,7 @@ extension DiscoverFeature.View { .font(.title2.weight(.medium)) Text(String(localizable: "There was an error retrieving content")) Button { - // TODO: Allow retrying + store.send(.view(.didTapRetryLoadingModule)) } label: { Text(localizable: "Retry") .padding(.horizontal, 12) @@ -284,10 +284,10 @@ extension DiscoverFeature.View { extension DiscoverFeature.View { @MainActor func lastWatchedListing() -> some View { - LazyVStack(alignment: .leading) { + LazyVStack(alignment: .leading) { HStack { Text("Last Watched") - .font(.title3.weight(.semibold)) + .font(.title3.weight(.semibold)) Spacer() @@ -335,7 +335,7 @@ extension DiscoverFeature.View { .fixedSize(horizontal: false, vertical: true) .foregroundColor(.white) .padding(.horizontal) - + GeometryReader { proxy in Color(.white) .opacity(0.8) @@ -355,19 +355,19 @@ extension DiscoverFeature.View { } .buttonStyle(.plain) } - + Text(item.epName ?? "No Title") .lineLimit(3) .font(.subheadline.weight(.medium)) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) } - .frame(width: 248) - .contentShape(Rectangle()) - .onTapGesture { + .frame(width: 248) + .contentShape(Rectangle()) + .onTapGesture { store.send(.view(.didTapContinueWatching(item))) } - .animation(.easeInOut, value: viewStore.lastWatched) + .animation(.easeInOut, value: viewStore.lastWatched) } } } @@ -376,7 +376,7 @@ extension DiscoverFeature.View { .frame(maxWidth: .infinity) } } - + @MainActor func rowListing(_ listing: DiscoverListing) -> some View { listingViewContainer(listing) { diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index 1748d88..a470c34 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -25,39 +25,40 @@ import ViewComponents public struct DiscoverFeature: Feature { public struct Captcha: ComposableArchitecture.Reducer { - public enum State: Equatable, Sendable { - case solveCaptcha(SolveCaptcha.State) - } + public enum State: Equatable, Sendable { + case solveCaptcha(SolveCaptcha.State) + } - public enum Action: Equatable, Sendable { - case solveCaptcha(SolveCaptcha.Action) - } + public enum Action: Equatable, Sendable { + case solveCaptcha(SolveCaptcha.Action) + } - public var body: some ReducerOf { - Scope(state: /State.solveCaptcha, action: /Action.solveCaptcha) { - SolveCaptcha() - } + public var body: some ReducerOf { + Scope(state: /State.solveCaptcha, action: /Action.solveCaptcha) { + SolveCaptcha() } + } - public struct SolveCaptcha: ComposableArchitecture.Reducer { - public struct State: Equatable, Sendable { - public let html: String - public let hostname: String - - public init( - html: String, - hostname: String - ) { - self.html = html - self.hostname = hostname - } + public struct SolveCaptcha: ComposableArchitecture.Reducer { + public struct State: Equatable, Sendable { + public let html: String + public let hostname: String + + public init( + html: String, + hostname: String + ) { + self.html = html + self.hostname = hostname } + } - public enum Action: Equatable, Sendable {} + public enum Action: Equatable, Sendable {} - public var body: some ReducerOf { EmptyReducer() } - } + public var body: some ReducerOf { EmptyReducer() } } + } + public enum Error: Swift.Error, Equatable, Sendable, Localizable { case system(System) case module(ModuleClient.Error) @@ -161,7 +162,7 @@ public struct DiscoverFeature: Feature { public struct State: FeatureState { public var section: Section public var path: StackState - + @PresentationState public var lastWatched: [PlaylistHistory]? @PresentationState public var moduleLists: ModuleListsFeature.State? @PresentationState public var solveCaptcha: DiscoverFeature.Captcha.State? @@ -194,6 +195,7 @@ public struct DiscoverFeature: Feature { case didTapPlaylist(Playlist) case didTapSearchButton case didTapViewMoreListing(DiscoverListing.ID) + case didTapRetryLoadingModule } @CasePathable diff --git a/Sources/Features/Discover/WebView.swift b/Sources/Features/Discover/WebView.swift index 412e010..bf076fb 100644 --- a/Sources/Features/Discover/WebView.swift +++ b/Sources/Features/Discover/WebView.swift @@ -8,31 +8,36 @@ import SwiftUI import WebKit +// MARK: - WebView + struct WebView: UIViewRepresentable { var html: String var hostname: String let observer = wkobserver() - func makeUIView(context: Context) -> WKWebView { + func makeUIView(context _: Context) -> WKWebView { WKWebsiteDataStore.default().httpCookieStore.add(observer) return WKWebView() } - - func updateUIView(_ webView: WKWebView, context: Context) { + + func updateUIView(_ webView: WKWebView, context _: Context) { if let ua = UserDefaults.standard.string(forKey: "userAgent") { + // NOTE: User Agent seems to be incorrent on a simulator webView.customUserAgent = ua } webView.loadHTMLString(html, baseURL: URL(string: hostname)) } } +// MARK: WebView.wkobserver + extension WebView { class wkobserver: NSObject, WKHTTPCookieStoreObserver { func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { cookieStore.getAllCookies { cookies in - let url = URL(string: "http\(cookies[0].isSecure ? "s" : "")://\(cookies[0].domain)")! - HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: url) - } + let url = URL(string: "http\(cookies[0].isSecure ? "s" : "")://\(cookies[0].domain)")! + HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: url) } - } - } + } + } +} diff --git a/Sources/Features/Settings/SettingsFeature+Reducer.swift b/Sources/Features/Settings/SettingsFeature+Reducer.swift index 8aee611..57d76f5 100644 --- a/Sources/Features/Settings/SettingsFeature+Reducer.swift +++ b/Sources/Features/Settings/SettingsFeature+Reducer.swift @@ -8,8 +8,8 @@ import Architecture import ComposableArchitecture -import UserSettingsClient import PlaylistHistoryClient +import UserSettingsClient extension SettingsFeature { @ReducerBuilder public var body: some ReducerOf { @@ -28,9 +28,9 @@ extension SettingsFeature { state.path.append(.logs(.init())) case .view(.clearHistory): @Dependency(\.playlistHistoryClient) var playlistHistoryClient - return .run { - try? await playlistHistoryClient.clearHistory() - } + return .run { + try? await playlistHistoryClient.clearHistory() + } case .view(.onTask): break case .view(.binding): diff --git a/Sources/Features/Settings/SettingsFeature+View.swift b/Sources/Features/Settings/SettingsFeature+View.swift index 6c7c88d..b362939 100644 --- a/Sources/Features/Settings/SettingsFeature+View.swift +++ b/Sources/Features/Settings/SettingsFeature+View.swift @@ -195,9 +195,9 @@ struct ThemePicker: View { struct HistoryView: View { var showTitle = true let store: StoreOf - + @SwiftUI.State private var confirmRemoval: Bool = false - + @Environment(\.theme) var theme var body: some View { @@ -207,10 +207,12 @@ struct HistoryView: View { confirmRemoval.toggle() } label: { Text("Clear Watch History").foregroundColor(.red) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity) } - .confirmationDialog("Are you sure?", - isPresented: $confirmRemoval) { + .confirmationDialog( + "Are you sure?", + isPresented: $confirmRemoval + ) { Button("Remove all watch history", role: .destructive) { viewStore.send(.view(.clearHistory)) } diff --git a/Sources/Shared/SharedModels/RepoModuleID.swift b/Sources/Shared/SharedModels/RepoModuleID.swift index af32727..ed22952 100644 --- a/Sources/Shared/SharedModels/RepoModuleID.swift +++ b/Sources/Shared/SharedModels/RepoModuleID.swift @@ -16,7 +16,7 @@ import Tagged public struct RepoModuleID: Hashable, Sendable { public let repoId: Repo.ID public let moduleId: Module.ID - + public init(repoId: Repo.ID, moduleId: Module.ID) { self.repoId = repoId self.moduleId = moduleId From 0b9cdb7d2b9ba53642b53bca38ddae8cbd0728b8 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 10 Mar 2024 11:20:12 +0100 Subject: [PATCH 12/45] Change identifier to uuid --- .../DatabaseClient/Models/Extensions/PlaylistHistory+.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift index fe9f478..27ba6b4 100644 --- a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift +++ b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift @@ -11,5 +11,5 @@ import Tagged // MARK: - PlaylistHistory + Identifiable extension PlaylistHistory: Identifiable { - public var id: Tagged { .init(playlistID) } + public var id: Tagged { .init(UUID().uuidString) } } From 14a62c1db2ff93830f566e97083df4e1bac946b0 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 10 Mar 2024 12:56:02 +0100 Subject: [PATCH 13/45] SwiftFormat --- Sources/Features/Discover/DiscoverFeature+Reducer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 17b3786..f4712d3 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -81,7 +81,7 @@ extension DiscoverFeature { await send(.internal(.removeLastWatchedPlaylist(playlistId))) } } - + case .view(.didTapRetryLoadingModule): return state.fetchLatestListings(state.section.module?.module) From 9b4925480c3403c03b0571165492bdce19e3329e Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Thu, 28 Mar 2024 21:19:49 +0100 Subject: [PATCH 14/45] Added a custom fast forward/backward amount. Can be set in settings. --- App/Shared/AppDelegate.swift | 4 ++ Sources/Clients/UserSettingsClient/Live.swift | 9 +++- .../UserSettingsClient/UserSettings.swift | 12 ++++-- .../Settings/SettingsFeature+View.swift | 42 ++++++++++++++----- Sources/Features/VideoPlayer/Models.swift | 3 +- .../VideoPlayerFeature+Reducer.swift | 6 +-- 6 files changed, 57 insertions(+), 19 deletions(-) diff --git a/App/Shared/AppDelegate.swift b/App/Shared/AppDelegate.swift index 9a5f818..02f1394 100644 --- a/App/Shared/AppDelegate.swift +++ b/App/Shared/AppDelegate.swift @@ -24,6 +24,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { store.send(.internal(.appDelegate(.didFinishLaunching))) + UserDefaults.standard.register(defaults: [ + "userSettings.fastForwardAmount": 15, + "userSettings.fastBackwardAmount": 5 + ]) return true } } diff --git a/Sources/Clients/UserSettingsClient/Live.swift b/Sources/Clients/UserSettingsClient/Live.swift index e31a9a5..e2a88d4 100644 --- a/Sources/Clients/UserSettingsClient/Live.swift +++ b/Sources/Clients/UserSettingsClient/Live.swift @@ -12,7 +12,11 @@ import Foundation extension UserSettingsClient: DependencyKey { public static let liveValue: Self = { - let userSettings = LockIsolated(UserSettings()) + let userSettings = LockIsolated(UserSettings( + developerModeEnabled: UserDefaults.standard.bool(forKey: "userSettings.developerModeEnabled"), + fastForwardAmount: UserDefaults.standard.value(forKey: "userSettings.fastForwardAmount") as? Double, + fastBackwardAmount: UserDefaults.standard.value(forKey: "userSettings.fastBackwardAmount") as? Double + )) let subject = PassthroughSubject() return Self { @@ -22,6 +26,9 @@ extension UserSettingsClient: DependencyKey { state = newValue subject.send(newValue) print("Save settings") + UserDefaults.standard.setValue(newValue.fastForwardAmount, forKey: "userSettings.fastForwardAmount") + UserDefaults.standard.setValue(newValue.fastBackwardAmount, forKey: "userSettings.fastBackwardAmount") + UserDefaults.standard.setValue(newValue.developerModeEnabled, forKey: "userSettings.developerModeEnabled") } } save: { // TODO: Save UserSettingsClient diff --git a/Sources/Clients/UserSettingsClient/UserSettings.swift b/Sources/Clients/UserSettingsClient/UserSettings.swift index 339bdc1..14ab8af 100644 --- a/Sources/Clients/UserSettingsClient/UserSettings.swift +++ b/Sources/Clients/UserSettingsClient/UserSettings.swift @@ -8,16 +8,22 @@ public struct UserSettings: Sendable, Equatable, Codable { public var theme: Theme + public var fastForwardAmount: Double + public var fastBackwardAmount: Double public var appIcon: AppIcon public var developerModeEnabled: Bool public init( - theme: Theme = .automatic, + theme: Theme? = .automatic, appIcon: AppIcon = .default, - developerModeEnabled: Bool = false + developerModeEnabled: Bool = false, + fastForwardAmount: Double? = 15, + fastBackwardAmount: Double? = 5 ) { - self.theme = theme + self.theme = theme ?? .automatic self.appIcon = appIcon self.developerModeEnabled = developerModeEnabled + self.fastForwardAmount = fastForwardAmount ?? 15 + self.fastBackwardAmount = fastBackwardAmount ?? 5 } } diff --git a/Sources/Features/Settings/SettingsFeature+View.swift b/Sources/Features/Settings/SettingsFeature+View.swift index b362939..c48c389 100644 --- a/Sources/Features/Settings/SettingsFeature+View.swift +++ b/Sources/Features/Settings/SettingsFeature+View.swift @@ -46,16 +46,38 @@ struct GeneralView: View { @Environment(\.theme) var theme var body: some View { - EmptyView() -// SettingsGroup(title: showTitle ? SettingsFeature.Section.general.localized : "") { -// // TODO: Actually allow users to set which discover page to show on startup -// SettingRow(title: "Discover Page", accessory: { -// Toggle("", isOn: .constant(true)) -// .labelsHidden() -// .toggleStyle(.switch) -// .controlSize(.small) -// }) -// } + WithViewStore(store, observe: \.`self`) { viewStore in + SettingsGroup(title: showTitle ? SettingsFeature.Section.general.localized : "") { + SettingRow(title: "Fast Forward skip amount", accessory: { + HStack { + TextField("", value: viewStore.$userSettings.fastForwardAmount, format: .number) + Text("s").foregroundStyle(.secondary) + } + .frame(maxWidth: 50) + .padding(8) + .background(Color.secondarySystemBackground) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.quaternary, lineWidth: 0.5) + ) + }) + SettingRow(title: "Fast Backward skip amount", accessory: { + HStack { + TextField("", value: viewStore.$userSettings.fastBackwardAmount, format: .number) + Text("s").foregroundStyle(.secondary) + } + .frame(maxWidth: 50) + .padding(8) + .background(Color.secondarySystemBackground) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.quaternary, lineWidth: 0.5) + ) + }) + } + } } } diff --git a/Sources/Features/VideoPlayer/Models.swift b/Sources/Features/VideoPlayer/Models.swift index 005d2f9..528b16a 100644 --- a/Sources/Features/VideoPlayer/Models.swift +++ b/Sources/Features/VideoPlayer/Models.swift @@ -13,7 +13,8 @@ public struct PlayerSettings: Equatable, Sendable { public var speed = 1.0 // In Seconds - public var skipTime = 15.0 + public var skipForwardTime = UserDefaults.standard.double(forKey: "userSettings.fastForwardAmount") + public var skipBackwardTime = UserDefaults.standard.double(forKey: "userSettings.fastBackwardAmount") public init(speed: Double = 1.0) { self.speed = speed diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index b6c67af..cb2563a 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -116,10 +116,9 @@ extension VideoPlayerFeature: Reducer { } case .view(.didSkipForward): - let skipTime = state.playerSettings.skipTime // In seconds let currentProgress = state.player.playback?.progress ?? .zero let totalDuration = state.player.playback?.totalDuration ?? 1 - let newProgress = min(1.0, max(0, currentProgress + (skipTime / totalDuration))) + let newProgress = min(1.0, max(0, currentProgress + (state.playerSettings.skipForwardTime / totalDuration))) return .merge( state.delayDismissOverlayIfNeeded(), .run { _ in @@ -128,10 +127,9 @@ extension VideoPlayerFeature: Reducer { ) case .view(.didSkipBackwards): - let skipTime = state.playerSettings.skipTime // In seconds let currentProgress = state.player.playback?.progress ?? .zero let totalDuration = state.player.playback?.totalDuration ?? 1 - let newProgress = min(1.0, max(0, currentProgress - (skipTime / totalDuration))) + let newProgress = min(1.0, max(0, currentProgress - (state.playerSettings.skipBackwardTime / totalDuration))) return .merge( state.delayDismissOverlayIfNeeded(), .run { _ in From 5176d7f8c601a412a5cca0292c5c231792895b49 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 5 May 2024 20:50:30 +0200 Subject: [PATCH 15/45] feat: Added home listing to discover page. Added library page. Playlists can now be bookmarked. --- App/Mochi.xcodeproj/project.pbxproj | 10 +- App/Shared/mochi.entitlements | 4 + Package.swift | 47 ++ Package/Sources/Clients/FileClient.swift | 1 + .../Clients/OfflineManagerClient.swift | 16 + Package/Sources/Features/ContentCore.swift | 2 + Package/Sources/Features/Discover.swift | 2 + Package/Sources/Features/Library.swift | 22 + Package/Sources/Features/MochiApp.swift | 1 + .../Sources/Features/PlaylistDetails.swift | 1 + Package/Sources/Features/Settings.swift | 1 + Package/Sources/Index.swift | 1 + .../DatabaseClient/Models/Library.swift | 16 - Sources/Clients/FileClient/Client+.swift | 118 +++ Sources/Clients/FileClient/Client.swift | 4 +- Sources/Clients/FileClient/Live.swift | 25 + Sources/Clients/ModuleClient/Client.swift | 2 +- .../Clients/OfflineManagerClient/Client.swift | 39 + .../Clients/OfflineManagerClient/Live.swift | 673 ++++++++++++++++++ .../Clients/OfflineManagerClient/Models.swift | 61 ++ .../PlayerClient/Internal/PlayerItem.swift | 2 +- .../PlaylistHistoryClient/Client.swift | 4 + .../Clients/PlaylistHistoryClient/Live.swift | 10 +- .../PlaylistHistoryClient/Models.swift | 17 +- .../Clients/UserSettingsClient/Theme.swift | 1 + Sources/Features/App/AppFeature+Reducer.swift | 35 + Sources/Features/App/AppFeature.swift | 23 +- .../Features/App/iOS/AppFeatureView+iOS.swift | 11 +- .../App/macOS/AppFeatureView+macOS.swift | 8 + .../ContentCore/ContentCore+View.swift | 164 ++++- .../Features/ContentCore/ContentCore.swift | 72 +- .../ContentCore/DownloadSelection.swift | 140 ++++ .../Discover/DiscoverFeature+Reducer.swift | 57 +- .../Discover/DiscoverFeature+View.swift | 240 +++++-- .../Features/Discover/DiscoverFeature.swift | 36 +- .../LIbrary/LibraryFeature+Reducer.swift | 120 ++++ .../LIbrary/LibraryFeature+View.swift | 144 ++++ Sources/Features/LIbrary/LibraryFeature.swift | 117 +++ .../Library/LibraryFeature+Reducer.swift | 28 - .../Library/LibraryFeature+View.swift | 37 - Sources/Features/Library/LibraryFeature.swift | 41 -- .../ModuleListsFeature+Reducer.swift | 12 +- .../ModuleLists/ModuleListsFeature+View.swift | 6 + .../ModuleLists/ModuleListsFeature.swift | 1 + .../PlaylistDetailsFeature+Reducer.swift | 74 +- .../PlaylistDetailsFeature.swift | 12 +- .../iOS/PlaylistDetailsFeature+View+iOS.swift | 22 +- .../VideoPlayerFeature+Reducer.swift | 27 +- .../VideoPlayer/VideoPlayerFeature.swift | 29 +- .../Shared/SharedModels/EpisodeMetadata.swift | 28 + .../SharedModels/LibraryDirectory.swift | 13 + Sources/Shared/SharedModels/Playlist.swift | 8 +- .../Shared/SharedModels/PlaylistCache.swift | 36 + Sources/Shared/SharedModels/Video.swift | 20 +- 54 files changed, 2378 insertions(+), 263 deletions(-) create mode 100644 Package/Sources/Clients/OfflineManagerClient.swift create mode 100644 Package/Sources/Features/Library.swift delete mode 100644 Sources/Clients/DatabaseClient/Models/Library.swift create mode 100644 Sources/Clients/OfflineManagerClient/Client.swift create mode 100644 Sources/Clients/OfflineManagerClient/Live.swift create mode 100644 Sources/Clients/OfflineManagerClient/Models.swift create mode 100644 Sources/Features/ContentCore/DownloadSelection.swift create mode 100644 Sources/Features/LIbrary/LibraryFeature+Reducer.swift create mode 100644 Sources/Features/LIbrary/LibraryFeature+View.swift create mode 100644 Sources/Features/LIbrary/LibraryFeature.swift delete mode 100644 Sources/Features/Library/LibraryFeature+Reducer.swift delete mode 100644 Sources/Features/Library/LibraryFeature+View.swift delete mode 100644 Sources/Features/Library/LibraryFeature.swift create mode 100644 Sources/Shared/SharedModels/EpisodeMetadata.swift create mode 100644 Sources/Shared/SharedModels/LibraryDirectory.swift create mode 100644 Sources/Shared/SharedModels/PlaylistCache.swift diff --git a/App/Mochi.xcodeproj/project.pbxproj b/App/Mochi.xcodeproj/project.pbxproj index 445edd9..f701c88 100644 --- a/App/Mochi.xcodeproj/project.pbxproj +++ b/App/Mochi.xcodeproj/project.pbxproj @@ -203,7 +203,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\nif which swiftlint > /dev/null; then\n swiftlint --config ../.swiftlint.yml ../\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "#export PATH=\"$PATH:/opt/homebrew/bin\"\n#if which swiftlint > /dev/null; then\n# swiftlint --config ../.swiftlint.yml ../\n#else\n# echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\n#fi\n"; }; 13BC25D22AD895AE001DAE2A /* Run SwiftFormat Script */ = { isa = PBXShellScriptBuildPhase; @@ -371,10 +371,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Shared/mochi.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; - DEVELOPMENT_TEAM = A6HC4Y86NJ; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -411,10 +413,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Shared/mochi.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; - DEVELOPMENT_TEAM = A6HC4Y86NJ; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; diff --git a/App/Shared/mochi.entitlements b/App/Shared/mochi.entitlements index ee95ab7..8fa9cdb 100644 --- a/App/Shared/mochi.entitlements +++ b/App/Shared/mochi.entitlements @@ -4,6 +4,10 @@ com.apple.security.app-sandbox + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + com.apple.security.network.client diff --git a/Package.swift b/Package.swift index 9f537b2..39eba27 100644 --- a/Package.swift +++ b/Package.swift @@ -780,6 +780,7 @@ struct DeviceClient: _Client { struct FileClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() + SharedModels() } } // @@ -860,6 +861,22 @@ extension ModuleClient: Testable { } } // +// OfflineManagerClient.swift +// +// +// Created by DeNeRr on 06.04.2024. +// + +import Foundation + +struct OfflineManagerClient: _Client { + var dependencies: any Dependencies { + FileClient() + SharedModels() + ComposableArchitecture() + } +} +// // PlayerClient.swift // // @@ -1218,6 +1235,8 @@ struct ContentCore: _Feature { Architecture() FoundationHelpers() ModuleClient() + PlaylistHistoryClient() + OfflineManagerClient() LoggerClient() Tagged() ComposableArchitecture() @@ -1247,6 +1266,30 @@ struct Discover: _Feature { ViewComponents() ComposableArchitecture() NukeUI() + OfflineManagerClient() + FileClient() + } +} +// +// Library.swift +// +// +// Created by DeNeRr on 09.04.2024. +// + +import Foundation + +struct Library: _Feature { + var dependencies: any Dependencies { + Architecture() + FileClient() + ViewComponents() + ComposableArchitecture() + OfflineManagerClient() + Styling() + PlaylistDetails() + NukeUI() + SharedModels() } } // @@ -1265,6 +1308,7 @@ struct MochiApp: _Feature { var dependencies: any Dependencies { Architecture() Discover() + Library() Repos() Settings() SharedModels() @@ -1313,6 +1357,7 @@ struct PlaylistDetails: _Feature { LoggerClient() ModuleClient() RepoClient() + OfflineManagerClient() PlaylistHistoryClient() Styling() SharedModels() @@ -1387,6 +1432,7 @@ struct Settings: _Feature { SharedModels() Styling() ViewComponents() + PlaylistHistoryClient() UserSettingsClient() ComposableArchitecture() NukeUI() @@ -1651,6 +1697,7 @@ let package = Package { ModuleLists() PlaylistDetails() Discover() + Library() Repos() Search() Settings() diff --git a/Package/Sources/Clients/FileClient.swift b/Package/Sources/Clients/FileClient.swift index 94015e2..ffd67cc 100644 --- a/Package/Sources/Clients/FileClient.swift +++ b/Package/Sources/Clients/FileClient.swift @@ -9,5 +9,6 @@ struct FileClient: _Client { var dependencies: any Dependencies { ComposableArchitecture() + SharedModels() } } diff --git a/Package/Sources/Clients/OfflineManagerClient.swift b/Package/Sources/Clients/OfflineManagerClient.swift new file mode 100644 index 0000000..30ab965 --- /dev/null +++ b/Package/Sources/Clients/OfflineManagerClient.swift @@ -0,0 +1,16 @@ +// +// OfflineManagerClient.swift +// +// +// Created by DeNeRr on 06.04.2024. +// + +import Foundation + +struct OfflineManagerClient: _Client { + var dependencies: any Dependencies { + FileClient() + SharedModels() + ComposableArchitecture() + } +} diff --git a/Package/Sources/Features/ContentCore.swift b/Package/Sources/Features/ContentCore.swift index 2a8495e..b12b274 100644 --- a/Package/Sources/Features/ContentCore.swift +++ b/Package/Sources/Features/ContentCore.swift @@ -13,6 +13,8 @@ struct ContentCore: _Feature { Architecture() FoundationHelpers() ModuleClient() + PlaylistHistoryClient() + OfflineManagerClient() LoggerClient() Tagged() ComposableArchitecture() diff --git a/Package/Sources/Features/Discover.swift b/Package/Sources/Features/Discover.swift index 4d3ba1b..1b7f64c 100644 --- a/Package/Sources/Features/Discover.swift +++ b/Package/Sources/Features/Discover.swift @@ -21,5 +21,7 @@ struct Discover: _Feature { ViewComponents() ComposableArchitecture() NukeUI() + OfflineManagerClient() + FileClient() } } diff --git a/Package/Sources/Features/Library.swift b/Package/Sources/Features/Library.swift new file mode 100644 index 0000000..02cfa7b --- /dev/null +++ b/Package/Sources/Features/Library.swift @@ -0,0 +1,22 @@ +// +// Library.swift +// +// +// Created by DeNeRr on 09.04.2024. +// + +import Foundation + +struct Library: _Feature { + var dependencies: any Dependencies { + Architecture() + FileClient() + ViewComponents() + ComposableArchitecture() + OfflineManagerClient() + Styling() + PlaylistDetails() + NukeUI() + SharedModels() + } +} diff --git a/Package/Sources/Features/MochiApp.swift b/Package/Sources/Features/MochiApp.swift index 964b5c9..d8765c3 100644 --- a/Package/Sources/Features/MochiApp.swift +++ b/Package/Sources/Features/MochiApp.swift @@ -14,6 +14,7 @@ struct MochiApp: _Feature { var dependencies: any Dependencies { Architecture() Discover() + Library() Repos() Settings() SharedModels() diff --git a/Package/Sources/Features/PlaylistDetails.swift b/Package/Sources/Features/PlaylistDetails.swift index c733c9d..6df0d2f 100644 --- a/Package/Sources/Features/PlaylistDetails.swift +++ b/Package/Sources/Features/PlaylistDetails.swift @@ -15,6 +15,7 @@ struct PlaylistDetails: _Feature { LoggerClient() ModuleClient() RepoClient() + OfflineManagerClient() PlaylistHistoryClient() Styling() SharedModels() diff --git a/Package/Sources/Features/Settings.swift b/Package/Sources/Features/Settings.swift index 000ed64..d177acd 100644 --- a/Package/Sources/Features/Settings.swift +++ b/Package/Sources/Features/Settings.swift @@ -16,6 +16,7 @@ struct Settings: _Feature { SharedModels() Styling() ViewComponents() + PlaylistHistoryClient() UserSettingsClient() ComposableArchitecture() NukeUI() diff --git a/Package/Sources/Index.swift b/Package/Sources/Index.swift index 83c0522..5684322 100644 --- a/Package/Sources/Index.swift +++ b/Package/Sources/Index.swift @@ -15,6 +15,7 @@ let package = Package { ModuleLists() PlaylistDetails() Discover() + Library() Repos() Search() Settings() diff --git a/Sources/Clients/DatabaseClient/Models/Library.swift b/Sources/Clients/DatabaseClient/Models/Library.swift deleted file mode 100644 index 50f9b64..0000000 --- a/Sources/Clients/DatabaseClient/Models/Library.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Library.swift -// -// -// Created by ErrorErrorError on 1/1/24. -// -// - -import CoreDB -import Foundation - -@Entity -struct Collection { - var title = "" - var entries = [Entry]() -} diff --git a/Sources/Clients/FileClient/Client+.swift b/Sources/Clients/FileClient/Client+.swift index 67bb530..38dbfde 100644 --- a/Sources/Clients/FileClient/Client+.swift +++ b/Sources/Clients/FileClient/Client+.swift @@ -7,6 +7,7 @@ // import Foundation +import SharedModels extension FileClient { public func createModuleDirectory(_ url: URL) throws { @@ -22,10 +23,127 @@ extension FileClient { .reposDir() .appendingPathComponent(url.absoluteString) } + + private func createDirectory(_ root: String, _ directory: String) throws -> URL { + var folderPath = try self.url(.documentDirectory, .userDomainMask, nil, true) + .LibraryDir() + if (!fileExists(folderPath.path)) { + try create(folderPath) + } + folderPath = folderPath.appendingPathComponent(root) + if (!fileExists(folderPath.path)) { + try create(folderPath) + } + folderPath = folderPath.appendingPathComponent(directory) + if (!fileExists(folderPath.path)) { + try create(folderPath) + } + return folderPath + } + + public func shouldCreateLibraryDirectory(_ root: LibraryDirectory, _ directory: String, _ metadata: T) throws { + let folderPath = try createDirectory(root.rawValue, directory) + let metadataPath = folderPath.appendingPathComponent("metadata.json") + // if !fileExists(metadataPath.path) { + try JSONEncoder().encode(metadata).write(to: metadataPath) + // } + } + public func shouldCreateLibraryDirectory(_ root: LibraryDirectory, _ directory: String) throws { + let _ = try createDirectory(root.rawValue, directory) + } + + public func initializeLibrary() throws { + var folderPath = try self.url(.documentDirectory, .userDomainMask, nil, true) + .LibraryDir() + if (!fileExists(folderPath.path)) { + try create(folderPath) + } + if (!fileExists(folderPath.appendingPathComponent(LibraryDirectory.playlistCache.rawValue).path)) { + try create(folderPath.appendingPathComponent(LibraryDirectory.playlistCache.rawValue)) + } + if (!fileExists(folderPath.appendingPathComponent(LibraryDirectory.downloaded.rawValue).path)) { + try create(folderPath.appendingPathComponent(LibraryDirectory.downloaded.rawValue)) + } + } + + public func retrieveLibraryDirectory(root: LibraryDirectory, playlist: String? = nil, episode: String? = nil) throws -> URL { + var url = try self.url(.documentDirectory, .userDomainMask, nil, false) + .LibraryDir() + .appendingPathComponent(root.rawValue) + if let playlist = playlist { + url = url.appendingPathComponent(playlist.sanitized) + } + if let episode = episode { + url = url.appendingPathComponent(episode.sanitized) + } + return url + } + + public func removePlaylistFromLibrary(_ root: LibraryDirectory, _ playlist: String, _ episode: String? = nil) throws { + let url = try self.url(.documentDirectory, .userDomainMask, nil, false) + .LibraryDir() + .appendingPathComponent(root.rawValue) + .appendingPathComponent(playlist.sanitized) + + if (fileExists(url.path)) { + try remove(url) + } + } + + public func getLibraryPlaylistImage(playlist: String) -> URL? { + return try? self.url(.documentDirectory, .userDomainMask, nil, false) + .LibraryDir() + .appendingPathComponent(LibraryDirectory.playlistCache.rawValue) + .appendingPathComponent(playlist.sanitized) + .appendingPathComponent("posterImage.jpeg") + } + + public func libraryEpisodeExists(folder: String, file: String) -> Bool { + guard let url = try? self.url(.documentDirectory, .userDomainMask, nil, false) + .LibraryDir() + .appendingPathComponent(LibraryDirectory.downloaded.rawValue) + .appendingPathComponent(folder.sanitized) + .appendingPathComponent(file.sanitized) + .appendingPathComponent("data") + .appendingPathExtension("movpkg") else { + return false + } + return fileExists(url.path) + } + + public func retrieveLibraryMetadata(root: LibraryDirectory, playlist: String, episode: String? = nil) throws -> Data? { + var url = try self.url(.documentDirectory, .userDomainMask, nil, false) + .LibraryDir() + .appendingPathComponent(root.rawValue) + .appendingPathComponent(playlist.sanitized) + if let episode = episode { + url = url.appendingPathComponent(episode.sanitized) + } + return FileManager.default.contents(atPath: url.appendingPathComponent("metadata.json").relativePath) + } + public func retrieveLibraryMetadata(root: LibraryDirectory, encodedPlaylist: String, episode: String? = nil) throws -> Data? { + var url = try self.url(.documentDirectory, .userDomainMask, nil, false) + .LibraryDir() + .appendingPathComponent(root.rawValue) + .appendingPathComponent(encodedPlaylist) + if let episode = episode { + url = url.appendingPathComponent(episode.sanitized) + } + return FileManager.default.contents(atPath: url.appendingPathComponent("metadata.json").relativePath) + } } extension URL { fileprivate func reposDir() -> URL { appendingPathComponent("Repos", isDirectory: true) } + fileprivate func LibraryDir() -> URL { + appendingPathComponent("Library", isDirectory: true) + } +} + +extension String { + var sanitized: String { + replacingOccurrences(of: "/", with: "\\") + } } diff --git a/Sources/Clients/FileClient/Client.swift b/Sources/Clients/FileClient/Client.swift index 725b6e6..9402acb 100644 --- a/Sources/Clients/FileClient/Client.swift +++ b/Sources/Clients/FileClient/Client.swift @@ -21,6 +21,7 @@ public struct FileClient { public let fileExists: @Sendable (_ path: String) -> Bool public let create: @Sendable (_ url: URL) throws -> Void public let remove: @Sendable (_ url: URL) throws -> Void + public let observeDirectory: @Sendable (_ url: URL) throws -> AsyncStream<[String]> } // MARK: TestDependencyKey @@ -30,7 +31,8 @@ extension FileClient: TestDependencyKey { url: unimplemented(".url"), fileExists: unimplemented(".remove"), create: unimplemented(".create"), - remove: unimplemented(".remove") + remove: unimplemented(".remove"), + observeDirectory: unimplemented(".observeDirectory") ) } diff --git a/Sources/Clients/FileClient/Live.swift b/Sources/Clients/FileClient/Live.swift index 4363e23..28254cb 100644 --- a/Sources/Clients/FileClient/Live.swift +++ b/Sources/Clients/FileClient/Live.swift @@ -8,10 +8,15 @@ import ComposableArchitecture import Foundation +import CoreData // MARK: - FileClient + DependencyKey extension FileClient: DependencyKey { + public enum Error: Swift.Error { + case FileNotFound + } + public static var liveValue: FileClient = Self { searchPathDir, mask, url, create in try FileManager.default.url( for: searchPathDir, @@ -25,6 +30,26 @@ extension FileClient: DependencyKey { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } remove: { url in try FileManager.default.removeItem(at: url) + } observeDirectory: { url in + let monitoredDirectoryFileDescriptor = open((url as NSURL).fileSystemRepresentation, O_EVTONLY) + if monitoredDirectoryFileDescriptor == -1 { + throw Error.FileNotFound + } + let directoryMonitorQueue = DispatchQueue(label: "directorymonitor", attributes: .concurrent) + let directoryMonitorSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: monitoredDirectoryFileDescriptor, eventMask: DispatchSource.FileSystemEvent.write, queue: directoryMonitorQueue) as? DispatchSource + return .init { continuation in + let values = try? FileManager.default.contentsOfDirectory(atPath: url.path) + continuation.yield(values ?? []) + directoryMonitorSource?.setEventHandler { + let values = try? FileManager.default.contentsOfDirectory(atPath: url.path) + continuation.yield(values ?? []) + } + directoryMonitorSource?.resume() + + continuation.onTermination = { _ in + directoryMonitorSource?.cancel() + } + } } } diff --git a/Sources/Clients/ModuleClient/Client.swift b/Sources/Clients/ModuleClient/Client.swift index 2aadc5b..1ca2ae4 100644 --- a/Sources/Clients/ModuleClient/Client.swift +++ b/Sources/Clients/ModuleClient/Client.swift @@ -16,7 +16,7 @@ import XCTestDynamicOverlay public struct ModuleClient: Sendable { public var initialize: @Sendable () async throws -> Void - var getModule: @Sendable (_ repoModuleId: RepoModuleID) async throws -> Self.Instance + 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 } diff --git a/Sources/Clients/OfflineManagerClient/Client.swift b/Sources/Clients/OfflineManagerClient/Client.swift new file mode 100644 index 0000000..51b9e0d --- /dev/null +++ b/Sources/Clients/OfflineManagerClient/Client.swift @@ -0,0 +1,39 @@ +// +// Client.swift +// +// +// Created by DeNeRr on 06.04.2024. +// + +import FileClient +import Dependencies +@_exported +import Foundation +import SharedModels +import Tagged +import XCTestDynamicOverlay + +// MARK: - OfflineManagerClient + +public struct OfflineManagerClient { + public var download: @Sendable (DownloadAsset) async throws -> Void + public var cache: @Sendable (CacheAsset) async throws -> Void + public var remove: @Sendable (RemoveType, String, String?) async throws -> Void +} + +// MARK: TestDependencyKey + +extension OfflineManagerClient: TestDependencyKey { + public static let testValue = Self( + download: unimplemented("\(Self.self).download"), + cache: unimplemented("\(Self.self).cache"), + remove: unimplemented("\(Self.self).remove") + ) +} + +extension DependencyValues { + public var offlineManagerClient: OfflineManagerClient { + get { self[OfflineManagerClient.self] } + set { self[OfflineManagerClient.self] = newValue } + } +} diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift new file mode 100644 index 0000000..524a54d --- /dev/null +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -0,0 +1,673 @@ +// +// Live.swift +// +// +// Created by DeNeRr on 06.04.2024. +// + +//import Dependencies +//import Foundation +//import FileClient +//import AVFoundation +//import UIKit +//import SharedModels +//import DatabaseClient +//import OrderedCollections +// +//// MARK: - OfflineManagerClient + DependencyKey +// +//extension OfflineManagerClient: DependencyKey { +// private static let downloadManager = OfflineDownloadManager() +// +// public static let liveValue = Self( +// download: { asset in +// try? await downloadManager.setupAssetDownload(asset) +// } +// ) +//} +// +//// MARK: - OfflineDownloadManager +// +//private class OfflineDownloadManager: NSObject { +// enum Error: Swift.Error { +// case M3U8Invalid +// case VTTInvalid +// } +// +// private var config: URLSessionConfiguration! +// private var downloadSession: AVAssetDownloadURLSession! +// private var downloadQueue: Set = [] +// +// @Dependency(\.fileClient) var fileClient +// +// override init() { +// super.init() +// config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background") +// downloadSession = AVAssetDownloadURLSession(configuration: config, assetDownloadDelegate: self, delegateQueue: OperationQueue.main) +// } +// +// private static let hlsSubtitlesScheme = "mochi-hls-subtitles" +// private static let hlsSubtitleGroupID = "mochi-sub" +// +// private func convertMainPlaylistToMultivariant(_ url: URL, _ subtitles: [Playlist.EpisodeServer.Subtitle]) -> String { +// // Build a multivariant playlist out of a single main playlist +// let subtitlesMediaStrings = subtitles.enumerated() +// .map(makeSubtitleTypes) +// +// return """ +// #EXTM3U +// \(subtitlesMediaStrings.joined(separator: "\n")) +// #EXT-X-STREAM-INF:BANDWIDTH=6400000,CODECS="mp4a.40.2,avc1.4d401e",SUBTITLES="\(Self.hlsSubtitleGroupID)" +// \(url.absoluteString) +// """ +// } +// +// private func makeSubtitleTypes(_ idx: Int, _ subtitle: Playlist.EpisodeServer.Subtitle) -> String { +// "#EXT-X-MEDIA:" + ( +// [ +// "TYPE": "SUBTITLES", +// "GROUP-ID": "\"\(Self.hlsSubtitleGroupID)\"", +// "NAME": "\"\(subtitle.name)\"", +// "CHARACTERISTICS": "\"public.accessibility.transcribes-spoken-dialog\"", +// "DEFAULT": subtitle.default ? "YES" : "NO", +// "AUTOSELECT": subtitle.autoselect ? "YES" : "NO", +// "FORCED": "NO", +// "URI": "\"\(subtitle.url.absoluteString)\"", +// "LANGUAGE": "\"\(subtitle.name)\"" +// ] as OrderedDictionary +// ) +// .map { "\($0.key)=\($0.value)" } +// .joined(separator: ",") +// } +// +// private func parseMainMultiVariantPlaylist(_ m3u8String: String, _ subtitles: [Playlist.EpisodeServer.Subtitle]) -> String { +// var lines = m3u8String.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) } +// var lastPositionMedia: Int? +// var firstPositionInf = 1 +// +// for (idx, line) in lines.enumerated() { +// if line.hasPrefix("#EXT-X-STREAM-INF") { +// firstPositionInf = idx +// break +// } else if line.hasPrefix("#EXT-X-MEDIA") { +// lastPositionMedia = idx + 1 +// } +// } +// +// var subtitlePosition = lastPositionMedia ?? firstPositionInf +// +// for (idx, subtitle) in subtitles.enumerated() { +// let m3u8SubtitlesString = makeSubtitleTypes(idx, subtitle) +// if subtitlePosition <= lines.endIndex { +// lines.insert(m3u8SubtitlesString, at: subtitlePosition) +// } else { +// lines.append(m3u8SubtitlesString) +// } +// subtitlePosition += 1 +// } +// +// for (idx, line) in lines.enumerated() where line.contains("#EXT-X-STREAM-INF") { +// lines[idx] = line + "," + "SUBTITLES=\"\(Self.hlsSubtitleGroupID)\"" +// } +// +// return lines.joined(separator: "\n") +// } +// +// public func setupAssetDownload(_ asset: OfflineManagerClient.Asset) async throws { +// let (data, _) = try await URLSession.shared.data(from: asset.episodeMetadata.link.url) +// guard let string = String(data: data, encoding: .utf8) else { +// throw Error.M3U8Invalid +// } +// let playlistData: String +// if string.contains("#EXT-X-STREAM-INF") { +// playlistData = parseMainMultiVariantPlaylist(string, asset.episodeMetadata.subtitles) +// } else { +// playlistData = convertMainPlaylistToMultivariant(asset.episodeMetadata.link.url, asset.episodeMetadata.subtitles) +// } +// +// let options = [AVURLAssetAllowsCellularAccessKey: false] +// let libraryFileUrl = try fileClient.retrieveLibraryDirectory(root: .playlistCache) +// let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlist.id.rawValue, episode: asset.episodeId.rawValue) +// try? fileClient.shouldCreateLibraryDirectory( +// .downloaded, +// outputURL.pathComponents.suffix(2).joined(separator: "/"), +// EpisodeMetadata( +// link: asset.episodeMetadata.link, +// source: Playlist.EpisodeSource(id: asset.episodeMetadata.source.id, displayName: asset.episodeMetadata.source.displayName, description: asset.episodeMetadata.source.description, servers: [asset.episodeMetadata.server]), +// subtitles: asset.episodeMetadata.subtitles, +// server: Playlist.EpisodeServer(id: asset.episodeMetadata.server.id, displayName: asset.episodeMetadata.server.displayName, description: asset.episodeMetadata.server.description), +// skipTimes: asset.episodeMetadata.skipTimes +// ) +// ) +// let avAsset = AVURLAsset(url: URL(string:"data:application/x-mpegURL;charset=utf-8,%23EXTM3U%0A%0A%23EXT-X-MEDIA%3ATYPE%3DAUDIO%2CGROUP-ID%3D%22bipbop_audio%22%2CLANGUAGE%3D%22eng%22%2CNAME%3D%22BipBop%20Audio%201%22%2CAUTOSELECT%3DYES%2CDEFAULT%3DYES%0A%23EXT-X-MEDIA%3ATYPE%3DAUDIO%2CGROUP-ID%3D%22bipbop_audio%22%2CLANGUAGE%3D%22eng%22%2CNAME%3D%22BipBop%20Audio%202%22%2CAUTOSELECT%3DNO%2CDEFAULT%3DNO%2CURI%3D%22https%3A%2F%2Fd2zihajmogu5jn.cloudfront.net%2Fbipbop-advanced%2Falternate_audio_aac_sinewave%2Fprog_index.m3u8%22%0A%0A%23EXT-X-MEDIA%3ATYPE%3DSUBTITLES%2CGROUP-ID%3D%22subs%22%2CNAME%3D%22English%22%2CDEFAULT%3DYES%2CAUTOSELECT%3DYES%2CFORCED%3DNO%2CLANGUAGE%3D%22en%22%2CCHARACTERISTICS%3D%22public.accessibility.transcribes-spoken-dialog%2C%20public.accessibility.describes-music-and-sound%22%2CURI%3D%22https%3A%2F%2Fd2zihajmogu5jn.cloudfront.net%2Fbipbop-advanced%2Fsubtitles%2Feng%2Fprog_index.m3u8%22%0A%0A%23EXT-X-STREAM-INF%3ABANDWIDTH%3D263851%2CCODECS%3D%22mp4a.40.2%2C%20avc1.4d400d%22%2CRESOLUTION%3D416x234%2CAUDIO%3D%22bipbop_audio%22%2CSUBTITLES%3D%22subs%22%0Ahttps%3A%2F%2Fd2zihajmogu5jn.cloudfront.net%2Fbipbop-advanced%2Fgear1%2Fprog_index.m3u8")!) +// +// +// +//// let downloadTask = downloadSession.makeAssetDownloadTask(asset: avAsset, +//// assetTitle: asset.playlist.title ?? "Unknown Title", +//// assetArtworkData: nil, +//// options: nil) +// let preferredMediaSelection = try await avAsset.load(.preferredMediaSelection) +// +// let downloadTask = downloadSession.aggregateAssetDownloadTask(with: avAsset, +// mediaSelections: [preferredMediaSelection], +// assetTitle: asset.playlist.title ?? "Unknown Title", +// assetArtworkData: nil, +// options: nil) +// +// let playlist = asset.playlist +// let playlistId = playlist.id.rawValue.replacingOccurrences(of: "/", with: "\\") +// let imageUrl = libraryFileUrl.appendingPathComponent(playlistId).appendingPathComponent("posterImage.jpeg") +// try? fileClient.shouldCreateLibraryDirectory(.playlistCache, playlistId, PlaylistCache( +// playlist: playlist, +// groups: asset.groups, +// details: asset.details, +// repoModuleId: .init(repoId: asset.repoModuleId.repoId, moduleId: asset.repoModuleId.moduleId) +// )) +// +// let image = asset.playlist.posterImage ?? asset.playlist.bannerImage ?? URL(string: "")! +// let (imageData, _) = try await URLSession.shared.data(from: image) +// +// if let imageData = UIImage(data: imageData)?.jpegData(compressionQuality: 1) { +// try imageData.write(to: imageUrl) +// } +// +// downloadTask?.resume() +// downloadQueue.insert(.init(url: asset.episodeMetadata.link.url, playlistId: playlist.id, episodeId: asset.episodeId, metadata: asset.episodeMetadata)) +// NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) +// } +// +// func restorePendingDownloads() { +// downloadSession.getAllTasks { tasksArray in +// for task in tasksArray { +// guard let downloadTask = task as? AVAssetDownloadTask else { break } +// +// let asset = downloadTask.urlAsset +// downloadTask.resume() +// } +// } +// } +// +// func playOfflineAsset() -> AVURLAsset? { +// guard let assetPath = UserDefaults.standard.value(forKey: "assetPath") as? String else { +// return nil +// } +// let baseURL = URL(fileURLWithPath: NSHomeDirectory()) +// let assetURL = baseURL.appendingPathComponent(assetPath) +// let asset = AVURLAsset(url: assetURL) +// if let cache = asset.assetCache, cache.isPlayableOffline { +// return asset +// } else { +// return nil +// } +// } +// +// func getPath() -> String { +// return UserDefaults.standard.value(forKey: "assetPath") as? String ?? "" +// } +// +// public func deleteOfflineAsset() { +// do { +// let userDefaults = UserDefaults.standard +// if let assetPath = userDefaults.value(forKey: "assetPath") as? String { +// let baseURL = URL(fileURLWithPath: NSHomeDirectory()) +// let assetURL = baseURL.appendingPathComponent(assetPath) +// try FileManager.default.removeItem(at: assetURL) +// userDefaults.removeObject(forKey: "assetPath") +// } +// } catch { +// print("An error occured deleting offline asset: \(error)") +// } +// } +// +// public func deleteDownloadedVideo(atPath path: String) { +// do { +// let baseURL = URL(fileURLWithPath: NSHomeDirectory()) +// let assetURL = baseURL.appendingPathComponent(path) +// try FileManager.default.removeItem(at: assetURL) +// +// if var downloadedPaths = UserDefaults.standard.array(forKey: "DownloadedVideoPaths") as? [String], +// let index = downloadedPaths.firstIndex(of: path) { +// downloadedPaths.remove(at: index) +// UserDefaults.standard.set(downloadedPaths, forKey: "DownloadedVideoPaths") +// } +// +//// NotificationCenter.default.post(name: .didDeleteVideo, object: nil) +// } catch { +// print("An error occurred deleting offline asset: \(error)") +// } +// } +//} +// +//extension OfflineDownloadManager: AVAssetDownloadDelegate { +// +// +// // 1. Tells the delegate the location this asset will be downloaded to. +// func urlSession(_ session: URLSession, +// aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, +// willDownloadTo location: URL) { +// debugPrint("willDownloadTo") +// UserDefaults.standard.set(location.absoluteString, forKey: "test_location") +// } +// func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { +// debugPrint("didCompleteWithError") +// } +// +// // 2. Report progress updates for the aggregate download task +// func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, +// didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], +// timeRangeExpectedToLoad: CMTimeRange, for mediaSelection: AVMediaSelection) { +// +// let percentComplete = loadedTimeRanges.reduce(0) { (rc, value) -> Float in +// let loadedTimeRange: CMTimeRange = value.timeRangeValue +// return rc + Float((loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds)) +// } +// debugPrint(percentComplete) +// let params = ["percent": percentComplete, "assetUrl": aggregateAssetDownloadTask.urlAsset.url] as [String : Any] +// NotificationCenter.default.post(name: .AssetDownloadProgress, object: nil, userInfo: params) +// +// if (percentComplete >= 1) { +// NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) +// let location = UserDefaults.standard.string(forKey: "test_location")! +// debugPrint(FileManager.default.fileExists(atPath: URL(string: location)!.path)) +// if let downloadedAsset = downloadQueue.first { +// try? saveVideo(asset: downloadedAsset, location: URL(string: location)!) +// } +// } +// } +// +// // 3. Tells the delegate that the task finished transferring data, either successfully or with an error +// func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { +// debugPrint("DOWNLOAD FINISHED") +// } +// func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { +// var percentComplete = 0.0 +// +// for value in loadedTimeRanges { +// let loadedTimeRange = value.timeRangeValue +// percentComplete += loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds +// } +// if (percentComplete >= 1) { +// NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) +// } +// percentComplete *= 100 +// +// debugPrint("Progress \( assetDownloadTask) \(percentComplete)") +// let params = ["percent": percentComplete, "assetUrl": assetDownloadTask.urlAsset.url] as [String : Any] +// NotificationCenter.default.post(name: .AssetDownloadProgress, object: nil, userInfo: params) +// } +// +// func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { +// if let downloadedAsset = downloadQueue.first(where: { $0.url == assetDownloadTask.urlAsset.url }) { +// try? saveVideo(asset: downloadedAsset, location: location) +// } +// } +// +// func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { +// debugPrint("didFinishCollecting") +// } +// +// func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { +// debugPrint("didBecomeInvalidWithError") +// } +// +// func urlSession(_ session: URLSession, task: URLSessionTask, needNewBodyStream completionHandler: @escaping (InputStream?) -> Void) { +// debugPrint("needNewBodyStream") +// } +// +// func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didResolve resolvedMediaSelection: AVMediaSelection) { +// debugPrint("didResolve") +// } +// func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { +// debugPrint("forBackgroundURLSession") +// } +// func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didCompleteFor mediaSelection: AVMediaSelection) { +// debugPrint("mediaSelection") +// } +// func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, willDownloadVariants variants: [AVAssetVariant]) { +// debugPrint("variants") +// } +// +//// func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { +//// debugPrint("Task completed: \(task), error: \(String(describing: error))") +//// +//// guard error == nil else { return } +//// guard let task = task as? AVAggregateAssetDownloadTask else { return } +//// print("DOWNLOAD: FINISHED") +//// } +//} +// +//extension OfflineDownloadManager { +// private func saveVideo(asset: OfflineManagerClient.DownloadingAsset, location: URL) throws { +// let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlistId.rawValue, episode: asset.episodeId.rawValue) +// debugPrint("File saved to: \(outputURL)") +// try FileManager.default.moveItem(at: location, to: outputURL.appendingPathComponent("data.movpkg")) +// } +//} +// +//extension Notification.Name { +// /// Notification for when download progress has changed. +// static let AssetDownloadProgress = Notification.Name(rawValue: "AssetDownloadProgressNotification") +// +// /// Notification for when the download state of an Asset has changed. +// static let AssetDownloadStateChanged = Notification.Name(rawValue: "AssetDownloadStateChangedNotification") +// +// /// Notification for when AssetPersistenceManager has completely restored its state. +// static let AssetPersistenceManagerDidRestoreState = Notification.Name(rawValue: "AssetPersistenceManagerDidRestoreStateNotification") +//} + + +// +// Live.swift +// +// +// Created by DeNeRr on 06.04.2024. +// + +import Dependencies +import Foundation +import FileClient +import AVFoundation +import UIKit +import SharedModels +import DatabaseClient + +extension Sequence { + /// Run an async closure for each element within the sequence. + /// + /// The closure calls will be performed in order, by waiting for + /// each call to complete before proceeding with the next one. If + /// any of the closure calls throw an error, then the iteration + /// will be terminated and the error rethrown. + /// + /// - parameter operation: The closure to run for each element. + /// - throws: Rethrows any error thrown by the passed closure. + func asyncForEach( + _ operation: (Element) async throws -> Void + ) async rethrows { + for element in self { + try await operation(element) + } + } + + /// Run an async closure for each element within the sequence. + /// + /// The closure calls will be performed concurrently, but the call + /// to this function won't return until all of the closure calls + /// have completed. + /// + /// - parameter priority: Any specific `TaskPriority` to assign to + /// the async tasks that will perform the closure calls. The + /// default is `nil` (meaning that the system picks a priority). + /// - parameter operation: The closure to run for each element. + func concurrentForEach( + withPriority priority: TaskPriority? = nil, + _ operation: @escaping (Element) async -> Void + ) async { + await withTaskGroup(of: Void.self) { group in + for element in self { + group.addTask(priority: priority) { + await operation(element) + } + } + } + } + + /// Run an async closure for each element within the sequence. + /// + /// The closure calls will be performed concurrently, but the call + /// to this function won't return until all of the closure calls + /// have completed. If any of the closure calls throw an error, + /// then the first error will be rethrown once all closure calls have + /// completed. + /// + /// - parameter priority: Any specific `TaskPriority` to assign to + /// the async tasks that will perform the closure calls. The + /// default is `nil` (meaning that the system picks a priority). + /// - parameter operation: The closure to run for each element. + /// - throws: Rethrows any error thrown by the passed closure. + func concurrentForEach( + withPriority priority: TaskPriority? = nil, + _ operation: @escaping (Element) async throws -> Void + ) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + for element in self { + group.addTask(priority: priority) { + try await operation(element) + } + } + + // Propagate any errors thrown by the group's tasks: + for try await _ in group {} + } + } +} + + +// MARK: - OfflineManagerClient + DependencyKey + +extension OfflineManagerClient: DependencyKey { + @Dependency(\.fileClient) private static var fileClient + private static let downloadManager = OfflineDownloadManager() + + public static let liveValue = Self( + download: { asset in + try? await downloadManager.setupAssetDownload(asset) + }, + cache: { asset in + let libraryFileUrl = try fileClient.retrieveLibraryDirectory(root: .playlistCache) + let playlist = asset.playlist + let playlistId = playlist.id.rawValue.replacingOccurrences(of: "/", with: "\\") + let imageUrl = libraryFileUrl.appendingPathComponent(playlistId).appendingPathComponent("posterImage.jpeg") + try? fileClient.shouldCreateLibraryDirectory(.playlistCache, playlistId, PlaylistCache( + playlist: playlist, + groups: asset.groups, + details: asset.details, + repoModuleId: .init(repoId: asset.repoModuleId.repoId, moduleId: asset.repoModuleId.moduleId) + )) + if let image = asset.playlist.posterImage ?? asset.playlist.bannerImage, !image.isFileURL { + let (data, _) = try await URLSession.shared.data(from: image) + + if let imageData = UIImage(data: data)?.jpegData(compressionQuality: 1) { + try imageData.write(to: imageUrl) + } + } + }, + remove: { type, playlist, episode in + switch type { + case .all: + try fileClient.removePlaylistFromLibrary(.downloaded, playlist, episode) + try fileClient.removePlaylistFromLibrary(.playlistCache, playlist, episode) + break + case .cache: + try fileClient.removePlaylistFromLibrary(.playlistCache, playlist, episode) + break + case .download: + try fileClient.removePlaylistFromLibrary(.downloaded, playlist, episode) + break + } + } + ) +} + +// MARK: - OfflineDownloadManager + +private class OfflineDownloadManager: NSObject { + private var config: URLSessionConfiguration! + private var downloadSession: AVAssetDownloadURLSession! + private var downloadQueue: Set = [] + + @Dependency(\.fileClient) var fileClient + + override init() { + super.init() + config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background") + downloadSession = AVAssetDownloadURLSession(configuration: config, assetDownloadDelegate: self, delegateQueue: OperationQueue.main) + } + + public func setupAssetDownload(_ asset: OfflineManagerClient.DownloadAsset) async throws { + let options = [AVURLAssetAllowsCellularAccessKey: false] + let avAsset = AVURLAsset(url: asset.episodeMetadata.link.url, options: options) + let libraryFileUrl = try fileClient.retrieveLibraryDirectory(root: .playlistCache) + + let downloadTask = downloadSession.makeAssetDownloadTask(asset: avAsset, + assetTitle: asset.playlist.title ?? "Unknown Title", + assetArtworkData: nil, + options: [AVAssetDownloadTaskPrefersHDRKey: false, AVAssetDownloadTaskPrefersLosslessAudioKey: false]) + + let playlist = asset.playlist + let playlistId = playlist.id.rawValue.replacingOccurrences(of: "/", with: "\\") + let imageUrl = libraryFileUrl.appendingPathComponent(playlistId).appendingPathComponent("posterImage.jpeg") + try? fileClient.shouldCreateLibraryDirectory(.playlistCache, playlistId, PlaylistCache( + playlist: playlist, + groups: asset.groups, + details: asset.details, + repoModuleId: .init(repoId: asset.repoModuleId.repoId, moduleId: asset.repoModuleId.moduleId) + )) + + let image = asset.playlist.posterImage ?? asset.playlist.bannerImage ?? URL(string: "")! + let (data, _) = try await URLSession.shared.data(from: image) + + if let imageData = UIImage(data: data)?.jpegData(compressionQuality: 1) { + try imageData.write(to: imageUrl) + } + + let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlist.id.rawValue, episode: asset.episodeId.rawValue) + try? fileClient.shouldCreateLibraryDirectory( + .downloaded, + outputURL.pathComponents.suffix(2).joined(separator: "/"), + EpisodeMetadata( + link: asset.episodeMetadata.link, + source: Playlist.EpisodeSource(id: asset.episodeMetadata.source.id, displayName: asset.episodeMetadata.source.displayName, description: asset.episodeMetadata.source.description, servers: [asset.episodeMetadata.server]), + subtitles: asset.episodeMetadata.subtitles, + server: Playlist.EpisodeServer(id: asset.episodeMetadata.server.id, displayName: asset.episodeMetadata.server.displayName, description: asset.episodeMetadata.server.description), + skipTimes: asset.episodeMetadata.skipTimes + ) + ) + await asset.episodeMetadata.subtitles.concurrentForEach { + if let data = try? await URLSession.shared.data(from: $0.url) { + let fileName = $0.id.rawValue.lastPathComponent + try? data.0.write(to: outputURL.appendingPathComponent(fileName)) + } + } + downloadTask?.resume() + downloadQueue.insert(.init(url: asset.episodeMetadata.link.url, playlistId: playlist.id, episodeId: asset.episodeId, metadata: asset.episodeMetadata)) + + NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) + } + + func restorePendingDownloads() { + downloadSession.getAllTasks { tasksArray in + for task in tasksArray { + guard let downloadTask = task as? AVAssetDownloadTask else { break } + + let asset = downloadTask.urlAsset + downloadTask.resume() + } + } + } + + func playOfflineAsset() -> AVURLAsset? { + guard let assetPath = UserDefaults.standard.value(forKey: "assetPath") as? String else { + return nil + } + let baseURL = URL(fileURLWithPath: NSHomeDirectory()) + let assetURL = baseURL.appendingPathComponent(assetPath) + let asset = AVURLAsset(url: assetURL) + if let cache = asset.assetCache, cache.isPlayableOffline { + return asset + } else { + return nil + } + } + + func getPath() -> String { + return UserDefaults.standard.value(forKey: "assetPath") as? String ?? "" + } + + public func deleteOfflineAsset() { + do { + let userDefaults = UserDefaults.standard + if let assetPath = userDefaults.value(forKey: "assetPath") as? String { + let baseURL = URL(fileURLWithPath: NSHomeDirectory()) + let assetURL = baseURL.appendingPathComponent(assetPath) + try FileManager.default.removeItem(at: assetURL) + userDefaults.removeObject(forKey: "assetPath") + } + } catch { + print("An error occured deleting offline asset: \(error)") + } + } + + public func deleteDownloadedVideo(atPath path: String) { + do { + let baseURL = URL(fileURLWithPath: NSHomeDirectory()) + let assetURL = baseURL.appendingPathComponent(path) + try FileManager.default.removeItem(at: assetURL) + + if var downloadedPaths = UserDefaults.standard.array(forKey: "DownloadedVideoPaths") as? [String], + let index = downloadedPaths.firstIndex(of: path) { + downloadedPaths.remove(at: index) + UserDefaults.standard.set(downloadedPaths, forKey: "DownloadedVideoPaths") + } + +// NotificationCenter.default.post(name: .didDeleteVideo, object: nil) + } catch { + print("An error occurred deleting offline asset: \(error)") + } + } +} + +extension OfflineDownloadManager: AVAssetDownloadDelegate { + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { + var percentComplete = 0.0 + + for value in loadedTimeRanges { + let loadedTimeRange = value.timeRangeValue + percentComplete += loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds + } + percentComplete *= 100 + + debugPrint("Progress \( assetDownloadTask) \(percentComplete)") + + let params = ["percent": percentComplete, "assetUrl": assetDownloadTask.urlAsset.url] as [String : Any] + NotificationCenter.default.post(name: .AssetDownloadProgress, object: nil, userInfo: params) + } + + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + if let downloadedAsset = downloadQueue.first(where: { $0.url == assetDownloadTask.urlAsset.url }) { + try? saveVideo(asset: downloadedAsset, location: location) + } + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + debugPrint("Download finished: \(location.absoluteString)") + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + debugPrint("Task completed: \(task), error: \(String(describing: error))") + + guard error == nil else { return } + guard let task = task as? AVAssetDownloadTask else { return } + print("DOWNLOAD: FINISHED") + } +} + +extension OfflineDownloadManager { + private func saveVideo(asset: OfflineManagerClient.DownloadingAsset, location: URL) throws { + let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlistId.rawValue, episode: asset.episodeId.rawValue) + debugPrint("File saved to: \(outputURL)") + try FileManager.default.moveItem(at: location, to: outputURL.appendingPathComponent("data.movpkg")) + } +} + +extension Notification.Name { + /// Notification for when download progress has changed. + static let AssetDownloadProgress = Notification.Name(rawValue: "AssetDownloadProgressNotification") + + /// Notification for when the download state of an Asset has changed. + static let AssetDownloadStateChanged = Notification.Name(rawValue: "AssetDownloadStateChangedNotification") + + /// Notification for when AssetPersistenceManager has completely restored its state. + static let AssetPersistenceManagerDidRestoreState = Notification.Name(rawValue: "AssetPersistenceManagerDidRestoreStateNotification") +} diff --git a/Sources/Clients/OfflineManagerClient/Models.swift b/Sources/Clients/OfflineManagerClient/Models.swift new file mode 100644 index 0000000..f9312b8 --- /dev/null +++ b/Sources/Clients/OfflineManagerClient/Models.swift @@ -0,0 +1,61 @@ +// +// Models.swift +// +// +// Created by DeNeRr on 06.04.2024. +// + +import Foundation +import SharedModels +import Tagged + +extension OfflineManagerClient { + public enum Error: Swift.Error, Equatable, Sendable { + case failedToGetPlaylistId + } + + public enum RemoveType { + case cache + case download + case all + } + + public struct DownloadAsset: Equatable, Sendable { + public let episodeMetadata: EpisodeMetadata + public let episodeId: Playlist.Item.ID + public let groups: [Playlist.Group]? + public let playlist: Playlist + public let details: Playlist.Details? + public let repoModuleId: RepoModuleID + + public init(episodeMetadata: EpisodeMetadata, episodeId: Playlist.Item.ID, groups: [Playlist.Group]?, playlist: Playlist, details: Playlist.Details?, repoModuleId: RepoModuleID) { + self.episodeMetadata = episodeMetadata + self.episodeId = episodeId + self.groups = groups + self.playlist = playlist + self.details = details + self.repoModuleId = repoModuleId + } + } + + public struct CacheAsset: Equatable, Sendable { + public let groups: [Playlist.Group]? + public let playlist: Playlist + public let details: Playlist.Details? + public let repoModuleId: RepoModuleID + + public init(groups: [Playlist.Group]?, playlist: Playlist, details: Playlist.Details?, repoModuleId: RepoModuleID) { + self.playlist = playlist + self.details = details + self.groups = groups + self.repoModuleId = repoModuleId + } + } + + public struct DownloadingAsset: Hashable { + public let url: URL + public let playlistId: Playlist.ID + public let episodeId: Playlist.Item.ID + public let metadata: EpisodeMetadata + } +} diff --git a/Sources/Clients/PlayerClient/Internal/PlayerItem.swift b/Sources/Clients/PlayerClient/Internal/PlayerItem.swift index 686fc39..f50a0e7 100644 --- a/Sources/Clients/PlayerClient/Internal/PlayerItem.swift +++ b/Sources/Clients/PlayerClient/Internal/PlayerItem.swift @@ -40,7 +40,7 @@ final class PlayerItem: AVPlayerItem { self.resourceQueue = DispatchQueue(label: "playeritem-\(payload.link.absoluteString)", qos: .utility) let headers = payload.headers - if payload.subtitles.isEmpty { + if payload.subtitles.isEmpty || payload.link.isFileURL { self.payload = .init(modifiedLink: payload.link, payload: payload) } else { self.payload = .init(modifiedLink: payload.link.change(scheme: Self.hlsCommonScheme), payload: payload) diff --git a/Sources/Clients/PlaylistHistoryClient/Client.swift b/Sources/Clients/PlaylistHistoryClient/Client.swift index 8c077cf..e377b67 100644 --- a/Sources/Clients/PlaylistHistoryClient/Client.swift +++ b/Sources/Clients/PlaylistHistoryClient/Client.swift @@ -19,6 +19,8 @@ public struct PlaylistHistoryClient: Sendable { public var updateEpId: @Sendable (EpIdPayload) async throws -> Void public var fetch: @Sendable (RMP) async throws -> PlaylistHistory public var fetchForModule: @Sendable (String, String) async throws -> [PlaylistHistory] + public var observeAll: @Sendable () -> AsyncStream<[PlaylistHistory]> + public var observeRepoModule: @Sendable (String, String) -> AsyncStream<[PlaylistHistory]> public var updateTimestamp: @Sendable (RMP, Double) async throws -> Void public var updateDateWatched: @Sendable (RMP) async throws -> Void public var observe: @Sendable (RMP) -> AsyncStream<[PlaylistHistory]> @@ -33,6 +35,8 @@ extension PlaylistHistoryClient: TestDependencyKey { updateEpId: unimplemented("\(Self.self).updateEpId"), fetch: unimplemented("\(Self.self).fetch"), fetchForModule: unimplemented("\(Self.self).fetchForModule"), + observeAll: unimplemented("\(Self.self).observeAll"), + observeRepoModule: unimplemented("\(Self.self).fetchForModule"), updateTimestamp: unimplemented("\(Self.self).updateTimestamp"), updateDateWatched: unimplemented("\(Self.self).updateDateWatched"), observe: unimplemented("\(Self.self).observe"), diff --git a/Sources/Clients/PlaylistHistoryClient/Live.swift b/Sources/Clients/PlaylistHistoryClient/Live.swift index 692c85a..807f8f4 100644 --- a/Sources/Clients/PlaylistHistoryClient/Live.swift +++ b/Sources/Clients/PlaylistHistoryClient/Live.swift @@ -19,7 +19,7 @@ extension PlaylistHistoryClient: DependencyKey { updateEpId: { payload in if var playlist = try? await databaseClient .fetch(.all.where(\PlaylistHistory.repoId == payload.rmp.repoId).where(\PlaylistHistory.moduleId == payload.rmp.moduleId).where(\PlaylistHistory.playlistID == payload.rmp.playlistId)).first { - playlist.epId = payload.episode.id.rawValue + playlist.epId = payload.episode.id playlist.dateWatched = Date.now playlist.epName = payload.episode.title playlist.groupId = payload.groupId @@ -30,7 +30,7 @@ extension PlaylistHistoryClient: DependencyKey { } else { _ = try await databaseClient.insert(PlaylistHistory( playlistID: payload.rmp.playlistId, - epId: payload.episode.id.rawValue, + epId: payload.episode.id, playlistName: payload.playlistName, moduleId: payload.rmp.moduleId, repoId: payload.rmp.repoId, @@ -54,6 +54,12 @@ extension PlaylistHistoryClient: DependencyKey { return history?.sorted(by: { $0.dateWatched > $1.dateWatched }) ?? [] }, + observeAll: { + databaseClient.observe(.all.where(\PlaylistHistory.moduleId != nil)) + }, + observeRepoModule: { repoId, moduleId in + databaseClient.observe(.all.where(\PlaylistHistory.repoId == repoId).where(\PlaylistHistory.moduleId == moduleId)) + }, updateTimestamp: { rmp, timestamp in if var playlist = try? await databaseClient .fetch(.all.where(\PlaylistHistory.repoId == rmp.repoId).where(\PlaylistHistory.moduleId == rmp.moduleId).where(\PlaylistHistory.playlistID == rmp.playlistId)).first { diff --git a/Sources/Clients/PlaylistHistoryClient/Models.swift b/Sources/Clients/PlaylistHistoryClient/Models.swift index 4ba7e36..9384b3a 100644 --- a/Sources/Clients/PlaylistHistoryClient/Models.swift +++ b/Sources/Clients/PlaylistHistoryClient/Models.swift @@ -6,7 +6,6 @@ // import Foundation -import SharedModels extension PlaylistHistoryClient { public enum Error: Swift.Error, Equatable, Sendable { @@ -24,16 +23,28 @@ extension PlaylistHistoryClient { self.playlistId = playlistId } } + + public struct Episode: Equatable, Sendable { + let id: String + let title: String + let thumbnail: URL? + + public init(id: String, title: String, thumbnail: URL?) { + self.id = id + self.title = title + self.thumbnail = thumbnail + } + } public struct EpIdPayload: Equatable, Sendable { public let rmp: RMP - public let episode: Playlist.Item + public let episode: Episode public let playlistName: String? public let pageId: String public let groupId: String public let variantId: String - public init(rmp: RMP, episode: Playlist.Item, playlistName: String?, pageId: String, groupId: String, variantId: String) { + public init(rmp: RMP, episode: Episode, playlistName: String?, pageId: String, groupId: String, variantId: String) { self.rmp = rmp self.episode = episode self.playlistName = playlistName diff --git a/Sources/Clients/UserSettingsClient/Theme.swift b/Sources/Clients/UserSettingsClient/Theme.swift index 2126f56..29df5c3 100644 --- a/Sources/Clients/UserSettingsClient/Theme.swift +++ b/Sources/Clients/UserSettingsClient/Theme.swift @@ -104,6 +104,7 @@ public enum Theme: Codable, Sendable, Hashable, Identifiable, CaseIterable { extension Theme { public static let pastelGreen = Color(hue: 138 / 360, saturation: 0.33, brightness: 0.63) + public static let pastelRed = Color(hue: 2 / 360, saturation: 0.42, brightness: 0.96) public static let pastelBlue = Color(hue: 178 / 360, saturation: 0.39, brightness: 0.7) public static let pastelOrange = Color(hue: 27 / 360, saturation: 0.41, brightness: 0.69) } diff --git a/Sources/Features/App/AppFeature+Reducer.swift b/Sources/Features/App/AppFeature+Reducer.swift index 8a15219..3d1e118 100644 --- a/Sources/Features/App/AppFeature+Reducer.swift +++ b/Sources/Features/App/AppFeature+Reducer.swift @@ -14,6 +14,7 @@ import Discover import Foundation import ModuleLists import Repos +import Library import Settings import VideoPlayer @@ -40,6 +41,8 @@ extension AppFeature: Reducer { case let .view(.didSelectTab(tab)): if state.selected == tab { switch tab { + case .library: + break case .discover: state.discover.path.removeAll() case .repos: @@ -54,6 +57,31 @@ extension AppFeature: Reducer { case .internal(.appDelegate): break + case let .internal(.library(.delegate(.playbackVideoItem(_, 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, + group: group, + variant: variant, + page: paging, + episodeId: itemId, + prefersOffline: true + ) + } + case let .internal(.discover(.delegate(.playbackVideoItem(_, repoModuleId, playlist, group, variant, paging, itemId)))): let effect = state.videoPlayer?.clearForNewPlaylistIfNeeded( repoModuleId: repoModuleId, @@ -81,6 +109,9 @@ extension AppFeature: Reducer { case .internal(.discover): break + case .internal(.library): + break + case .internal(.repos): break @@ -106,6 +137,10 @@ extension AppFeature: Reducer { DiscoverFeature() } + Scope(state: \.library, action: \.internal.library) { + LibraryFeature() + } + Scope(state: \.repos, action: \.internal.repos) { ReposFeature() } diff --git a/Sources/Features/App/AppFeature.swift b/Sources/Features/App/AppFeature.swift index b459a9b..6b4abb0 100644 --- a/Sources/Features/App/AppFeature.swift +++ b/Sources/Features/App/AppFeature.swift @@ -9,6 +9,7 @@ import Architecture import DatabaseClient import Discover +import Library import Foundation import ModuleLists import Repos @@ -23,6 +24,7 @@ public struct AppFeature: Feature { public struct State: FeatureState { public var appDelegate = AppDelegateFeature.State() public var discover = DiscoverFeature.State() + public var library = LibraryFeature.State() public var repos = ReposFeature.State() public var settings = SettingsFeature.State() @@ -34,16 +36,19 @@ public struct AppFeature: Feature { discover: DiscoverFeature.State = .init(), repos: ReposFeature.State = .init(), settings: SettingsFeature.State = .init(), - selected: AppFeature.State.Tab = Tab.discover + selected: AppFeature.State.Tab = Tab.discover, + library: LibraryFeature.State = .init() ) { self.discover = discover self.repos = repos self.settings = settings self.selected = selected + self.library = library } public enum Tab: String, CaseIterable, Sendable, Localizable, Hashable { case discover = "Discover" + case library = "Library" case repos = "Repos" case settings = "Settings" @@ -51,6 +56,8 @@ public struct AppFeature: Feature { switch self { case .discover: "doc.text.image" + case .library: + "rectangle.stack" case .repos: "globe" case .settings: @@ -58,21 +65,12 @@ public struct AppFeature: Feature { } } - var selected: String { - switch self { - case .discover: - "doc.text.image.fill" - case .repos: - image - case .settings: - "gearshape.fill" - } - } - var colorAccent: Color { switch self { case .discover: Theme.pastelGreen + case .library: + Theme.pastelRed case .repos: Theme.pastelBlue case .settings: @@ -98,6 +96,7 @@ public struct AppFeature: Feature { public enum InternalAction: SendableAction { case appDelegate(AppDelegateFeature.Action) case discover(DiscoverFeature.Action) + case library(LibraryFeature.Action) case repos(ReposFeature.Action) case settings(SettingsFeature.Action) case videoPlayer(PresentationAction) diff --git a/Sources/Features/App/iOS/AppFeatureView+iOS.swift b/Sources/Features/App/iOS/AppFeatureView+iOS.swift index 5e927e5..e84e200 100644 --- a/Sources/Features/App/iOS/AppFeatureView+iOS.swift +++ b/Sources/Features/App/iOS/AppFeatureView+iOS.swift @@ -13,6 +13,7 @@ import Foundation import FoundationHelpers import ModuleLists import Repos +import Library import Settings import Styling import SwiftUI @@ -48,6 +49,14 @@ extension AppFeature.View: View { ) ) .accentColor(nil) + case .library: + LibraryFeature.View( + store: store.scope( + state: \.library, + action: \.internal.library + ) + ) + .accentColor(nil) case .settings: SettingsFeature.View( store: store.scope( @@ -59,7 +68,7 @@ extension AppFeature.View: View { } } .tabItem { - Label(tab.localized, systemImage: viewStore.state == tab ? tab.selected : tab.image) + Label(tab.localized, systemImage: tab.image) } .tag(tab) } diff --git a/Sources/Features/App/macOS/AppFeatureView+macOS.swift b/Sources/Features/App/macOS/AppFeatureView+macOS.swift index 7fd0882..a356ca3 100644 --- a/Sources/Features/App/macOS/AppFeatureView+macOS.swift +++ b/Sources/Features/App/macOS/AppFeatureView+macOS.swift @@ -38,6 +38,14 @@ extension AppFeature.View: View { action: \.internal.discover ) ) + case .library: + LibraryFeature.View( + store: store.scope( + state: \.library, + action: \.internal.library + ) + ) + .accentColor(nil) case .repos: ReposFeature.View( store: store.scope( diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index 3eaff03..603d1f0 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -208,19 +208,17 @@ extension ContentCore { 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)) + .foregroundStyle(Color.primary) + .multilineTextAlignment(.leading) } .frame(width: 228) .contentShape(Rectangle()) @@ -232,6 +230,51 @@ extension ContentCore { } } .id(item.id) +// Menu { +// Button() { +// store.send(.didTapDownloadPlaylist(item.id)) +// } label: { +// Label("Download episode", systemImage: "square.and.arrow.down") +// } +// .buttonStyle(.plain) +// } label: { +// VStack(alignment: .leading, spacing: 0) { +// FillAspectImage(url: item.thumbnail ?? viewStore.playlist.posterImage) +// .aspectRatio(16 / 9, contentMode: .fit) +// .cornerRadius(12) +// +// Spacer() +// .frame(height: 8) +// +// WithViewStore(store, observe: \.downloadedEpisodes) { viewStore in +// HStack(spacing: 10) { +// Text(String(format: contentType.itemTypeWithNumber, item.number.withoutTrailingZeroes)) +// .font(.footnote.weight(.semibold)) +// if (viewStore.state.contains(item.id.rawValue.replacingOccurrences(of: "/", with: "\\"))) { +// Image(systemName: "cloud.fill") +// } +// } +// .foregroundColor(.init(white: 0.4)) +// } +// +// Spacer() +// .frame(height: 4) +// +// Text(item.title ?? String(format: contentType.itemTypeWithNumber, item.number.withoutTrailingZeroes)) +// .font(.body.weight(.semibold)) +// .foregroundStyle(Color.primary) +// .multilineTextAlignment(.leading) +// } +// .frame(width: 228) +// .contentShape(Rectangle()) +// .id(item.id) +// } primaryAction: { +// if let groupId = groupLoadable.value?.id, +// let variantId = variantLoadable.value?.id, +// let pageId = pageLoadable.value?.id { +// store.send(.didTapPlaylistItem(groupId, variantId, pageId, id: item.id, shouldReset: true)) +// } +// } } .frame(maxHeight: .infinity, alignment: .top) } @@ -269,6 +312,110 @@ extension ContentCore { .animation(.easeInOut, value: _selectedGroupId) .animation(.easeInOut, value: _selectedVariantId) .animation(.easeInOut, value: _selectedPagingId) + .sheet( + store: store.scope( + state: \.$downloadSelection, + action: \.downloadSelection + ), + state: /DownloadSelection.State.selection, + action: DownloadSelection.Action.selection + ) { store in + VStack { + Capsule() + .frame(width: 48, height: 4) + .foregroundColor(.gray.opacity(0.26)) + .padding(.top, 8) + + WithViewStore(store, observe: \.`self`) { viewStore in + List { + LoadableView(loadable: viewStore.state.sources) { sources in + Section("Sources") { + ForEach(sources) { source in + Button { + store.send(.selectSource(source)) + } label: { + HStack(alignment: .center) { + Text(source.displayName) + Spacer() + if (viewStore.state.selectedSource?.id == source.id) { Image(systemName: "checkmark").foregroundColor(.blue) } + } + } + .foregroundColor(Color.primary) + } + } + + if let selectedSource = viewStore.state.selectedSource { + Section("Servers") { + ForEach(sources.first(where: { $0.id.rawValue == selectedSource.id.rawValue })?.servers ?? []) { server in + Button { + store.send(.selectServer(server)) + } label: { + HStack(alignment: .center) { + Text(server.displayName) + Spacer() + if (viewStore.state.selectedServer?.id == server.id) { + if case .loading = viewStore.state.serverResponse { + ProgressView().progressViewStyle(CircularProgressViewStyle(tint: Color.blue)) + } else { + Image(systemName: "checkmark").foregroundColor(.blue) + } + } + } + } + .foregroundColor(Color.primary) + } + } + } + } + + LoadableView(loadable: viewStore.serverResponse) { serverResponse in + Section("Quality") { + ForEach(serverResponse.links) { link in + Button { + store.send(.selectQuality(link)) + } label: { + HStack(alignment: .center) { + Text(link.quality.description) + Spacer() + if (viewStore.state.selectedQuality?.id == link.id) { Image(systemName: "checkmark").foregroundColor(.blue) } + } + } + .foregroundColor(Color.primary) + } + } + Section("Subtitles") { + ForEach(serverResponse.subtitles) { subtitle in + Button { + store.send(.selectSubtitle(subtitle)) + } label: { + HStack(alignment: .center) { + Text(subtitle.name) + Spacer() + if (viewStore.state.selectedSubtitle?.id == subtitle.id) { Image(systemName: "checkmark").foregroundColor(.blue) } + } + } + .foregroundColor(Color.primary) + } + } + if let selectedQuality = viewStore.state.selectedQuality { + Button { + store.send(.download(viewStore.selectedSource!, viewStore.selectedServer!, selectedQuality, viewStore.selectedSubtitle != nil ? [viewStore.selectedSubtitle!] : [], serverResponse.skipTimes, viewStore.state.episodeId)) + } label: { + Text("Download") + } + .frame(maxWidth: .infinity, alignment: .center) + } + } + } + .animation(.easeInOut, value: viewStore.state.selectedSource) + .animation(.easeInOut, value: viewStore.serverResponse.value) + .animation(.easeInOut, value: viewStore.state.selectedQuality) + } + } + .onAppear { + store.send(.didAppear) + } + } } .onChange(of: _selectedGroupId) { _ in _selectedVariantId = nil @@ -277,6 +424,9 @@ extension ContentCore { .onChange(of: _selectedVariantId) { _ in _selectedPagingId = nil } + .onAppear { + store.send(.didAppear) + } } } } diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index 3be5186..6dd71bc 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -16,6 +16,7 @@ import OrderedCollections import PlaylistHistoryClient import SharedModels import Tagged +import FileClient // MARK: - Cancellable @@ -30,24 +31,34 @@ public struct ContentCore: Reducer { public var repoModuleId: RepoModuleID public var playlist: Playlist public var groups: Loadable<[Playlist.Group]> + public var cachedGroups: [Playlist.Group]? public var playlistHistory: Loadable + + @PresentationState public var downloadSelection: DownloadSelection.State? + + public var downloadedEpisodes: [String] = [] public init( repoModuleId: RepoModuleID, playlist: Playlist, groups: Loadable<[Playlist.Group]> = .pending, - playlistHistory: Loadable = .pending + cachedGroups: [Playlist.Group]? = nil, + playlistHistory: Loadable = .pending, + downloadSelection: DownloadSelection.State? = nil ) { self.repoModuleId = repoModuleId self.playlist = playlist self.groups = groups + self.cachedGroups = cachedGroups self.playlistHistory = playlistHistory + self.downloadSelection = downloadSelection } } @CasePathable @dynamicMemberLookup public enum Action: SendableAction { + case didAppear case update(option: Playlist.ItemsRequestOptions?, Loadable) case didRequestLoadingPendingContent(Playlist.ItemsRequestOptions?) case didTapContent(Playlist.ItemsRequestOptions) @@ -59,6 +70,10 @@ public struct ContentCore: Reducer { id: Playlist.Item.ID, shouldReset: Bool = false ) + case observeDirectory(URL, Bool) + case didTapDownloadPlaylist(Playlist.Item.ID) + case setDownloadedEpisodes([String]) + case downloadSelection(PresentationAction) } public enum Error: Swift.Error, Equatable, Sendable { @@ -70,9 +85,24 @@ public struct ContentCore: Reducer { public var body: some ReducerOf { Reduce { state, action in switch action { + case .didAppear: + break +// @Dependency(\.fileClient) var fileClient +// let playlistId = state.playlist.id.rawValue +// return .run { send in +// if let directory = try? fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: playlistId), FileManager.default.fileExists(atPath: directory.path) { +// await send(.observeDirectory(directory, true)) +// } else if let directory = try? fileClient.retrieveLibraryDirectory(root: .downloaded) { +// await send(.observeDirectory(directory, false)) +// } +// } + case let .didTapContent(option): return state.fetchContent(option) - + + case let .didTapDownloadPlaylist(episodeId): + state.downloadSelection = .selection(.init(repoModuleId: state.repoModuleId, playlistId: state.playlist.id, episodeId: episodeId)) + case let .didTapPlaylistItem(groupId, variantId, pageId, itemId, shouldReset): @Dependency(\.playlistHistoryClient) var playlistHistoryClient let playlist = state.playlist @@ -82,7 +112,7 @@ public struct ContentCore: Reducer { if let item { try? await playlistHistoryClient.updateEpId(.init( rmp: .init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), - episode: item, + episode: .init(id: item.id.rawValue, title: item.title ?? "Unknown", thumbnail: item.thumbnail ?? playlist.posterImage ?? playlist.bannerImage), playlistName: playlist.title, pageId: pageId.rawValue, groupId: groupId.rawValue, @@ -93,6 +123,25 @@ public struct ContentCore: Reducer { } } } + + case let .observeDirectory(directory, isPlaylistDirectory): + @Dependency(\.fileClient) var fileClient + let playlistId = state.playlist.id.rawValue + return .run { send in + for await contents in try fileClient.observeDirectory(directory) { + if (isPlaylistDirectory) { + await send(.setDownloadedEpisodes(contents)) + } else { + if let directory = try? fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: playlistId), FileManager.default.fileExists(atPath: directory.path) { + await send(.observeDirectory(directory, true)) + } + } + + } + } + + case let .setDownloadedEpisodes(episodes): + state.downloadedEpisodes = episodes case let .playlistHistoryResponse(response): state.playlistHistory = response @@ -102,9 +151,15 @@ public struct ContentCore: Reducer { case let .update(option, response): state.update(option, response) + + case .downloadSelection: + break } return .none } + .ifLet(\.$downloadSelection, action: \.downloadSelection) { + DownloadSelection() + } } } @@ -146,14 +201,17 @@ extension ContentCore.State { } update(option, .loading) - + let cachedGroups = cachedGroups return .run { send in try await withTaskCancellation(id: Cancellable.fetchContent, cancelInFlight: true) { - let value = try await moduleClient.withModule(id: repoModuleId) { module in - try await module.playlistEpisodes(playlistId, option) + let module = try await moduleClient.getModule(repoModuleId) + do { + + await send(.update(option: option, .loaded(try await module.playlistEpisodes(playlistId, option)))) + } catch let error { + await send(.update(option: option, cachedGroups != nil ? .loaded(cachedGroups!) : .failed(error))) } - await send(.update(option: option, .loaded(value))) for await playlistHistoryItems in playlistHistoryClient.observe(.init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlistId.rawValue)) { if let playlistHistory = playlistHistoryItems.first { await send(.playlistHistoryResponse(.loaded(playlistHistory))) diff --git a/Sources/Features/ContentCore/DownloadSelection.swift b/Sources/Features/ContentCore/DownloadSelection.swift new file mode 100644 index 0000000..5d7f22a --- /dev/null +++ b/Sources/Features/ContentCore/DownloadSelection.swift @@ -0,0 +1,140 @@ +// +// DownloadSection.swift +// +// +// DownloadSelection by DeNeRr on 15.04.2024. +// + +import Foundation +import OfflineManagerClient +import ComposableArchitecture +import SharedModels + +private enum Cancellable: Hashable, CaseIterable { + case fetchingSources + case fetchingServer +} + + +public struct DownloadSelection: Reducer { + public enum State: Equatable, Sendable { + case selection(Selection.State) + } + + public enum Action: Equatable, Sendable { + case selection(Selection.Action) + } + + public var body: some ReducerOf { + Scope(state: /State.selection, action: /Action.selection) { + Selection() + } + } + + public struct Selection: Reducer { + public struct State: Equatable, Sendable { + public let repoModuleId: RepoModuleID + public let playlistId: Playlist.ID + public let episodeId: Playlist.Item.ID + + public var sources: Loadable<[Playlist.EpisodeSource]> + public var serverResponse: Loadable + + public var selectedSource: Playlist.EpisodeSource? = nil + public var selectedServer: Playlist.EpisodeServer? = nil + public var selectedQuality: Playlist.EpisodeServer.Link? = nil + public var selectedSubtitle: Playlist.EpisodeServer.Subtitle? = nil + + public init(repoModuleId: RepoModuleID, playlistId: Playlist.ID, episodeId: Playlist.Item.ID, sources: Loadable<[Playlist.EpisodeSource]> = .pending, serverResponse: Loadable = .pending) { + self.repoModuleId = repoModuleId + self.playlistId = playlistId + self.episodeId = episodeId + self.sources = sources + self.serverResponse = serverResponse + } + } + + public enum Action: Equatable, Sendable { + case didAppear + case sourcesResponse(Loadable<[Playlist.EpisodeSource]>) + case selectSource(Playlist.EpisodeSource) + case selectServer(Playlist.EpisodeServer) + case selectQuality(Playlist.EpisodeServer.Link) + case selectSubtitle(Playlist.EpisodeServer.Subtitle) + case serverResponse(Loadable) + case download(Playlist.EpisodeSource, Playlist.EpisodeServer, Playlist.EpisodeServer.Link, [Playlist.EpisodeServer.Subtitle], [Playlist.EpisodeServer.SkipTime], Playlist.Item.ID) + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .didAppear: + @Dependency(\.moduleClient) var moduleClient + let episodeId = state.episodeId + let playlistId = state.playlistId + let repoModuleId = state.repoModuleId + return .run { send in + try await withTaskCancellation(id: Cancellable.fetchingSources, cancelInFlight: true) { + let value = try await moduleClient.withModule(id: repoModuleId) { module in + try await module.playlistEpisodeSources( + .init( + playlistId: playlistId, + episodeId: episodeId + ) + ) + } + await send(.sourcesResponse(.loaded(value))) + } + } + case let .sourcesResponse(sources): + state.sources = sources + + case let .serverResponse(serverResponse): + state.serverResponse = serverResponse + + case let .selectSource(source): + state.selectedSource = source + + case let .selectQuality(quality): + state.selectedQuality = quality + + case let .selectSubtitle(subtitle): + state.selectedSubtitle = subtitle + + case let .selectServer(server): + state.serverResponse = .loading + guard let source = state.selectedSource else { + return .none + } + let episodeId = state.episodeId + let playlistId = state.playlistId + let repoModuleId = state.repoModuleId + @Dependency(\.moduleClient) var moduleClient + state.selectedServer = server + return .run { send in + try await withTaskCancellation(id: Cancellable.fetchingServer, cancelInFlight: true) { + let value = try await moduleClient.withModule(id: repoModuleId) { module in + try await module.playlistEpisodeServer( + .init( + playlistId: playlistId, + episodeId: episodeId, + sourceId: source.id, + serverId: server.id + ) + ) + } + await send(.serverResponse(.loaded(value))) + } + } + + case .download: + @Dependency(\.dismiss) var dismiss + return .run { + await dismiss() + } + } + return .none + } + } + } +} diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index f4712d3..438a687 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -35,7 +35,7 @@ extension DiscoverFeature { } guard let moduleId = UserDefaults.standard.string(forKey: "LastSelectedModuleId"), let repoId = UserDefaults.standard.url(forKey: "LastSelectedRepoId") else { - state.section = .home() + state.section = .home(.init(listings: .pending)) break } return .run { send in @@ -50,11 +50,12 @@ extension DiscoverFeature { case let .view(.didTapContinueWatching(item)): let blankUrl = URL(string: "_blank")! return .run { send in - try? await moduleClient.withModule(id: .init(repoId: Repo.ID(URL(string: item.repoId)!), moduleId: Module.ID(item.moduleId))) { module in + await send(.internal(.setPlaylistLoading(item.playlistID))) + try await moduleClient.withModule(id: .init(repoId: Repo.ID(URL(string: item.repoId)!), moduleId: Module.ID(item.moduleId))) { module in let options = Playlist.ItemsRequestOptions.page(.init(item.groupId), .init(item.variantId), .init(item.pageId)) - let eps = try? await module.playlistEpisodes(Playlist.ID(rawValue: item.playlistID), options) + let eps = try await module.playlistEpisodes(Playlist.ID(rawValue: item.playlistID), options) let playlist = Playlist(id: Playlist.ID(rawValue: item.playlistID), title: item.playlistName, posterImage: nil, bannerImage: nil, url: blankUrl, status: .unknown, type: .video) @@ -62,7 +63,7 @@ extension DiscoverFeature { await send( .delegate( .playbackVideoItem( - eps ?? [], + eps, repoModuleId: .init(repoId: Repo.ID(URL(string: item.repoId)!), moduleId: Module.ID(item.moduleId)), playlist: playlist, group: .init(item.groupId), @@ -72,7 +73,11 @@ extension DiscoverFeature { ) ) ) + await send(.internal(.setPlaylistLoading(nil))) } + } catch: { error, send in + logger.error("failed to play last watched episode: \(error)") + await send(.internal(.setPlaylistLoading(nil))) } case let .view(.didTapRemovePlaylistHistory(repoId, moduleId, playlistId)): @@ -111,12 +116,38 @@ extension DiscoverFeature { } state.path.append(.viewMoreListing(.init(repoModuleId: id, listing: listing))) + + case .view(.onLastWatchedAppear): + return .run { send in + await send(.internal(.fetchLastWatchedListing)) + } + + case .view(.didHomeAppear): + return .run { send in + try await Task.sleep(nanoseconds: 50_000_000) + let items = try await databaseClient.fetch(Request.all).flatMap { $0.modules.map { $0.manifest } } + + for await history in playlistHistoryClient.observeAll() { + let grouped = Dictionary(grouping: history.sorted(by: { $0.dateWatched > $1.dateWatched }), by: { $0.moduleId }).sorted(by: { $0.key < $1.key }) + let listings = grouped.filter { (key, value) in items.contains(where: { $0.id.rawValue == key } ) }.map { (key, value) in + let manifest = items[id: Tagged(rawValue: key)] + return DiscoverFeature.Section.HistoryListings(id: Tagged(rawValue: key), history: value, title: manifest?.name, icon: manifest?.icon) + } + await send(.internal(.setHomeListings(.loaded(listings)))) + } + } + + case let .internal(.setHomeListings(listings)): + if var homeState = state.section.home { + homeState.listings = listings + state.section = .home(homeState) + } case let .internal(.selectedModule(selection)): if let selection { state.section = .module(.init(module: selection, listings: .pending)) } else { - state.section = .home() + state.section = .home(.init(listings: .pending)) } return state.fetchLatestListings(selection) @@ -154,15 +185,14 @@ extension DiscoverFeature { ) ) - case .internal(.onLastWatchedAppear): + case .internal(.fetchLastWatchedListing): guard let repoModule = state.section.module?.module.id else { break } return .run { send in - if let history = try? await playlistHistoryClient.fetchForModule(repoModule.repoId.absoluteString, repoModule.moduleId.rawValue) { - await send(.internal(.updateLastWatched(history))) - } + let history = try await playlistHistoryClient.fetchForModule(repoModule.repoId.absoluteString, repoModule.moduleId.rawValue) + await send(.internal(.updateLastWatched(history))) } case let .internal(.removeLastWatchedPlaylist(playlistId)): @@ -176,6 +206,9 @@ extension DiscoverFeature { case let .internal(.showCaptcha(html, hostname)): state.solveCaptcha = .solveCaptcha(.init(html: html, hostname: hostname)) + + case let .internal(.setPlaylistLoading(loadingState)): + state.playlistLoading = loadingState case .internal(.solveCaptcha): break @@ -184,7 +217,7 @@ extension DiscoverFeature { break case .delegate(.playbackDismissed): - return .send(.internal(.onLastWatchedAppear)) + return .send(.internal(.fetchLastWatchedListing)) case .delegate: break @@ -208,7 +241,7 @@ extension DiscoverFeature.State { @Dependency(\.moduleClient) var moduleClient guard let selectedModule else { - section = .home(.init()) + section = .home(.init(listings: .loading)) return .none } @@ -222,7 +255,7 @@ extension DiscoverFeature.State { try await module.discoverListings() } - await send(.internal(.onLastWatchedAppear)) + await send(.internal(.fetchLastWatchedListing)) await send(.internal(.loadedListings(id, .loaded(value)))) } diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index 5b0d510..0db1db3 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -33,13 +33,8 @@ extension DiscoverFeature.View: View { switch viewStore.state { case .empty: VStack {} - case .home: - // TODO: Create home listing - VStack { - Spacer() - Text("Coming soon!") - Spacer() - } + case let .home(homeState): + buildHomeView(homeState: homeState) case let .module(moduleState): buildModuleView(moduleState: moduleState) } @@ -164,6 +159,110 @@ extension DiscoverFeature.View: View { } } +extension DiscoverFeature.View { + @MainActor + func buildHomeView(homeState: DiscoverFeature.Section.HomeState) -> some View { + LoadableView(loadable: homeState.listings) { listings in + ScrollView(.vertical, showsIndicators: false) { + ForEach(listings, id: \.id) { listing in + Group { + if listing.history.isEmpty { + VStack(spacing: 12) { + Spacer() + Text(localizable: "Listings Empty") + .font(.title2.weight(.medium)) + Text(localizable: "There are no listings for this module") + Spacer() + } + .foregroundColor(.gray) + } else { + VStack(spacing: 24) { + Spacer() + .frame(height: 0) + .fixedSize(horizontal: false, vertical: true) + lastWatchedListing(listing) + } + } + } + .transition(.opacity) + } + } + } failedView: { _ in + VStack(spacing: 12) { + Spacer() + + Text(localizable: "Module Error") + .font(.title2.weight(.medium)) + Text(String(localizable: "There was an error retrieving content")) + Button { + store.send(.view(.didTapRetryLoadingModule)) + } label: { + Text(localizable: "Retry") + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.gray.opacity(0.25)) + } + } + .buttonStyle(.plain) + + Spacer() + } + .transition(.opacity) + } waitingView: { + let placeholders: [Playlist] = (0..<10).map { .placeholder($0) } + + buildListingsView( + [ + .init( + id: "0", + title: "Continue Watching", + type: .lastWatched, + paging: .init( + id: "demo-1", + items: placeholders + ) + ), + .init( + id: "1", + title: "Continue Watching", + type: .lastWatched, + paging: .init( + id: "demo-1", + items: placeholders + ) + ), + .init( + id: "2", + title: "Continue Watching", + type: .lastWatched, + paging: .init( + id: "demo-1", + items: placeholders + ) + ), + .init( + id: "3", + title: "Continue Watching", + type: .lastWatched, + paging: .init( + id: "demo-1", + items: placeholders + ) + ) + ] + ) + .shimmering() + .disabled(true) + .transition(.opacity) + .onAppear { + store.send(.view(.didHomeAppear)) + } + } + } +} + extension DiscoverFeature.View { @MainActor func buildModuleView(moduleState: DiscoverFeature.Section.ModuleListingState) -> some View { @@ -250,7 +349,6 @@ extension DiscoverFeature.View { ] ) .shimmering() - .disabled(true) .transition(.opacity) } } @@ -273,7 +371,7 @@ extension DiscoverFeature.View { case .featured: featuredListing(listing) case .lastWatched: - lastWatchedListing() + lastWatchedListing(nil) } } } @@ -283,12 +381,39 @@ extension DiscoverFeature.View { extension DiscoverFeature.View { @MainActor - func lastWatchedListing() -> some View { + func lastWatchedListing(_ listing: DiscoverFeature.Section.HistoryListings?) -> some View { LazyVStack(alignment: .leading) { HStack { - Text("Last Watched") - .font(.title3.weight(.semibold)) - + if let listing = listing { + HStack { + LazyImage(url: URL(string: listing.icon ?? "")) { state in + if let image = state.image { + image + .resizable() + .scaledToFit() + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + EmptyView() + } + } + .transition(.opacity) + + VStack(alignment: .leading) { + Text("Continue Watching") + .font(.title3.weight(.semibold)) + if let title = listing.title { + Text(title) + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + } else { + Text("Continue Watching") + .font(.title3.weight(.semibold)) + } + Spacer() // if listing.paging.nextPage != nil { @@ -308,45 +433,55 @@ extension DiscoverFeature.View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 12) { WithViewStore(store, observe: \.`self`) { viewStore in - ForEach(viewStore.lastWatched ?? [], id: \.self) { item in + ForEach(listing?.history ?? viewStore.lastWatched ?? [], id: \.self) { item in VStack(alignment: .leading, spacing: 8) { - ZStack(alignment: .bottom) { - FillAspectImage(url: item.thumbnail ?? URL(string: "")) - .aspectRatio(16 / 10, contentMode: .fit) - .overlay { - LinearGradient( - gradient: .init( - colors: [ - .black.opacity(0), - .black.opacity(0.8) - ], - easing: .easeIn - ), - startPoint: .top, - endPoint: .bottom - ) - } - - VStack(alignment: .leading, spacing: 5) { - Text(item.playlistName ?? "No Title") - .lineLimit(3) - .font(.subheadline.weight(.medium)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.white) - .padding(.horizontal) - - GeometryReader { proxy in - Color(.white) - .opacity(0.8) - .frame(maxWidth: proxy.size.width * item.timestamp) + ZStack { + ZStack(alignment: .bottom) { + FillAspectImage(url: item.thumbnail ?? URL(string: "")) + .aspectRatio(16 / 10, contentMode: .fit) + .overlay { + LinearGradient( + gradient: .init( + colors: [ + .black.opacity(0), + .black.opacity(0.8) + ], + easing: .easeIn + ), + startPoint: .top, + endPoint: .bottom + ) + } + + VStack(alignment: .leading, spacing: 5) { + Text(item.playlistName ?? "No Title") + .lineLimit(3) + .font(.subheadline.weight(.medium)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.white) + .padding(.horizontal) + + GeometryReader { proxy in + Color(.white) + .opacity(0.8) + .frame(maxWidth: proxy.size.width * item.timestamp) + } + .clipShape(Capsule(style: .continuous)) + .frame(maxWidth: .infinity) + .frame(height: 6) } - .clipShape(Capsule(style: .continuous)) - .frame(maxWidth: .infinity) - .frame(height: 6) + } + .blur(radius: viewStore.playlistLoading == item.playlistID ? 5 : 0) + .animation(.easeOut, value: viewStore.playlistLoading) + if (viewStore.playlistLoading == item.playlistID) { + ProgressView() + .controlSize(.large) + .tint(.white) + .frame(width: 50, height: 50) } } - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) .contextMenu { Button(role: .destructive) { viewStore.send(.view(.didTapRemovePlaylistHistory(item.repoId, item.moduleId, item.playlistID))) @@ -365,9 +500,11 @@ extension DiscoverFeature.View { .frame(width: 248) .contentShape(Rectangle()) .onTapGesture { - store.send(.view(.didTapContinueWatching(item))) + if (viewStore.playlistLoading == nil) { + store.send(.view(.didTapContinueWatching(item))) + } } - .animation(.easeInOut, value: viewStore.lastWatched) + .animation(.easeInOut, value: listing?.history) } } } @@ -375,6 +512,9 @@ extension DiscoverFeature.View { } .frame(maxWidth: .infinity) } + .onAppear { + store.send(.view(.onLastWatchedAppear)) + } } @MainActor diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index a470c34..811557a 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -20,6 +20,9 @@ import SharedModels import Styling import SwiftUI import ViewComponents +import Tagged +import OfflineManagerClient +import FileClient // MARK: - DiscoverFeature @@ -115,7 +118,7 @@ public struct DiscoverFeature: Feature { @CasePathable @dynamicMemberLookup public enum Section: Equatable, Sendable { - case home(HomeState = .init()) + case home(HomeState) case module(ModuleListingState) case empty @@ -140,9 +143,20 @@ public struct DiscoverFeature: Feature { moduleState.module.module.icon.flatMap { URL(string: $0) } } } + + public struct HistoryListings: Equatable, Sendable, Identifiable { + public let id: Module.ID + public let history: [PlaylistHistory] + public let title: String? + public let icon: String? + } public struct HomeState: Equatable, Sendable { - public init() {} + public var listings: Loadable<[HistoryListings]> + + init(listings: Loadable<[HistoryListings]>) { + self.listings = listings + } } public struct ModuleListingState: Equatable, Sendable { @@ -162,8 +176,9 @@ public struct DiscoverFeature: Feature { public struct State: FeatureState { public var section: Section public var path: StackState + public var playlistLoading = String?.none - @PresentationState public var lastWatched: [PlaylistHistory]? + public var lastWatched: [PlaylistHistory]? = [] @PresentationState public var moduleLists: ModuleListsFeature.State? @PresentationState public var solveCaptcha: DiscoverFeature.Captcha.State? @@ -171,14 +186,12 @@ public struct DiscoverFeature: Feature { section: DiscoverFeature.Section = .empty, path: StackState = .init(), moduleLists: ModuleListsFeature.State? = nil, - solveCaptcha: DiscoverFeature.Captcha.State? = nil, - lastWatched: [PlaylistHistory]? = [] + solveCaptcha: DiscoverFeature.Captcha.State? = nil ) { self.section = section self.path = path self.moduleLists = moduleLists self.solveCaptcha = solveCaptcha - self.lastWatched = lastWatched } } @@ -189,6 +202,7 @@ public struct DiscoverFeature: Feature { @dynamicMemberLookup public enum ViewAction: SendableAction { case didAppear + case didHomeAppear case didTapOpenModules case didTapContinueWatching(PlaylistHistory) case didTapRemovePlaylistHistory(String, String, String) @@ -196,6 +210,7 @@ public struct DiscoverFeature: Feature { case didTapSearchButton case didTapViewMoreListing(DiscoverListing.ID) case didTapRetryLoadingModule + case onLastWatchedAppear } @CasePathable @@ -223,7 +238,9 @@ public struct DiscoverFeature: Feature { case path(StackAction) case updateLastWatched([PlaylistHistory]) case removeLastWatchedPlaylist(String) - case onLastWatchedAppear + case setPlaylistLoading(String?) + case fetchLastWatchedListing + case setHomeListings(Loadable<[DiscoverFeature.Section.HistoryListings]>) } case view(ViewAction) @@ -234,7 +251,8 @@ public struct DiscoverFeature: Feature { @MainActor public struct View: FeatureView { public let store: StoreOf - + + @Dependency(\.fileClient) var fileClient @Dependency(\.localizableClient.localize) var localize @Environment(\.horizontalSizeClass) var horizontalSizeClass @@ -247,6 +265,8 @@ public struct DiscoverFeature: Feature { @Dependency(\.repoClient) var repoClient @Dependency(\.databaseClient) var databaseClient @Dependency(\.moduleClient) var moduleClient + @Dependency(\.fileClient) var fileClient + @Dependency(\.offlineManagerClient) var offlineManagerClient @Dependency(\.playlistHistoryClient) var playlistHistoryClient public init() {} diff --git a/Sources/Features/LIbrary/LibraryFeature+Reducer.swift b/Sources/Features/LIbrary/LibraryFeature+Reducer.swift new file mode 100644 index 0000000..bb1845e --- /dev/null +++ b/Sources/Features/LIbrary/LibraryFeature+Reducer.swift @@ -0,0 +1,120 @@ +// +// LibraryFeature+Reducer.swift +// +// +// Created by DeNeRr on 09.04.2024. +// + +import Architecture +import ComposableArchitecture +import Foundation +import SharedModels +import OfflineManagerClient +import FileClient + +// MARK: - LibraryFeature + Reducer + +extension LibraryFeature: Reducer { + public var body: some ReducerOf { + Scope(state: \.self, action: \.view) { + BindingReducer() + } + + Reduce { state, action in + switch action { + case .view(.didAppear): + return .run { send in + try fileClient.initializeLibrary() + await send(.internal(.observeDirectory(try fileClient.retrieveLibraryDirectory(root: .playlistCache)))) + } + + case let .view(.didTapPlaylist(fileMetadata)): + state.path.append(.playlistDetails(.init( + content: .init( + repoModuleId: .init(repoId: .init(rawValue: fileMetadata.repoModuleId.repoId), moduleId: .init(rawValue: fileMetadata.repoModuleId.moduleId)), + playlist: fileMetadata.playlist, + cachedGroups: fileMetadata.groups + ), + details: fileMetadata.details != nil ? .loaded(fileMetadata.details!) : .pending))) + + case let .view(.didTapRemoveBookmark(cache)): + return .run { _ in + try await offlineManagerClient.remove(.cache, cache.playlist.id.rawValue, nil); + } + + case let .view(.didTapRemovePlaylist(cache)): + return .run { _ in + try await offlineManagerClient.remove(.all, cache.playlist.id.rawValue, nil); + } + + case .view(.didtapOpenLibraryCollectionSheet): + break + + case .view(.didTapShowDownloadedOnly): + let lastOfflineOnlyState = !state.showOfflineOnly + state.showOfflineOnly = !state.showOfflineOnly + return .run { send in + await send(.internal(.observeDirectory(try fileClient.retrieveLibraryDirectory(root: lastOfflineOnlyState ? .downloaded : .playlistCache)))) + } + + + case .view(.binding(\.$searchValue)): + if let playlists = state.playlists.value { + state.searchedPlaylists = playlists.filter { $0.playlist.title?.lowercased().contains(state.searchValue.lowercased()) ?? false } + } + + case .view(.binding): + break + + case .view: + break + + case let .internal(.path(.element(_, .playlistDetails(.delegate(.playbackVideoItem(items, id, playlist, group, variant, paging, itemId)))))): + return .run { send in + await send( + .delegate( + .playbackVideoItem( + items, + repoModuleId: id, + playlist: playlist, + group: group, + variant: variant, + paging: paging, + itemId: itemId + ) + ) + ) + } + + case let .internal(.observeDirectory(directory)): + return .run { send in + for try await playlistIds in try fileClient.observeDirectory(directory) { + let playlists = try playlistIds.flatMap { + if let json = try fileClient.retrieveLibraryMetadata(root: .playlistCache, encodedPlaylist: $0) { + var cache: PlaylistCache = try JSONDecoder().decode(PlaylistCache.self, from: json) + if let image = fileClient.getLibraryPlaylistImage(playlist: cache.playlist.id.rawValue) { + cache.playlist.posterImage = image + cache.playlist.bannerImage = image + } + return cache + } + return nil + } + await send(.internal(.playlistsDidLoad(playlists))) + } + } + + case let .internal(.playlistsDidLoad(playlists)): + state.playlists = .loaded(playlists.sorted(by: { $0.playlist.title ?? "" < $1.playlist.title ?? "" })) + case .internal: + break + case .delegate: + break + } + return .none + } + .forEach(\.path, action: \.internal.path) { + Path() + } + } +} diff --git a/Sources/Features/LIbrary/LibraryFeature+View.swift b/Sources/Features/LIbrary/LibraryFeature+View.swift new file mode 100644 index 0000000..4807e2b --- /dev/null +++ b/Sources/Features/LIbrary/LibraryFeature+View.swift @@ -0,0 +1,144 @@ +// +// LibraryFeature+View.swift +// +// +// Created by DeNeRr on 09.04.2024. +// + +import Architecture +import ComposableArchitecture +import LocalizableClient +import Foundation +import SwiftUI +import ViewComponents +import Styling +import PlaylistDetails +import AVKit + +// MARK: - LibraryFeature + View + +extension LibraryFeature.View: View { + @MainActor public var body: some View { + NavStack( + store.scope( + state: \.path, + action: \.internal.path + ) + ) { + WithViewStore(store, observe: \.`self`) { viewStore in + LoadableView(loadable: viewStore.state.playlists) { playlists in + ScrollView(.vertical) { + LazyVGrid( + columns: [.init(.adaptive(minimum: 120), alignment: .top)], + alignment: .leading + ) { + ForEach(viewStore.searchValue.isEmpty ? playlists : viewStore.searchedPlaylists, id: \.playlist.id) { item in + VStack(alignment: .leading) { + FillAspectImage(url: item.playlist.posterImage ?? item.playlist.bannerImage ?? URL(string: "")!) + .aspectRatio(3 / 4, contentMode: .fit) + .cornerRadius(12) + .contextMenu { + Button(role: .destructive) { + viewStore.send(.didTapRemoveBookmark(item)) + } label: { + Label("Remove Bookmark", systemImage: "bookmark.slash") + } + } + Text(item.playlist.title ?? "") + .font(.footnote) + } + .contentShape(Rectangle()) + .onTapGesture { + viewStore.send(.didTapPlaylist(item)) + } + } + } + .animation(.easeInOut, value: viewStore.searchedPlaylists) + .animation(.easeInOut, value: playlists) +// .safeAreaInset(edge: .top) { +// ScrollView(.horizontal, showsIndicators: false) { +// WithViewStore(store, observe: \.showOfflineOnly) { viewStore in +// Button { +// viewStore.send(.didTapShowDownloadedOnly) +// } label: { +// Text("Downloaded") +// .font(.footnote) +// .foregroundStyle(viewStore.state ? Color.white : Theme.pastelRed) +// .padding(8) +// .background( +// Capsule() +// .style( +// withStroke: Color.gray.opacity(0.2), +// fill: viewStore.state ? Theme.pastelRed : buttonBackgroundColor +// ) +// ) +// } +// } +// } +// } + .padding(.horizontal) + } + .searchable(text: viewStore.$searchValue.removeDuplicates(), placement: .toolbar) + } + } + .toolbar { + ToolbarItem(placement: .navigation) { + Button { +// store.send(.view(.didtapOpenLibraryCollectionSheet)) + } label: { + HStack(alignment: .center, spacing: 8) { + Text(selectedDirectory ?? "Library") + +// Image(systemName: "chevron.down") +// .font(.caption.weight(.bold)) +// .foregroundColor(.gray) + } + #if os(iOS) + .font(.title.bold()) + #else + .font(.title3.bold()) + #endif + .contentShape(Rectangle()) + .scaleEffect(1.0) + .transition(.opacity) + } + #if os(macOS) + .buttonStyle(.bordered) + #else + .buttonStyle(.plain) + #endif + } + } + .navigationTitle("") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + } destination: { store in + SwitchStore(store) { state in + switch state { + case .playlistDetails: + CaseLet( + /LibraryFeature.Path.State.playlistDetails, + action: LibraryFeature.Path.Action.playlistDetails, + then: { store in PlaylistDetailsFeature.View(store: store) } + ) + } + } + } + .onAppear { + store.send(.view(.didAppear)) + } + } +} + +#Preview { + LibraryFeature.View( + store: .init( + initialState: .init( + path: .init(), + playlists: .loaded(.init()) + ), + reducer: { EmptyReducer() } + ) + ) +} diff --git a/Sources/Features/LIbrary/LibraryFeature.swift b/Sources/Features/LIbrary/LibraryFeature.swift new file mode 100644 index 0000000..4b3ba3b --- /dev/null +++ b/Sources/Features/LIbrary/LibraryFeature.swift @@ -0,0 +1,117 @@ +// +// LibraryFeature.swift +// +// +// Created by DeNeRr on 09.04.2024. +// + +import Architecture +import ComposableArchitecture +import Foundation +import FileClient +import SwiftUI +import ViewComponents +import PlaylistDetails +import SharedModels + + +// MARK: - LibraryFeature + +public struct LibraryFeature: Feature { + public struct Path: Reducer { + @CasePathable + @dynamicMemberLookup + public enum State: Equatable, Sendable { + case playlistDetails(PlaylistDetailsFeature.State) + } + + @CasePathable + @dynamicMemberLookup + public enum Action: Equatable, Sendable { + case playlistDetails(PlaylistDetailsFeature.Action) + } + + @ReducerBuilder public var body: some ReducerOf { + + Scope(state: \.playlistDetails, action: \.playlistDetails) { + PlaylistDetailsFeature() + } + } + } + + public struct State: FeatureState { + public var path: StackState + public var playlists: Loadable<[PlaylistCache]> + + public var searchedPlaylists: [PlaylistCache] = [] + @BindingState public var searchValue: String = "" + public var showOfflineOnly: Bool = false + + public init(path: StackState = .init(), playlists: Loadable<[PlaylistCache]> = .pending) { + self.path = path + self.playlists = playlists + } + } + + @CasePathable + @dynamicMemberLookup + public enum Action: FeatureAction { + @CasePathable + @dynamicMemberLookup + public enum ViewAction: SendableAction, BindableAction { + case didAppear + case didTapPlaylist(PlaylistCache) + case didtapOpenLibraryCollectionSheet + case didTapRemoveBookmark(PlaylistCache) + case didTapRemovePlaylist(PlaylistCache) + case didTapShowDownloadedOnly + + case binding(BindingAction) + } + + @CasePathable + @dynamicMemberLookup + public enum DelegateAction: SendableAction { + case playbackVideoItem( + Playlist.ItemsResponse, + repoModuleId: RepoModuleID, + playlist: Playlist, + group: Playlist.Group.ID, + variant: Playlist.Group.Variant.ID, + paging: PagingID, + itemId: Playlist.Item.ID + ) + } + + @CasePathable + @dynamicMemberLookup + public enum InternalAction: SendableAction { + case path(StackAction) + case playlistsDidLoad([PlaylistCache]) + case observeDirectory(URL) + } + + case view(ViewAction) + case delegate(DelegateAction) + case `internal`(InternalAction) + } + + @MainActor + public struct View: FeatureView { + public let store: StoreOf + @Environment(\.colorScheme) var scheme + var buttonBackgroundColor: Color { scheme == .dark ? .init(white: 0.2) : .init(white: 0.94) } + + @SwiftUI.State public var selectedDirectory: String? + + @MainActor + public init(store: StoreOf) { + self.store = store + } + } + + @Dependency(\.fileClient) var fileClient + @Dependency(\.offlineManagerClient) var offlineManagerClient + + public init() {} +} diff --git a/Sources/Features/Library/LibraryFeature+Reducer.swift b/Sources/Features/Library/LibraryFeature+Reducer.swift deleted file mode 100644 index 9295f2f..0000000 --- a/Sources/Features/Library/LibraryFeature+Reducer.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// LibraryFeature+Reducer.swift -// -// -// Created ErrorErrorError on 1/2/24. -// Copyright © 2024. All rights reserved. -// - -import Architecture -import ComposableArchitecture - -extension LibraryFeature: Reducer { - public var body: some ReducerOf { - Reduce { _, action in - switch action { - case .view: - break - - case .internal: - break - - case .delegate: - break - } - return .none - } - } -} diff --git a/Sources/Features/Library/LibraryFeature+View.swift b/Sources/Features/Library/LibraryFeature+View.swift deleted file mode 100644 index b99cf81..0000000 --- a/Sources/Features/Library/LibraryFeature+View.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// LibraryFeature+View.swift -// -// -// Created ErrorErrorError on 1/2/24. -// Copyright © 2024. All rights reserved. -// - -import Architecture -import ComposableArchitecture -import SwiftUI - -// MARK: - LibraryFeature.View + View - -extension LibraryFeature.View: View { - @MainActor public var body: some View { - WithViewStore(store, observe: \.`self`) { viewStore in - Text("Hello, World!") - .onAppear { - viewStore.send(.didAppear) - } - } - } -} - -// MARK: - LibraryFeatureView_Previews - -struct LibraryFeatureView_Previews: PreviewProvider { - static var previews: some View { - LibraryFeature.View( - store: .init( - initialState: .init(), - reducer: { LibraryFeature() } - ) - ) - } -} diff --git a/Sources/Features/Library/LibraryFeature.swift b/Sources/Features/Library/LibraryFeature.swift deleted file mode 100644 index 313e7c7..0000000 --- a/Sources/Features/Library/LibraryFeature.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// LibraryFeature.swift -// -// -// Created ErrorErrorError on 1/2/24. -// Copyright © 2024. All rights reserved. -// - -import Architecture -import ComposableArchitecture - -public enum LibraryFeature: Feature { - public struct State: FeatureState { - // TODO: Set state - - public init() {} - } - - @CasePathable - @dynamicMemberLookup - public enum Action: FeatureAction { - public enum ViewAction: SendableAction {} - public enum DelegateAction: SendableAction {} - public enum InternalAction: SendableAction {} - - case view(ViewAction) - case delegate(DelegateAction) - case `internal`(InternalAction) - } - - @MainActor - public struct View: FeatureView { - public let store: StoreOf - - public nonisolated init(store: StoreOf) { - self.store = store - } - } - - public init() {} -} diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift b/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift index c53e14a..9c87956 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift @@ -12,8 +12,6 @@ import DatabaseClient import Foundation import RepoClient -let defaults = UserDefaults.standard - extension ModuleListsFeature { public var body: some ReducerOf { Reduce { state, action in @@ -29,13 +27,19 @@ extension ModuleListsFeature { return .run { await dismiss() } + + case .view(.didTapHome): + UserDefaults.standard.set(nil, forKey: "LastSelectedModuleId") + UserDefaults.standard.set(nil, forKey: "LastSelectedRepoId") + return .concatenate(.send(.delegate(.selectedModule(nil)))) + case let .view(.didSelectModule(repoId, moduleId)): guard let module = state.repos[id: repoId]?.modules[id: moduleId]?.manifest else { break } - defaults.set(moduleId.rawValue, forKey: "LastSelectedModuleId") - defaults.set(repoId.rawValue, forKey: "LastSelectedRepoId") + UserDefaults.standard.set(moduleId.rawValue, forKey: "LastSelectedModuleId") + UserDefaults.standard.set(repoId.rawValue, forKey: "LastSelectedRepoId") return .concatenate(.send(.delegate(.selectedModule(.init(repoId: repoId, module: module))))) case let .internal(.fetchRepos(.success(repos))): diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift index 1464b08..46cf78d 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift @@ -48,6 +48,12 @@ extension ModuleListsFeature.View: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal) + Button { + store.send(.view(.didTapHome)) + } label: { + Image(systemName: "house") + } + .buttonStyle(.materialToolbarItem) Button { store.send(.view(.didTapToDismiss)) } label: { diff --git a/Sources/Features/ModuleLists/ModuleListsFeature.swift b/Sources/Features/ModuleLists/ModuleListsFeature.swift index e089e3a..6b12f8a 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature.swift @@ -46,6 +46,7 @@ public struct ModuleListsFeature: Feature { public enum ViewAction: SendableAction { case onTask case didTapToDismiss + case didTapHome case didSelectModule(Repo.ID, Module.ID) } diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift index d1d05fc..b6014c2 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift @@ -37,7 +37,32 @@ extension PlaylistDetailsFeature { Reduce { state, action in switch action { case .view(.onTask): - return state.fetchPlaylistDetails() + @Dependency(\.fileClient) var fileClient + + let playlistId = state.playlist.id.rawValue + let cacheDirectory = try? fileClient.retrieveLibraryDirectory(root: .playlistCache) + let downloadsDirectory = try? fileClient.retrieveLibraryDirectory(root: .downloaded) + return .merge( + state.fetchPlaylistDetails(), + .run { send in + if let cacheDirectory = cacheDirectory, fileClient.fileExists(cacheDirectory.path) { + for await _ in try fileClient.observeDirectory(cacheDirectory) { + let dir = try fileClient.retrieveLibraryDirectory(root: .playlistCache, playlist: playlistId) + await send(.internal(.setBookmark(FileManager.default.fileExists(atPath: dir.path)))) + } + } + }, + .run { send in + if let downloadsDirectory = downloadsDirectory, fileClient.fileExists(downloadsDirectory.path) { + for await _ in try fileClient.observeDirectory(downloadsDirectory) { + if let dir = try? fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: playlistId) { + let isEmpty = (try? FileManager.default.contentsOfDirectory(atPath: dir.path).isEmpty) ?? true + await send(.internal(.setHasDownloadedContent(!isEmpty))) + } + } + } + } + ) case .view(.didTappedBackButton): return .run { await self.dismiss() } @@ -52,6 +77,31 @@ extension PlaylistDetailsFeature { description: state.details.value?.synopsis ?? "No Description Available" ) ) + + case .view(.didTapAddToLibrary): + let playlist = state.playlist + if (state.isInLibrary) { + return .run { + try await offlineManagerClient.remove(.cache, playlist.id.rawValue, nil) + } + } + let repoModuleId = state.content.repoModuleId + let details = state.details.value + let groups = state.content.groups.value + return .run { + try await offlineManagerClient.cache(.init( + groups: groups, + playlist: playlist, + details: details, + repoModuleId: repoModuleId + )) + } + + case .view(.didTapRemoveDownloads): + let playlist = state.playlist + return .run { + try await offlineManagerClient.remove(.download, playlist.id.rawValue, nil) + } case .view(.binding): break @@ -61,6 +111,28 @@ extension PlaylistDetailsFeature { case let .internal(.playlistDetailsResponse(loadable)): state.details = loadable + + case let .internal(.content(.downloadSelection(.presented(.selection(.download(source, server, link, subtitles, skipTimes, episodeId)))))): + let playlist = state.playlist + let details = state.details.value + let groups = state.content.groups.value + let repoModuleId = state.content.repoModuleId + return .run { send in + try await offlineManagerClient.download(.init( + episodeMetadata: .init(link: link, source: source, subtitles: subtitles, server: server, skipTimes: skipTimes), + episodeId: episodeId, + groups: groups, + playlist: playlist, + details: details, + repoModuleId: repoModuleId + )) + } + + case let .internal(.setBookmark(bookmarked)): + state.isInLibrary = bookmarked + + case let .internal(.setHasDownloadedContent(isDownloaded)): + state.hasDownloadedContent = isDownloaded case let .internal(.content(.didTapPlaylistItem(groupId, variantId, pageId, itemId, _))): guard state.content.groups.value != nil else { diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift index 12ea655..3d9295c 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift @@ -17,6 +17,7 @@ import SharedModels import Styling import SwiftUI import ViewComponents +import OfflineManagerClient public struct PlaylistDetailsFeature: Feature { public struct Destination: ComposableArchitecture.Reducer { @@ -58,6 +59,8 @@ public struct PlaylistDetailsFeature: Feature { public var content: ContentCore.State public var playlist: Playlist { content.playlist } public var details: Loadable + public var isInLibrary: Bool = false + public var hasDownloadedContent: Bool = false @PresentationState public var destination: Destination.State? @@ -68,14 +71,13 @@ public struct PlaylistDetailsFeature: Feature { } public var resumableState: Resumable { - // TODO: Show start based on last resumed or selected content? if playlist.status == .upcoming { return .upcoming } if let group = content.groups.value?.first(where: { $0.default ?? false }) ?? content.groups.value?.first, let variant = group.variants.value?.first { if let epId = playlistHistory.value?.epId { - if let page = variant.pagings.value?.first(where: { $0.items.value!.contains(where: { $0.id.rawValue == epId }) }), + if let page = variant.pagings.value?.first(where: { ($0.items.value ?? []).contains(where: { $0.id.rawValue == epId }) }), let item = page.items.value?.first(where: { $0.id.rawValue == epId }) { return .resume(group.id, variant.id, page.id, item.id, item.title ?? "", playlistHistory.value?.timestamp ?? 0.0) } @@ -159,6 +161,8 @@ public struct PlaylistDetailsFeature: Feature { case didTapToRetryDetails case didTapOnReadMore case binding(BindingAction) + case didTapAddToLibrary + case didTapRemoveDownloads } @CasePathable @@ -179,6 +183,8 @@ public struct PlaylistDetailsFeature: Feature { case playlistDetailsResponse(Loadable) case content(ContentCore.Action) case destination(PresentationAction) + case setBookmark(Bool) + case setHasDownloadedContent(Bool) } case view(ViewAction) @@ -200,6 +206,8 @@ public struct PlaylistDetailsFeature: Feature { } } + @Dependency(\.offlineManagerClient) var offlineManagerClient + @Dependency(\.fileClient) var fileClient @Dependency(\.moduleClient) var moduleClient @Dependency(\.databaseClient) var databaseClient @Dependency(\.repoClient) var repoClient diff --git a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift index 88e6c0b..e8b6844 100644 --- a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift +++ b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift @@ -83,11 +83,15 @@ extension PlaylistDetailsFeature.View: View { .navigationBarTitle("", displayMode: .inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button {} label: { - Image(systemName: "plus") + WithViewStore(store, observe: \.isInLibrary) { viewStore in + Button { + viewStore.send(.didTapAddToLibrary) + } label: { + Image(systemName: viewStore.state ? "bookmark.fill" : "plus") + } + .animation(.spring, value: viewStore.state) + .buttonStyle(.materialToolbarItem) } - .buttonStyle(.materialToolbarItem) - .disabled(true) } ToolbarItem(placement: .topBarTrailing) { @@ -100,6 +104,16 @@ extension PlaylistDetailsFeature.View: View { Text("Open Playlist URL") } } + WithViewStore(store, observe: \.hasDownloadedContent) { viewStore in + if (viewStore.state) { + Button(role: .destructive) { + viewStore.send(.didTapRemoveDownloads) + } label: { + Image(systemName: "trash.fill") + Text("Remove Downloaded Content") + } + } + } } label: { Image(systemName: "ellipsis") .materialToolbarItemStyle() diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index cb2563a..d1ee62b 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -14,6 +14,7 @@ import ModuleClient import PlayerClient import PlaylistHistoryClient import SharedModels +import FileClient // MARK: - Cancellables @@ -411,14 +412,24 @@ extension VideoPlayerFeature.State { public mutating func fetchSourcesIfNecessary(forced: Bool = false) -> Effect { @Dependency(\.moduleClient) var moduleClient + @Dependency(\.fileClient) var fileClient let repoModuleId = content.repoModuleId let playlist = playlist let episodeId = selected.itemId - + let prefersOffline = prefersOffline + if forced || !loadables[episodeId: episodeId].hasInitialized { loadables.update(with: episodeId, response: .loading) return .run { send in + if (prefersOffline) { + if let directory = try? fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: playlist.id.rawValue, episode: episodeId.rawValue).appendingPathComponent("metadata.json") { + if let sources = try? JSONDecoder().decode(EpisodeMetadata.self, from: FileManager.default.contents(atPath: directory.path ?? "") ?? .init()) { + return await send(.internal(.sourcesResponse(episodeId, .loaded([sources.source])))) + } + } + } + try await withTaskCancellation(id: Cancellables.fetchingSources, cancelInFlight: true) { let value = try await moduleClient.withModule(id: repoModuleId) { module in try await module.playlistEpisodeSources( @@ -441,12 +452,14 @@ extension VideoPlayerFeature.State { public mutating func fetchServerIfNecessary(forced: Bool = false) -> Effect { @Dependency(\.moduleClient) var moduleClient + @Dependency(\.fileClient) var fileClient let repoModuleId = content.repoModuleId let playlist = playlist let episodeId = selected.itemId let sourceId = selected.sourceId let serverId = selected.serverId + let prefersOffline = prefersOffline guard let sourceId else { return .none @@ -458,6 +471,18 @@ extension VideoPlayerFeature.State { if forced || !loadables[serverId: serverId].hasInitialized { loadables.update(with: serverId, response: .loading) return .run { send in + if (prefersOffline) { + if let directory = try? fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: playlist.id.rawValue, episode: episodeId.rawValue) { + if let sources = try? JSONDecoder().decode(EpisodeMetadata.self, from: FileManager.default.contents(atPath: directory.appendingPathComponent("metadata.json").path) ?? .init()) { + let linkPath = directory.appendingPathComponent("data").appendingPathExtension("movpkg") + return await send(.internal(.serverResponse(serverId, .loaded(.init(links: [.init(url: linkPath, quality: sources.link.quality, format: sources.link.format)], subtitles: sources.subtitles.map{ + var newValue = $0 + newValue.url = directory.appendingPathComponent(newValue.url.lastPathComponent) + return newValue + }, headers: [:], skipTimes: sources.skipTimes))))) + } + } + } try await withTaskCancellation(id: Cancellables.fetchingServer, cancelInFlight: true) { let value = try await moduleClient.withModule(id: repoModuleId) { module in try await module.playlistEpisodeServer( diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift index e54994a..e4eabfe 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift @@ -67,6 +67,7 @@ public struct VideoPlayerFeature: Feature { public var overlay: Overlay? public var player: PlayerClient.Status public var playerSettings: PlayerSettings + public var prefersOffline: Bool public init( repoModuleId: RepoModuleID, @@ -77,7 +78,8 @@ public struct VideoPlayerFeature: Feature { page: PagingID, episodeId: Playlist.Item.ID, overlay: Overlay? = .tools, - playerSettings: PlayerSettings = .init() + playerSettings: PlayerSettings = .init(), + prefersOffline: Bool? = false ) { @Dependency(\.playerClient.get) var status @@ -91,7 +93,8 @@ public struct VideoPlayerFeature: Feature { episodeId: episodeId, overlay: overlay, player: status(), - playerSettings: playerSettings + playerSettings: playerSettings, + prefersOffline: prefersOffline ?? false ) } @@ -105,12 +108,23 @@ public struct VideoPlayerFeature: Feature { episodeId: Playlist.Item.ID, overlay: Overlay? = .tools, player: PlayerClient.Status, - playerSettings: PlayerSettings = .init() + playerSettings: PlayerSettings = .init(), + prefersOffline: Bool ) { - self.content = .init( - repoModuleId: repoModuleId, - playlist: playlist - ) + if (prefersOffline) { + @Dependency(\.fileClient) var fileClient + let metadata = try? JSONDecoder().decode(PlaylistCache.self, from: try fileClient.retrieveLibraryMetadata(root: .playlistCache, playlist: playlist.id.rawValue) ?? .init()) + self.content = .init( + repoModuleId: repoModuleId, + playlist: playlist, + cachedGroups: metadata?.groups + ) + } else { + self.content = .init( + repoModuleId: repoModuleId, + playlist: playlist + ) + } self.loadables = loadables self.selected = .init( groupId: group, @@ -121,6 +135,7 @@ public struct VideoPlayerFeature: Feature { self.overlay = overlay self.player = player self.playerSettings = playerSettings + self.prefersOffline = prefersOffline } } diff --git a/Sources/Shared/SharedModels/EpisodeMetadata.swift b/Sources/Shared/SharedModels/EpisodeMetadata.swift new file mode 100644 index 0000000..0b49fdd --- /dev/null +++ b/Sources/Shared/SharedModels/EpisodeMetadata.swift @@ -0,0 +1,28 @@ +// +// EpisodeMetadata.swift +// +// +// Created by DeNeRr on 20.04.2024. +// + +import Foundation + +public struct EpisodeMetadata: Codable, Equatable, Sendable, Hashable { + public static func == (lhs: EpisodeMetadata, rhs: EpisodeMetadata) -> Bool { + lhs.link.id != rhs.link.id + } + + public let link: Playlist.EpisodeServer.Link + public let source: Playlist.EpisodeSource + public let server: Playlist.EpisodeServer + public var subtitles: [Playlist.EpisodeServer.Subtitle] + public let skipTimes: [Playlist.EpisodeServer.SkipTime] + + public init(link: Playlist.EpisodeServer.Link, source: Playlist.EpisodeSource, subtitles: [Playlist.EpisodeServer.Subtitle], server: Playlist.EpisodeServer, skipTimes: [Playlist.EpisodeServer.SkipTime]) { + self.link = link + self.source = source + self.server = server + self.subtitles = subtitles + self.skipTimes = skipTimes + } +} diff --git a/Sources/Shared/SharedModels/LibraryDirectory.swift b/Sources/Shared/SharedModels/LibraryDirectory.swift new file mode 100644 index 0000000..fb61793 --- /dev/null +++ b/Sources/Shared/SharedModels/LibraryDirectory.swift @@ -0,0 +1,13 @@ +// +// LibraryDirectory.swift +// +// +// Created by DeNeRr on 24.04.2024. +// + +import Foundation + +public enum LibraryDirectory: String, CaseIterable { + case playlistCache = "PlaylistCache" + case downloaded = "Downloaded" +} diff --git a/Sources/Shared/SharedModels/Playlist.swift b/Sources/Shared/SharedModels/Playlist.swift index 67852e4..6e5dc35 100644 --- a/Sources/Shared/SharedModels/Playlist.swift +++ b/Sources/Shared/SharedModels/Playlist.swift @@ -16,8 +16,8 @@ import Tagged public struct Playlist: Sendable, Identifiable, Hashable, Codable { public let id: Tagged public let title: String? - public let posterImage: URL? - public let bannerImage: URL? + public var posterImage: URL? + public var bannerImage: URL? public let url: URL public let status: Status public let type: PlaylistType @@ -209,7 +209,7 @@ extension Playlist { public typealias ItemsResponse = [Playlist.Group] - public struct Group: Sendable, Equatable, Identifiable, Decodable { + public struct Group: Sendable, Equatable, Identifiable, Codable { public let id: Tagged public let number: Double public let altTitle: String? @@ -232,7 +232,7 @@ extension Playlist { self.default = `default` } - public struct Variant: Sendable, Equatable, Identifiable, Decodable { + public struct Variant: Sendable, Equatable, Identifiable, Codable { public let id: Tagged public let title: String public let pagings: Loadable diff --git a/Sources/Shared/SharedModels/PlaylistCache.swift b/Sources/Shared/SharedModels/PlaylistCache.swift new file mode 100644 index 0000000..6a37dd7 --- /dev/null +++ b/Sources/Shared/SharedModels/PlaylistCache.swift @@ -0,0 +1,36 @@ +// +// PlaylistCache.swift +// +// +// Created by DeNeRr on 17.04.2024. +// + +import Foundation + +public struct RepoModuleId: Codable, Sendable { + public let repoId: URL + public let moduleId: String + + public init(repoId: URL, moduleId: String) { + self.repoId = repoId + self.moduleId = moduleId + } +} + +public struct PlaylistCache: Codable, Equatable, Sendable { + public static func == (lhs: PlaylistCache, rhs: PlaylistCache) -> Bool { + lhs.playlist != rhs.playlist || lhs.details != rhs.details || lhs.repoModuleId.moduleId != rhs.repoModuleId.moduleId || lhs.repoModuleId.repoId != rhs.repoModuleId.repoId || lhs.groups != rhs.groups + } + + public var playlist: Playlist + public var details: Playlist.Details? + public var repoModuleId: RepoModuleId + public var groups: [Playlist.Group]? + + public init(playlist: Playlist, groups: [Playlist.Group]?, details: Playlist.Details?, repoModuleId: RepoModuleID) { + self.playlist = playlist + self.groups = groups + self.details = details + self.repoModuleId = .init(repoId: repoModuleId.repoId.rawValue, moduleId: repoModuleId.moduleId.rawValue) + } +} diff --git a/Sources/Shared/SharedModels/Video.swift b/Sources/Shared/SharedModels/Video.swift index cc31e16..58cb711 100644 --- a/Sources/Shared/SharedModels/Video.swift +++ b/Sources/Shared/SharedModels/Video.swift @@ -42,7 +42,7 @@ extension Playlist { } } - public struct EpisodeSource: Sendable, Equatable, Identifiable, Decodable { + public struct EpisodeSource: Sendable, Equatable, Identifiable, Codable, Hashable { public let id: Tagged public let displayName: String public let description: String? @@ -61,7 +61,7 @@ extension Playlist { } } - public struct EpisodeServer: Sendable, Equatable, Identifiable, Decodable { + public struct EpisodeServer: Sendable, Equatable, Identifiable, Codable, Hashable { public let id: Tagged public let displayName: String public let description: String? @@ -76,7 +76,7 @@ extension Playlist { self.description = description } - public struct Link: Sendable, Equatable, Identifiable, Decodable { + public struct Link: Sendable, Equatable, Identifiable, Codable, Hashable { public var id: Tagged { .init(url) } public let url: URL public let quality: Quality @@ -92,7 +92,7 @@ extension Playlist { self.format = format } - public enum Quality: Int, Sendable, Equatable, CustomStringConvertible, Decodable { + public enum Quality: Int, Sendable, Equatable, CustomStringConvertible, Codable { case auto case q360 case q480 @@ -115,15 +115,15 @@ extension Playlist { } } - public enum Format: Int, Equatable, Sendable, Decodable { + public enum Format: Int, Equatable, Sendable, Codable { case hls case dash } } - public struct Subtitle: Sendable, Equatable, Identifiable, Decodable { + public struct Subtitle: Sendable, Equatable, Identifiable, Codable, Hashable { public var id: Tagged { .init(url) } - public let url: URL + public var url: URL public let name: String public let format: Format public let `default`: Bool @@ -143,14 +143,14 @@ extension Playlist { self.autoselect = autoselect } - public enum Format: Int32, Sendable, Equatable, Decodable { + public enum Format: Int32, Sendable, Equatable, Codable { case vtt case ass case srt } } - public struct SkipTime: Hashable, Sendable, Decodable { + public struct SkipTime: Hashable, Sendable, Codable { public let startTime: Double public let endTime: Double public let type: SkipType @@ -165,7 +165,7 @@ extension Playlist { self.type = type } - public enum SkipType: Int32, Equatable, Sendable, CustomStringConvertible, Decodable { + public enum SkipType: Int32, Equatable, Sendable, CustomStringConvertible, Codable { case opening case ending case recap From 1a44cdcc2e1af87bb82a699d0b5a81564f8b9365 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 5 May 2024 23:14:46 +0200 Subject: [PATCH 16/45] feat: update local cache of bookmarked playlists when connected to the internet. --- Sources/Features/ContentCore/ContentCore.swift | 9 +++++++-- .../PlaylistDetailsFeature+Reducer.swift | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index 6dd71bc..2a94b66 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -74,6 +74,7 @@ public struct ContentCore: Reducer { case didTapDownloadPlaylist(Playlist.Item.ID) case setDownloadedEpisodes([String]) case downloadSelection(PresentationAction) + case updateCache([Playlist.Group]) } public enum Error: Swift.Error, Equatable, Sendable { @@ -151,6 +152,9 @@ public struct ContentCore: Reducer { case let .update(option, response): state.update(option, response) + + case .updateCache: + break case .downloadSelection: break @@ -206,8 +210,9 @@ extension ContentCore.State { try await withTaskCancellation(id: Cancellable.fetchContent, cancelInFlight: true) { let module = try await moduleClient.getModule(repoModuleId) do { - - await send(.update(option: option, .loaded(try await module.playlistEpisodes(playlistId, option)))) + let newGroups = try await module.playlistEpisodes(playlistId, option) + await send(.updateCache(newGroups)) + await send(.update(option: option, .loaded(newGroups))) } catch let error { await send(.update(option: option, cachedGroups != nil ? .loaded(cachedGroups!) : .failed(error))) } diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift index b6014c2..fb5cbf8 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift @@ -133,6 +133,18 @@ extension PlaylistDetailsFeature { case let .internal(.setHasDownloadedContent(isDownloaded)): state.hasDownloadedContent = isDownloaded + + case let .internal(.content(.updateCache(newCache))): + if (!state.isInLibrary) { + break + } + let playlist = state.playlist + let details = state.details.value + let repoModuleId = state.content.repoModuleId + return .run { send in + try await offlineManagerClient.cache(.init(groups: newCache, playlist: playlist, details: details, repoModuleId: repoModuleId)) + } + case let .internal(.content(.didTapPlaylistItem(groupId, variantId, pageId, itemId, _))): guard state.content.groups.value != nil else { From ea3b00fe452bd004bcade7cbf176cb74bd20084c Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sat, 18 May 2024 23:59:13 +0200 Subject: [PATCH 17/45] feat: Added basic hls downloader(WIP), downloading queue screen, show downloaded only filter to library. --- Package.swift | 35 + .../Clients/OfflineManagerClient.swift | 1 + Package/Sources/Dependencies/FlyingFox.swift | 14 + Package/Sources/Features/DownloadQueue.swift | 19 + Package/Sources/Features/Library.swift | 1 + Sources/Clients/FileClient/Client+.swift | 10 +- .../Clients/OfflineManagerClient/Client.swift | 6 +- .../Clients/OfflineManagerClient/Live.swift | 706 +++++------------- .../Clients/OfflineManagerClient/Models.swift | 50 +- .../ContentCore/ContentCore+View.swift | 74 +- .../Features/ContentCore/ContentCore.swift | 35 +- .../ContentCore/DownloadSelection.swift | 6 +- .../DownloadQueueFeature+Reducer.swift | 34 + .../DownloadQueueFeature+View.swift | 85 +++ .../DownloadQueue/DownloadQueueFeature.swift | 63 ++ .../LIbrary/LibraryFeature+Reducer.swift | 3 + .../LIbrary/LibraryFeature+View.swift | 57 +- Sources/Features/LIbrary/LibraryFeature.swift | 8 +- .../PlaylistDetailsFeature+Reducer.swift | 4 +- .../VideoPlayer/VideoPlayerFeature+View.swift | 3 +- 20 files changed, 613 insertions(+), 601 deletions(-) create mode 100644 Package/Sources/Dependencies/FlyingFox.swift create mode 100644 Package/Sources/Features/DownloadQueue.swift create mode 100644 Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift create mode 100644 Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift create mode 100644 Sources/Features/DownloadQueue/DownloadQueueFeature.swift diff --git a/Package.swift b/Package.swift index 39eba27..8d290db 100644 --- a/Package.swift +++ b/Package.swift @@ -874,6 +874,7 @@ struct OfflineManagerClient: _Client { FileClient() SharedModels() ComposableArchitecture() + FlyingFox() } } // @@ -1031,6 +1032,20 @@ struct FluidGradient: PackageDependency { } } // +// FlyingFox.swift +// +// +// Created by DeNeRr on 09.05.2024. +// + +import Foundation + +struct FlyingFox: PackageDependency { + var dependency: Package.Dependency { + .package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.14.0")) + } +} +// // Nuke.swift // // @@ -1271,6 +1286,25 @@ struct Discover: _Feature { } } // +// DownloadQueue.swift +// +// +// Created by DeNeRr on 16.05.2024. +// + +import Foundation + +struct DownloadQueue: _Feature { + var dependencies: any Dependencies { + Architecture() + FileClient() + ViewComponents() + ComposableArchitecture() + OfflineManagerClient() + Styling() + } +} +// // Library.swift // // @@ -1288,6 +1322,7 @@ struct Library: _Feature { OfflineManagerClient() Styling() PlaylistDetails() + DownloadQueue() NukeUI() SharedModels() } diff --git a/Package/Sources/Clients/OfflineManagerClient.swift b/Package/Sources/Clients/OfflineManagerClient.swift index 30ab965..1235115 100644 --- a/Package/Sources/Clients/OfflineManagerClient.swift +++ b/Package/Sources/Clients/OfflineManagerClient.swift @@ -12,5 +12,6 @@ struct OfflineManagerClient: _Client { FileClient() SharedModels() ComposableArchitecture() + FlyingFox() } } diff --git a/Package/Sources/Dependencies/FlyingFox.swift b/Package/Sources/Dependencies/FlyingFox.swift new file mode 100644 index 0000000..44ff497 --- /dev/null +++ b/Package/Sources/Dependencies/FlyingFox.swift @@ -0,0 +1,14 @@ +// +// FlyingFox.swift +// +// +// Created by DeNeRr on 09.05.2024. +// + +import Foundation + +struct FlyingFox: PackageDependency { + var dependency: Package.Dependency { + .package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.14.0")) + } +} diff --git a/Package/Sources/Features/DownloadQueue.swift b/Package/Sources/Features/DownloadQueue.swift new file mode 100644 index 0000000..d50fd2e --- /dev/null +++ b/Package/Sources/Features/DownloadQueue.swift @@ -0,0 +1,19 @@ +// +// DownloadQueue.swift +// +// +// Created by DeNeRr on 16.05.2024. +// + +import Foundation + +struct DownloadQueue: _Feature { + var dependencies: any Dependencies { + Architecture() + FileClient() + ViewComponents() + ComposableArchitecture() + OfflineManagerClient() + Styling() + } +} diff --git a/Package/Sources/Features/Library.swift b/Package/Sources/Features/Library.swift index 02cfa7b..142d521 100644 --- a/Package/Sources/Features/Library.swift +++ b/Package/Sources/Features/Library.swift @@ -16,6 +16,7 @@ struct Library: _Feature { OfflineManagerClient() Styling() PlaylistDetails() + DownloadQueue() NukeUI() SharedModels() } diff --git a/Sources/Clients/FileClient/Client+.swift b/Sources/Clients/FileClient/Client+.swift index 38dbfde..de22a1e 100644 --- a/Sources/Clients/FileClient/Client+.swift +++ b/Sources/Clients/FileClient/Client+.swift @@ -66,6 +66,10 @@ extension FileClient { } } + public func retrieveLibraryDirectory() throws -> URL { + return try self.url(.documentDirectory, .userDomainMask, nil, false) + .LibraryDir() + } public func retrieveLibraryDirectory(root: LibraryDirectory, playlist: String? = nil, episode: String? = nil) throws -> URL { var url = try self.url(.documentDirectory, .userDomainMask, nil, false) .LibraryDir() @@ -80,11 +84,15 @@ extension FileClient { } public func removePlaylistFromLibrary(_ root: LibraryDirectory, _ playlist: String, _ episode: String? = nil) throws { - let url = try self.url(.documentDirectory, .userDomainMask, nil, false) + var url = try self.url(.documentDirectory, .userDomainMask, nil, false) .LibraryDir() .appendingPathComponent(root.rawValue) .appendingPathComponent(playlist.sanitized) + if let episode = episode { + url = url.appendingPathComponent(episode.sanitized) + } + if (fileExists(url.path)) { try remove(url) } diff --git a/Sources/Clients/OfflineManagerClient/Client.swift b/Sources/Clients/OfflineManagerClient/Client.swift index 51b9e0d..4dd6425 100644 --- a/Sources/Clients/OfflineManagerClient/Client.swift +++ b/Sources/Clients/OfflineManagerClient/Client.swift @@ -19,6 +19,8 @@ public struct OfflineManagerClient { public var download: @Sendable (DownloadAsset) async throws -> Void public var cache: @Sendable (CacheAsset) async throws -> Void public var remove: @Sendable (RemoveType, String, String?) async throws -> Void + public var togglePause: @Sendable (Int) async throws -> Void + public var observeDownloading: @Sendable () -> AsyncStream<[DownloadingItem]> } // MARK: TestDependencyKey @@ -27,7 +29,9 @@ extension OfflineManagerClient: TestDependencyKey { public static let testValue = Self( download: unimplemented("\(Self.self).download"), cache: unimplemented("\(Self.self).cache"), - remove: unimplemented("\(Self.self).remove") + remove: unimplemented("\(Self.self).remove"), + togglePause: unimplemented("\(Self.self).togglePause"), + observeDownloading: unimplemented("\(Self.self).observeDownloading") ) } diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift index 524a54d..9885af2 100644 --- a/Sources/Clients/OfflineManagerClient/Live.swift +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -5,365 +5,6 @@ // Created by DeNeRr on 06.04.2024. // -//import Dependencies -//import Foundation -//import FileClient -//import AVFoundation -//import UIKit -//import SharedModels -//import DatabaseClient -//import OrderedCollections -// -//// MARK: - OfflineManagerClient + DependencyKey -// -//extension OfflineManagerClient: DependencyKey { -// private static let downloadManager = OfflineDownloadManager() -// -// public static let liveValue = Self( -// download: { asset in -// try? await downloadManager.setupAssetDownload(asset) -// } -// ) -//} -// -//// MARK: - OfflineDownloadManager -// -//private class OfflineDownloadManager: NSObject { -// enum Error: Swift.Error { -// case M3U8Invalid -// case VTTInvalid -// } -// -// private var config: URLSessionConfiguration! -// private var downloadSession: AVAssetDownloadURLSession! -// private var downloadQueue: Set = [] -// -// @Dependency(\.fileClient) var fileClient -// -// override init() { -// super.init() -// config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background") -// downloadSession = AVAssetDownloadURLSession(configuration: config, assetDownloadDelegate: self, delegateQueue: OperationQueue.main) -// } -// -// private static let hlsSubtitlesScheme = "mochi-hls-subtitles" -// private static let hlsSubtitleGroupID = "mochi-sub" -// -// private func convertMainPlaylistToMultivariant(_ url: URL, _ subtitles: [Playlist.EpisodeServer.Subtitle]) -> String { -// // Build a multivariant playlist out of a single main playlist -// let subtitlesMediaStrings = subtitles.enumerated() -// .map(makeSubtitleTypes) -// -// return """ -// #EXTM3U -// \(subtitlesMediaStrings.joined(separator: "\n")) -// #EXT-X-STREAM-INF:BANDWIDTH=6400000,CODECS="mp4a.40.2,avc1.4d401e",SUBTITLES="\(Self.hlsSubtitleGroupID)" -// \(url.absoluteString) -// """ -// } -// -// private func makeSubtitleTypes(_ idx: Int, _ subtitle: Playlist.EpisodeServer.Subtitle) -> String { -// "#EXT-X-MEDIA:" + ( -// [ -// "TYPE": "SUBTITLES", -// "GROUP-ID": "\"\(Self.hlsSubtitleGroupID)\"", -// "NAME": "\"\(subtitle.name)\"", -// "CHARACTERISTICS": "\"public.accessibility.transcribes-spoken-dialog\"", -// "DEFAULT": subtitle.default ? "YES" : "NO", -// "AUTOSELECT": subtitle.autoselect ? "YES" : "NO", -// "FORCED": "NO", -// "URI": "\"\(subtitle.url.absoluteString)\"", -// "LANGUAGE": "\"\(subtitle.name)\"" -// ] as OrderedDictionary -// ) -// .map { "\($0.key)=\($0.value)" } -// .joined(separator: ",") -// } -// -// private func parseMainMultiVariantPlaylist(_ m3u8String: String, _ subtitles: [Playlist.EpisodeServer.Subtitle]) -> String { -// var lines = m3u8String.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) } -// var lastPositionMedia: Int? -// var firstPositionInf = 1 -// -// for (idx, line) in lines.enumerated() { -// if line.hasPrefix("#EXT-X-STREAM-INF") { -// firstPositionInf = idx -// break -// } else if line.hasPrefix("#EXT-X-MEDIA") { -// lastPositionMedia = idx + 1 -// } -// } -// -// var subtitlePosition = lastPositionMedia ?? firstPositionInf -// -// for (idx, subtitle) in subtitles.enumerated() { -// let m3u8SubtitlesString = makeSubtitleTypes(idx, subtitle) -// if subtitlePosition <= lines.endIndex { -// lines.insert(m3u8SubtitlesString, at: subtitlePosition) -// } else { -// lines.append(m3u8SubtitlesString) -// } -// subtitlePosition += 1 -// } -// -// for (idx, line) in lines.enumerated() where line.contains("#EXT-X-STREAM-INF") { -// lines[idx] = line + "," + "SUBTITLES=\"\(Self.hlsSubtitleGroupID)\"" -// } -// -// return lines.joined(separator: "\n") -// } -// -// public func setupAssetDownload(_ asset: OfflineManagerClient.Asset) async throws { -// let (data, _) = try await URLSession.shared.data(from: asset.episodeMetadata.link.url) -// guard let string = String(data: data, encoding: .utf8) else { -// throw Error.M3U8Invalid -// } -// let playlistData: String -// if string.contains("#EXT-X-STREAM-INF") { -// playlistData = parseMainMultiVariantPlaylist(string, asset.episodeMetadata.subtitles) -// } else { -// playlistData = convertMainPlaylistToMultivariant(asset.episodeMetadata.link.url, asset.episodeMetadata.subtitles) -// } -// -// let options = [AVURLAssetAllowsCellularAccessKey: false] -// let libraryFileUrl = try fileClient.retrieveLibraryDirectory(root: .playlistCache) -// let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlist.id.rawValue, episode: asset.episodeId.rawValue) -// try? fileClient.shouldCreateLibraryDirectory( -// .downloaded, -// outputURL.pathComponents.suffix(2).joined(separator: "/"), -// EpisodeMetadata( -// link: asset.episodeMetadata.link, -// source: Playlist.EpisodeSource(id: asset.episodeMetadata.source.id, displayName: asset.episodeMetadata.source.displayName, description: asset.episodeMetadata.source.description, servers: [asset.episodeMetadata.server]), -// subtitles: asset.episodeMetadata.subtitles, -// server: Playlist.EpisodeServer(id: asset.episodeMetadata.server.id, displayName: asset.episodeMetadata.server.displayName, description: asset.episodeMetadata.server.description), -// skipTimes: asset.episodeMetadata.skipTimes -// ) -// ) -// let avAsset = AVURLAsset(url: URL(string:"data:application/x-mpegURL;charset=utf-8,%23EXTM3U%0A%0A%23EXT-X-MEDIA%3ATYPE%3DAUDIO%2CGROUP-ID%3D%22bipbop_audio%22%2CLANGUAGE%3D%22eng%22%2CNAME%3D%22BipBop%20Audio%201%22%2CAUTOSELECT%3DYES%2CDEFAULT%3DYES%0A%23EXT-X-MEDIA%3ATYPE%3DAUDIO%2CGROUP-ID%3D%22bipbop_audio%22%2CLANGUAGE%3D%22eng%22%2CNAME%3D%22BipBop%20Audio%202%22%2CAUTOSELECT%3DNO%2CDEFAULT%3DNO%2CURI%3D%22https%3A%2F%2Fd2zihajmogu5jn.cloudfront.net%2Fbipbop-advanced%2Falternate_audio_aac_sinewave%2Fprog_index.m3u8%22%0A%0A%23EXT-X-MEDIA%3ATYPE%3DSUBTITLES%2CGROUP-ID%3D%22subs%22%2CNAME%3D%22English%22%2CDEFAULT%3DYES%2CAUTOSELECT%3DYES%2CFORCED%3DNO%2CLANGUAGE%3D%22en%22%2CCHARACTERISTICS%3D%22public.accessibility.transcribes-spoken-dialog%2C%20public.accessibility.describes-music-and-sound%22%2CURI%3D%22https%3A%2F%2Fd2zihajmogu5jn.cloudfront.net%2Fbipbop-advanced%2Fsubtitles%2Feng%2Fprog_index.m3u8%22%0A%0A%23EXT-X-STREAM-INF%3ABANDWIDTH%3D263851%2CCODECS%3D%22mp4a.40.2%2C%20avc1.4d400d%22%2CRESOLUTION%3D416x234%2CAUDIO%3D%22bipbop_audio%22%2CSUBTITLES%3D%22subs%22%0Ahttps%3A%2F%2Fd2zihajmogu5jn.cloudfront.net%2Fbipbop-advanced%2Fgear1%2Fprog_index.m3u8")!) -// -// -// -//// let downloadTask = downloadSession.makeAssetDownloadTask(asset: avAsset, -//// assetTitle: asset.playlist.title ?? "Unknown Title", -//// assetArtworkData: nil, -//// options: nil) -// let preferredMediaSelection = try await avAsset.load(.preferredMediaSelection) -// -// let downloadTask = downloadSession.aggregateAssetDownloadTask(with: avAsset, -// mediaSelections: [preferredMediaSelection], -// assetTitle: asset.playlist.title ?? "Unknown Title", -// assetArtworkData: nil, -// options: nil) -// -// let playlist = asset.playlist -// let playlistId = playlist.id.rawValue.replacingOccurrences(of: "/", with: "\\") -// let imageUrl = libraryFileUrl.appendingPathComponent(playlistId).appendingPathComponent("posterImage.jpeg") -// try? fileClient.shouldCreateLibraryDirectory(.playlistCache, playlistId, PlaylistCache( -// playlist: playlist, -// groups: asset.groups, -// details: asset.details, -// repoModuleId: .init(repoId: asset.repoModuleId.repoId, moduleId: asset.repoModuleId.moduleId) -// )) -// -// let image = asset.playlist.posterImage ?? asset.playlist.bannerImage ?? URL(string: "")! -// let (imageData, _) = try await URLSession.shared.data(from: image) -// -// if let imageData = UIImage(data: imageData)?.jpegData(compressionQuality: 1) { -// try imageData.write(to: imageUrl) -// } -// -// downloadTask?.resume() -// downloadQueue.insert(.init(url: asset.episodeMetadata.link.url, playlistId: playlist.id, episodeId: asset.episodeId, metadata: asset.episodeMetadata)) -// NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) -// } -// -// func restorePendingDownloads() { -// downloadSession.getAllTasks { tasksArray in -// for task in tasksArray { -// guard let downloadTask = task as? AVAssetDownloadTask else { break } -// -// let asset = downloadTask.urlAsset -// downloadTask.resume() -// } -// } -// } -// -// func playOfflineAsset() -> AVURLAsset? { -// guard let assetPath = UserDefaults.standard.value(forKey: "assetPath") as? String else { -// return nil -// } -// let baseURL = URL(fileURLWithPath: NSHomeDirectory()) -// let assetURL = baseURL.appendingPathComponent(assetPath) -// let asset = AVURLAsset(url: assetURL) -// if let cache = asset.assetCache, cache.isPlayableOffline { -// return asset -// } else { -// return nil -// } -// } -// -// func getPath() -> String { -// return UserDefaults.standard.value(forKey: "assetPath") as? String ?? "" -// } -// -// public func deleteOfflineAsset() { -// do { -// let userDefaults = UserDefaults.standard -// if let assetPath = userDefaults.value(forKey: "assetPath") as? String { -// let baseURL = URL(fileURLWithPath: NSHomeDirectory()) -// let assetURL = baseURL.appendingPathComponent(assetPath) -// try FileManager.default.removeItem(at: assetURL) -// userDefaults.removeObject(forKey: "assetPath") -// } -// } catch { -// print("An error occured deleting offline asset: \(error)") -// } -// } -// -// public func deleteDownloadedVideo(atPath path: String) { -// do { -// let baseURL = URL(fileURLWithPath: NSHomeDirectory()) -// let assetURL = baseURL.appendingPathComponent(path) -// try FileManager.default.removeItem(at: assetURL) -// -// if var downloadedPaths = UserDefaults.standard.array(forKey: "DownloadedVideoPaths") as? [String], -// let index = downloadedPaths.firstIndex(of: path) { -// downloadedPaths.remove(at: index) -// UserDefaults.standard.set(downloadedPaths, forKey: "DownloadedVideoPaths") -// } -// -//// NotificationCenter.default.post(name: .didDeleteVideo, object: nil) -// } catch { -// print("An error occurred deleting offline asset: \(error)") -// } -// } -//} -// -//extension OfflineDownloadManager: AVAssetDownloadDelegate { -// -// -// // 1. Tells the delegate the location this asset will be downloaded to. -// func urlSession(_ session: URLSession, -// aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, -// willDownloadTo location: URL) { -// debugPrint("willDownloadTo") -// UserDefaults.standard.set(location.absoluteString, forKey: "test_location") -// } -// func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { -// debugPrint("didCompleteWithError") -// } -// -// // 2. Report progress updates for the aggregate download task -// func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, -// didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], -// timeRangeExpectedToLoad: CMTimeRange, for mediaSelection: AVMediaSelection) { -// -// let percentComplete = loadedTimeRanges.reduce(0) { (rc, value) -> Float in -// let loadedTimeRange: CMTimeRange = value.timeRangeValue -// return rc + Float((loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds)) -// } -// debugPrint(percentComplete) -// let params = ["percent": percentComplete, "assetUrl": aggregateAssetDownloadTask.urlAsset.url] as [String : Any] -// NotificationCenter.default.post(name: .AssetDownloadProgress, object: nil, userInfo: params) -// -// if (percentComplete >= 1) { -// NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) -// let location = UserDefaults.standard.string(forKey: "test_location")! -// debugPrint(FileManager.default.fileExists(atPath: URL(string: location)!.path)) -// if let downloadedAsset = downloadQueue.first { -// try? saveVideo(asset: downloadedAsset, location: URL(string: location)!) -// } -// } -// } -// -// // 3. Tells the delegate that the task finished transferring data, either successfully or with an error -// func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { -// debugPrint("DOWNLOAD FINISHED") -// } -// func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { -// var percentComplete = 0.0 -// -// for value in loadedTimeRanges { -// let loadedTimeRange = value.timeRangeValue -// percentComplete += loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds -// } -// if (percentComplete >= 1) { -// NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) -// } -// percentComplete *= 100 -// -// debugPrint("Progress \( assetDownloadTask) \(percentComplete)") -// let params = ["percent": percentComplete, "assetUrl": assetDownloadTask.urlAsset.url] as [String : Any] -// NotificationCenter.default.post(name: .AssetDownloadProgress, object: nil, userInfo: params) -// } -// -// func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { -// if let downloadedAsset = downloadQueue.first(where: { $0.url == assetDownloadTask.urlAsset.url }) { -// try? saveVideo(asset: downloadedAsset, location: location) -// } -// } -// -// func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { -// debugPrint("didFinishCollecting") -// } -// -// func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { -// debugPrint("didBecomeInvalidWithError") -// } -// -// func urlSession(_ session: URLSession, task: URLSessionTask, needNewBodyStream completionHandler: @escaping (InputStream?) -> Void) { -// debugPrint("needNewBodyStream") -// } -// -// func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didResolve resolvedMediaSelection: AVMediaSelection) { -// debugPrint("didResolve") -// } -// func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { -// debugPrint("forBackgroundURLSession") -// } -// func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didCompleteFor mediaSelection: AVMediaSelection) { -// debugPrint("mediaSelection") -// } -// func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, willDownloadVariants variants: [AVAssetVariant]) { -// debugPrint("variants") -// } -// -//// func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { -//// debugPrint("Task completed: \(task), error: \(String(describing: error))") -//// -//// guard error == nil else { return } -//// guard let task = task as? AVAggregateAssetDownloadTask else { return } -//// print("DOWNLOAD: FINISHED") -//// } -//} -// -//extension OfflineDownloadManager { -// private func saveVideo(asset: OfflineManagerClient.DownloadingAsset, location: URL) throws { -// let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlistId.rawValue, episode: asset.episodeId.rawValue) -// debugPrint("File saved to: \(outputURL)") -// try FileManager.default.moveItem(at: location, to: outputURL.appendingPathComponent("data.movpkg")) -// } -//} -// -//extension Notification.Name { -// /// Notification for when download progress has changed. -// static let AssetDownloadProgress = Notification.Name(rawValue: "AssetDownloadProgressNotification") -// -// /// Notification for when the download state of an Asset has changed. -// static let AssetDownloadStateChanged = Notification.Name(rawValue: "AssetDownloadStateChangedNotification") -// -// /// Notification for when AssetPersistenceManager has completely restored its state. -// static let AssetPersistenceManagerDidRestoreState = Notification.Name(rawValue: "AssetPersistenceManagerDidRestoreStateNotification") -//} - - -// -// Live.swift -// -// -// Created by DeNeRr on 06.04.2024. -// - import Dependencies import Foundation import FileClient @@ -371,78 +12,8 @@ import AVFoundation import UIKit import SharedModels import DatabaseClient - -extension Sequence { - /// Run an async closure for each element within the sequence. - /// - /// The closure calls will be performed in order, by waiting for - /// each call to complete before proceeding with the next one. If - /// any of the closure calls throw an error, then the iteration - /// will be terminated and the error rethrown. - /// - /// - parameter operation: The closure to run for each element. - /// - throws: Rethrows any error thrown by the passed closure. - func asyncForEach( - _ operation: (Element) async throws -> Void - ) async rethrows { - for element in self { - try await operation(element) - } - } - - /// Run an async closure for each element within the sequence. - /// - /// The closure calls will be performed concurrently, but the call - /// to this function won't return until all of the closure calls - /// have completed. - /// - /// - parameter priority: Any specific `TaskPriority` to assign to - /// the async tasks that will perform the closure calls. The - /// default is `nil` (meaning that the system picks a priority). - /// - parameter operation: The closure to run for each element. - func concurrentForEach( - withPriority priority: TaskPriority? = nil, - _ operation: @escaping (Element) async -> Void - ) async { - await withTaskGroup(of: Void.self) { group in - for element in self { - group.addTask(priority: priority) { - await operation(element) - } - } - } - } - - /// Run an async closure for each element within the sequence. - /// - /// The closure calls will be performed concurrently, but the call - /// to this function won't return until all of the closure calls - /// have completed. If any of the closure calls throw an error, - /// then the first error will be rethrown once all closure calls have - /// completed. - /// - /// - parameter priority: Any specific `TaskPriority` to assign to - /// the async tasks that will perform the closure calls. The - /// default is `nil` (meaning that the system picks a priority). - /// - parameter operation: The closure to run for each element. - /// - throws: Rethrows any error thrown by the passed closure. - func concurrentForEach( - withPriority priority: TaskPriority? = nil, - _ operation: @escaping (Element) async throws -> Void - ) async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - for element in self { - group.addTask(priority: priority) { - try await operation(element) - } - } - - // Propagate any errors thrown by the group's tasks: - for try await _ in group {} - } - } -} - +import FlyingFox +import OrderedCollections // MARK: - OfflineManagerClient + DependencyKey @@ -486,6 +57,31 @@ extension OfflineManagerClient: DependencyKey { try fileClient.removePlaylistFromLibrary(.downloaded, playlist, episode) break } + }, + togglePause: { taskId in + downloadManager.togglePauseDownload(taskId) + }, + observeDownloading: { + .init { continuation in + let cancellable = Task.detached { + var values = downloadManager.downloadingItems.compactMap { + DownloadingItem(id: $0.metadata.link.url, percentComplete: $0.percentage, image: $0.playlist.posterImage ?? $0.playlist.bannerImage ?? URL(string: "")!, playlistName: $0.playlist.title ?? "", title: $0.episodeTitle, taskId: $0.taskId, status: $0.status) + } + continuation.yield(values) + + let notifications = NotificationCenter.default.notifications( + named: .AssetDownloadProgress + ) + for await notification in notifications { + continuation.yield(downloadManager.downloadingItems.compactMap { + DownloadingItem(id: $0.metadata.link.url, percentComplete: $0.percentage, image: $0.playlist.posterImage ?? $0.playlist.bannerImage ?? URL(string: "")!, playlistName: $0.playlist.title ?? "", title: $0.episodeTitle, taskId: $0.taskId, status: $0.status) + }) + } + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } } ) } @@ -495,27 +91,38 @@ extension OfflineManagerClient: DependencyKey { private class OfflineDownloadManager: NSObject { private var config: URLSessionConfiguration! private var downloadSession: AVAssetDownloadURLSession! - private var downloadQueue: Set = [] + public var downloadingItems: [OfflineManagerClient.DownloadingAsset] = [] + private let server = HTTPServer(port: 64390) @Dependency(\.fileClient) var fileClient override init() { super.init() + Task { + try await server.start() + } config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background") downloadSession = AVAssetDownloadURLSession(configuration: config, assetDownloadDelegate: self, delegateQueue: OperationQueue.main) } public func setupAssetDownload(_ asset: OfflineManagerClient.DownloadAsset) async throws { + await initializeRoutes(asset) + try await server.waitUntilListening() let options = [AVURLAssetAllowsCellularAccessKey: false] - let avAsset = AVURLAsset(url: asset.episodeMetadata.link.url, options: options) let libraryFileUrl = try fileClient.retrieveLibraryDirectory(root: .playlistCache) + let playlist = asset.playlist + let avAsset = AVURLAsset(url: URL(string: "http://localhost:64390/download.m3u?url=\(asset.episodeMetadata.link.url.absoluteString)")!, options: options) + let preferredMediaSelection = try await avAsset.load(.preferredMediaSelection) - let downloadTask = downloadSession.makeAssetDownloadTask(asset: avAsset, - assetTitle: asset.playlist.title ?? "Unknown Title", - assetArtworkData: nil, - options: [AVAssetDownloadTaskPrefersHDRKey: false, AVAssetDownloadTaskPrefersLosslessAudioKey: false]) + guard let downloadTask = downloadSession.aggregateAssetDownloadTask(with: avAsset, + mediaSelections: [preferredMediaSelection], + assetTitle: asset.playlist.title ?? "Unknown Title", + assetArtworkData: nil, + options: nil) else { + throw OfflineManagerClient.Error.failedToCreateDownloadTask + } + downloadingItems.append(.init(url: asset.episodeMetadata.link.url, playlist: playlist, episodeId: asset.episodeId, episodeTitle: asset.episodeTitle, metadata: asset.episodeMetadata, taskId: downloadTask.taskIdentifier, status: .downloading)) - let playlist = asset.playlist let playlistId = playlist.id.rawValue.replacingOccurrences(of: "/", with: "\\") let imageUrl = libraryFileUrl.appendingPathComponent(playlistId).appendingPathComponent("posterImage.jpeg") try? fileClient.shouldCreateLibraryDirectory(.playlistCache, playlistId, PlaylistCache( @@ -532,59 +139,36 @@ private class OfflineDownloadManager: NSObject { try imageData.write(to: imageUrl) } - let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlist.id.rawValue, episode: asset.episodeId.rawValue) - try? fileClient.shouldCreateLibraryDirectory( - .downloaded, - outputURL.pathComponents.suffix(2).joined(separator: "/"), - EpisodeMetadata( - link: asset.episodeMetadata.link, - source: Playlist.EpisodeSource(id: asset.episodeMetadata.source.id, displayName: asset.episodeMetadata.source.displayName, description: asset.episodeMetadata.source.description, servers: [asset.episodeMetadata.server]), - subtitles: asset.episodeMetadata.subtitles, - server: Playlist.EpisodeServer(id: asset.episodeMetadata.server.id, displayName: asset.episodeMetadata.server.displayName, description: asset.episodeMetadata.server.description), - skipTimes: asset.episodeMetadata.skipTimes - ) - ) - await asset.episodeMetadata.subtitles.concurrentForEach { - if let data = try? await URLSession.shared.data(from: $0.url) { - let fileName = $0.id.rawValue.lastPathComponent - try? data.0.write(to: outputURL.appendingPathComponent(fileName)) - } - } - downloadTask?.resume() - downloadQueue.insert(.init(url: asset.episodeMetadata.link.url, playlistId: playlist.id, episodeId: asset.episodeId, metadata: asset.episodeMetadata)) + downloadTask.resume() NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) } + func togglePauseDownload(_ taskId: Int) { + downloadSession.getAllTasks { tasksArray in + if let task = tasksArray.first(where: { $0.taskIdentifier == taskId }), let idx = self.downloadingItems.firstIndex(where: { $0.taskId == taskId }) { + if (task.state == .suspended) { + task.resume() + self.downloadingItems[idx].status = .downloading + } else if (task.state == .running) { + task.suspend() + self.downloadingItems[idx].status = .suspended + } + } + } + } + func restorePendingDownloads() { downloadSession.getAllTasks { tasksArray in for task in tasksArray { guard let downloadTask = task as? AVAssetDownloadTask else { break } - let asset = downloadTask.urlAsset + let _ = downloadTask.urlAsset downloadTask.resume() } } } - func playOfflineAsset() -> AVURLAsset? { - guard let assetPath = UserDefaults.standard.value(forKey: "assetPath") as? String else { - return nil - } - let baseURL = URL(fileURLWithPath: NSHomeDirectory()) - let assetURL = baseURL.appendingPathComponent(assetPath) - let asset = AVURLAsset(url: assetURL) - if let cache = asset.assetCache, cache.isPlayableOffline { - return asset - } else { - return nil - } - } - - func getPath() -> String { - return UserDefaults.standard.value(forKey: "assetPath") as? String ?? "" - } - public func deleteOfflineAsset() { do { let userDefaults = UserDefaults.standard @@ -599,65 +183,165 @@ private class OfflineDownloadManager: NSObject { } } - public func deleteDownloadedVideo(atPath path: String) { - do { - let baseURL = URL(fileURLWithPath: NSHomeDirectory()) - let assetURL = baseURL.appendingPathComponent(path) - try FileManager.default.removeItem(at: assetURL) +} + +extension OfflineDownloadManager { + private func initializeRoutes(_ asset: OfflineManagerClient.DownloadAsset) async { + await server.appendRoute("GET /download.m3u", handler: { req in + let hlsSubtitleGroupID = "mochi-sub" - if var downloadedPaths = UserDefaults.standard.array(forKey: "DownloadedVideoPaths") as? [String], - let index = downloadedPaths.firstIndex(of: path) { - downloadedPaths.remove(at: index) - UserDefaults.standard.set(downloadedPaths, forKey: "DownloadedVideoPaths") + func convertMainPlaylistToMultivariant(_ url: URL, _ subtitles: [Playlist.EpisodeServer.Subtitle]) -> String { + // Build a multivariant playlist out of a single main playlist + let subtitlesMediaStrings = subtitles.enumerated() + .map(makeSubtitleTypes) + + return """ + #EXTM3U + \(subtitlesMediaStrings.joined(separator: "\n")) + #EXT-X-STREAM-INF:BANDWIDTH=640000\(!subtitles.isEmpty ? ",SUBTITLES=\"\(hlsSubtitleGroupID)\"": "") + \(url.absoluteString) + """ } -// NotificationCenter.default.post(name: .didDeleteVideo, object: nil) - } catch { - print("An error occurred deleting offline asset: \(error)") - } + func makeSubtitleTypes(_ idx: Int, _ subtitle: Playlist.EpisodeServer.Subtitle) -> String { + "#EXT-X-MEDIA:" + ( + [ + "TYPE": "SUBTITLES", + "GROUP-ID": "\"\(hlsSubtitleGroupID)\"", + "NAME": "\"\(subtitle.name)\"", + "CHARACTERISTICS": "\"public.accessibility.transcribes-spoken-dialog\"", + "DEFAULT": subtitle.default ? "YES" : "NO", + "AUTOSELECT": subtitle.autoselect ? "YES" : "NO", + "FORCED": "NO", + "URI": "\"http://localhost:64390/subs.m3u8?url=\(subtitle.url.absoluteString)\"", + "LANGUAGE": "\"\(subtitle.name)\"" + ] as OrderedDictionary + ) + .map { "\($0.key)=\($0.value)" } + .joined(separator: ",") + } + + var path = req.path + path.remove(at: req.path.startIndex) + let m3u8 = convertMainPlaylistToMultivariant(asset.episodeMetadata.link.url, asset.episodeMetadata.subtitles) + return HTTPResponse(statusCode: .ok, headers: [.contentType: "application/vnd.apple.mpegurl"], body: m3u8.data(using: .utf8)!) + }) + + await server.appendRoute("GET /subs.m3u8", handler: { req in + func setupSubM3U8(_ url: URL) async throws -> String { + let (data, _) = try await URLSession.shared.data(for: .init(url: url)) + let vttString = String(data: data , encoding: .utf8)! + + let lastTimeStampString = ( + try? NSRegularExpression(pattern: "(?:(\\d+):)?(\\d+):([\\d\\.]+)") + .matches( + in: vttString, + range: .init(location: 0, length: vttString.utf16.count) + ) + .last + .flatMap { Range($0.range, in: vttString) } + .flatMap { String(vttString[$0]) } + ) ?? "0.000" + + let duration = lastTimeStampString.components(separatedBy: ":").reversed() + .compactMap { Double($0) } + .enumerated() + .map { pow(60.0, Double($0.offset)) * $0.element } + .reduce(0, +) + + let m3u8Subtitle = """ + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-MEDIA-SEQUENCE:1 + #EXT-X-PLAYLIST-TYPE:VOD + #EXT-X-ALLOW-CACHE:NO + #EXT-X-TARGETDURATION:\(Int(duration)) + #EXTINF:\(String(format: "%.3f", duration)), no desc + \(url.absoluteString) + #EXT-X-ENDLIST + """ + + return m3u8Subtitle + } + + let idx = URL(string: req.query.first!.value)! + let m3u8 = try await setupSubM3U8(idx) + return HTTPResponse(statusCode: .ok, headers: [.contentType: "application/vnd.apple.mpegurl"], body: m3u8.data(using: .utf8)!) + }) } } extension OfflineDownloadManager: AVAssetDownloadDelegate { - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { - var percentComplete = 0.0 - - for value in loadedTimeRanges { - let loadedTimeRange = value.timeRangeValue - percentComplete += loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds + func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, + didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], + timeRangeExpectedToLoad: CMTimeRange, for mediaSelection: AVMediaSelection) { + let percentComplete = loadedTimeRanges.reduce(0) { (rc, value) -> Double in + let loadedTimeRange: CMTimeRange = value.timeRangeValue + return rc + Double((loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds)) } - percentComplete *= 100 - - debugPrint("Progress \( assetDownloadTask) \(percentComplete)") - - let params = ["percent": percentComplete, "assetUrl": assetDownloadTask.urlAsset.url] as [String : Any] + guard let idx = downloadingItems.firstIndex(where: { $0.metadata.link.url.absoluteString == aggregateAssetDownloadTask.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) else { + return + } + downloadingItems[idx].percentage = percentComplete +// debugPrint(percentComplete) + let params: [String : Any] = ["percent": percentComplete, "assetUrl": aggregateAssetDownloadTask.urlAsset.url] NotificationCenter.default.post(name: .AssetDownloadProgress, object: nil, userInfo: params) } - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { - if let downloadedAsset = downloadQueue.first(where: { $0.url == assetDownloadTask.urlAsset.url }) { - try? saveVideo(asset: downloadedAsset, location: location) + func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, willDownloadTo location: URL) { + guard let idx = downloadingItems.firstIndex(where: { + return $0.metadata.link.url.absoluteString == aggregateAssetDownloadTask.urlAsset.url.absoluteString.components(separatedBy: "url=").last + }) else { + return } + downloadingItems[idx].location = location } - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - debugPrint("Download finished: \(location.absoluteString)") + func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didCompleteFor mediaSelection: AVMediaSelection) { + if let downloadedAsset = downloadingItems.first(where: { $0.metadata.link.url.absoluteString == aggregateAssetDownloadTask.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) { + guard let outputURL = try? fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: downloadedAsset.playlist.id.rawValue, episode: downloadedAsset.episodeId.rawValue) else { + return + } + do { + try saveVideo(asset: downloadedAsset, location: downloadedAsset.location!) + } catch { + debugPrint(error) + } + } } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { debugPrint("Task completed: \(task), error: \(String(describing: error))") - guard error == nil else { return } - guard let task = task as? AVAssetDownloadTask else { return } - print("DOWNLOAD: FINISHED") + guard let task = task as? AVAggregateAssetDownloadTask else { return } + guard error == nil else { + if let idx = downloadingItems.firstIndex(where: { $0.url.absoluteString == task.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) { + downloadingItems[idx].status = .finished + } + return + } } } extension OfflineDownloadManager { private func saveVideo(asset: OfflineManagerClient.DownloadingAsset, location: URL) throws { - let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlistId.rawValue, episode: asset.episodeId.rawValue) + let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlist.id.rawValue, episode: asset.episodeId.rawValue) debugPrint("File saved to: \(outputURL)") - try FileManager.default.moveItem(at: location, to: outputURL.appendingPathComponent("data.movpkg")) + if (FileManager.default.fileExists(atPath: outputURL.path)) { + try FileManager.default.removeItem(at: outputURL.appendingPathComponent("data").appendingPathExtension("movpkg")) + } + try fileClient.shouldCreateLibraryDirectory( + .downloaded, + outputURL.pathComponents.suffix(2).joined(separator: "/"), + EpisodeMetadata( + link: asset.metadata.link, + source: Playlist.EpisodeSource(id: asset.metadata.source.id, displayName: asset.metadata.source.displayName, description: asset.metadata.source.description, servers: [asset.metadata.server]), + subtitles: asset.metadata.subtitles, + server: Playlist.EpisodeServer(id: asset.metadata.server.id, displayName: asset.metadata.server.displayName, description: asset.metadata.server.description), + skipTimes: asset.metadata.skipTimes + ) + ) + try FileManager.default.moveItem(at: location, to: outputURL.appendingPathComponent("data").appendingPathExtension("movpkg")) } } diff --git a/Sources/Clients/OfflineManagerClient/Models.swift b/Sources/Clients/OfflineManagerClient/Models.swift index f9312b8..8877c18 100644 --- a/Sources/Clients/OfflineManagerClient/Models.swift +++ b/Sources/Clients/OfflineManagerClient/Models.swift @@ -12,6 +12,7 @@ import Tagged extension OfflineManagerClient { public enum Error: Swift.Error, Equatable, Sendable { case failedToGetPlaylistId + case failedToCreateDownloadTask } public enum RemoveType { @@ -23,14 +24,16 @@ extension OfflineManagerClient { public struct DownloadAsset: Equatable, Sendable { public let episodeMetadata: EpisodeMetadata public let episodeId: Playlist.Item.ID + public let episodeTitle: String public let groups: [Playlist.Group]? public let playlist: Playlist public let details: Playlist.Details? public let repoModuleId: RepoModuleID - public init(episodeMetadata: EpisodeMetadata, episodeId: Playlist.Item.ID, groups: [Playlist.Group]?, playlist: Playlist, details: Playlist.Details?, repoModuleId: RepoModuleID) { + public init(episodeMetadata: EpisodeMetadata, episodeId: Playlist.Item.ID, episodeTitle: String, groups: [Playlist.Group]?, playlist: Playlist, details: Playlist.Details?, repoModuleId: RepoModuleID) { self.episodeMetadata = episodeMetadata self.episodeId = episodeId + self.episodeTitle = episodeTitle self.groups = groups self.playlist = playlist self.details = details @@ -52,10 +55,53 @@ extension OfflineManagerClient { } } + public struct DownloadingItem: Identifiable, Sendable, Equatable, Hashable { + public let id: URL + public var percentComplete: Double + public let image: URL + public let playlistName: String + public let title: String + public let taskId: Int + public var status: StatusType + + public init(id: URL, percentComplete: Double, image: URL, playlistName: String, title: String, taskId: Int, status: StatusType) { + self.id = id + self.percentComplete = percentComplete + self.image = image + self.playlistName = playlistName + self.title = title + self.taskId = taskId + self.status = status + } + } + + public enum StatusType: Sendable { + case downloading + case suspended + case finished + case cancelled + } + public struct DownloadingAsset: Hashable { public let url: URL - public let playlistId: Playlist.ID + public let playlist: Playlist public let episodeId: Playlist.Item.ID + public let episodeTitle: String public let metadata: EpisodeMetadata + public var location: URL? + public var percentage: Double = 0 + public var taskId: Int + public var status: StatusType + + public init(url: URL, playlist: Playlist, episodeId: Playlist.Item.ID, episodeTitle: String, metadata: EpisodeMetadata, location: URL? = nil, taskId: Int, status: StatusType) { + self.url = url + self.playlist = playlist + self.episodeId = episodeId + self.episodeTitle = episodeTitle + self.metadata = metadata + self.location = location + self.taskId = taskId + self.status = status + } } } diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index 603d1f0..448edf8 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -204,6 +204,7 @@ extension ContentCore { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 12) { ForEach(items.value ?? Self.placeholderItems, id: \.number) { item in + let isDownloaded = viewStore.downloadedEpisodes.contains(item.id.rawValue.replacingOccurrences(of: "/", with: "\\")) VStack(alignment: .leading, spacing: 0) { FillAspectImage(url: item.thumbnail ?? viewStore.playlist.posterImage) .aspectRatio(16 / 9, contentMode: .fit) @@ -211,6 +212,16 @@ extension ContentCore { Spacer() .frame(height: 8) + + HStack { + Text(String(format: contentType.itemTypeWithNumber, item.number.withoutTrailingZeroes)) + .font(.footnote.weight(.semibold)) + .foregroundColor(.init(white: 0.4)) + if isDownloaded { + Image(systemName: "cloud.fill") + .foregroundColor(.init(white: 0.4)) + } + } Spacer() .frame(height: 4) @@ -230,51 +241,22 @@ extension ContentCore { } } .id(item.id) -// Menu { -// Button() { -// store.send(.didTapDownloadPlaylist(item.id)) -// } label: { -// Label("Download episode", systemImage: "square.and.arrow.down") -// } -// .buttonStyle(.plain) -// } label: { -// VStack(alignment: .leading, spacing: 0) { -// FillAspectImage(url: item.thumbnail ?? viewStore.playlist.posterImage) -// .aspectRatio(16 / 9, contentMode: .fit) -// .cornerRadius(12) -// -// Spacer() -// .frame(height: 8) -// -// WithViewStore(store, observe: \.downloadedEpisodes) { viewStore in -// HStack(spacing: 10) { -// Text(String(format: contentType.itemTypeWithNumber, item.number.withoutTrailingZeroes)) -// .font(.footnote.weight(.semibold)) -// if (viewStore.state.contains(item.id.rawValue.replacingOccurrences(of: "/", with: "\\"))) { -// Image(systemName: "cloud.fill") -// } -// } -// .foregroundColor(.init(white: 0.4)) -// } -// -// Spacer() -// .frame(height: 4) -// -// Text(item.title ?? String(format: contentType.itemTypeWithNumber, item.number.withoutTrailingZeroes)) -// .font(.body.weight(.semibold)) -// .foregroundStyle(Color.primary) -// .multilineTextAlignment(.leading) -// } -// .frame(width: 228) -// .contentShape(Rectangle()) -// .id(item.id) -// } primaryAction: { -// if let groupId = groupLoadable.value?.id, -// let variantId = variantLoadable.value?.id, -// let pageId = pageLoadable.value?.id { -// store.send(.didTapPlaylistItem(groupId, variantId, pageId, id: item.id, shouldReset: true)) -// } -// } + .contextMenu { + Button() { + store.send(.didTapDownloadPlaylist(item)) + } label: { + Label("Download episode", systemImage: "square.and.arrow.down") + } + .buttonStyle(.plain) + if isDownloaded { + Button(role: .destructive) { + store.send(.didTapRemoveDownloadedPlaylist(item)) + } label: { + Label("Remove episode", systemImage: "trash") + } + .buttonStyle(.plain) + } + } } .frame(maxHeight: .infinity, alignment: .top) } @@ -399,7 +381,7 @@ extension ContentCore { } if let selectedQuality = viewStore.state.selectedQuality { Button { - store.send(.download(viewStore.selectedSource!, viewStore.selectedServer!, selectedQuality, viewStore.selectedSubtitle != nil ? [viewStore.selectedSubtitle!] : [], serverResponse.skipTimes, viewStore.state.episodeId)) + store.send(.download(viewStore.selectedSource!, viewStore.selectedServer!, selectedQuality, viewStore.selectedSubtitle != nil ? [viewStore.selectedSubtitle!] : [], serverResponse.skipTimes, viewStore.state.episodeId, viewStore.state.episodeTitle)) } label: { Text("Download") } diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index 2a94b66..cad848d 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -71,7 +71,8 @@ public struct ContentCore: Reducer { shouldReset: Bool = false ) case observeDirectory(URL, Bool) - case didTapDownloadPlaylist(Playlist.Item.ID) + case didTapDownloadPlaylist(Playlist.Item) + case didTapRemoveDownloadedPlaylist(Playlist.Item) case setDownloadedEpisodes([String]) case downloadSelection(PresentationAction) case updateCache([Playlist.Group]) @@ -87,22 +88,23 @@ public struct ContentCore: Reducer { Reduce { state, action in switch action { case .didAppear: - break -// @Dependency(\.fileClient) var fileClient -// let playlistId = state.playlist.id.rawValue -// return .run { send in -// if let directory = try? fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: playlistId), FileManager.default.fileExists(atPath: directory.path) { -// await send(.observeDirectory(directory, true)) -// } else if let directory = try? fileClient.retrieveLibraryDirectory(root: .downloaded) { -// await send(.observeDirectory(directory, false)) -// } -// } + let playlist = state.playlist + return .run { send in + @Dependency(\.fileClient) var fileClient + let playlistDir = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: playlist.id.rawValue) + if fileClient.fileExists(playlistDir.path) { + await send(.observeDirectory(playlistDir, true)) + } else { + await send(.observeDirectory(playlistDir.deletingLastPathComponent(), false)) + } + + } case let .didTapContent(option): return state.fetchContent(option) - case let .didTapDownloadPlaylist(episodeId): - state.downloadSelection = .selection(.init(repoModuleId: state.repoModuleId, playlistId: state.playlist.id, episodeId: episodeId)) + case let .didTapDownloadPlaylist(episode): + state.downloadSelection = .selection(.init(repoModuleId: state.repoModuleId, playlistId: state.playlist.id, episodeId: episode.id, episodeTitle: episode.title ?? "Unknown Title")) case let .didTapPlaylistItem(groupId, variantId, pageId, itemId, shouldReset): @Dependency(\.playlistHistoryClient) var playlistHistoryClient @@ -125,6 +127,13 @@ public struct ContentCore: Reducer { } } + case let .didTapRemoveDownloadedPlaylist(episode): + @Dependency(\.fileClient) var fileClient + let playlist = state.playlist + return .run { _ in + try fileClient.removePlaylistFromLibrary(.downloaded, playlist.id.rawValue, episode.id.rawValue) + } + case let .observeDirectory(directory, isPlaylistDirectory): @Dependency(\.fileClient) var fileClient let playlistId = state.playlist.id.rawValue diff --git a/Sources/Features/ContentCore/DownloadSelection.swift b/Sources/Features/ContentCore/DownloadSelection.swift index 5d7f22a..6762974 100644 --- a/Sources/Features/ContentCore/DownloadSelection.swift +++ b/Sources/Features/ContentCore/DownloadSelection.swift @@ -36,6 +36,7 @@ public struct DownloadSelection: Reducer { public let repoModuleId: RepoModuleID public let playlistId: Playlist.ID public let episodeId: Playlist.Item.ID + public let episodeTitle: String public var sources: Loadable<[Playlist.EpisodeSource]> public var serverResponse: Loadable @@ -45,10 +46,11 @@ public struct DownloadSelection: Reducer { public var selectedQuality: Playlist.EpisodeServer.Link? = nil public var selectedSubtitle: Playlist.EpisodeServer.Subtitle? = nil - public init(repoModuleId: RepoModuleID, playlistId: Playlist.ID, episodeId: Playlist.Item.ID, sources: Loadable<[Playlist.EpisodeSource]> = .pending, serverResponse: Loadable = .pending) { + public init(repoModuleId: RepoModuleID, playlistId: Playlist.ID, episodeId: Playlist.Item.ID, episodeTitle: String, sources: Loadable<[Playlist.EpisodeSource]> = .pending, serverResponse: Loadable = .pending) { self.repoModuleId = repoModuleId self.playlistId = playlistId self.episodeId = episodeId + self.episodeTitle = episodeTitle self.sources = sources self.serverResponse = serverResponse } @@ -62,7 +64,7 @@ public struct DownloadSelection: Reducer { case selectQuality(Playlist.EpisodeServer.Link) case selectSubtitle(Playlist.EpisodeServer.Subtitle) case serverResponse(Loadable) - case download(Playlist.EpisodeSource, Playlist.EpisodeServer, Playlist.EpisodeServer.Link, [Playlist.EpisodeServer.Subtitle], [Playlist.EpisodeServer.SkipTime], Playlist.Item.ID) + case download(Playlist.EpisodeSource, Playlist.EpisodeServer, Playlist.EpisodeServer.Link, [Playlist.EpisodeServer.Subtitle], [Playlist.EpisodeServer.SkipTime], Playlist.Item.ID, String) } public var body: some ReducerOf { diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift new file mode 100644 index 0000000..9e37b0f --- /dev/null +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift @@ -0,0 +1,34 @@ +// +// DownloadQueueFeature+Reducer.swift +// +// +// Created by DeNeRr on 17.05.2024. +// + +import Architecture +import ComposableArchitecture +import Foundation + +extension DownloadQueueFeature: Reducer { + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .view(.didAppear): + return .run { send in + for await items in offlineManagerClient.observeDownloading() { + await send(.internal(.updateDownloadingItems(items))) + } + } + + case let .view(.pause(item)): + return .run { send in + try await offlineManagerClient.togglePause(item.taskId) + } + + case let .internal(.updateDownloadingItems(items)): + state.downloadQueue = items + } + return .none + } + } +} diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift new file mode 100644 index 0000000..b142ac2 --- /dev/null +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift @@ -0,0 +1,85 @@ +// +// DownloadQueueFeature+View.swift +// +// +// Created by DeNeRr on 17.05.2024. +// + +import Foundation +import ComposableArchitecture +import SwiftUI +import ViewComponents +import Styling + +// MARK: - DownloadQueueFeature + View + +extension DownloadQueueFeature.View: View { + @MainActor public var body: some View { + WithViewStore(store, observe: \.downloadQueue) { viewStore in + ScrollView { + ForEach(viewStore.state, id: \.`self`) { item in + HStack(spacing: 6) { + FillAspectImage(url: item.image) + .aspectRatio(3 / 4, contentMode: .fit) + .cornerRadius(12) + .frame(height: 80) + + VStack(alignment: .leading, spacing: 6) { + Text(item.title) + .lineLimit(3) + .font(.headline.weight(.medium)) + .multilineTextAlignment(.leading) + Text(item.playlistName) + .lineLimit(2) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer() + switch item.status { + case .suspended: + CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: .gray, width: 4, blurRadius: 0)) { + Image(systemName: "play.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(6) + .foregroundStyle(Theme.pastelRed) + } + .onTapGesture { + viewStore.send(.pause(item)) + } + .frame(width: 30, height: 30) + case .finished: + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(6) + .foregroundStyle(Theme.pastelRed) + case .cancelled: + EmptyView() + case .downloading: + CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: Theme.pastelRed, width: 4, blurRadius: 0)) { + Image(systemName: "pause.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(6) + .foregroundStyle(Theme.pastelRed) + } + .onTapGesture { + viewStore.send(.pause(item)) + } + .frame(width: 30, height: 30) + } + + } + } + } + .frame(maxWidth: .infinity) + .padding() + .onAppear { + viewStore.send(.didAppear) + } + } + } +} diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature.swift new file mode 100644 index 0000000..a153974 --- /dev/null +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature.swift @@ -0,0 +1,63 @@ +// +// DownloadQueueFeature.swift +// +// +// Created by DeNeRr on 16.05.2024. +// + +import Architecture +import ComposableArchitecture +import Foundation +import OfflineManagerClient + +// MARK: - DownloadQueueFeature + +public struct DownloadQueueFeature: Feature { + public struct State: FeatureState { + public var downloadQueue: [OfflineManagerClient.DownloadingItem] + + public init( + downloadQueue: [OfflineManagerClient.DownloadingItem] = [] + ) { + self.downloadQueue = downloadQueue + } + } + + @CasePathable + @dynamicMemberLookup + public enum Action: FeatureAction { + @CasePathable + @dynamicMemberLookup + public enum ViewAction: SendableAction { + case didAppear + case pause(OfflineManagerClient.DownloadingItem) + } + + @CasePathable + @dynamicMemberLookup + public enum DelegateAction: SendableAction {} + + @CasePathable + @dynamicMemberLookup + public enum InternalAction: SendableAction { + case updateDownloadingItems([OfflineManagerClient.DownloadingItem]) + } + + case view(ViewAction) + case delegate(DelegateAction) + case `internal`(InternalAction) + } + + @MainActor + public struct View: FeatureView { + public let store: StoreOf + @MainActor + public init(store: StoreOf) { + self.store = store + } + } + + @Dependency(\.offlineManagerClient) var offlineManagerClient + + public init() {} +} diff --git a/Sources/Features/LIbrary/LibraryFeature+Reducer.swift b/Sources/Features/LIbrary/LibraryFeature+Reducer.swift index bb1845e..0a075ff 100644 --- a/Sources/Features/LIbrary/LibraryFeature+Reducer.swift +++ b/Sources/Features/LIbrary/LibraryFeature+Reducer.swift @@ -36,6 +36,9 @@ extension LibraryFeature: Reducer { cachedGroups: fileMetadata.groups ), details: fileMetadata.details != nil ? .loaded(fileMetadata.details!) : .pending))) + + case let .view(.didTapDownloadQueue): + state.path.append(.downloadQueue(.init())) case let .view(.didTapRemoveBookmark(cache)): return .run { _ in diff --git a/Sources/Features/LIbrary/LibraryFeature+View.swift b/Sources/Features/LIbrary/LibraryFeature+View.swift index 4807e2b..3032a00 100644 --- a/Sources/Features/LIbrary/LibraryFeature+View.swift +++ b/Sources/Features/LIbrary/LibraryFeature+View.swift @@ -14,6 +14,7 @@ import ViewComponents import Styling import PlaylistDetails import AVKit +import DownloadQueue // MARK: - LibraryFeature + View @@ -55,27 +56,27 @@ extension LibraryFeature.View: View { } .animation(.easeInOut, value: viewStore.searchedPlaylists) .animation(.easeInOut, value: playlists) -// .safeAreaInset(edge: .top) { -// ScrollView(.horizontal, showsIndicators: false) { -// WithViewStore(store, observe: \.showOfflineOnly) { viewStore in -// Button { -// viewStore.send(.didTapShowDownloadedOnly) -// } label: { -// Text("Downloaded") -// .font(.footnote) -// .foregroundStyle(viewStore.state ? Color.white : Theme.pastelRed) -// .padding(8) -// .background( -// Capsule() -// .style( -// withStroke: Color.gray.opacity(0.2), -// fill: viewStore.state ? Theme.pastelRed : buttonBackgroundColor -// ) -// ) -// } -// } -// } -// } + .safeAreaInset(edge: .top) { + ScrollView(.horizontal, showsIndicators: false) { + WithViewStore(store, observe: \.showOfflineOnly) { viewStore in + Button { + viewStore.send(.didTapShowDownloadedOnly) + } label: { + Text("Downloaded") + .font(.footnote) + .foregroundStyle(viewStore.state ? Color.white : Theme.pastelRed) + .padding(8) + .background( + Capsule() + .style( + withStroke: Color.gray.opacity(0.2), + fill: viewStore.state ? Theme.pastelRed : buttonBackgroundColor + ) + ) + } + } + } + } .padding(.horizontal) } .searchable(text: viewStore.$searchValue.removeDuplicates(), placement: .toolbar) @@ -108,6 +109,14 @@ extension LibraryFeature.View: View { .buttonStyle(.plain) #endif } + ToolbarItem(placement: .topBarTrailing) { + Button { + store.send(.view(.didTapDownloadQueue)) + } label: { + Image(systemName: "arrow.down.circle") + } + .foregroundColor(Theme.pastelRed) + } } .navigationTitle("") #if os(iOS) @@ -122,6 +131,12 @@ extension LibraryFeature.View: View { action: LibraryFeature.Path.Action.playlistDetails, then: { store in PlaylistDetailsFeature.View(store: store) } ) + case .downloadQueue: + CaseLet( + /LibraryFeature.Path.State.downloadQueue, + action: LibraryFeature.Path.Action.downloadQueue, + then: { store in DownloadQueueFeature.View(store: store) } + ) } } } diff --git a/Sources/Features/LIbrary/LibraryFeature.swift b/Sources/Features/LIbrary/LibraryFeature.swift index 4b3ba3b..f864871 100644 --- a/Sources/Features/LIbrary/LibraryFeature.swift +++ b/Sources/Features/LIbrary/LibraryFeature.swift @@ -13,6 +13,7 @@ import SwiftUI import ViewComponents import PlaylistDetails import SharedModels +import DownloadQueue // MARK: - LibraryFeature @@ -23,19 +24,23 @@ public struct LibraryFeature: Feature { @dynamicMemberLookup public enum State: Equatable, Sendable { case playlistDetails(PlaylistDetailsFeature.State) + case downloadQueue(DownloadQueueFeature.State) } @CasePathable @dynamicMemberLookup public enum Action: Equatable, Sendable { case playlistDetails(PlaylistDetailsFeature.Action) + case downloadQueue(DownloadQueueFeature.Action) } @ReducerBuilder public var body: some ReducerOf { - Scope(state: \.playlistDetails, action: \.playlistDetails) { PlaylistDetailsFeature() } + Scope(state: \.downloadQueue, action: \.downloadQueue) { + DownloadQueueFeature() + } } } @@ -65,6 +70,7 @@ public struct LibraryFeature: Feature { case didTapRemoveBookmark(PlaylistCache) case didTapRemovePlaylist(PlaylistCache) case didTapShowDownloadedOnly + case didTapDownloadQueue case binding(BindingAction) } diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift index fb5cbf8..94e8982 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift @@ -111,8 +111,9 @@ extension PlaylistDetailsFeature { case let .internal(.playlistDetailsResponse(loadable)): state.details = loadable + break - case let .internal(.content(.downloadSelection(.presented(.selection(.download(source, server, link, subtitles, skipTimes, episodeId)))))): + case let .internal(.content(.downloadSelection(.presented(.selection(.download(source, server, link, subtitles, skipTimes, episodeId, episodeTitle)))))): let playlist = state.playlist let details = state.details.value let groups = state.content.groups.value @@ -121,6 +122,7 @@ extension PlaylistDetailsFeature { try await offlineManagerClient.download(.init( episodeMetadata: .init(link: link, source: source, subtitles: subtitles, server: server, skipTimes: skipTimes), episodeId: episodeId, + episodeTitle: episodeTitle, groups: groups, playlist: playlist, details: details, diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift index 3705104..31dcde3 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift @@ -588,8 +588,7 @@ extension VideoPlayerFeature.View { contentType: .video, selectedGroupId: viewStore.groupId, selectedVariantId: viewStore.variantId, - selectedPageId: viewStore.pageId, - selectedItemId: viewStore.itemId + selectedPageId: viewStore.pageId ) } } From e5423647a084e7c4c32b6d38661e75adf2c88d46 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 19 May 2024 22:46:38 +0200 Subject: [PATCH 18/45] fix: download queue is now being updated correctly when progress or status changes --- Package.swift | 2 + Package/Sources/Features/MochiApp.swift | 1 + Package/Sources/Index.swift | 1 + .../Clients/OfflineManagerClient/Live.swift | 43 +++++++--- .../DownloadQueueFeature+View.swift | 84 ++++++++++++------- 5 files changed, 86 insertions(+), 45 deletions(-) diff --git a/Package.swift b/Package.swift index 8d290db..00a1fb5 100644 --- a/Package.swift +++ b/Package.swift @@ -1344,6 +1344,7 @@ struct MochiApp: _Feature { Architecture() Discover() Library() + DownloadQueue() Repos() Settings() SharedModels() @@ -1733,6 +1734,7 @@ let package = Package { PlaylistDetails() Discover() Library() + DownloadQueue() Repos() Search() Settings() diff --git a/Package/Sources/Features/MochiApp.swift b/Package/Sources/Features/MochiApp.swift index d8765c3..1be0ae1 100644 --- a/Package/Sources/Features/MochiApp.swift +++ b/Package/Sources/Features/MochiApp.swift @@ -15,6 +15,7 @@ struct MochiApp: _Feature { Architecture() Discover() Library() + DownloadQueue() Repos() Settings() SharedModels() diff --git a/Package/Sources/Index.swift b/Package/Sources/Index.swift index 5684322..855acd3 100644 --- a/Package/Sources/Index.swift +++ b/Package/Sources/Index.swift @@ -16,6 +16,7 @@ let package = Package { PlaylistDetails() Discover() Library() + DownloadQueue() Repos() Search() Settings() diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift index 9885af2..535d6a3 100644 --- a/Sources/Clients/OfflineManagerClient/Live.swift +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -70,12 +70,27 @@ extension OfflineManagerClient: DependencyKey { continuation.yield(values) let notifications = NotificationCenter.default.notifications( - named: .AssetDownloadProgress + named: .AssetDownloadTaskChanged ) for await notification in notifications { - continuation.yield(downloadManager.downloadingItems.compactMap { - DownloadingItem(id: $0.metadata.link.url, percentComplete: $0.percentage, image: $0.playlist.posterImage ?? $0.playlist.bannerImage ?? URL(string: "")!, playlistName: $0.playlist.title ?? "", title: $0.episodeTitle, taskId: $0.taskId, status: $0.status) - }) + switch notification.userInfo!["type"] as! Notification.Name { + case .AssetDownloadProgress: + let taskId = notification.userInfo?["taskId"] as! Int + let percent = notification.userInfo?["percent"] as! Double + let idx = values.firstIndex(where: { $0.taskId == taskId })! + values[idx].percentComplete = percent + break + case .AssetDownloadStateChanged: + let taskId = notification.userInfo?["taskId"] as! Int + let status = notification.userInfo?["status"] as! StatusType + let idx = values.firstIndex(where: { $0.taskId == taskId })! + values[idx].status = status + break + default: + break + } + + continuation.yield(values) } } continuation.onTermination = { _ in @@ -141,7 +156,7 @@ private class OfflineDownloadManager: NSObject { downloadTask.resume() - NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: nil) + NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: ["type": Notification.Name.AssetDownloadStateChanged, "taskId": downloadTask.taskIdentifier, "status": OfflineManagerClient.StatusType.downloading]) } func togglePauseDownload(_ taskId: Int) { @@ -150,9 +165,11 @@ private class OfflineDownloadManager: NSObject { if (task.state == .suspended) { task.resume() self.downloadingItems[idx].status = .downloading + NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: ["type": Notification.Name.AssetDownloadStateChanged, "taskId": taskId, "status": OfflineManagerClient.StatusType.downloading]) } else if (task.state == .running) { task.suspend() self.downloadingItems[idx].status = .suspended + NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: ["type": Notification.Name.AssetDownloadStateChanged, "taskId": taskId, "status": OfflineManagerClient.StatusType.suspended]) } } } @@ -284,8 +301,8 @@ extension OfflineDownloadManager: AVAssetDownloadDelegate { } downloadingItems[idx].percentage = percentComplete // debugPrint(percentComplete) - let params: [String : Any] = ["percent": percentComplete, "assetUrl": aggregateAssetDownloadTask.urlAsset.url] - NotificationCenter.default.post(name: .AssetDownloadProgress, object: nil, userInfo: params) + let params: [String : Any] = ["type": Notification.Name.AssetDownloadProgress, "taskId": aggregateAssetDownloadTask.taskIdentifier, "percent": percentComplete] + NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: params) } func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, willDownloadTo location: URL) { @@ -299,9 +316,6 @@ extension OfflineDownloadManager: AVAssetDownloadDelegate { func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didCompleteFor mediaSelection: AVMediaSelection) { if let downloadedAsset = downloadingItems.first(where: { $0.metadata.link.url.absoluteString == aggregateAssetDownloadTask.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) { - guard let outputURL = try? fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: downloadedAsset.playlist.id.rawValue, episode: downloadedAsset.episodeId.rawValue) else { - return - } do { try saveVideo(asset: downloadedAsset, location: downloadedAsset.location!) } catch { @@ -315,11 +329,12 @@ extension OfflineDownloadManager: AVAssetDownloadDelegate { guard let task = task as? AVAggregateAssetDownloadTask else { return } guard error == nil else { - if let idx = downloadingItems.firstIndex(where: { $0.url.absoluteString == task.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) { - downloadingItems[idx].status = .finished - } return } + if let idx = downloadingItems.firstIndex(where: { $0.url.absoluteString == task.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) { + downloadingItems[idx].status = .finished + NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: ["type": Notification.Name.AssetDownloadStateChanged, "taskId": downloadingItems[idx].taskId, "status": OfflineManagerClient.StatusType.finished]) + } } } @@ -352,6 +367,8 @@ extension Notification.Name { /// Notification for when the download state of an Asset has changed. static let AssetDownloadStateChanged = Notification.Name(rawValue: "AssetDownloadStateChangedNotification") + static let AssetDownloadTaskChanged = Notification.Name(rawValue: "AssetDownloadTaskChanged") + /// Notification for when AssetPersistenceManager has completely restored its state. static let AssetPersistenceManagerDidRestoreState = Notification.Name(rawValue: "AssetPersistenceManagerDidRestoreStateNotification") } diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift index b142ac2..94f599b 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift @@ -37,49 +37,69 @@ extension DownloadQueueFeature.View: View { } Spacer() - switch item.status { - case .suspended: - CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: .gray, width: 4, blurRadius: 0)) { - Image(systemName: "play.fill") + switch item.status { + case .suspended: + CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: Theme.pastelRed.opacity(0.4), width: 4, blurRadius: 0)) { + Image(systemName: "play.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(6) + .foregroundStyle(Theme.pastelRed) + } + .onTapGesture { + viewStore.send(.pause(item)) + } + .frame(width: 30, height: 30) + .animation(.easeInOut, value: item.status) + case .finished: + Image(systemName: "checkmark.circle.fill") .resizable() .aspectRatio(contentMode: .fit) - .padding(6) + .frame(width: 29, height: 29) .foregroundStyle(Theme.pastelRed) - } - .onTapGesture { - viewStore.send(.pause(item)) - } - .frame(width: 30, height: 30) - case .finished: - Image(systemName: "checkmark.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(6) - .foregroundStyle(Theme.pastelRed) - case .cancelled: - EmptyView() - case .downloading: - CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: Theme.pastelRed, width: 4, blurRadius: 0)) { - Image(systemName: "pause.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(6) - .foregroundStyle(Theme.pastelRed) - } - .onTapGesture { - viewStore.send(.pause(item)) - } - .frame(width: 30, height: 30) - } - + .animation(.easeInOut, value: item.status) + case .cancelled: + EmptyView() + case .downloading: + CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: Theme.pastelRed, width: 4, blurRadius: 0)) { + Image(systemName: "pause.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(6) + .foregroundStyle(Theme.pastelRed) + } + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + .onTapGesture { + viewStore.send(.pause(item)) + } + .animation(.easeInOut, value: item.status) + } } } } .frame(maxWidth: .infinity) .padding() + .navigationTitle("Download Queue") .onAppear { viewStore.send(.didAppear) } } } } + +import OfflineManagerClient +#Preview { + DownloadQueueFeature.View( + store: .init( + initialState: .init( + downloadQueue: [ + OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0, image: URL(string: "https://fastly.picsum.photos/id/306/200/300.jpg?hmac=T-FQeWIc7YbLbcYdpyDGypNif0btJ8n5P4ozBJx8WgE")!, playlistName: "downloading", title: "Test 3", taskId: 0, status: .downloading), + OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 1, image: URL(string: "https://fastly.picsum.photos/id/1006/200/300.jpg?hmac=8H_lylM_UA6ot7bOUTm-ZzZkGKHmdjC-QU4yB3Xo5aQ")!, playlistName: "finished", title: "Test 2", taskId: 1, status: .finished), + OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0.35, image: URL(string: "https://fastly.picsum.photos/id/978/200/300.jpg?hmac=sP2_huC-v5a6cNxpdmxp1FPInoDET7j7O3GoftdaEJk")!, playlistName: "suspended", title: "Test 1", taskId: 2, status: .suspended) + ] + ), + reducer: { EmptyReducer() } + ) + ) +} From a0813c2adf31576d71c852b903a66a0884cbe024 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Mon, 20 May 2024 22:48:59 +0200 Subject: [PATCH 19/45] fix: change structure --- .../Clients/OfflineManagerClient/Live.swift | 8 +++---- .../Clients/OfflineManagerClient/Models.swift | 22 +++++++++---------- .../ContentCore/ContentCore+View.swift | 2 +- .../Features/ContentCore/ContentCore.swift | 2 +- .../ContentCore/DownloadSelection.swift | 18 +++++++-------- .../DownloadQueueFeature+View.swift | 6 ++--- .../PlaylistDetailsFeature+Reducer.swift | 6 ++--- Sources/Shared/SharedModels/Playlist.swift | 2 +- 8 files changed, 32 insertions(+), 34 deletions(-) diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift index 535d6a3..bcb472e 100644 --- a/Sources/Clients/OfflineManagerClient/Live.swift +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -65,7 +65,7 @@ extension OfflineManagerClient: DependencyKey { .init { continuation in let cancellable = Task.detached { var values = downloadManager.downloadingItems.compactMap { - DownloadingItem(id: $0.metadata.link.url, percentComplete: $0.percentage, image: $0.playlist.posterImage ?? $0.playlist.bannerImage ?? URL(string: "")!, playlistName: $0.playlist.title ?? "", title: $0.episodeTitle, taskId: $0.taskId, status: $0.status) + DownloadingItem(id: $0.metadata.link.url, percentComplete: $0.percentage, image: $0.playlist.posterImage ?? $0.playlist.bannerImage ?? URL(string: "")!, playlistName: $0.playlist.title ?? "", title: $0.episode.title ?? "Unknown Title", epNumber: 0, taskId: $0.taskId, status: $0.status) } continuation.yield(values) @@ -123,7 +123,7 @@ private class OfflineDownloadManager: NSObject { public func setupAssetDownload(_ asset: OfflineManagerClient.DownloadAsset) async throws { await initializeRoutes(asset) try await server.waitUntilListening() - let options = [AVURLAssetAllowsCellularAccessKey: false] + let options = ["AVURLAssetHTTPHeaderFieldsKey": asset.headers] as [String : Any] let libraryFileUrl = try fileClient.retrieveLibraryDirectory(root: .playlistCache) let playlist = asset.playlist let avAsset = AVURLAsset(url: URL(string: "http://localhost:64390/download.m3u?url=\(asset.episodeMetadata.link.url.absoluteString)")!, options: options) @@ -136,7 +136,7 @@ private class OfflineDownloadManager: NSObject { options: nil) else { throw OfflineManagerClient.Error.failedToCreateDownloadTask } - downloadingItems.append(.init(url: asset.episodeMetadata.link.url, playlist: playlist, episodeId: asset.episodeId, episodeTitle: asset.episodeTitle, metadata: asset.episodeMetadata, taskId: downloadTask.taskIdentifier, status: .downloading)) + downloadingItems.append(.init(url: asset.episodeMetadata.link.url, playlist: playlist, episode: asset.episode, metadata: asset.episodeMetadata, taskId: downloadTask.taskIdentifier, status: .downloading)) let playlistId = playlist.id.rawValue.replacingOccurrences(of: "/", with: "\\") let imageUrl = libraryFileUrl.appendingPathComponent(playlistId).appendingPathComponent("posterImage.jpeg") @@ -340,7 +340,7 @@ extension OfflineDownloadManager: AVAssetDownloadDelegate { extension OfflineDownloadManager { private func saveVideo(asset: OfflineManagerClient.DownloadingAsset, location: URL) throws { - let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlist.id.rawValue, episode: asset.episodeId.rawValue) + let outputURL = try fileClient.retrieveLibraryDirectory(root: .downloaded, playlist: asset.playlist.id.rawValue, episode: asset.episode.id.rawValue) debugPrint("File saved to: \(outputURL)") if (FileManager.default.fileExists(atPath: outputURL.path)) { try FileManager.default.removeItem(at: outputURL.appendingPathComponent("data").appendingPathExtension("movpkg")) diff --git a/Sources/Clients/OfflineManagerClient/Models.swift b/Sources/Clients/OfflineManagerClient/Models.swift index 8877c18..1fdb78d 100644 --- a/Sources/Clients/OfflineManagerClient/Models.swift +++ b/Sources/Clients/OfflineManagerClient/Models.swift @@ -23,17 +23,17 @@ extension OfflineManagerClient { public struct DownloadAsset: Equatable, Sendable { public let episodeMetadata: EpisodeMetadata - public let episodeId: Playlist.Item.ID - public let episodeTitle: String + public let episode: Playlist.Item + public let headers: [String: String] public let groups: [Playlist.Group]? public let playlist: Playlist public let details: Playlist.Details? public let repoModuleId: RepoModuleID - public init(episodeMetadata: EpisodeMetadata, episodeId: Playlist.Item.ID, episodeTitle: String, groups: [Playlist.Group]?, playlist: Playlist, details: Playlist.Details?, repoModuleId: RepoModuleID) { + public init(episodeMetadata: EpisodeMetadata, headers: [String: String], episode: Playlist.Item, groups: [Playlist.Group]?, playlist: Playlist, details: Playlist.Details?, repoModuleId: RepoModuleID) { self.episodeMetadata = episodeMetadata - self.episodeId = episodeId - self.episodeTitle = episodeTitle + self.headers = headers + self.episode = episode self.groups = groups self.playlist = playlist self.details = details @@ -61,15 +61,17 @@ extension OfflineManagerClient { public let image: URL public let playlistName: String public let title: String + public let epNumber: Int public let taskId: Int public var status: StatusType - public init(id: URL, percentComplete: Double, image: URL, playlistName: String, title: String, taskId: Int, status: StatusType) { + public init(id: URL, percentComplete: Double, image: URL, playlistName: String, title: String, epNumber: Int, taskId: Int, status: StatusType) { self.id = id self.percentComplete = percentComplete self.image = image self.playlistName = playlistName self.title = title + self.epNumber = epNumber self.taskId = taskId self.status = status } @@ -85,19 +87,17 @@ extension OfflineManagerClient { public struct DownloadingAsset: Hashable { public let url: URL public let playlist: Playlist - public let episodeId: Playlist.Item.ID - public let episodeTitle: String + public let episode: Playlist.Item public let metadata: EpisodeMetadata public var location: URL? public var percentage: Double = 0 public var taskId: Int public var status: StatusType - public init(url: URL, playlist: Playlist, episodeId: Playlist.Item.ID, episodeTitle: String, metadata: EpisodeMetadata, location: URL? = nil, taskId: Int, status: StatusType) { + public init(url: URL, playlist: Playlist, episode: Playlist.Item, metadata: EpisodeMetadata, location: URL? = nil, taskId: Int, status: StatusType) { self.url = url self.playlist = playlist - self.episodeId = episodeId - self.episodeTitle = episodeTitle + self.episode = episode self.metadata = metadata self.location = location self.taskId = taskId diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index 448edf8..c9f7e18 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -381,7 +381,7 @@ extension ContentCore { } if let selectedQuality = viewStore.state.selectedQuality { Button { - store.send(.download(viewStore.selectedSource!, viewStore.selectedServer!, selectedQuality, viewStore.selectedSubtitle != nil ? [viewStore.selectedSubtitle!] : [], serverResponse.skipTimes, viewStore.state.episodeId, viewStore.state.episodeTitle)) + store.send(.download(viewStore.selectedSource!, viewStore.selectedServer!, selectedQuality, viewStore.selectedSubtitle != nil ? [viewStore.selectedSubtitle!] : [], serverResponse.skipTimes, viewStore.state.episode, serverResponse.headers)) } label: { Text("Download") } diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index cad848d..ea7102b 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -104,7 +104,7 @@ public struct ContentCore: Reducer { return state.fetchContent(option) case let .didTapDownloadPlaylist(episode): - state.downloadSelection = .selection(.init(repoModuleId: state.repoModuleId, playlistId: state.playlist.id, episodeId: episode.id, episodeTitle: episode.title ?? "Unknown Title")) + state.downloadSelection = .selection(.init(repoModuleId: state.repoModuleId, playlistId: state.playlist.id, episode: episode)) case let .didTapPlaylistItem(groupId, variantId, pageId, itemId, shouldReset): @Dependency(\.playlistHistoryClient) var playlistHistoryClient diff --git a/Sources/Features/ContentCore/DownloadSelection.swift b/Sources/Features/ContentCore/DownloadSelection.swift index 6762974..46c94ec 100644 --- a/Sources/Features/ContentCore/DownloadSelection.swift +++ b/Sources/Features/ContentCore/DownloadSelection.swift @@ -35,8 +35,7 @@ public struct DownloadSelection: Reducer { public struct State: Equatable, Sendable { public let repoModuleId: RepoModuleID public let playlistId: Playlist.ID - public let episodeId: Playlist.Item.ID - public let episodeTitle: String + public let episode: Playlist.Item public var sources: Loadable<[Playlist.EpisodeSource]> public var serverResponse: Loadable @@ -46,11 +45,10 @@ public struct DownloadSelection: Reducer { public var selectedQuality: Playlist.EpisodeServer.Link? = nil public var selectedSubtitle: Playlist.EpisodeServer.Subtitle? = nil - public init(repoModuleId: RepoModuleID, playlistId: Playlist.ID, episodeId: Playlist.Item.ID, episodeTitle: String, sources: Loadable<[Playlist.EpisodeSource]> = .pending, serverResponse: Loadable = .pending) { + public init(repoModuleId: RepoModuleID, playlistId: Playlist.ID, episode: Playlist.Item, sources: Loadable<[Playlist.EpisodeSource]> = .pending, serverResponse: Loadable = .pending) { self.repoModuleId = repoModuleId self.playlistId = playlistId - self.episodeId = episodeId - self.episodeTitle = episodeTitle + self.episode = episode self.sources = sources self.serverResponse = serverResponse } @@ -64,7 +62,7 @@ public struct DownloadSelection: Reducer { case selectQuality(Playlist.EpisodeServer.Link) case selectSubtitle(Playlist.EpisodeServer.Subtitle) case serverResponse(Loadable) - case download(Playlist.EpisodeSource, Playlist.EpisodeServer, Playlist.EpisodeServer.Link, [Playlist.EpisodeServer.Subtitle], [Playlist.EpisodeServer.SkipTime], Playlist.Item.ID, String) + case download(Playlist.EpisodeSource, Playlist.EpisodeServer, Playlist.EpisodeServer.Link, [Playlist.EpisodeServer.Subtitle], [Playlist.EpisodeServer.SkipTime], Playlist.Item, [String: String]) } public var body: some ReducerOf { @@ -72,7 +70,7 @@ public struct DownloadSelection: Reducer { switch action { case .didAppear: @Dependency(\.moduleClient) var moduleClient - let episodeId = state.episodeId + let episode = state.episode let playlistId = state.playlistId let repoModuleId = state.repoModuleId return .run { send in @@ -81,7 +79,7 @@ public struct DownloadSelection: Reducer { try await module.playlistEpisodeSources( .init( playlistId: playlistId, - episodeId: episodeId + episodeId: episode.id ) ) } @@ -108,7 +106,7 @@ public struct DownloadSelection: Reducer { guard let source = state.selectedSource else { return .none } - let episodeId = state.episodeId + let episode = state.episode let playlistId = state.playlistId let repoModuleId = state.repoModuleId @Dependency(\.moduleClient) var moduleClient @@ -119,7 +117,7 @@ public struct DownloadSelection: Reducer { try await module.playlistEpisodeServer( .init( playlistId: playlistId, - episodeId: episodeId, + episodeId: episode.id, sourceId: source.id, serverId: server.id ) diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift index 94f599b..cad8cd9 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift @@ -94,9 +94,9 @@ import OfflineManagerClient store: .init( initialState: .init( downloadQueue: [ - OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0, image: URL(string: "https://fastly.picsum.photos/id/306/200/300.jpg?hmac=T-FQeWIc7YbLbcYdpyDGypNif0btJ8n5P4ozBJx8WgE")!, playlistName: "downloading", title: "Test 3", taskId: 0, status: .downloading), - OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 1, image: URL(string: "https://fastly.picsum.photos/id/1006/200/300.jpg?hmac=8H_lylM_UA6ot7bOUTm-ZzZkGKHmdjC-QU4yB3Xo5aQ")!, playlistName: "finished", title: "Test 2", taskId: 1, status: .finished), - OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0.35, image: URL(string: "https://fastly.picsum.photos/id/978/200/300.jpg?hmac=sP2_huC-v5a6cNxpdmxp1FPInoDET7j7O3GoftdaEJk")!, playlistName: "suspended", title: "Test 1", taskId: 2, status: .suspended) + OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0, image: URL(string: "https://fastly.picsum.photos/id/306/200/300.jpg?hmac=T-FQeWIc7YbLbcYdpyDGypNif0btJ8n5P4ozBJx8WgE")!, playlistName: "downloading", title: "Test 3", epNumber: 1, taskId: 0, status: .downloading), + OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 1, image: URL(string: "https://fastly.picsum.photos/id/1006/200/300.jpg?hmac=8H_lylM_UA6ot7bOUTm-ZzZkGKHmdjC-QU4yB3Xo5aQ")!, playlistName: "finished", title: "Test 2", epNumber: 2, taskId: 1, status: .finished), + OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0.35, image: URL(string: "https://fastly.picsum.photos/id/978/200/300.jpg?hmac=sP2_huC-v5a6cNxpdmxp1FPInoDET7j7O3GoftdaEJk")!, playlistName: "suspended", title: "Test 1", epNumber: 3, taskId: 2, status: .suspended) ] ), reducer: { EmptyReducer() } diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift index 94e8982..eaa3171 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift @@ -113,7 +113,7 @@ extension PlaylistDetailsFeature { state.details = loadable break - case let .internal(.content(.downloadSelection(.presented(.selection(.download(source, server, link, subtitles, skipTimes, episodeId, episodeTitle)))))): + case let .internal(.content(.downloadSelection(.presented(.selection(.download(source, server, link, subtitles, skipTimes, episode, headers)))))): let playlist = state.playlist let details = state.details.value let groups = state.content.groups.value @@ -121,8 +121,8 @@ extension PlaylistDetailsFeature { return .run { send in try await offlineManagerClient.download(.init( episodeMetadata: .init(link: link, source: source, subtitles: subtitles, server: server, skipTimes: skipTimes), - episodeId: episodeId, - episodeTitle: episodeTitle, + headers: headers, + episode: episode, groups: groups, playlist: playlist, details: details, diff --git a/Sources/Shared/SharedModels/Playlist.swift b/Sources/Shared/SharedModels/Playlist.swift index 6e5dc35..1651e61 100644 --- a/Sources/Shared/SharedModels/Playlist.swift +++ b/Sources/Shared/SharedModels/Playlist.swift @@ -121,7 +121,7 @@ extension Playlist { // MARK: Playlist.Item extension Playlist { - public struct Item: Sendable, Equatable, Identifiable, Codable { + public struct Item: Sendable, Equatable, Identifiable, Codable, Hashable { public let id: Tagged public let title: String? public let description: String? From d96ef5d36f82704d7190ae73cc787af1a47c25e6 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Fri, 24 May 2024 00:03:33 +0200 Subject: [PATCH 20/45] feat: removed authors per request. Headers are now added to hls downloader. Logger shows errors that caused the download to fail. --- App/Mochi.xcodeproj/project.pbxproj | 8 +- App/Shared/AppDelegate.swift | 2 +- App/Shared/MochiApp.swift | 2 +- App/Shared/mochi-info.plist | 2 +- App/iOS/PreferenceHostingController.swift | 2 +- App/iOS/PreferenceHostingView.swift | 2 +- Package.swift | 116 +++++++++--------- Package/Sources/Clients/AnalyticsClient.swift | 2 +- Package/Sources/Clients/BuildClient.swift | 2 +- Package/Sources/Clients/ClipboardClient.swift | 2 +- Package/Sources/Clients/DatabaseClient.swift | 2 +- Package/Sources/Clients/DeviceClient.swift | 2 +- Package/Sources/Clients/FileClient.swift | 2 +- .../Sources/Clients/LocalizableClient.swift | 2 +- Package/Sources/Clients/LoggerClient.swift | 2 +- Package/Sources/Clients/ModuleClient.swift | 2 +- .../Clients/OfflineManagerClient.swift | 2 +- Package/Sources/Clients/PlayerClient.swift | 2 +- .../Clients/PlaylistHistoryClient.swift | 2 +- Package/Sources/Clients/RepoClient.swift | 2 +- .../Sources/Clients/UserDefaultsClient.swift | 2 +- .../Sources/Clients/UserSettingsClient.swift | 2 +- Package/Sources/Clients/_Client.swift | 2 +- .../Dependencies/ComposableArchitecture.swift | 2 +- Package/Sources/Dependencies/CustomDump.swift | 2 +- .../Sources/Dependencies/FluidGradient.swift | 2 +- Package/Sources/Dependencies/FlyingFox.swift | 2 +- Package/Sources/Dependencies/Nuke.swift | 2 +- Package/Sources/Dependencies/Parsing.swift | 2 +- Package/Sources/Dependencies/Semaphore.swift | 2 +- Package/Sources/Dependencies/Semver.swift | 2 +- Package/Sources/Dependencies/SwiftLog.swift | 2 +- Package/Sources/Dependencies/SwiftSoup.swift | 2 +- .../Sources/Dependencies/SwiftSyntax.swift | 2 +- .../Dependencies/SwiftUIBackports.swift | 2 +- Package/Sources/Dependencies/Tagged.swift | 2 +- Package/Sources/Dependencies/XMLCoder.swift | 2 +- Package/Sources/Features/ContentCore.swift | 2 +- Package/Sources/Features/Discover.swift | 2 +- Package/Sources/Features/DownloadQueue.swift | 2 +- Package/Sources/Features/Library.swift | 2 +- Package/Sources/Features/MochiApp.swift | 2 +- Package/Sources/Features/ModuleLists.swift | 2 +- .../Sources/Features/PlaylistDetails.swift | 2 +- Package/Sources/Features/Repos.swift | 2 +- Package/Sources/Features/Search.swift | 2 +- Package/Sources/Features/Settings.swift | 2 +- Package/Sources/Features/VideoPlayer.swift | 2 +- Package/Sources/Features/_Feature.swift | 2 +- Package/Sources/Index.swift | 2 +- Package/Sources/Macros/CoreDBMacros.swift | 2 +- Package/Sources/Macros/_Macro.swift | 2 +- .../Sources/Platforms/MochiPlatforms.swift | 2 +- Package/Sources/Shared/Architecture.swift | 2 +- Package/Sources/Shared/CoreDB.swift | 2 +- .../Sources/Shared/FoundationHelpers.swift | 2 +- Package/Sources/Shared/JSValueCoder.swift | 2 +- Package/Sources/Shared/SharedModels.swift | 2 +- Package/Sources/Shared/Styling.swift | 2 +- Package/Sources/Shared/ViewComponents.swift | 2 +- Package/Sources/Shared/_Shared.swift | 2 +- Package/Support/CSettingsBuilder.swift | 2 +- Package/Support/Macro.swift | 2 +- Package/Support/Testable.swift | 2 +- Package/Support/_Path.swift | 2 +- Sources/Clients/AnalyticsClient/Client.swift | 2 +- Sources/Clients/AnalyticsClient/Live.swift | 2 +- Sources/Clients/AnalyticsClient/Models.swift | 2 +- Sources/Clients/AnalyticsClient/Reducer.swift | 2 +- Sources/Clients/BuildClient/Client.swift | 2 +- Sources/Clients/BuildClient/Model.swift | 2 +- Sources/Clients/ClipboardClient/Client.swift | 2 +- Sources/Clients/ClipboardClient/Live.swift | 2 +- Sources/Clients/DatabaseClient/Client.swift | 2 +- Sources/Clients/DatabaseClient/Exports.swift | 2 +- Sources/Clients/DatabaseClient/Live.swift | 2 +- .../Clients/DatabaseClient/MochiSchema.swift | 2 +- .../Clients/DatabaseClient/Models/Entry.swift | 2 +- .../DatabaseClient/Models/EntryItem.swift | 2 +- .../Models/Extensions/Module+.swift | 2 +- .../Models/Extensions/PlaylistHistory+.swift | 2 +- .../Models/Extensions/Repo+.swift | 2 +- .../DatabaseClient/Models/Module.swift | 2 +- .../Models/PlaylistHistory.swift | 2 +- .../Clients/DatabaseClient/Models/Repo.swift | 2 +- Sources/Clients/DeviceClient/Client.swift | 2 +- Sources/Clients/FileClient/Client+.swift | 2 +- Sources/Clients/FileClient/Client.swift | 2 +- Sources/Clients/FileClient/Live.swift | 2 +- .../Clients/LocalizableClient/Client.swift | 2 +- .../LocalizableClient/Localizable.swift | 2 +- Sources/Clients/LoggerClient/Client.swift | 4 +- Sources/Clients/LoggerClient/Models.swift | 2 +- Sources/Clients/ModuleClient/Client.swift | 2 +- .../ModuleClient/Extensions/JSContext+.swift | 2 +- .../ModuleClient/Extensions/JSValue+.swift | 2 +- Sources/Clients/ModuleClient/Instance.swift | 2 +- .../JS+Bindings/JSContext+Console.swift | 2 +- .../JS+Bindings/JSContext+JSRuntime.swift | 2 +- .../JS+Bindings/JSContext+Request.swift | 2 +- .../ModuleClient/JS+Bindings/JSRuntime.swift | 2 +- Sources/Clients/ModuleClient/Live.swift | 2 +- Sources/Clients/ModuleClient/Logger.swift | 2 +- .../Clients/OfflineManagerClient/Client.swift | 2 +- .../Clients/OfflineManagerClient/Live.swift | 99 ++++++++++++--- .../Clients/OfflineManagerClient/Models.swift | 4 +- Sources/Clients/PlayerClient/Client.swift | 2 +- .../AVMediaSelectionGroup+Struct.swift | 2 +- .../PlayerClient/Extension/AVPlayer+.swift | 2 +- .../Internal/PlayerItem+DASH.swift | 2 +- .../Internal/PlayerItem+HLS.swift | 2 +- .../PlayerClient/Internal/PlayerItem.swift | 2 +- Sources/Clients/PlayerClient/Live.swift | 2 +- Sources/Clients/PlayerClient/Models.swift | 2 +- .../Views/PlayerRoutePickerView.swift | 2 +- .../PlayerClient/Views/PlayerView.swift | 2 +- .../PlaylistHistoryClient/Client.swift | 2 +- .../Clients/PlaylistHistoryClient/Live.swift | 2 +- .../PlaylistHistoryClient/Models.swift | 2 +- Sources/Clients/RepoClient/Client.swift | 2 +- Sources/Clients/RepoClient/Live.swift | 2 +- Sources/Clients/RepoClient/Models.swift | 2 +- .../Clients/UserDefaultsClient/Client.swift | 2 +- Sources/Clients/UserDefaultsClient/Live.swift | 2 +- .../Clients/UserSettingsClient/AppIcon.swift | 2 +- .../Clients/UserSettingsClient/Client.swift | 2 +- Sources/Clients/UserSettingsClient/Live.swift | 2 +- .../Clients/UserSettingsClient/Theme.swift | 2 +- .../UserSettingsClient/UserSettings.swift | 2 +- Sources/Features/App/AppDelegateFeature.swift | 2 +- Sources/Features/App/AppFeature+Reducer.swift | 2 +- Sources/Features/App/AppFeature.swift | 2 +- Sources/Features/App/Exported.swift | 2 +- .../Features/App/iOS/AppFeatureView+iOS.swift | 2 +- .../App/macOS/AppFeatureView+macOS.swift | 2 +- .../ContentCore/ContentCore+View.swift | 2 +- .../Features/ContentCore/ContentCore.swift | 2 +- .../ContentCore/DownloadSelection.swift | 2 +- .../Discover/DiscoverFeature+Reducer.swift | 2 +- .../Discover/DiscoverFeature+View.swift | 2 +- .../Features/Discover/DiscoverFeature.swift | 2 +- .../Features/Discover/ViewMoreListing.swift | 2 +- Sources/Features/Discover/WebView.swift | 2 +- .../DownloadQueueFeature+Reducer.swift | 2 +- .../DownloadQueueFeature+View.swift | 9 +- .../DownloadQueue/DownloadQueueFeature.swift | 2 +- .../LIbrary/LibraryFeature+Reducer.swift | 2 +- .../LIbrary/LibraryFeature+View.swift | 2 +- Sources/Features/LIbrary/LibraryFeature.swift | 2 +- .../ModuleListsFeature+Reducer.swift | 2 +- .../ModuleLists/ModuleListsFeature+View.swift | 4 +- .../ModuleLists/ModuleListsFeature.swift | 2 +- .../PlaylistDetailsFeature+Reducer.swift | 2 +- .../PlaylistDetailsFeature.swift | 2 +- .../iOS/PlaylistDetailsFeature+View+iOS.swift | 2 +- .../PlaylistDetailsFeature+View+macOS.swift | 2 +- .../Components/RepoURLTextField+iOS.swift | 2 +- .../Components/RepoURLTextField+macOS.swift | 2 +- .../RepoPackagesFeature+Reducer.swift | 2 +- .../RepoPackagesFeature+View.swift | 4 +- .../RepoPackages/RepoPackagesFeature.swift | 2 +- .../Features/Repos/ReposFeature+Reducer.swift | 2 +- .../Features/Repos/ReposFeature+View.swift | 2 +- Sources/Features/Repos/ReposFeature.swift | 2 +- .../Search/SearchFeature+Reducer.swift | 2 +- .../Features/Search/SearchFeature+View.swift | 2 +- Sources/Features/Search/SearchFeature.swift | 2 +- .../Features/Settings/Components/Logs.swift | 2 +- .../Platforms/SettingsFeature+iOS.swift | 4 +- .../Platforms/SettingsFeature+macOS.swift | 2 +- .../Settings/SettingsFeature+Reducer.swift | 2 +- .../Settings/SettingsFeature+View.swift | 2 +- .../Features/Settings/SettingsFeature.swift | 2 +- .../VideoPlayer/Components/ProgressBar.swift | 2 +- .../Extensions/DateComponentsFormatter+.swift | 2 +- Sources/Features/VideoPlayer/Models.swift | 2 +- .../VideoPlayerFeature+Reducer.swift | 2 +- .../VideoPlayer/VideoPlayerFeature+View.swift | 2 +- .../VideoPlayer/VideoPlayerFeature.swift | 2 +- .../iOS/VideoPlayerFeature+iOS.swift | 2 +- .../macOS/VideoPlayerFeature+macOS.swift | 2 +- .../Macros/CoreDBMacros/AttributeMacro.swift | 2 +- Sources/Macros/CoreDBMacros/EntityMacro.swift | 2 +- Sources/Macros/CoreDBMacros/Helpers.swift | 2 +- Sources/Macros/CoreDBMacros/Plugins.swift | 2 +- .../Macros/CoreDBMacros/RelationMacro.swift | 2 +- ...Dependencies+DateComponentsFormatter.swift | 2 +- .../Dependencies+DateFormater.swift | 2 +- .../Dependencies+NumberFormatter.swift | 2 +- Sources/Shared/Architecture/Exported.swift | 2 +- Sources/Shared/Architecture/Feature.swift | 2 +- .../Shared/Architecture/TCA+Extensions.swift | 2 +- .../Utils/Binding+Equatable.swift | 2 +- .../Architecture/Utils/SelectableState.swift | 2 +- .../Shared/Architecture/Utils/Swizzle.swift | 2 +- .../CoreDB/Extension/EntityDescription.swift | 2 +- .../CoreDB/Extension/NSManagedObject+.swift | 2 +- .../Extension/NSManagedObjectContext+.swift | 2 +- .../Extension/NSManagedObjectModel+.swift | 2 +- .../Extension/NSPersistentContainer+.swift | 2 +- .../CoreDB/Extension/NSPersistentStore+.swift | 2 +- .../Extension/NSPropertyDescriptors+.swift | 2 +- Sources/Shared/CoreDB/Macros.swift | 2 +- Sources/Shared/CoreDB/PersistentCoreDB.swift | 2 +- .../Shared/CoreDB/Properties/Attribute.swift | 2 +- .../Shared/CoreDB/Properties/Property.swift | 2 +- .../Shared/CoreDB/Properties/Relation.swift | 2 +- .../Shared/CoreDB/Supporting Files/Cast.swift | 2 +- .../CoreDB/Supporting Files/Entity.swift | 2 +- .../CoreDB/Supporting Files/EntityID.swift | 2 +- .../CoreDB/Supporting Files/Optional.swift | 2 +- .../CoreDB/Supporting Files/Request.swift | 2 +- .../CoreDB/Supporting Files/Schema.swift | 2 +- .../Supporting Files/TransformableValue.swift | 2 +- .../Shared/FoundationHelpers/Array+ID.swift | 2 +- .../Shared/FoundationHelpers/Equatable+.swift | 2 +- .../Shared/FoundationHelpers/KeyPath+.swift | 2 +- Sources/Shared/FoundationHelpers/URL+.swift | 2 +- .../JSVEnumAssociatedCodable.swift | 2 +- .../JSValueCoder/JSValueCodingKey.swift | 2 +- .../Shared/JSValueCoder/JSValueDecoder.swift | 2 +- .../Shared/JSValueCoder/JSValueEncoder.swift | 2 +- .../Shared/SharedModels/EpisodeMetadata.swift | 2 +- .../SharedModels/Extensions/Entry+.swift | 2 +- Sources/Shared/SharedModels/Image.swift | 2 +- .../SharedModels/LibraryDirectory.swift | 2 +- Sources/Shared/SharedModels/Meta.swift | 2 +- Sources/Shared/SharedModels/Playlist.swift | 2 +- .../Shared/SharedModels/PlaylistCache.swift | 2 +- .../Shared/SharedModels/RepoModuleID.swift | 4 +- Sources/Shared/SharedModels/Text.swift | 2 +- .../SharedModels/Utilities/Loadable.swift | 2 +- .../SharedModels/Utilities/Paging.swift | 2 +- Sources/Shared/SharedModels/Video.swift | 2 +- Sources/Shared/Styling/NavStack.swift | 2 +- Sources/Shared/Styling/Popups.swift | 2 +- .../Shared/Styling/ScaledButtonStyle.swift | 2 +- .../Styling/Settings/SettingsGroup.swift | 2 +- .../Shared/Styling/Settings/SettingsRow.swift | 2 +- Sources/Shared/Styling/SheetView.swift | 2 +- Sources/Shared/Styling/StatusView.swift | 2 +- Sources/Shared/Styling/ThemeModifier.swift | 2 +- Sources/Shared/Styling/TopBar.swift | 2 +- Sources/Shared/Styling/_Exported.swift | 2 +- Sources/Shared/Styling/macOS/NSWindow+.swift | 2 +- Sources/Shared/ViewComponents/ChipView.swift | 2 +- .../ViewComponents/CircularProgressView.swift | 2 +- .../Shared/ViewComponents/DynamicStack.swift | 2 +- .../ViewComponents/ElasticParallaxView.swift | 2 +- .../ViewComponents/ExpandableText.swift | 2 +- .../ViewComponents/Extensions/Color+Ext.swift | 2 +- .../Extensions/Gradient+Easing.swift | 2 +- .../Extensions/PlatformColor+Ext.swift | 2 +- .../ViewComponents/Extensions/Shape+Ext.swift | 2 +- .../Extensions/View+Squircle.swift | 2 +- .../ViewComponents/FillAspectImage.swift | 2 +- .../ViewComponents/InsetValue+Values.swift | 2 +- .../Shared/ViewComponents/InsetValue.swift | 2 +- Sources/Shared/ViewComponents/LazyView.swift | 2 +- .../Shared/ViewComponents/LoadableView.swift | 2 +- Sources/Shared/ViewComponents/NukeImage.swift | 2 +- .../Shared/ViewComponents/OnInitialTask.swift | 2 +- .../PlatformViewRepresentable.swift | 2 +- .../Shared/ViewComponents/Refreshable.swift | 2 +- .../ViewComponents/ScrollViewTracker.swift | 2 +- .../Shared/ViewComponents/SheetDetent.swift | 2 +- .../Shared/ViewComponents/SnapScroll.swift | 2 +- Sources/Shared/ViewComponents/Swipable.swift | 2 +- .../Shared/ViewComponents/View+ReadSize.swift | 2 +- .../iOS/View+HomeIndicator.swift | 2 +- .../macOS/ToolbarAccessory.swift | 2 +- Tests/CoreDBTests/CoreDBTests.swift | 2 +- Tests/CoreDBTests/Models.swift | 2 +- .../DatabaseClientTests.swift | 2 +- .../JSValueCoderTests/JSValueCoderTests.swift | 2 +- Tests/ModuleClientTests/JSRunnerTests.swift | 2 +- cog.toml | 2 +- fastlane/Appfile | 2 +- 278 files changed, 430 insertions(+), 362 deletions(-) diff --git a/App/Mochi.xcodeproj/project.pbxproj b/App/Mochi.xcodeproj/project.pbxproj index f701c88..8c209fb 100644 --- a/App/Mochi.xcodeproj/project.pbxproj +++ b/App/Mochi.xcodeproj/project.pbxproj @@ -376,7 +376,7 @@ CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GYXF583PFT; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -394,7 +394,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.0.1; - PRODUCT_BUNDLE_IDENTIFIER = dev.errorerrorerror.mochi; + PRODUCT_BUNDLE_IDENTIFIER = "com.mochi-team.mochi"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; @@ -418,7 +418,7 @@ CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GYXF583PFT; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -436,7 +436,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.0.1; - PRODUCT_BUNDLE_IDENTIFIER = dev.errorerrorerror.mochi; + PRODUCT_BUNDLE_IDENTIFIER = "com.mochi-team.mochi"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; diff --git a/App/Shared/AppDelegate.swift b/App/Shared/AppDelegate.swift index 02f1394..0c2a3b7 100644 --- a/App/Shared/AppDelegate.swift +++ b/App/Shared/AppDelegate.swift @@ -2,7 +2,7 @@ // AppDelegate.swift // mochi // -// Created by ErrorErrorError on 5/19/23. +// Created by MochiTeam on 5/19/23. // // diff --git a/App/Shared/MochiApp.swift b/App/Shared/MochiApp.swift index d2c3bf9..84eecd7 100644 --- a/App/Shared/MochiApp.swift +++ b/App/Shared/MochiApp.swift @@ -2,7 +2,7 @@ // MochiApp.swift // mochi // -// Created by ErrorErrorError on 3/24/23. +// Created by MochiTeam on 3/24/23. // // diff --git a/App/Shared/mochi-info.plist b/App/Shared/mochi-info.plist index f26dfc4..7edbf4e 100644 --- a/App/Shared/mochi-info.plist +++ b/App/Shared/mochi-info.plist @@ -8,7 +8,7 @@ CFBundleTypeRole Viewer CFBundleURLName - dev.errorerrorerror.mochi + dev.MochiTeam.mochi CFBundleURLSchemes mochi diff --git a/App/iOS/PreferenceHostingController.swift b/App/iOS/PreferenceHostingController.swift index df94d3c..f353da3 100644 --- a/App/iOS/PreferenceHostingController.swift +++ b/App/iOS/PreferenceHostingController.swift @@ -2,7 +2,7 @@ // PreferenceHostingController.swift // mochi // -// Created by ErrorErrorError on 6/27/23. +// Created by MochiTeam on 6/27/23. // // diff --git a/App/iOS/PreferenceHostingView.swift b/App/iOS/PreferenceHostingView.swift index 3f9fcad..93b0898 100644 --- a/App/iOS/PreferenceHostingView.swift +++ b/App/iOS/PreferenceHostingView.swift @@ -2,7 +2,7 @@ // PreferenceHostingView.swift // Mochi // -// Created by ErrorErrorError on 11/28/23. +// Created by MochiTeam on 11/28/23. // // diff --git a/Package.swift b/Package.swift index 00a1fb5..d9d99c2 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ extension [TestTarget]: TestTargets { // CSettingsBuilder.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -100,7 +100,7 @@ extension LanguageTag { // Macro.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // @@ -531,7 +531,7 @@ protocol TestTargets: Sequence where Element == TestTarget { // Testable.swift // // -// Created by ErrorErrorError on 10/13/23. +// Created by MochiTeam on 10/13/23. // // @@ -673,7 +673,7 @@ extension _PackageDescription_Target { // _Path.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -692,7 +692,7 @@ extension _Path { // AnalyticsClient.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -707,7 +707,7 @@ struct AnalyticsClient: _Client { // BuildClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -723,7 +723,7 @@ struct BuildClient: _Client { // ClipboardClient.swift // // -// Created by ErrorErrorError on 12/15/23. +// Created by MochiTeam on 12/15/23. // // @@ -738,7 +738,7 @@ struct ClipboardClient: _Client { // DatabaseClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -760,7 +760,7 @@ struct DatabaseClient: _Client { // DeviceClient.swift // // -// Created by ErrorErrorError on 11/29/23. +// Created by MochiTeam on 11/29/23. // // @@ -773,7 +773,7 @@ struct DeviceClient: _Client { // FileClient.swift // // -// Created by ErrorErrorError on 10/6/23. +// Created by MochiTeam on 10/6/23. // // @@ -787,7 +787,7 @@ struct FileClient: _Client { // LocalizableClient.swift // // -// Created by ErrorErrorError on 12/1/23. +// Created by MochiTeam on 12/1/23. // // @@ -806,7 +806,7 @@ struct LocalizableClient: _Client { // LoggerClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -822,7 +822,7 @@ struct LoggerClient: _Client { // ModuleClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -864,7 +864,7 @@ extension ModuleClient: Testable { // OfflineManagerClient.swift // // -// Created by DeNeRr on 06.04.2024. +// Created by MochiTeam on 06.04.2024. // import Foundation @@ -881,7 +881,7 @@ struct OfflineManagerClient: _Client { // PlayerClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -903,7 +903,7 @@ struct PlayerClient: _Client { // PlaylistHistoryClient.swift // // -// Created by DeNeRr on 29.01.2024. +// Created by MochiTeam on 29.01.2024. // import Foundation @@ -921,7 +921,7 @@ struct PlaylistHistoryClient: _Client { // RepoClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -941,7 +941,7 @@ struct RepoClient: _Client { // UserDefaultsClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -956,7 +956,7 @@ struct UserDefaultsClient: _Client { // UserSettingsClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -973,7 +973,7 @@ struct UserSettingsClient: _Client { // _Client.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -992,7 +992,7 @@ extension _Client { // ComposableArchitecture.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -1005,7 +1005,7 @@ struct ComposableArchitecture: PackageDependency { // CustomDump.swift // // -// Created by ErrorErrorError on 1/1/24. +// Created by MochiTeam on 1/1/24. // // @@ -1020,7 +1020,7 @@ struct CustomDump: PackageDependency { // FluidGradient.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // @@ -1035,7 +1035,7 @@ struct FluidGradient: PackageDependency { // FlyingFox.swift // // -// Created by DeNeRr on 09.05.2024. +// Created by MochiTeam on 09.05.2024. // import Foundation @@ -1049,7 +1049,7 @@ struct FlyingFox: PackageDependency { // Nuke.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -1075,7 +1075,7 @@ struct NukeUI: PackageDependency { // Parsing.swift // // -// Created by ErrorErrorError on 12/17/23. +// Created by MochiTeam on 12/17/23. // // @@ -1088,7 +1088,7 @@ struct Parsing: PackageDependency { // Semaphore.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -1101,7 +1101,7 @@ struct Semaphore: PackageDependency { // Semver.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -1114,7 +1114,7 @@ struct Semver: PackageDependency { // SwiftLog.swift // // -// Created by ErrorErrorError on 11/9/23. +// Created by MochiTeam on 11/9/23. // // @@ -1144,7 +1144,7 @@ struct Logging: _Depending, Dependency { // SwiftSoup.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -1157,7 +1157,7 @@ struct SwiftSoup: PackageDependency { // SwiftSyntax.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // @@ -1198,7 +1198,7 @@ struct SwiftCompilerPlugin: _Depending, Dependency { // SwiftUIBackports.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -1211,7 +1211,7 @@ struct SwiftUIBackports: PackageDependency { // Tagged.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1226,7 +1226,7 @@ struct Tagged: PackageDependency { // XMLCoder.swift // // -// Created by ErrorErrorError on 12/27/23. +// Created by MochiTeam on 12/27/23. // // @@ -1239,7 +1239,7 @@ struct XMLCoder: PackageDependency { // ContentCore.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1262,7 +1262,7 @@ struct ContentCore: _Feature { // Discover.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1289,7 +1289,7 @@ struct Discover: _Feature { // DownloadQueue.swift // // -// Created by DeNeRr on 16.05.2024. +// Created by MochiTeam on 16.05.2024. // import Foundation @@ -1308,7 +1308,7 @@ struct DownloadQueue: _Feature { // Library.swift // // -// Created by DeNeRr on 09.04.2024. +// Created by MochiTeam on 09.04.2024. // import Foundation @@ -1331,7 +1331,7 @@ struct Library: _Feature { // MochiApp.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -1360,7 +1360,7 @@ struct MochiApp: _Feature { // ModuleLists.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1380,7 +1380,7 @@ struct ModuleLists: _Feature { // PlaylistDetails.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1406,7 +1406,7 @@ struct PlaylistDetails: _Feature { // Repos.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1429,7 +1429,7 @@ struct Repos: _Feature { // Search.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1454,7 +1454,7 @@ struct Search: _Feature { // Settings.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1478,7 +1478,7 @@ struct Settings: _Feature { // VideoPlayer.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1500,7 +1500,7 @@ struct VideoPlayer: _Feature { // _Feature.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1519,7 +1519,7 @@ extension _Feature { // CoreDBMacros.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // @@ -1533,7 +1533,7 @@ struct CoreDBMacros: _Macro { // _Macro.swift // // -// Created by ErrorErrorError on 10/27/23. +// Created by MochiTeam on 10/27/23. // // @@ -1552,7 +1552,7 @@ extension _Macro { // MochiPlatforms.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // @@ -1568,7 +1568,7 @@ struct MochiPlatforms: PlatformSet { // Architecture.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1584,7 +1584,7 @@ struct Architecture: _Shared { // CoreDB.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // @@ -1612,7 +1612,7 @@ extension CoreDB: Testable { // FoundationHelpers.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1621,7 +1621,7 @@ struct FoundationHelpers: _Shared {} // JSValueCoder.swift // // -// Created by ErrorErrorError on 11/6/23. +// Created by MochiTeam on 11/6/23. // // @@ -1646,7 +1646,7 @@ extension JSValueCoder: Testable { // SharedModels.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1665,7 +1665,7 @@ struct SharedModels: _Shared { // Styling.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1684,7 +1684,7 @@ struct Styling: _Shared { // ViewComponents.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1701,7 +1701,7 @@ struct ViewComponents: _Shared { // _Shared.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // @@ -1720,7 +1720,7 @@ extension _Shared { // Index.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Clients/AnalyticsClient.swift b/Package/Sources/Clients/AnalyticsClient.swift index 255b907..57b34dc 100644 --- a/Package/Sources/Clients/AnalyticsClient.swift +++ b/Package/Sources/Clients/AnalyticsClient.swift @@ -2,7 +2,7 @@ // AnalyticsClient.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Clients/BuildClient.swift b/Package/Sources/Clients/BuildClient.swift index 57c5e26..736cdc7 100644 --- a/Package/Sources/Clients/BuildClient.swift +++ b/Package/Sources/Clients/BuildClient.swift @@ -2,7 +2,7 @@ // BuildClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Clients/ClipboardClient.swift b/Package/Sources/Clients/ClipboardClient.swift index 4217b3f..a30ad55 100644 --- a/Package/Sources/Clients/ClipboardClient.swift +++ b/Package/Sources/Clients/ClipboardClient.swift @@ -2,7 +2,7 @@ // ClipboardClient.swift // // -// Created by ErrorErrorError on 12/15/23. +// Created by MochiTeam on 12/15/23. // // diff --git a/Package/Sources/Clients/DatabaseClient.swift b/Package/Sources/Clients/DatabaseClient.swift index 2f14ffc..2689f0b 100644 --- a/Package/Sources/Clients/DatabaseClient.swift +++ b/Package/Sources/Clients/DatabaseClient.swift @@ -2,7 +2,7 @@ // DatabaseClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Clients/DeviceClient.swift b/Package/Sources/Clients/DeviceClient.swift index 2e5fb6b..d683623 100644 --- a/Package/Sources/Clients/DeviceClient.swift +++ b/Package/Sources/Clients/DeviceClient.swift @@ -2,7 +2,7 @@ // DeviceClient.swift // // -// Created by ErrorErrorError on 11/29/23. +// Created by MochiTeam on 11/29/23. // // diff --git a/Package/Sources/Clients/FileClient.swift b/Package/Sources/Clients/FileClient.swift index ffd67cc..514768b 100644 --- a/Package/Sources/Clients/FileClient.swift +++ b/Package/Sources/Clients/FileClient.swift @@ -2,7 +2,7 @@ // FileClient.swift // // -// Created by ErrorErrorError on 10/6/23. +// Created by MochiTeam on 10/6/23. // // diff --git a/Package/Sources/Clients/LocalizableClient.swift b/Package/Sources/Clients/LocalizableClient.swift index 9d21838..8514e0e 100644 --- a/Package/Sources/Clients/LocalizableClient.swift +++ b/Package/Sources/Clients/LocalizableClient.swift @@ -2,7 +2,7 @@ // LocalizableClient.swift // // -// Created by ErrorErrorError on 12/1/23. +// Created by MochiTeam on 12/1/23. // // diff --git a/Package/Sources/Clients/LoggerClient.swift b/Package/Sources/Clients/LoggerClient.swift index 29c121c..a556245 100644 --- a/Package/Sources/Clients/LoggerClient.swift +++ b/Package/Sources/Clients/LoggerClient.swift @@ -2,7 +2,7 @@ // LoggerClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Clients/ModuleClient.swift b/Package/Sources/Clients/ModuleClient.swift index 57ff9ab..064b27d 100644 --- a/Package/Sources/Clients/ModuleClient.swift +++ b/Package/Sources/Clients/ModuleClient.swift @@ -2,7 +2,7 @@ // ModuleClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Clients/OfflineManagerClient.swift b/Package/Sources/Clients/OfflineManagerClient.swift index 1235115..82c48ec 100644 --- a/Package/Sources/Clients/OfflineManagerClient.swift +++ b/Package/Sources/Clients/OfflineManagerClient.swift @@ -2,7 +2,7 @@ // OfflineManagerClient.swift // // -// Created by DeNeRr on 06.04.2024. +// Created by MochiTeam on 06.04.2024. // import Foundation diff --git a/Package/Sources/Clients/PlayerClient.swift b/Package/Sources/Clients/PlayerClient.swift index cb3de95..a0903ff 100644 --- a/Package/Sources/Clients/PlayerClient.swift +++ b/Package/Sources/Clients/PlayerClient.swift @@ -2,7 +2,7 @@ // PlayerClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Clients/PlaylistHistoryClient.swift b/Package/Sources/Clients/PlaylistHistoryClient.swift index b6f0b5e..6ef2e42 100644 --- a/Package/Sources/Clients/PlaylistHistoryClient.swift +++ b/Package/Sources/Clients/PlaylistHistoryClient.swift @@ -2,7 +2,7 @@ // PlaylistHistoryClient.swift // // -// Created by DeNeRr on 29.01.2024. +// Created by MochiTeam on 29.01.2024. // import Foundation diff --git a/Package/Sources/Clients/RepoClient.swift b/Package/Sources/Clients/RepoClient.swift index 6a97f80..972d288 100644 --- a/Package/Sources/Clients/RepoClient.swift +++ b/Package/Sources/Clients/RepoClient.swift @@ -2,7 +2,7 @@ // RepoClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Clients/UserDefaultsClient.swift b/Package/Sources/Clients/UserDefaultsClient.swift index af4a0ee..80bb57d 100644 --- a/Package/Sources/Clients/UserDefaultsClient.swift +++ b/Package/Sources/Clients/UserDefaultsClient.swift @@ -2,7 +2,7 @@ // UserDefaultsClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Clients/UserSettingsClient.swift b/Package/Sources/Clients/UserSettingsClient.swift index fcd4b52..3164e98 100644 --- a/Package/Sources/Clients/UserSettingsClient.swift +++ b/Package/Sources/Clients/UserSettingsClient.swift @@ -2,7 +2,7 @@ // UserSettingsClient.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Clients/_Client.swift b/Package/Sources/Clients/_Client.swift index 43b2d7e..4a3256f 100644 --- a/Package/Sources/Clients/_Client.swift +++ b/Package/Sources/Clients/_Client.swift @@ -2,7 +2,7 @@ // _Client.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Dependencies/ComposableArchitecture.swift b/Package/Sources/Dependencies/ComposableArchitecture.swift index c8f4861..70f8227 100644 --- a/Package/Sources/Dependencies/ComposableArchitecture.swift +++ b/Package/Sources/Dependencies/ComposableArchitecture.swift @@ -2,7 +2,7 @@ // ComposableArchitecture.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Dependencies/CustomDump.swift b/Package/Sources/Dependencies/CustomDump.swift index b6ef44f..40a76b2 100644 --- a/Package/Sources/Dependencies/CustomDump.swift +++ b/Package/Sources/Dependencies/CustomDump.swift @@ -2,7 +2,7 @@ // CustomDump.swift // // -// Created by ErrorErrorError on 1/1/24. +// Created by MochiTeam on 1/1/24. // // diff --git a/Package/Sources/Dependencies/FluidGradient.swift b/Package/Sources/Dependencies/FluidGradient.swift index 78f32ec..bdeef7b 100644 --- a/Package/Sources/Dependencies/FluidGradient.swift +++ b/Package/Sources/Dependencies/FluidGradient.swift @@ -2,7 +2,7 @@ // FluidGradient.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // diff --git a/Package/Sources/Dependencies/FlyingFox.swift b/Package/Sources/Dependencies/FlyingFox.swift index 44ff497..26bba75 100644 --- a/Package/Sources/Dependencies/FlyingFox.swift +++ b/Package/Sources/Dependencies/FlyingFox.swift @@ -2,7 +2,7 @@ // FlyingFox.swift // // -// Created by DeNeRr on 09.05.2024. +// Created by MochiTeam on 09.05.2024. // import Foundation diff --git a/Package/Sources/Dependencies/Nuke.swift b/Package/Sources/Dependencies/Nuke.swift index 0c1d9b0..0ba1a68 100644 --- a/Package/Sources/Dependencies/Nuke.swift +++ b/Package/Sources/Dependencies/Nuke.swift @@ -2,7 +2,7 @@ // Nuke.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Dependencies/Parsing.swift b/Package/Sources/Dependencies/Parsing.swift index 71203f1..fef5ec9 100644 --- a/Package/Sources/Dependencies/Parsing.swift +++ b/Package/Sources/Dependencies/Parsing.swift @@ -2,7 +2,7 @@ // Parsing.swift // // -// Created by ErrorErrorError on 12/17/23. +// Created by MochiTeam on 12/17/23. // // diff --git a/Package/Sources/Dependencies/Semaphore.swift b/Package/Sources/Dependencies/Semaphore.swift index 3f2d66a..7c2f45f 100644 --- a/Package/Sources/Dependencies/Semaphore.swift +++ b/Package/Sources/Dependencies/Semaphore.swift @@ -2,7 +2,7 @@ // Semaphore.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Dependencies/Semver.swift b/Package/Sources/Dependencies/Semver.swift index 5b100b0..e837e33 100644 --- a/Package/Sources/Dependencies/Semver.swift +++ b/Package/Sources/Dependencies/Semver.swift @@ -2,7 +2,7 @@ // Semver.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Dependencies/SwiftLog.swift b/Package/Sources/Dependencies/SwiftLog.swift index 7045e92..64719d0 100644 --- a/Package/Sources/Dependencies/SwiftLog.swift +++ b/Package/Sources/Dependencies/SwiftLog.swift @@ -2,7 +2,7 @@ // SwiftLog.swift // // -// Created by ErrorErrorError on 11/9/23. +// Created by MochiTeam on 11/9/23. // // diff --git a/Package/Sources/Dependencies/SwiftSoup.swift b/Package/Sources/Dependencies/SwiftSoup.swift index a351f96..daaa7ba 100644 --- a/Package/Sources/Dependencies/SwiftSoup.swift +++ b/Package/Sources/Dependencies/SwiftSoup.swift @@ -2,7 +2,7 @@ // SwiftSoup.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Dependencies/SwiftSyntax.swift b/Package/Sources/Dependencies/SwiftSyntax.swift index a228801..4abcb84 100644 --- a/Package/Sources/Dependencies/SwiftSyntax.swift +++ b/Package/Sources/Dependencies/SwiftSyntax.swift @@ -2,7 +2,7 @@ // SwiftSyntax.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // diff --git a/Package/Sources/Dependencies/SwiftUIBackports.swift b/Package/Sources/Dependencies/SwiftUIBackports.swift index a370293..39a7fd3 100644 --- a/Package/Sources/Dependencies/SwiftUIBackports.swift +++ b/Package/Sources/Dependencies/SwiftUIBackports.swift @@ -2,7 +2,7 @@ // SwiftUIBackports.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Dependencies/Tagged.swift b/Package/Sources/Dependencies/Tagged.swift index 234113d..cf91250 100644 --- a/Package/Sources/Dependencies/Tagged.swift +++ b/Package/Sources/Dependencies/Tagged.swift @@ -2,7 +2,7 @@ // Tagged.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Dependencies/XMLCoder.swift b/Package/Sources/Dependencies/XMLCoder.swift index abb2888..e42271d 100644 --- a/Package/Sources/Dependencies/XMLCoder.swift +++ b/Package/Sources/Dependencies/XMLCoder.swift @@ -2,7 +2,7 @@ // XMLCoder.swift // // -// Created by ErrorErrorError on 12/27/23. +// Created by MochiTeam on 12/27/23. // // diff --git a/Package/Sources/Features/ContentCore.swift b/Package/Sources/Features/ContentCore.swift index b12b274..c11d13f 100644 --- a/Package/Sources/Features/ContentCore.swift +++ b/Package/Sources/Features/ContentCore.swift @@ -2,7 +2,7 @@ // ContentCore.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Features/Discover.swift b/Package/Sources/Features/Discover.swift index 1b7f64c..be7ad82 100644 --- a/Package/Sources/Features/Discover.swift +++ b/Package/Sources/Features/Discover.swift @@ -2,7 +2,7 @@ // Discover.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Features/DownloadQueue.swift b/Package/Sources/Features/DownloadQueue.swift index d50fd2e..cf7745d 100644 --- a/Package/Sources/Features/DownloadQueue.swift +++ b/Package/Sources/Features/DownloadQueue.swift @@ -2,7 +2,7 @@ // DownloadQueue.swift // // -// Created by DeNeRr on 16.05.2024. +// Created by MochiTeam on 16.05.2024. // import Foundation diff --git a/Package/Sources/Features/Library.swift b/Package/Sources/Features/Library.swift index 142d521..f0faf38 100644 --- a/Package/Sources/Features/Library.swift +++ b/Package/Sources/Features/Library.swift @@ -2,7 +2,7 @@ // Library.swift // // -// Created by DeNeRr on 09.04.2024. +// Created by MochiTeam on 09.04.2024. // import Foundation diff --git a/Package/Sources/Features/MochiApp.swift b/Package/Sources/Features/MochiApp.swift index 1be0ae1..b417bca 100644 --- a/Package/Sources/Features/MochiApp.swift +++ b/Package/Sources/Features/MochiApp.swift @@ -2,7 +2,7 @@ // MochiApp.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Features/ModuleLists.swift b/Package/Sources/Features/ModuleLists.swift index 2327b76..e54e08b 100644 --- a/Package/Sources/Features/ModuleLists.swift +++ b/Package/Sources/Features/ModuleLists.swift @@ -2,7 +2,7 @@ // ModuleLists.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Features/PlaylistDetails.swift b/Package/Sources/Features/PlaylistDetails.swift index 6df0d2f..5196374 100644 --- a/Package/Sources/Features/PlaylistDetails.swift +++ b/Package/Sources/Features/PlaylistDetails.swift @@ -2,7 +2,7 @@ // PlaylistDetails.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Features/Repos.swift b/Package/Sources/Features/Repos.swift index a436061..145d5ef 100644 --- a/Package/Sources/Features/Repos.swift +++ b/Package/Sources/Features/Repos.swift @@ -2,7 +2,7 @@ // Repos.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Features/Search.swift b/Package/Sources/Features/Search.swift index 4e980f7..bfae522 100644 --- a/Package/Sources/Features/Search.swift +++ b/Package/Sources/Features/Search.swift @@ -2,7 +2,7 @@ // Search.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Features/Settings.swift b/Package/Sources/Features/Settings.swift index d177acd..288b91a 100644 --- a/Package/Sources/Features/Settings.swift +++ b/Package/Sources/Features/Settings.swift @@ -2,7 +2,7 @@ // Settings.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Features/VideoPlayer.swift b/Package/Sources/Features/VideoPlayer.swift index b862fa1..0fd14c6 100644 --- a/Package/Sources/Features/VideoPlayer.swift +++ b/Package/Sources/Features/VideoPlayer.swift @@ -2,7 +2,7 @@ // VideoPlayer.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Features/_Feature.swift b/Package/Sources/Features/_Feature.swift index 8988146..c6bbbf7 100644 --- a/Package/Sources/Features/_Feature.swift +++ b/Package/Sources/Features/_Feature.swift @@ -2,7 +2,7 @@ // _Feature.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Index.swift b/Package/Sources/Index.swift index 855acd3..da15f29 100644 --- a/Package/Sources/Index.swift +++ b/Package/Sources/Index.swift @@ -2,7 +2,7 @@ // Index.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Macros/CoreDBMacros.swift b/Package/Sources/Macros/CoreDBMacros.swift index 04bb554..36b69f3 100644 --- a/Package/Sources/Macros/CoreDBMacros.swift +++ b/Package/Sources/Macros/CoreDBMacros.swift @@ -2,7 +2,7 @@ // CoreDBMacros.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Package/Sources/Macros/_Macro.swift b/Package/Sources/Macros/_Macro.swift index a510cd4..e00ed78 100644 --- a/Package/Sources/Macros/_Macro.swift +++ b/Package/Sources/Macros/_Macro.swift @@ -2,7 +2,7 @@ // _Macro.swift // // -// Created by ErrorErrorError on 10/27/23. +// Created by MochiTeam on 10/27/23. // // diff --git a/Package/Sources/Platforms/MochiPlatforms.swift b/Package/Sources/Platforms/MochiPlatforms.swift index d3fa63e..7458126 100644 --- a/Package/Sources/Platforms/MochiPlatforms.swift +++ b/Package/Sources/Platforms/MochiPlatforms.swift @@ -2,7 +2,7 @@ // MochiPlatforms.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Package/Sources/Shared/Architecture.swift b/Package/Sources/Shared/Architecture.swift index 9af7860..c1a2ebf 100644 --- a/Package/Sources/Shared/Architecture.swift +++ b/Package/Sources/Shared/Architecture.swift @@ -2,7 +2,7 @@ // Architecture.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Shared/CoreDB.swift b/Package/Sources/Shared/CoreDB.swift index 8b0297e..2813c2b 100644 --- a/Package/Sources/Shared/CoreDB.swift +++ b/Package/Sources/Shared/CoreDB.swift @@ -2,7 +2,7 @@ // CoreDB.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Package/Sources/Shared/FoundationHelpers.swift b/Package/Sources/Shared/FoundationHelpers.swift index 2ca879a..815af3c 100644 --- a/Package/Sources/Shared/FoundationHelpers.swift +++ b/Package/Sources/Shared/FoundationHelpers.swift @@ -2,7 +2,7 @@ // FoundationHelpers.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Shared/JSValueCoder.swift b/Package/Sources/Shared/JSValueCoder.swift index f7d42d1..d5b5d66 100644 --- a/Package/Sources/Shared/JSValueCoder.swift +++ b/Package/Sources/Shared/JSValueCoder.swift @@ -2,7 +2,7 @@ // JSValueCoder.swift // // -// Created by ErrorErrorError on 11/6/23. +// Created by MochiTeam on 11/6/23. // // diff --git a/Package/Sources/Shared/SharedModels.swift b/Package/Sources/Shared/SharedModels.swift index 4599c13..c3f0057 100644 --- a/Package/Sources/Shared/SharedModels.swift +++ b/Package/Sources/Shared/SharedModels.swift @@ -2,7 +2,7 @@ // SharedModels.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Shared/Styling.swift b/Package/Sources/Shared/Styling.swift index b0c22b8..c2d105c 100644 --- a/Package/Sources/Shared/Styling.swift +++ b/Package/Sources/Shared/Styling.swift @@ -2,7 +2,7 @@ // Styling.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Shared/ViewComponents.swift b/Package/Sources/Shared/ViewComponents.swift index e72364a..ef26205 100644 --- a/Package/Sources/Shared/ViewComponents.swift +++ b/Package/Sources/Shared/ViewComponents.swift @@ -2,7 +2,7 @@ // ViewComponents.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Sources/Shared/_Shared.swift b/Package/Sources/Shared/_Shared.swift index dab1af1..65c2249 100644 --- a/Package/Sources/Shared/_Shared.swift +++ b/Package/Sources/Shared/_Shared.swift @@ -2,7 +2,7 @@ // _Shared.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Support/CSettingsBuilder.swift b/Package/Support/CSettingsBuilder.swift index f992f92..eaafebd 100644 --- a/Package/Support/CSettingsBuilder.swift +++ b/Package/Support/CSettingsBuilder.swift @@ -2,7 +2,7 @@ // CSettingsBuilder.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Package/Support/Macro.swift b/Package/Support/Macro.swift index 37e6669..13d76f4 100644 --- a/Package/Support/Macro.swift +++ b/Package/Support/Macro.swift @@ -2,7 +2,7 @@ // Macro.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // diff --git a/Package/Support/Testable.swift b/Package/Support/Testable.swift index 468c0a6..508e812 100644 --- a/Package/Support/Testable.swift +++ b/Package/Support/Testable.swift @@ -2,7 +2,7 @@ // Testable.swift // // -// Created by ErrorErrorError on 10/13/23. +// Created by MochiTeam on 10/13/23. // // diff --git a/Package/Support/_Path.swift b/Package/Support/_Path.swift index 628e737..4ccde4e 100644 --- a/Package/Support/_Path.swift +++ b/Package/Support/_Path.swift @@ -2,7 +2,7 @@ // _Path.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Sources/Clients/AnalyticsClient/Client.swift b/Sources/Clients/AnalyticsClient/Client.swift index 05c0525..ed2fd56 100644 --- a/Sources/Clients/AnalyticsClient/Client.swift +++ b/Sources/Clients/AnalyticsClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 5/19/23. +// Created MochiTeam on 5/19/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/AnalyticsClient/Live.swift b/Sources/Clients/AnalyticsClient/Live.swift index 1a1b8ce..a2cd5c8 100644 --- a/Sources/Clients/AnalyticsClient/Live.swift +++ b/Sources/Clients/AnalyticsClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created ErrorErrorError on 5/19/23. +// Created MochiTeam on 5/19/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/AnalyticsClient/Models.swift b/Sources/Clients/AnalyticsClient/Models.swift index 3250dd4..14e03e3 100644 --- a/Sources/Clients/AnalyticsClient/Models.swift +++ b/Sources/Clients/AnalyticsClient/Models.swift @@ -2,7 +2,7 @@ // Models.swift // // -// Created by ErrorErrorError on 5/19/23. +// Created by MochiTeam on 5/19/23. // // diff --git a/Sources/Clients/AnalyticsClient/Reducer.swift b/Sources/Clients/AnalyticsClient/Reducer.swift index a5358df..6e9e92f 100644 --- a/Sources/Clients/AnalyticsClient/Reducer.swift +++ b/Sources/Clients/AnalyticsClient/Reducer.swift @@ -2,7 +2,7 @@ // Reducer.swift // // -// Created by ErrorErrorError on 5/19/23. +// Created by MochiTeam on 5/19/23. // // diff --git a/Sources/Clients/BuildClient/Client.swift b/Sources/Clients/BuildClient/Client.swift index a940b76..421901e 100644 --- a/Sources/Clients/BuildClient/Client.swift +++ b/Sources/Clients/BuildClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created by ErrorErrorError on 7/28/23. +// Created by MochiTeam on 7/28/23. // // diff --git a/Sources/Clients/BuildClient/Model.swift b/Sources/Clients/BuildClient/Model.swift index 0308ea6..d51a742 100644 --- a/Sources/Clients/BuildClient/Model.swift +++ b/Sources/Clients/BuildClient/Model.swift @@ -2,7 +2,7 @@ // Model.swift // // -// Created by ErrorErrorError on 11/27/23. +// Created by MochiTeam on 11/27/23. // // diff --git a/Sources/Clients/ClipboardClient/Client.swift b/Sources/Clients/ClipboardClient/Client.swift index a70a181..aead34d 100644 --- a/Sources/Clients/ClipboardClient/Client.swift +++ b/Sources/Clients/ClipboardClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 12/15/23. +// Created MochiTeam on 12/15/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/ClipboardClient/Live.swift b/Sources/Clients/ClipboardClient/Live.swift index 25c57ba..f78ff45 100644 --- a/Sources/Clients/ClipboardClient/Live.swift +++ b/Sources/Clients/ClipboardClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created ErrorErrorError on 12/15/23. +// Created MochiTeam on 12/15/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/DatabaseClient/Client.swift b/Sources/Clients/DatabaseClient/Client.swift index 0328959..1cbe9f6 100644 --- a/Sources/Clients/DatabaseClient/Client.swift +++ b/Sources/Clients/DatabaseClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/DatabaseClient/Exports.swift b/Sources/Clients/DatabaseClient/Exports.swift index fa8458f..1b85d9d 100644 --- a/Sources/Clients/DatabaseClient/Exports.swift +++ b/Sources/Clients/DatabaseClient/Exports.swift @@ -2,7 +2,7 @@ // Exports.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Clients/DatabaseClient/Live.swift b/Sources/Clients/DatabaseClient/Live.swift index 582b760..6688bf9 100644 --- a/Sources/Clients/DatabaseClient/Live.swift +++ b/Sources/Clients/DatabaseClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/DatabaseClient/MochiSchema.swift b/Sources/Clients/DatabaseClient/MochiSchema.swift index fc9b5db..7bd3f07 100644 --- a/Sources/Clients/DatabaseClient/MochiSchema.swift +++ b/Sources/Clients/DatabaseClient/MochiSchema.swift @@ -2,7 +2,7 @@ // MochiSchema.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Clients/DatabaseClient/Models/Entry.swift b/Sources/Clients/DatabaseClient/Models/Entry.swift index d0ca125..9880903 100644 --- a/Sources/Clients/DatabaseClient/Models/Entry.swift +++ b/Sources/Clients/DatabaseClient/Models/Entry.swift @@ -2,7 +2,7 @@ // Entry.swift // // -// Created by ErrorErrorError on 1/1/24. +// Created by MochiTeam on 1/1/24. // // diff --git a/Sources/Clients/DatabaseClient/Models/EntryItem.swift b/Sources/Clients/DatabaseClient/Models/EntryItem.swift index 70729c1..27ee430 100644 --- a/Sources/Clients/DatabaseClient/Models/EntryItem.swift +++ b/Sources/Clients/DatabaseClient/Models/EntryItem.swift @@ -2,7 +2,7 @@ // EntryItem.swift // // -// Created by ErrorErrorError on 1/2/24. +// Created by MochiTeam on 1/2/24. // // diff --git a/Sources/Clients/DatabaseClient/Models/Extensions/Module+.swift b/Sources/Clients/DatabaseClient/Models/Extensions/Module+.swift index 9415844..b1266be 100644 --- a/Sources/Clients/DatabaseClient/Models/Extensions/Module+.swift +++ b/Sources/Clients/DatabaseClient/Models/Extensions/Module+.swift @@ -2,7 +2,7 @@ // Module+.swift // // -// Created by ErrorErrorError on 11/12/23. +// Created by MochiTeam on 11/12/23. // // diff --git a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift index 27ba6b4..08c2254 100644 --- a/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift +++ b/Sources/Clients/DatabaseClient/Models/Extensions/PlaylistHistory+.swift @@ -2,7 +2,7 @@ // PlaylistHistory+.swift // // -// Created by DeNeRr on 31.01.2024. +// Created by MochiTeam on 31.01.2024. // import Foundation diff --git a/Sources/Clients/DatabaseClient/Models/Extensions/Repo+.swift b/Sources/Clients/DatabaseClient/Models/Extensions/Repo+.swift index 43efc3e..1566e85 100644 --- a/Sources/Clients/DatabaseClient/Models/Extensions/Repo+.swift +++ b/Sources/Clients/DatabaseClient/Models/Extensions/Repo+.swift @@ -2,7 +2,7 @@ // Repo+.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Clients/DatabaseClient/Models/Module.swift b/Sources/Clients/DatabaseClient/Models/Module.swift index 2eaddc2..7844d79 100644 --- a/Sources/Clients/DatabaseClient/Models/Module.swift +++ b/Sources/Clients/DatabaseClient/Models/Module.swift @@ -2,7 +2,7 @@ // Module.swift // // -// Created by ErrorErrorError on 5/17/23. +// Created by MochiTeam on 5/17/23. // // diff --git a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift index 4c1d75a..48cd81a 100644 --- a/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift +++ b/Sources/Clients/DatabaseClient/Models/PlaylistHistory.swift @@ -2,7 +2,7 @@ // PlaylistHistory.swift // // -// Created by DeNeRr on 27.01.2024. +// Created by MochiTeam on 27.01.2024. // import CoreDB diff --git a/Sources/Clients/DatabaseClient/Models/Repo.swift b/Sources/Clients/DatabaseClient/Models/Repo.swift index 4c3c7f2..5f414bc 100644 --- a/Sources/Clients/DatabaseClient/Models/Repo.swift +++ b/Sources/Clients/DatabaseClient/Models/Repo.swift @@ -2,7 +2,7 @@ // Repo.swift // // -// Created by ErrorErrorError on 4/10/23. +// Created by MochiTeam on 4/10/23. // // diff --git a/Sources/Clients/DeviceClient/Client.swift b/Sources/Clients/DeviceClient/Client.swift index 46e20bf..25f1b37 100644 --- a/Sources/Clients/DeviceClient/Client.swift +++ b/Sources/Clients/DeviceClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created by ErrorErrorError on 11/29/23. +// Created by MochiTeam on 11/29/23. // // diff --git a/Sources/Clients/FileClient/Client+.swift b/Sources/Clients/FileClient/Client+.swift index de22a1e..083b138 100644 --- a/Sources/Clients/FileClient/Client+.swift +++ b/Sources/Clients/FileClient/Client+.swift @@ -2,7 +2,7 @@ // Client+.swift // // -// Created by ErrorErrorError on 11/12/23. +// Created by MochiTeam on 11/12/23. // // diff --git a/Sources/Clients/FileClient/Client.swift b/Sources/Clients/FileClient/Client.swift index 9402acb..e187e78 100644 --- a/Sources/Clients/FileClient/Client.swift +++ b/Sources/Clients/FileClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created by ErrorErrorError on 10/6/23. +// Created by MochiTeam on 10/6/23. // // diff --git a/Sources/Clients/FileClient/Live.swift b/Sources/Clients/FileClient/Live.swift index 28254cb..ffebd08 100644 --- a/Sources/Clients/FileClient/Live.swift +++ b/Sources/Clients/FileClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created by ErrorErrorError on 10/6/23. +// Created by MochiTeam on 10/6/23. // // diff --git a/Sources/Clients/LocalizableClient/Client.swift b/Sources/Clients/LocalizableClient/Client.swift index deada98..96d24e3 100644 --- a/Sources/Clients/LocalizableClient/Client.swift +++ b/Sources/Clients/LocalizableClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created by ErrorErrorError on 12/1/23. +// Created by MochiTeam on 12/1/23. // // diff --git a/Sources/Clients/LocalizableClient/Localizable.swift b/Sources/Clients/LocalizableClient/Localizable.swift index b5cd4b0..0bd5825 100644 --- a/Sources/Clients/LocalizableClient/Localizable.swift +++ b/Sources/Clients/LocalizableClient/Localizable.swift @@ -2,7 +2,7 @@ // Localizable.swift // // -// Created by ErrorErrorError on 11/22/23. +// Created by MochiTeam on 11/22/23. // // diff --git a/Sources/Clients/LoggerClient/Client.swift b/Sources/Clients/LoggerClient/Client.swift index ccd1a23..abdd0ab 100644 --- a/Sources/Clients/LoggerClient/Client.swift +++ b/Sources/Clients/LoggerClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 5/30/23. +// Created MochiTeam on 5/30/23. // Copyright © 2023. All rights reserved. // @@ -13,7 +13,7 @@ import Logging import XCTestDynamicOverlay // Global App Logger -public let logger = Logger(label: "dev.errorerrorerror.mochi.app") { label in +public let logger = Logger(label: "dev.MochiTeam.mochi.app") { label in MultiplexLogHandler([ StreamLogHandler.standardOutput(label: label), ConsumableLogsHandler() diff --git a/Sources/Clients/LoggerClient/Models.swift b/Sources/Clients/LoggerClient/Models.swift index 342a5f2..5a8273d 100644 --- a/Sources/Clients/LoggerClient/Models.swift +++ b/Sources/Clients/LoggerClient/Models.swift @@ -2,7 +2,7 @@ // Models.swift // // -// Created by ErrorErrorError on 11/29/23. +// Created by MochiTeam on 11/29/23. // // diff --git a/Sources/Clients/ModuleClient/Client.swift b/Sources/Clients/ModuleClient/Client.swift index 1ca2ae4..f84e81f 100644 --- a/Sources/Clients/ModuleClient/Client.swift +++ b/Sources/Clients/ModuleClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 4/10/23. +// Created MochiTeam on 4/10/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/ModuleClient/Extensions/JSContext+.swift b/Sources/Clients/ModuleClient/Extensions/JSContext+.swift index 27a3684..72b60b1 100644 --- a/Sources/Clients/ModuleClient/Extensions/JSContext+.swift +++ b/Sources/Clients/ModuleClient/Extensions/JSContext+.swift @@ -2,7 +2,7 @@ // JSContext+.swift // // -// Created by ErrorErrorError on 11/17/23. +// Created by MochiTeam on 11/17/23. // // diff --git a/Sources/Clients/ModuleClient/Extensions/JSValue+.swift b/Sources/Clients/ModuleClient/Extensions/JSValue+.swift index f52abea..0c107fa 100644 --- a/Sources/Clients/ModuleClient/Extensions/JSValue+.swift +++ b/Sources/Clients/ModuleClient/Extensions/JSValue+.swift @@ -2,7 +2,7 @@ // JSValue+.swift // // -// Created by ErrorErrorError on 11/17/23. +// Created by MochiTeam on 11/17/23. // // diff --git a/Sources/Clients/ModuleClient/Instance.swift b/Sources/Clients/ModuleClient/Instance.swift index 58fe6b4..3b38f6f 100644 --- a/Sources/Clients/ModuleClient/Instance.swift +++ b/Sources/Clients/ModuleClient/Instance.swift @@ -2,7 +2,7 @@ // Instance.swift // // -// Created by ErrorErrorError on 10/28/23. +// Created by MochiTeam on 10/28/23. // // diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift index 8e9b62e..ddd5ff2 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift @@ -2,7 +2,7 @@ // JSContext+Console.swift // // -// Created by ErrorErrorError on 11/17/23. +// Created by MochiTeam on 11/17/23. // // diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift index 659c5ef..6a95046 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift @@ -2,7 +2,7 @@ // JSContext+JSRuntime.swift // // -// Created by ErrorErrorError on 11/4/23. +// Created by MochiTeam on 11/4/23. // // diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift index 3058f97..9145e48 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift @@ -2,7 +2,7 @@ // JSContext+Request.swift // // -// Created by ErrorErrorError on 11/17/23. +// Created by MochiTeam on 11/17/23. // // diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSRuntime.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSRuntime.swift index 1d32d7d..a7eee3e 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSRuntime.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSRuntime.swift @@ -2,7 +2,7 @@ // JSRuntime.swift // // -// Created by ErrorErrorError on 11/4/23. +// Created by MochiTeam on 11/4/23. // // diff --git a/Sources/Clients/ModuleClient/Live.swift b/Sources/Clients/ModuleClient/Live.swift index f24e58c..0e047ac 100644 --- a/Sources/Clients/ModuleClient/Live.swift +++ b/Sources/Clients/ModuleClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created ErrorErrorError on 6/3/23. +// Created MochiTeam on 6/3/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/ModuleClient/Logger.swift b/Sources/Clients/ModuleClient/Logger.swift index 0722f61..413067b 100644 --- a/Sources/Clients/ModuleClient/Logger.swift +++ b/Sources/Clients/ModuleClient/Logger.swift @@ -2,7 +2,7 @@ // Logger.swift // // -// Created by ErrorErrorError on 11/29/23. +// Created by MochiTeam on 11/29/23. // // diff --git a/Sources/Clients/OfflineManagerClient/Client.swift b/Sources/Clients/OfflineManagerClient/Client.swift index 4dd6425..0c0f581 100644 --- a/Sources/Clients/OfflineManagerClient/Client.swift +++ b/Sources/Clients/OfflineManagerClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created by DeNeRr on 06.04.2024. +// Created by MochiTeam on 06.04.2024. // import FileClient diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift index bcb472e..afef99d 100644 --- a/Sources/Clients/OfflineManagerClient/Live.swift +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created by DeNeRr on 06.04.2024. +// Created by MochiTeam on 06.04.2024. // import Dependencies @@ -14,6 +14,7 @@ import SharedModels import DatabaseClient import FlyingFox import OrderedCollections +import LoggerClient // MARK: - OfflineManagerClient + DependencyKey @@ -77,14 +78,16 @@ extension OfflineManagerClient: DependencyKey { case .AssetDownloadProgress: let taskId = notification.userInfo?["taskId"] as! Int let percent = notification.userInfo?["percent"] as! Double - let idx = values.firstIndex(where: { $0.taskId == taskId })! - values[idx].percentComplete = percent + if let idx = values.firstIndex(where: { $0.taskId == taskId }) { + values[idx].percentComplete = percent + } break case .AssetDownloadStateChanged: let taskId = notification.userInfo?["taskId"] as! Int let status = notification.userInfo?["status"] as! StatusType - let idx = values.firstIndex(where: { $0.taskId == taskId })! - values[idx].status = status + if let idx = values.firstIndex(where: { $0.taskId == taskId }) { + values[idx].status = status + } break default: break @@ -121,12 +124,12 @@ private class OfflineDownloadManager: NSObject { } public func setupAssetDownload(_ asset: OfflineManagerClient.DownloadAsset) async throws { - await initializeRoutes(asset) + await initializeRoutes() try await server.waitUntilListening() - let options = ["AVURLAssetHTTPHeaderFieldsKey": asset.headers] as [String : Any] + let options = ["AVURLAssetHTTPHeaderFieldsKey": asset.headers] let libraryFileUrl = try fileClient.retrieveLibraryDirectory(root: .playlistCache) let playlist = asset.playlist - let avAsset = AVURLAsset(url: URL(string: "http://localhost:64390/download.m3u?url=\(asset.episodeMetadata.link.url.absoluteString)")!, options: options) + let avAsset = AVURLAsset(url: URL(string: "http://localhost:64390/download.m3u?url=\(asset.episodeMetadata.link.url.absoluteString.replacingOccurrences(of: "&", with: ">>"))\(!asset.episodeMetadata.subtitles.isEmpty ? "&subs=\(String(data: try JSONEncoder().encode(asset.episodeMetadata.subtitles), encoding: .utf8)!)" : "")")!, options: options) let preferredMediaSelection = try await avAsset.load(.preferredMediaSelection) guard let downloadTask = downloadSession.aggregateAssetDownloadTask(with: avAsset, @@ -203,11 +206,33 @@ private class OfflineDownloadManager: NSObject { } extension OfflineDownloadManager { - private func initializeRoutes(_ asset: OfflineManagerClient.DownloadAsset) async { + private func initializeRoutes() async { await server.appendRoute("GET /download.m3u", handler: { req in let hlsSubtitleGroupID = "mochi-sub" - func convertMainPlaylistToMultivariant(_ url: URL, _ subtitles: [Playlist.EpisodeServer.Subtitle]) -> String { + func getHighestResolutionUrlFromMultivariant(_ m3u8String: String) -> String { + let lines = m3u8String.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) } + var highestResIdx = 0 + var highestRes = 0 + + let regex = try! NSRegularExpression(pattern: "BANDWIDTH=(\\d+)") + + + for (i, line) in lines.enumerated() { + let range = NSRange(location: 0, length: line.utf16.count) + if let result = regex.firstMatch(in: line, range: range) { + let newRes = Int(line[Range(result.range, in: line)!].split(separator: "=")[1])! + if (newRes > highestRes) { + highestRes = newRes + highestResIdx = i + } + } + } + + return lines[highestResIdx + 1] + } + + func convertMainPlaylistToMultivariant(_ url: String, _ subtitles: [Playlist.EpisodeServer.Subtitle]) -> String { // Build a multivariant playlist out of a single main playlist let subtitlesMediaStrings = subtitles.enumerated() .map(makeSubtitleTypes) @@ -216,7 +241,7 @@ extension OfflineDownloadManager { #EXTM3U \(subtitlesMediaStrings.joined(separator: "\n")) #EXT-X-STREAM-INF:BANDWIDTH=640000\(!subtitles.isEmpty ? ",SUBTITLES=\"\(hlsSubtitleGroupID)\"": "") - \(url.absoluteString) + \(url) """ } @@ -237,10 +262,29 @@ extension OfflineDownloadManager { .map { "\($0.key)=\($0.value)" } .joined(separator: ",") } + + var m3u8: String + var urlString = req.query["url"]!.replacingOccurrences(of: ">>", with: "&") + let rq = URLRequest(url: URL(string: urlString)!) + var headers = req.headers + headers.removeValue(forKey: .host) + let (data, _) = try await URLSession.shared.data(for: rq) + guard let string = String(data: data, encoding: .utf8) else { + throw OfflineManagerClient.Error.failedToGenerateHLS + } + + if string.contains("#EXT-X-STREAM-INF") { + urlString = getHighestResolutionUrlFromMultivariant(string) + } + + var subs: [Playlist.EpisodeServer.Subtitle] = [] + if let subString = req.query["subs"] { + subs = try JSONDecoder().decode([Playlist.EpisodeServer.Subtitle].self, from: subString.data(using: .utf8)!) + } + m3u8 = convertMainPlaylistToMultivariant(urlString, subs) var path = req.path path.remove(at: req.path.startIndex) - let m3u8 = convertMainPlaylistToMultivariant(asset.episodeMetadata.link.url, asset.episodeMetadata.subtitles) return HTTPResponse(statusCode: .ok, headers: [.contentType: "application/vnd.apple.mpegurl"], body: m3u8.data(using: .utf8)!) }) @@ -283,7 +327,9 @@ extension OfflineDownloadManager { let idx = URL(string: req.query.first!.value)! let m3u8 = try await setupSubM3U8(idx) - return HTTPResponse(statusCode: .ok, headers: [.contentType: "application/vnd.apple.mpegurl"], body: m3u8.data(using: .utf8)!) + var headers = req.headers + headers.updateValue("application/vnd.apple.mpegurl", forKey: .contentType) + return HTTPResponse(statusCode: .ok, headers: headers, body: m3u8.data(using: .utf8)!) }) } } @@ -296,7 +342,10 @@ extension OfflineDownloadManager: AVAssetDownloadDelegate { let loadedTimeRange: CMTimeRange = value.timeRangeValue return rc + Double((loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds)) } - guard let idx = downloadingItems.firstIndex(where: { $0.metadata.link.url.absoluteString == aggregateAssetDownloadTask.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) else { + guard let idx = downloadingItems.firstIndex(where: { + let components = NSURLComponents(url: aggregateAssetDownloadTask.urlAsset.url, resolvingAgainstBaseURL: true) + return $0.metadata.link.url.absoluteString == components?.queryItems?.first(where: { $0.name == "url" })?.value + }) else { return } downloadingItems[idx].percentage = percentComplete @@ -307,7 +356,8 @@ extension OfflineDownloadManager: AVAssetDownloadDelegate { func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, willDownloadTo location: URL) { guard let idx = downloadingItems.firstIndex(where: { - return $0.metadata.link.url.absoluteString == aggregateAssetDownloadTask.urlAsset.url.absoluteString.components(separatedBy: "url=").last + let components = NSURLComponents(url: aggregateAssetDownloadTask.urlAsset.url, resolvingAgainstBaseURL: true) + return $0.metadata.link.url.absoluteString == components?.queryItems?.first(where: { $0.name == "url" })?.value }) else { return } @@ -315,7 +365,10 @@ extension OfflineDownloadManager: AVAssetDownloadDelegate { } func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didCompleteFor mediaSelection: AVMediaSelection) { - if let downloadedAsset = downloadingItems.first(where: { $0.metadata.link.url.absoluteString == aggregateAssetDownloadTask.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) { + if let downloadedAsset = downloadingItems.first(where: { + let components = NSURLComponents(url: aggregateAssetDownloadTask.urlAsset.url, resolvingAgainstBaseURL: true) + return $0.metadata.link.url.absoluteString == components?.queryItems?.first(where: { $0.name == "url" })?.value + }) { do { try saveVideo(asset: downloadedAsset, location: downloadedAsset.location!) } catch { @@ -328,10 +381,16 @@ extension OfflineDownloadManager: AVAssetDownloadDelegate { debugPrint("Task completed: \(task), error: \(String(describing: error))") guard let task = task as? AVAggregateAssetDownloadTask else { return } - guard error == nil else { - return - } - if let idx = downloadingItems.firstIndex(where: { $0.url.absoluteString == task.urlAsset.url.absoluteString.components(separatedBy: "url=").last }) { + if let idx = downloadingItems.firstIndex(where: { + let components = NSURLComponents(url: task.urlAsset.url, resolvingAgainstBaseURL: true) + return $0.url.absoluteString == components?.queryItems?.first(where: { $0.name == "url" })?.value + }) { + guard error == nil else { + downloadingItems[idx].status = .error + NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: ["type": Notification.Name.AssetDownloadStateChanged, "taskId": downloadingItems[idx].taskId, "status": OfflineManagerClient.StatusType.error]) + logger.error("\(error)") + return + } downloadingItems[idx].status = .finished NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: ["type": Notification.Name.AssetDownloadStateChanged, "taskId": downloadingItems[idx].taskId, "status": OfflineManagerClient.StatusType.finished]) } diff --git a/Sources/Clients/OfflineManagerClient/Models.swift b/Sources/Clients/OfflineManagerClient/Models.swift index 1fdb78d..09bd1ad 100644 --- a/Sources/Clients/OfflineManagerClient/Models.swift +++ b/Sources/Clients/OfflineManagerClient/Models.swift @@ -2,7 +2,7 @@ // Models.swift // // -// Created by DeNeRr on 06.04.2024. +// Created by MochiTeam on 06.04.2024. // import Foundation @@ -13,6 +13,7 @@ extension OfflineManagerClient { public enum Error: Swift.Error, Equatable, Sendable { case failedToGetPlaylistId case failedToCreateDownloadTask + case failedToGenerateHLS } public enum RemoveType { @@ -82,6 +83,7 @@ extension OfflineManagerClient { case suspended case finished case cancelled + case error } public struct DownloadingAsset: Hashable { diff --git a/Sources/Clients/PlayerClient/Client.swift b/Sources/Clients/PlayerClient/Client.swift index b84e191..45e4a0e 100644 --- a/Sources/Clients/PlayerClient/Client.swift +++ b/Sources/Clients/PlayerClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 5/26/23. +// Created MochiTeam on 5/26/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/PlayerClient/Extension/AVMediaSelectionGroup+Struct.swift b/Sources/Clients/PlayerClient/Extension/AVMediaSelectionGroup+Struct.swift index 53f94c2..94e13db 100644 --- a/Sources/Clients/PlayerClient/Extension/AVMediaSelectionGroup+Struct.swift +++ b/Sources/Clients/PlayerClient/Extension/AVMediaSelectionGroup+Struct.swift @@ -2,7 +2,7 @@ // AVMediaSelectionGroup+Struct.swift // // -// Created by ErrorErrorError on 7/16/23. +// Created by MochiTeam on 7/16/23. // // diff --git a/Sources/Clients/PlayerClient/Extension/AVPlayer+.swift b/Sources/Clients/PlayerClient/Extension/AVPlayer+.swift index 43fa308..5e0770f 100644 --- a/Sources/Clients/PlayerClient/Extension/AVPlayer+.swift +++ b/Sources/Clients/PlayerClient/Extension/AVPlayer+.swift @@ -2,7 +2,7 @@ // AVPlayer+.swift // // -// Created by ErrorErrorError on 6/10/23. +// Created by MochiTeam on 6/10/23. // // diff --git a/Sources/Clients/PlayerClient/Internal/PlayerItem+DASH.swift b/Sources/Clients/PlayerClient/Internal/PlayerItem+DASH.swift index cdbfb35..d8d3ee9 100644 --- a/Sources/Clients/PlayerClient/Internal/PlayerItem+DASH.swift +++ b/Sources/Clients/PlayerClient/Internal/PlayerItem+DASH.swift @@ -2,7 +2,7 @@ // PlayerItem+DASH.swift // // -// Created by ErrorErrorError on 12/27/23. +// Created by MochiTeam on 12/27/23. // // diff --git a/Sources/Clients/PlayerClient/Internal/PlayerItem+HLS.swift b/Sources/Clients/PlayerClient/Internal/PlayerItem+HLS.swift index d6382c1..f17a857 100644 --- a/Sources/Clients/PlayerClient/Internal/PlayerItem+HLS.swift +++ b/Sources/Clients/PlayerClient/Internal/PlayerItem+HLS.swift @@ -2,7 +2,7 @@ // PlayerItem+HLS.swift // // -// Created by ErrorErrorError on 6/18/23. +// Created by MochiTeam on 6/18/23. // // // Source: https://github.com/jbweimar/external-webvtt-example/blob/master/External%20WebVTT%20Example/CustomResourceLoaderDelegate.swift diff --git a/Sources/Clients/PlayerClient/Internal/PlayerItem.swift b/Sources/Clients/PlayerClient/Internal/PlayerItem.swift index f50a0e7..16927a6 100644 --- a/Sources/Clients/PlayerClient/Internal/PlayerItem.swift +++ b/Sources/Clients/PlayerClient/Internal/PlayerItem.swift @@ -2,7 +2,7 @@ // PlayerItem.swift // // -// Created by ErrorErrorError on 6/18/23. +// Created by MochiTeam on 6/18/23. // // diff --git a/Sources/Clients/PlayerClient/Live.swift b/Sources/Clients/PlayerClient/Live.swift index 557231d..a19d25d 100644 --- a/Sources/Clients/PlayerClient/Live.swift +++ b/Sources/Clients/PlayerClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created ErrorErrorError on 5/26/23. +// Created MochiTeam on 5/26/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/PlayerClient/Models.swift b/Sources/Clients/PlayerClient/Models.swift index ba4faba..804420f 100644 --- a/Sources/Clients/PlayerClient/Models.swift +++ b/Sources/Clients/PlayerClient/Models.swift @@ -2,7 +2,7 @@ // Models.swift // // -// Created by ErrorErrorError on 5/26/23. +// Created by MochiTeam on 5/26/23. // // diff --git a/Sources/Clients/PlayerClient/Views/PlayerRoutePickerView.swift b/Sources/Clients/PlayerClient/Views/PlayerRoutePickerView.swift index 326c573..8b7f5e1 100644 --- a/Sources/Clients/PlayerClient/Views/PlayerRoutePickerView.swift +++ b/Sources/Clients/PlayerClient/Views/PlayerRoutePickerView.swift @@ -2,7 +2,7 @@ // PlayerRoutePickerView.swift // // -// Created by ErrorErrorError on 6/17/23. +// Created by MochiTeam on 6/17/23. // // diff --git a/Sources/Clients/PlayerClient/Views/PlayerView.swift b/Sources/Clients/PlayerClient/Views/PlayerView.swift index fc4942f..03c3de3 100644 --- a/Sources/Clients/PlayerClient/Views/PlayerView.swift +++ b/Sources/Clients/PlayerClient/Views/PlayerView.swift @@ -2,7 +2,7 @@ // PlayerView.swift // // -// Created by ErrorErrorError on 5/31/23. +// Created by MochiTeam on 5/31/23. // // diff --git a/Sources/Clients/PlaylistHistoryClient/Client.swift b/Sources/Clients/PlaylistHistoryClient/Client.swift index e377b67..8899632 100644 --- a/Sources/Clients/PlaylistHistoryClient/Client.swift +++ b/Sources/Clients/PlaylistHistoryClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created by DeNeRr on 28.01.2024. +// Created by MochiTeam on 28.01.2024. // import DatabaseClient diff --git a/Sources/Clients/PlaylistHistoryClient/Live.swift b/Sources/Clients/PlaylistHistoryClient/Live.swift index 807f8f4..4545852 100644 --- a/Sources/Clients/PlaylistHistoryClient/Live.swift +++ b/Sources/Clients/PlaylistHistoryClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created by DeNeRr on 28.01.2024. +// Created by MochiTeam on 28.01.2024. // import DatabaseClient diff --git a/Sources/Clients/PlaylistHistoryClient/Models.swift b/Sources/Clients/PlaylistHistoryClient/Models.swift index 9384b3a..fd7e885 100644 --- a/Sources/Clients/PlaylistHistoryClient/Models.swift +++ b/Sources/Clients/PlaylistHistoryClient/Models.swift @@ -2,7 +2,7 @@ // Models.swift // // -// Created by DeNeRr on 29.01.2024. +// Created by MochiTeam on 29.01.2024. // import Foundation diff --git a/Sources/Clients/RepoClient/Client.swift b/Sources/Clients/RepoClient/Client.swift index 648a910..7dd2a7a 100644 --- a/Sources/Clients/RepoClient/Client.swift +++ b/Sources/Clients/RepoClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/RepoClient/Live.swift b/Sources/Clients/RepoClient/Live.swift index c632c4a..070cfba 100644 --- a/Sources/Clients/RepoClient/Live.swift +++ b/Sources/Clients/RepoClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/RepoClient/Models.swift b/Sources/Clients/RepoClient/Models.swift index 6df2cf9..ce1d5dd 100644 --- a/Sources/Clients/RepoClient/Models.swift +++ b/Sources/Clients/RepoClient/Models.swift @@ -2,7 +2,7 @@ // Models.swift // // -// Created by ErrorErrorError on 4/8/23. +// Created by MochiTeam on 4/8/23. // // diff --git a/Sources/Clients/UserDefaultsClient/Client.swift b/Sources/Clients/UserDefaultsClient/Client.swift index 54c34e5..0c69539 100644 --- a/Sources/Clients/UserDefaultsClient/Client.swift +++ b/Sources/Clients/UserDefaultsClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 4/6/23. +// Created MochiTeam on 4/6/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/UserDefaultsClient/Live.swift b/Sources/Clients/UserDefaultsClient/Live.swift index 6c9523c..cc20dad 100644 --- a/Sources/Clients/UserDefaultsClient/Live.swift +++ b/Sources/Clients/UserDefaultsClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created ErrorErrorError on 4/6/23. +// Created MochiTeam on 4/6/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/UserSettingsClient/AppIcon.swift b/Sources/Clients/UserSettingsClient/AppIcon.swift index c5300ac..46440e6 100644 --- a/Sources/Clients/UserSettingsClient/AppIcon.swift +++ b/Sources/Clients/UserSettingsClient/AppIcon.swift @@ -2,7 +2,7 @@ // AppIcon.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // diff --git a/Sources/Clients/UserSettingsClient/Client.swift b/Sources/Clients/UserSettingsClient/Client.swift index 51c4a98..fb144a6 100644 --- a/Sources/Clients/UserSettingsClient/Client.swift +++ b/Sources/Clients/UserSettingsClient/Client.swift @@ -2,7 +2,7 @@ // Client.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/UserSettingsClient/Live.swift b/Sources/Clients/UserSettingsClient/Live.swift index e2a88d4..1de64ea 100644 --- a/Sources/Clients/UserSettingsClient/Live.swift +++ b/Sources/Clients/UserSettingsClient/Live.swift @@ -2,7 +2,7 @@ // Live.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Clients/UserSettingsClient/Theme.swift b/Sources/Clients/UserSettingsClient/Theme.swift index 29df5c3..98bd9d0 100644 --- a/Sources/Clients/UserSettingsClient/Theme.swift +++ b/Sources/Clients/UserSettingsClient/Theme.swift @@ -2,7 +2,7 @@ // Theme.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // diff --git a/Sources/Clients/UserSettingsClient/UserSettings.swift b/Sources/Clients/UserSettingsClient/UserSettings.swift index 14ab8af..f9ea531 100644 --- a/Sources/Clients/UserSettingsClient/UserSettings.swift +++ b/Sources/Clients/UserSettingsClient/UserSettings.swift @@ -2,7 +2,7 @@ // UserSettings.swift // // -// Created by ErrorErrorError on 5/19/23. +// Created by MochiTeam on 5/19/23. // // diff --git a/Sources/Features/App/AppDelegateFeature.swift b/Sources/Features/App/AppDelegateFeature.swift index 6cca364..7c9c1cc 100644 --- a/Sources/Features/App/AppDelegateFeature.swift +++ b/Sources/Features/App/AppDelegateFeature.swift @@ -2,7 +2,7 @@ // AppDelegateFeature.swift // // -// Created by ErrorErrorError on 5/19/23. +// Created by MochiTeam on 5/19/23. // // diff --git a/Sources/Features/App/AppFeature+Reducer.swift b/Sources/Features/App/AppFeature+Reducer.swift index 3d1e118..023faa4 100644 --- a/Sources/Features/App/AppFeature+Reducer.swift +++ b/Sources/Features/App/AppFeature+Reducer.swift @@ -2,7 +2,7 @@ // AppFeature+Reducer.swift // // -// Created by ErrorErrorError on 4/6/23. +// Created by MochiTeam on 4/6/23. // // diff --git a/Sources/Features/App/AppFeature.swift b/Sources/Features/App/AppFeature.swift index 6b4abb0..764f586 100644 --- a/Sources/Features/App/AppFeature.swift +++ b/Sources/Features/App/AppFeature.swift @@ -2,7 +2,7 @@ // AppFeature.swift // // -// Created by ErrorErrorError on 4/6/23. +// Created by MochiTeam on 4/6/23. // // diff --git a/Sources/Features/App/Exported.swift b/Sources/Features/App/Exported.swift index f80894e..f2e1085 100644 --- a/Sources/Features/App/Exported.swift +++ b/Sources/Features/App/Exported.swift @@ -2,7 +2,7 @@ // Exported.swift // // -// Created by ErrorErrorError on 11/23/23. +// Created by MochiTeam on 11/23/23. // // diff --git a/Sources/Features/App/iOS/AppFeatureView+iOS.swift b/Sources/Features/App/iOS/AppFeatureView+iOS.swift index e84e200..33e5d31 100644 --- a/Sources/Features/App/iOS/AppFeatureView+iOS.swift +++ b/Sources/Features/App/iOS/AppFeatureView+iOS.swift @@ -2,7 +2,7 @@ // AppFeatureView+iOS.swift // // -// Created by ErrorErrorError on 11/23/23. +// Created by MochiTeam on 11/23/23. // // diff --git a/Sources/Features/App/macOS/AppFeatureView+macOS.swift b/Sources/Features/App/macOS/AppFeatureView+macOS.swift index a356ca3..05acdcf 100644 --- a/Sources/Features/App/macOS/AppFeatureView+macOS.swift +++ b/Sources/Features/App/macOS/AppFeatureView+macOS.swift @@ -2,7 +2,7 @@ // AppFeatureView+macOS.swift // // -// Created by ErrorErrorError on 11/23/23. +// Created by MochiTeam on 11/23/23. // // diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index c9f7e18..ff80acf 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -2,7 +2,7 @@ // ContentCore+View.swift // // -// Created by ErrorErrorError on 7/13/23. +// Created by MochiTeam on 7/13/23. // // diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index ea7102b..8147bf9 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -2,7 +2,7 @@ // ContentCore.swift // // -// Created by ErrorErrorError on 7/2/23. +// Created by MochiTeam on 7/2/23. // // diff --git a/Sources/Features/ContentCore/DownloadSelection.swift b/Sources/Features/ContentCore/DownloadSelection.swift index 46c94ec..1fc075d 100644 --- a/Sources/Features/ContentCore/DownloadSelection.swift +++ b/Sources/Features/ContentCore/DownloadSelection.swift @@ -2,7 +2,7 @@ // DownloadSection.swift // // -// DownloadSelection by DeNeRr on 15.04.2024. +// DownloadSelection by MochiTeam on 15.04.2024. // import Foundation diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 438a687..3454ee1 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -2,7 +2,7 @@ // DiscoverFeature+Reducer.swift // // -// Created by ErrorErrorError on 4/5/23. +// Created by MochiTeam on 4/5/23. // // diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index 0db1db3..e1f59a6 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -2,7 +2,7 @@ // DiscoverFeature+View.swift // // -// Created by ErrorErrorError on 4/5/23. +// Created by MochiTeam on 4/5/23. // // diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index 811557a..4a85023 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -2,7 +2,7 @@ // DiscoverFeature.swift // // -// Created by ErrorErrorError on 4/5/23. +// Created by MochiTeam on 4/5/23. // // diff --git a/Sources/Features/Discover/ViewMoreListing.swift b/Sources/Features/Discover/ViewMoreListing.swift index 5425ff5..631ae24 100644 --- a/Sources/Features/Discover/ViewMoreListing.swift +++ b/Sources/Features/Discover/ViewMoreListing.swift @@ -2,7 +2,7 @@ // ViewMoreListing.swift // // -// Created by ErrorErrorError on 12/13/23. +// Created by MochiTeam on 12/13/23. // // diff --git a/Sources/Features/Discover/WebView.swift b/Sources/Features/Discover/WebView.swift index bf076fb..a7b9cc4 100644 --- a/Sources/Features/Discover/WebView.swift +++ b/Sources/Features/Discover/WebView.swift @@ -2,7 +2,7 @@ // WebView.swift // // -// Created by DeNeRr on 22.02.2024. +// Created by MochiTeam on 22.02.2024. // import SwiftUI diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift index 9e37b0f..eb3f68f 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift @@ -2,7 +2,7 @@ // DownloadQueueFeature+Reducer.swift // // -// Created by DeNeRr on 17.05.2024. +// Created by MochiTeam on 17.05.2024. // import Architecture diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift index cad8cd9..0d74ce4 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift @@ -2,7 +2,7 @@ // DownloadQueueFeature+View.swift // // -// Created by DeNeRr on 17.05.2024. +// Created by MochiTeam on 17.05.2024. // import Foundation @@ -74,6 +74,13 @@ extension DownloadQueueFeature.View: View { viewStore.send(.pause(item)) } .animation(.easeInOut, value: item.status) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 29, height: 29) + .foregroundStyle(Theme.pastelRed) + .animation(.easeInOut, value: item.status) } } } diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature.swift index a153974..4f08077 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature.swift @@ -2,7 +2,7 @@ // DownloadQueueFeature.swift // // -// Created by DeNeRr on 16.05.2024. +// Created by MochiTeam on 16.05.2024. // import Architecture diff --git a/Sources/Features/LIbrary/LibraryFeature+Reducer.swift b/Sources/Features/LIbrary/LibraryFeature+Reducer.swift index 0a075ff..2963c16 100644 --- a/Sources/Features/LIbrary/LibraryFeature+Reducer.swift +++ b/Sources/Features/LIbrary/LibraryFeature+Reducer.swift @@ -2,7 +2,7 @@ // LibraryFeature+Reducer.swift // // -// Created by DeNeRr on 09.04.2024. +// Created by MochiTeam on 09.04.2024. // import Architecture diff --git a/Sources/Features/LIbrary/LibraryFeature+View.swift b/Sources/Features/LIbrary/LibraryFeature+View.swift index 3032a00..853e6e5 100644 --- a/Sources/Features/LIbrary/LibraryFeature+View.swift +++ b/Sources/Features/LIbrary/LibraryFeature+View.swift @@ -2,7 +2,7 @@ // LibraryFeature+View.swift // // -// Created by DeNeRr on 09.04.2024. +// Created by MochiTeam on 09.04.2024. // import Architecture diff --git a/Sources/Features/LIbrary/LibraryFeature.swift b/Sources/Features/LIbrary/LibraryFeature.swift index f864871..3067d2b 100644 --- a/Sources/Features/LIbrary/LibraryFeature.swift +++ b/Sources/Features/LIbrary/LibraryFeature.swift @@ -2,7 +2,7 @@ // LibraryFeature.swift // // -// Created by DeNeRr on 09.04.2024. +// Created by MochiTeam on 09.04.2024. // import Architecture diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift b/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift index 9c87956..375564f 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+Reducer.swift @@ -2,7 +2,7 @@ // ModuleListsFeature+Reducer.swift // // -// Created ErrorErrorError on 4/23/23. +// Created MochiTeam on 4/23/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift index 46cf78d..787c499 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift @@ -2,7 +2,7 @@ // ModuleListsFeature+View.swift // // -// Created ErrorErrorError on 4/23/23. +// Created MochiTeam on 4/23/23. // Copyright © 2023. All rights reserved. // @@ -206,7 +206,7 @@ import Styling remoteURL: .init(string: "/").unsafelyUnwrapped, manifest: .init( name: "Local Repo", - author: "errorerrorerror", + author: "MochiTeam", description: "This is a local repo" ), modules: [ diff --git a/Sources/Features/ModuleLists/ModuleListsFeature.swift b/Sources/Features/ModuleLists/ModuleListsFeature.swift index 6b12f8a..fc48c4e 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature.swift @@ -2,7 +2,7 @@ // ModuleListsFeature.swift // // -// Created ErrorErrorError on 4/23/23. +// Created MochiTeam on 4/23/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift index eaa3171..9e41e74 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift @@ -2,7 +2,7 @@ // PlaylistDetailsFeature+Reducer.swift // // -// Created ErrorErrorError on 5/19/23. +// Created MochiTeam on 5/19/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift index 3d9295c..e88d29f 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift @@ -2,7 +2,7 @@ // PlaylistDetailsFeature.swift // // -// Created ErrorErrorError on 5/19/23. +// Created MochiTeam on 5/19/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift index e8b6844..4425fb2 100644 --- a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift +++ b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift @@ -2,7 +2,7 @@ // PlaylistDetailsFeature+View+iOS.swift // // -// Created ErrorErrorError on 5/19/23. +// Created MochiTeam on 5/19/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/PlaylistDetails/macOS/PlaylistDetailsFeature+View+macOS.swift b/Sources/Features/PlaylistDetails/macOS/PlaylistDetailsFeature+View+macOS.swift index cec111f..1f3b625 100644 --- a/Sources/Features/PlaylistDetails/macOS/PlaylistDetailsFeature+View+macOS.swift +++ b/Sources/Features/PlaylistDetails/macOS/PlaylistDetailsFeature+View+macOS.swift @@ -2,7 +2,7 @@ // PlaylistDetailsFeature+View+macOS.swift // // -// Created by ErrorErrorError on 11/23/23. +// Created by MochiTeam on 11/23/23. // // diff --git a/Sources/Features/Repos/Components/RepoURLTextField+iOS.swift b/Sources/Features/Repos/Components/RepoURLTextField+iOS.swift index b4e7d8f..c005265 100644 --- a/Sources/Features/Repos/Components/RepoURLTextField+iOS.swift +++ b/Sources/Features/Repos/Components/RepoURLTextField+iOS.swift @@ -2,7 +2,7 @@ // RepoURLTextField+iOS.swift // // -// Created by ErrorErrorError on 12/15/23. +// Created by MochiTeam on 12/15/23. // // diff --git a/Sources/Features/Repos/Components/RepoURLTextField+macOS.swift b/Sources/Features/Repos/Components/RepoURLTextField+macOS.swift index 74ccac2..eee2da6 100644 --- a/Sources/Features/Repos/Components/RepoURLTextField+macOS.swift +++ b/Sources/Features/Repos/Components/RepoURLTextField+macOS.swift @@ -2,7 +2,7 @@ // RepoURLTextField+macOS.swift // // -// Created by ErrorErrorError on 12/15/23. +// Created by MochiTeam on 12/15/23. // // diff --git a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+Reducer.swift b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+Reducer.swift index 505a6f7..71d3748 100644 --- a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+Reducer.swift +++ b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+Reducer.swift @@ -2,7 +2,7 @@ // RepoPackagesFeature+Reducer.swift // // -// Created by ErrorErrorError on 8/16/23. +// Created by MochiTeam on 8/16/23. // // diff --git a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift index 43e8540..c3ea8e5 100644 --- a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift +++ b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift @@ -2,7 +2,7 @@ // RepoPackagesFeature+View.swift // // -// Created ErrorErrorError on 5/4/23. +// Created MochiTeam on 5/4/23. // Copyright © 2023. All rights reserved. // @@ -357,7 +357,7 @@ extension StatusView { initialState: .init( repo: .init( remoteURL: .init(string: "/").unsafelyUnwrapped, - manifest: .init(name: "Repo 1", author: "errorerrorerror") + manifest: .init(name: "Repo 1", author: "MochiTeam") ) ), reducer: { EmptyReducer() } diff --git a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift index ca1428d..8fa4a71 100644 --- a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift +++ b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift @@ -2,7 +2,7 @@ // RepoPackagesFeature.swift // // -// Created ErrorErrorError on 5/4/23. +// Created MochiTeam on 5/4/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Repos/ReposFeature+Reducer.swift b/Sources/Features/Repos/ReposFeature+Reducer.swift index 81dd17a..0884b50 100644 --- a/Sources/Features/Repos/ReposFeature+Reducer.swift +++ b/Sources/Features/Repos/ReposFeature+Reducer.swift @@ -2,7 +2,7 @@ // ReposFeature+Reducer.swift // // -// Created ErrorErrorError on 4/18/23. +// Created MochiTeam on 4/18/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Repos/ReposFeature+View.swift b/Sources/Features/Repos/ReposFeature+View.swift index b95b7fd..756b8d9 100644 --- a/Sources/Features/Repos/ReposFeature+View.swift +++ b/Sources/Features/Repos/ReposFeature+View.swift @@ -2,7 +2,7 @@ // ReposFeature+View.swift // // -// Created ErrorErrorError on 4/18/23. +// Created MochiTeam on 4/18/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Repos/ReposFeature.swift b/Sources/Features/Repos/ReposFeature.swift index 3985c41..870bfa6 100644 --- a/Sources/Features/Repos/ReposFeature.swift +++ b/Sources/Features/Repos/ReposFeature.swift @@ -2,7 +2,7 @@ // ReposFeature.swift // // -// Created ErrorErrorError on 4/18/23. +// Created MochiTeam on 4/18/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Search/SearchFeature+Reducer.swift b/Sources/Features/Search/SearchFeature+Reducer.swift index 9a88a18..0b41903 100644 --- a/Sources/Features/Search/SearchFeature+Reducer.swift +++ b/Sources/Features/Search/SearchFeature+Reducer.swift @@ -2,7 +2,7 @@ // SearchFeature+Reducer.swift // // -// Created ErrorErrorError on 4/18/23. +// Created MochiTeam on 4/18/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Search/SearchFeature+View.swift b/Sources/Features/Search/SearchFeature+View.swift index a69b14d..fb75278 100644 --- a/Sources/Features/Search/SearchFeature+View.swift +++ b/Sources/Features/Search/SearchFeature+View.swift @@ -2,7 +2,7 @@ // SearchFeature+View.swift // // -// Created ErrorErrorError on 4/18/23. +// Created MochiTeam on 4/18/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Search/SearchFeature.swift b/Sources/Features/Search/SearchFeature.swift index 7d948d5..49f90e0 100644 --- a/Sources/Features/Search/SearchFeature.swift +++ b/Sources/Features/Search/SearchFeature.swift @@ -2,7 +2,7 @@ // SearchFeature.swift // // -// Created ErrorErrorError on 4/18/23. +// Created MochiTeam on 4/18/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Settings/Components/Logs.swift b/Sources/Features/Settings/Components/Logs.swift index 7bcffb3..aaed777 100644 --- a/Sources/Features/Settings/Components/Logs.swift +++ b/Sources/Features/Settings/Components/Logs.swift @@ -2,7 +2,7 @@ // Logs.swift // // -// Created by ErrorErrorError on 11/29/23. +// Created by MochiTeam on 11/29/23. // // diff --git a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift index f719d44..e7568cd 100644 --- a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift +++ b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift @@ -2,7 +2,7 @@ // SettingsFeature+iOS.swift // // -// Created by ErrorErrorError on 11/27/23. +// Created by MochiTeam on 11/27/23. // // @@ -44,7 +44,7 @@ private struct VersionView: View { Text( """ Design and developed by \ - [@errorerrorerror](https://errorerrorerror.dev) \ + [@MochiTeam](https://MochiTeam.dev) \ & \ [contributors](https://github.com/Mochi-Team/mochi/contributors) """ diff --git a/Sources/Features/Settings/Platforms/SettingsFeature+macOS.swift b/Sources/Features/Settings/Platforms/SettingsFeature+macOS.swift index 0607798..d3b974d 100644 --- a/Sources/Features/Settings/Platforms/SettingsFeature+macOS.swift +++ b/Sources/Features/Settings/Platforms/SettingsFeature+macOS.swift @@ -2,7 +2,7 @@ // SettingsFeature+macOS.swift // // -// Created by ErrorErrorError on 11/27/23. +// Created by MochiTeam on 11/27/23. // // diff --git a/Sources/Features/Settings/SettingsFeature+Reducer.swift b/Sources/Features/Settings/SettingsFeature+Reducer.swift index 57d76f5..09d36c6 100644 --- a/Sources/Features/Settings/SettingsFeature+Reducer.swift +++ b/Sources/Features/Settings/SettingsFeature+Reducer.swift @@ -2,7 +2,7 @@ // SettingsFeature+Reducer.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Settings/SettingsFeature+View.swift b/Sources/Features/Settings/SettingsFeature+View.swift index c48c389..7e69bd8 100644 --- a/Sources/Features/Settings/SettingsFeature+View.swift +++ b/Sources/Features/Settings/SettingsFeature+View.swift @@ -2,7 +2,7 @@ // SettingsFeature+View.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/Settings/SettingsFeature.swift b/Sources/Features/Settings/SettingsFeature.swift index fd7bc0e..4052e81 100644 --- a/Sources/Features/Settings/SettingsFeature.swift +++ b/Sources/Features/Settings/SettingsFeature.swift @@ -2,7 +2,7 @@ // SettingsFeature.swift // // -// Created ErrorErrorError on 4/8/23. +// Created MochiTeam on 4/8/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/VideoPlayer/Components/ProgressBar.swift b/Sources/Features/VideoPlayer/Components/ProgressBar.swift index 4c87aa2..f498e01 100644 --- a/Sources/Features/VideoPlayer/Components/ProgressBar.swift +++ b/Sources/Features/VideoPlayer/Components/ProgressBar.swift @@ -2,7 +2,7 @@ // ProgressBar.swift // // -// Created by ErrorErrorError on 11/22/23. +// Created by MochiTeam on 11/22/23. // // diff --git a/Sources/Features/VideoPlayer/Extensions/DateComponentsFormatter+.swift b/Sources/Features/VideoPlayer/Extensions/DateComponentsFormatter+.swift index 6b38df9..9204f9f 100644 --- a/Sources/Features/VideoPlayer/Extensions/DateComponentsFormatter+.swift +++ b/Sources/Features/VideoPlayer/Extensions/DateComponentsFormatter+.swift @@ -2,7 +2,7 @@ // DateComponentsFormatter+.swift // // -// Created by ErrorErrorError on 11/22/23. +// Created by MochiTeam on 11/22/23. // // diff --git a/Sources/Features/VideoPlayer/Models.swift b/Sources/Features/VideoPlayer/Models.swift index 528b16a..6173990 100644 --- a/Sources/Features/VideoPlayer/Models.swift +++ b/Sources/Features/VideoPlayer/Models.swift @@ -2,7 +2,7 @@ // Models.swift // // -// Created by ErrorErrorError on 11/22/23. +// Created by MochiTeam on 11/22/23. // // diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index d1ee62b..41e9bc4 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -2,7 +2,7 @@ // VideoPlayerFeature+Reducer.swift // // -// Created ErrorErrorError on 5/26/23. +// Created MochiTeam on 5/26/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift index 31dcde3..42f183e 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift @@ -2,7 +2,7 @@ // VideoPlayerFeature+View.swift // // -// Created by ErrorErrorError on 11/23/23. +// Created by MochiTeam on 11/23/23. // // diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift index e4eabfe..1523a50 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift @@ -2,7 +2,7 @@ // VideoPlayerFeature.swift // // -// Created ErrorErrorError on 5/26/23. +// Created MochiTeam on 5/26/23. // Copyright © 2023. All rights reserved. // diff --git a/Sources/Features/VideoPlayer/iOS/VideoPlayerFeature+iOS.swift b/Sources/Features/VideoPlayer/iOS/VideoPlayerFeature+iOS.swift index 4a1cddf..59e010d 100644 --- a/Sources/Features/VideoPlayer/iOS/VideoPlayerFeature+iOS.swift +++ b/Sources/Features/VideoPlayer/iOS/VideoPlayerFeature+iOS.swift @@ -2,7 +2,7 @@ // VideoPlayerFeature+iOS.swift // // -// Created by ErrorErrorError on 11/23/23. +// Created by MochiTeam on 11/23/23. // // diff --git a/Sources/Features/VideoPlayer/macOS/VideoPlayerFeature+macOS.swift b/Sources/Features/VideoPlayer/macOS/VideoPlayerFeature+macOS.swift index 8358af6..1fac2d8 100644 --- a/Sources/Features/VideoPlayer/macOS/VideoPlayerFeature+macOS.swift +++ b/Sources/Features/VideoPlayer/macOS/VideoPlayerFeature+macOS.swift @@ -2,7 +2,7 @@ // VideoPlayerFeature+macOS.swift // // -// Created by ErrorErrorError on 11/23/23. +// Created by MochiTeam on 11/23/23. // // diff --git a/Sources/Macros/CoreDBMacros/AttributeMacro.swift b/Sources/Macros/CoreDBMacros/AttributeMacro.swift index 0b747c7..3b7f0a4 100644 --- a/Sources/Macros/CoreDBMacros/AttributeMacro.swift +++ b/Sources/Macros/CoreDBMacros/AttributeMacro.swift @@ -2,7 +2,7 @@ // AttributeMacro.swift // // -// Created by ErrorErrorError on 12/29/23. +// Created by MochiTeam on 12/29/23. // // diff --git a/Sources/Macros/CoreDBMacros/EntityMacro.swift b/Sources/Macros/CoreDBMacros/EntityMacro.swift index 4343506..9e9570f 100644 --- a/Sources/Macros/CoreDBMacros/EntityMacro.swift +++ b/Sources/Macros/CoreDBMacros/EntityMacro.swift @@ -2,7 +2,7 @@ // EntityMacro.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Macros/CoreDBMacros/Helpers.swift b/Sources/Macros/CoreDBMacros/Helpers.swift index 94169d3..0c4e787 100644 --- a/Sources/Macros/CoreDBMacros/Helpers.swift +++ b/Sources/Macros/CoreDBMacros/Helpers.swift @@ -2,7 +2,7 @@ // Helpers.swift // // -// Created by ErrorErrorError on 12/29/23. +// Created by MochiTeam on 12/29/23. // // diff --git a/Sources/Macros/CoreDBMacros/Plugins.swift b/Sources/Macros/CoreDBMacros/Plugins.swift index 34d5d7e..5c61120 100644 --- a/Sources/Macros/CoreDBMacros/Plugins.swift +++ b/Sources/Macros/CoreDBMacros/Plugins.swift @@ -2,7 +2,7 @@ // Plugins.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Macros/CoreDBMacros/RelationMacro.swift b/Sources/Macros/CoreDBMacros/RelationMacro.swift index 3914f6e..995d4b3 100644 --- a/Sources/Macros/CoreDBMacros/RelationMacro.swift +++ b/Sources/Macros/CoreDBMacros/RelationMacro.swift @@ -2,7 +2,7 @@ // RelationMacro.swift // // -// Created by ErrorErrorError on 12/29/23. +// Created by MochiTeam on 12/29/23. // // diff --git a/Sources/Shared/Architecture/Dependencies/Dependencies+DateComponentsFormatter.swift b/Sources/Shared/Architecture/Dependencies/Dependencies+DateComponentsFormatter.swift index 5be73b6..2f2aae5 100644 --- a/Sources/Shared/Architecture/Dependencies/Dependencies+DateComponentsFormatter.swift +++ b/Sources/Shared/Architecture/Dependencies/Dependencies+DateComponentsFormatter.swift @@ -2,7 +2,7 @@ // Dependencies+DateComponentsFormatter.swift // // -// Created by ErrorErrorError on 6/11/23. +// Created by MochiTeam on 6/11/23. // // diff --git a/Sources/Shared/Architecture/Dependencies/Dependencies+DateFormater.swift b/Sources/Shared/Architecture/Dependencies/Dependencies+DateFormater.swift index c91099e..9ce096e 100644 --- a/Sources/Shared/Architecture/Dependencies/Dependencies+DateFormater.swift +++ b/Sources/Shared/Architecture/Dependencies/Dependencies+DateFormater.swift @@ -2,7 +2,7 @@ // Dependencies+DateFormater.swift // // -// Created by ErrorErrorError on 5/2/23. +// Created by MochiTeam on 5/2/23. // // diff --git a/Sources/Shared/Architecture/Dependencies/Dependencies+NumberFormatter.swift b/Sources/Shared/Architecture/Dependencies/Dependencies+NumberFormatter.swift index 5332f20..8588271 100644 --- a/Sources/Shared/Architecture/Dependencies/Dependencies+NumberFormatter.swift +++ b/Sources/Shared/Architecture/Dependencies/Dependencies+NumberFormatter.swift @@ -2,7 +2,7 @@ // Dependencies+NumberFormatter.swift // // -// Created by ErrorErrorError on 5/2/23. +// Created by MochiTeam on 5/2/23. // // diff --git a/Sources/Shared/Architecture/Exported.swift b/Sources/Shared/Architecture/Exported.swift index c3a78d3..b4bb471 100644 --- a/Sources/Shared/Architecture/Exported.swift +++ b/Sources/Shared/Architecture/Exported.swift @@ -2,7 +2,7 @@ // Exported.swift // // -// Created by ErrorErrorError on 4/21/23. +// Created by MochiTeam on 4/21/23. // // diff --git a/Sources/Shared/Architecture/Feature.swift b/Sources/Shared/Architecture/Feature.swift index cd53516..ddcf0c4 100644 --- a/Sources/Shared/Architecture/Feature.swift +++ b/Sources/Shared/Architecture/Feature.swift @@ -2,7 +2,7 @@ // Feature.swift // // -// Created by ErrorErrorError on 4/5/23. +// Created by MochiTeam on 4/5/23. // // diff --git a/Sources/Shared/Architecture/TCA+Extensions.swift b/Sources/Shared/Architecture/TCA+Extensions.swift index 9554a41..c29ddb9 100644 --- a/Sources/Shared/Architecture/TCA+Extensions.swift +++ b/Sources/Shared/Architecture/TCA+Extensions.swift @@ -2,7 +2,7 @@ // TCA+Extensions.swift // // -// Created by ErrorErrorError on 4/7/23. +// Created by MochiTeam on 4/7/23. // // diff --git a/Sources/Shared/Architecture/Utils/Binding+Equatable.swift b/Sources/Shared/Architecture/Utils/Binding+Equatable.swift index f8c5873..426695e 100644 --- a/Sources/Shared/Architecture/Utils/Binding+Equatable.swift +++ b/Sources/Shared/Architecture/Utils/Binding+Equatable.swift @@ -2,7 +2,7 @@ // Binding+Equatable.swift // // -// Created by ErrorErrorError on 5/20/23. +// Created by MochiTeam on 5/20/23. // // diff --git a/Sources/Shared/Architecture/Utils/SelectableState.swift b/Sources/Shared/Architecture/Utils/SelectableState.swift index b0ab641..452238b 100644 --- a/Sources/Shared/Architecture/Utils/SelectableState.swift +++ b/Sources/Shared/Architecture/Utils/SelectableState.swift @@ -2,7 +2,7 @@ // SelectableState.swift // // -// Created by ErrorErrorError on 5/11/23. +// Created by MochiTeam on 5/11/23. // // // diff --git a/Sources/Shared/Architecture/Utils/Swizzle.swift b/Sources/Shared/Architecture/Utils/Swizzle.swift index abf5419..e987787 100644 --- a/Sources/Shared/Architecture/Utils/Swizzle.swift +++ b/Sources/Shared/Architecture/Utils/Swizzle.swift @@ -2,7 +2,7 @@ // Swizzle.swift // // -// Created by ErrorErrorError on 12/2/23. +// Created by MochiTeam on 12/2/23. // // Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d diff --git a/Sources/Shared/CoreDB/Extension/EntityDescription.swift b/Sources/Shared/CoreDB/Extension/EntityDescription.swift index 4d8292e..bfe29ef 100644 --- a/Sources/Shared/CoreDB/Extension/EntityDescription.swift +++ b/Sources/Shared/CoreDB/Extension/EntityDescription.swift @@ -2,7 +2,7 @@ // EntityDescription.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Shared/CoreDB/Extension/NSManagedObject+.swift b/Sources/Shared/CoreDB/Extension/NSManagedObject+.swift index 5512d2c..7fecf72 100644 --- a/Sources/Shared/CoreDB/Extension/NSManagedObject+.swift +++ b/Sources/Shared/CoreDB/Extension/NSManagedObject+.swift @@ -2,7 +2,7 @@ // NSManagedObject+.swift // // -// Created by ErrorErrorError on 5/15/23. +// Created by MochiTeam on 5/15/23. // // diff --git a/Sources/Shared/CoreDB/Extension/NSManagedObjectContext+.swift b/Sources/Shared/CoreDB/Extension/NSManagedObjectContext+.swift index aacafcc..d42e3f9 100644 --- a/Sources/Shared/CoreDB/Extension/NSManagedObjectContext+.swift +++ b/Sources/Shared/CoreDB/Extension/NSManagedObjectContext+.swift @@ -2,7 +2,7 @@ // NSManagedObjectContext+.swift // // -// Created by ErrorErrorError on 5/3/23. +// Created by MochiTeam on 5/3/23. // // diff --git a/Sources/Shared/CoreDB/Extension/NSManagedObjectModel+.swift b/Sources/Shared/CoreDB/Extension/NSManagedObjectModel+.swift index a15374f..8ae0b04 100644 --- a/Sources/Shared/CoreDB/Extension/NSManagedObjectModel+.swift +++ b/Sources/Shared/CoreDB/Extension/NSManagedObjectModel+.swift @@ -2,7 +2,7 @@ // NSManagedObjectModel+.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Shared/CoreDB/Extension/NSPersistentContainer+.swift b/Sources/Shared/CoreDB/Extension/NSPersistentContainer+.swift index eec693a..264238c 100644 --- a/Sources/Shared/CoreDB/Extension/NSPersistentContainer+.swift +++ b/Sources/Shared/CoreDB/Extension/NSPersistentContainer+.swift @@ -2,7 +2,7 @@ // NSPersistentContainer+.swift // // -// Created by ErrorErrorError on 5/15/23. +// Created by MochiTeam on 5/15/23. // // diff --git a/Sources/Shared/CoreDB/Extension/NSPersistentStore+.swift b/Sources/Shared/CoreDB/Extension/NSPersistentStore+.swift index e105bc6..17e0992 100644 --- a/Sources/Shared/CoreDB/Extension/NSPersistentStore+.swift +++ b/Sources/Shared/CoreDB/Extension/NSPersistentStore+.swift @@ -2,7 +2,7 @@ // NSPersistentStore+.swift // // -// Created by ErrorErrorError on 5/16/23. +// Created by MochiTeam on 5/16/23. // // diff --git a/Sources/Shared/CoreDB/Extension/NSPropertyDescriptors+.swift b/Sources/Shared/CoreDB/Extension/NSPropertyDescriptors+.swift index 4f3c21c..9ea894c 100644 --- a/Sources/Shared/CoreDB/Extension/NSPropertyDescriptors+.swift +++ b/Sources/Shared/CoreDB/Extension/NSPropertyDescriptors+.swift @@ -2,7 +2,7 @@ // NSPropertyDescriptors+.swift // // -// Created by ErrorErrorError on 12/30/23. +// Created by MochiTeam on 12/30/23. // // diff --git a/Sources/Shared/CoreDB/Macros.swift b/Sources/Shared/CoreDB/Macros.swift index 3e4275c..19dce82 100644 --- a/Sources/Shared/CoreDB/Macros.swift +++ b/Sources/Shared/CoreDB/Macros.swift @@ -2,7 +2,7 @@ // Macros.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Shared/CoreDB/PersistentCoreDB.swift b/Sources/Shared/CoreDB/PersistentCoreDB.swift index 12a870e..cc4c06c 100644 --- a/Sources/Shared/CoreDB/PersistentCoreDB.swift +++ b/Sources/Shared/CoreDB/PersistentCoreDB.swift @@ -2,7 +2,7 @@ // PersistentCoreDB.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Shared/CoreDB/Properties/Attribute.swift b/Sources/Shared/CoreDB/Properties/Attribute.swift index 8100c4a..2a44a90 100644 --- a/Sources/Shared/CoreDB/Properties/Attribute.swift +++ b/Sources/Shared/CoreDB/Properties/Attribute.swift @@ -2,7 +2,7 @@ // Attribute.swift // // -// Created by ErrorErrorError on 5/17/23. +// Created by MochiTeam on 5/17/23. // // diff --git a/Sources/Shared/CoreDB/Properties/Property.swift b/Sources/Shared/CoreDB/Properties/Property.swift index 0bfeb75..345e8db 100644 --- a/Sources/Shared/CoreDB/Properties/Property.swift +++ b/Sources/Shared/CoreDB/Properties/Property.swift @@ -2,7 +2,7 @@ // Property.swift // // -// Created by ErrorErrorError on 5/15/23. +// Created by MochiTeam on 5/15/23. // // diff --git a/Sources/Shared/CoreDB/Properties/Relation.swift b/Sources/Shared/CoreDB/Properties/Relation.swift index 35b2b9b..b0191ed 100644 --- a/Sources/Shared/CoreDB/Properties/Relation.swift +++ b/Sources/Shared/CoreDB/Properties/Relation.swift @@ -2,7 +2,7 @@ // Relation.swift // // -// Created by ErrorErrorError on 5/15/23. +// Created by MochiTeam on 5/15/23. // // diff --git a/Sources/Shared/CoreDB/Supporting Files/Cast.swift b/Sources/Shared/CoreDB/Supporting Files/Cast.swift index df14cb4..0954c2f 100644 --- a/Sources/Shared/CoreDB/Supporting Files/Cast.swift +++ b/Sources/Shared/CoreDB/Supporting Files/Cast.swift @@ -2,7 +2,7 @@ // Cast.swift // // -// Created by ErrorErrorError on 5/16/23. +// Created by MochiTeam on 5/16/23. // // diff --git a/Sources/Shared/CoreDB/Supporting Files/Entity.swift b/Sources/Shared/CoreDB/Supporting Files/Entity.swift index 3ca0752..6fbb725 100644 --- a/Sources/Shared/CoreDB/Supporting Files/Entity.swift +++ b/Sources/Shared/CoreDB/Supporting Files/Entity.swift @@ -2,7 +2,7 @@ // Entity.swift // // -// Created by ErrorErrorError on 9/12/23. +// Created by MochiTeam on 9/12/23. // // diff --git a/Sources/Shared/CoreDB/Supporting Files/EntityID.swift b/Sources/Shared/CoreDB/Supporting Files/EntityID.swift index 3173482..98e7181 100644 --- a/Sources/Shared/CoreDB/Supporting Files/EntityID.swift +++ b/Sources/Shared/CoreDB/Supporting Files/EntityID.swift @@ -2,7 +2,7 @@ // EntityID.swift // // -// Created by ErrorErrorError on 9/18/23. +// Created by MochiTeam on 9/18/23. // // diff --git a/Sources/Shared/CoreDB/Supporting Files/Optional.swift b/Sources/Shared/CoreDB/Supporting Files/Optional.swift index e947e3b..0a20c0e 100644 --- a/Sources/Shared/CoreDB/Supporting Files/Optional.swift +++ b/Sources/Shared/CoreDB/Supporting Files/Optional.swift @@ -2,7 +2,7 @@ // Optional.swift // // -// Created by ErrorErrorError on 5/18/23. +// Created by MochiTeam on 5/18/23. // // diff --git a/Sources/Shared/CoreDB/Supporting Files/Request.swift b/Sources/Shared/CoreDB/Supporting Files/Request.swift index 3a7b7cc..fc3a179 100644 --- a/Sources/Shared/CoreDB/Supporting Files/Request.swift +++ b/Sources/Shared/CoreDB/Supporting Files/Request.swift @@ -1,7 +1,7 @@ // Request.swift // mochi // -// Created by ErrorErrorError on 11/16/22. +// Created by MochiTeam on 11/16/22. // // Modified version of https://github.com/prisma-ai/Sworm diff --git a/Sources/Shared/CoreDB/Supporting Files/Schema.swift b/Sources/Shared/CoreDB/Supporting Files/Schema.swift index e136448..6d6e4f0 100644 --- a/Sources/Shared/CoreDB/Supporting Files/Schema.swift +++ b/Sources/Shared/CoreDB/Supporting Files/Schema.swift @@ -2,7 +2,7 @@ // Schema.swift // // -// Created by ErrorErrorError on 12/28/23. +// Created by MochiTeam on 12/28/23. // // diff --git a/Sources/Shared/CoreDB/Supporting Files/TransformableValue.swift b/Sources/Shared/CoreDB/Supporting Files/TransformableValue.swift index ce86d1e..0e30163 100644 --- a/Sources/Shared/CoreDB/Supporting Files/TransformableValue.swift +++ b/Sources/Shared/CoreDB/Supporting Files/TransformableValue.swift @@ -2,7 +2,7 @@ // TransformableValue.swift // // -// Created by ErrorErrorError on 5/3/23. +// Created by MochiTeam on 5/3/23. // // diff --git a/Sources/Shared/FoundationHelpers/Array+ID.swift b/Sources/Shared/FoundationHelpers/Array+ID.swift index e575f0f..615e199 100644 --- a/Sources/Shared/FoundationHelpers/Array+ID.swift +++ b/Sources/Shared/FoundationHelpers/Array+ID.swift @@ -2,7 +2,7 @@ // Array+ID.swift // // -// Created by ErrorErrorError on 5/11/23. +// Created by MochiTeam on 5/11/23. // // diff --git a/Sources/Shared/FoundationHelpers/Equatable+.swift b/Sources/Shared/FoundationHelpers/Equatable+.swift index f7ecee7..a54017a 100644 --- a/Sources/Shared/FoundationHelpers/Equatable+.swift +++ b/Sources/Shared/FoundationHelpers/Equatable+.swift @@ -2,7 +2,7 @@ // Equatable+.swift // // -// Created by ErrorErrorError on 8/14/23. +// Created by MochiTeam on 8/14/23. // // diff --git a/Sources/Shared/FoundationHelpers/KeyPath+.swift b/Sources/Shared/FoundationHelpers/KeyPath+.swift index 92a5ff0..1ff1670 100644 --- a/Sources/Shared/FoundationHelpers/KeyPath+.swift +++ b/Sources/Shared/FoundationHelpers/KeyPath+.swift @@ -2,7 +2,7 @@ // KeyPath+.swift // // -// Created by ErrorErrorError on 4/21/23. +// Created by MochiTeam on 4/21/23. // // diff --git a/Sources/Shared/FoundationHelpers/URL+.swift b/Sources/Shared/FoundationHelpers/URL+.swift index 653ea92..bfdd7a8 100644 --- a/Sources/Shared/FoundationHelpers/URL+.swift +++ b/Sources/Shared/FoundationHelpers/URL+.swift @@ -2,7 +2,7 @@ // URL+.swift // // -// Created by ErrorErrorError on 11/12/23. +// Created by MochiTeam on 11/12/23. // // diff --git a/Sources/Shared/JSValueCoder/JSVEnumAssociatedCodable.swift b/Sources/Shared/JSValueCoder/JSVEnumAssociatedCodable.swift index f4d978f..d79e6bf 100644 --- a/Sources/Shared/JSValueCoder/JSVEnumAssociatedCodable.swift +++ b/Sources/Shared/JSValueCoder/JSVEnumAssociatedCodable.swift @@ -2,7 +2,7 @@ // JSVEnumAssociatedCodable.swift // // -// Created by ErrorErrorError on 11/11/23. +// Created by MochiTeam on 11/11/23. // // diff --git a/Sources/Shared/JSValueCoder/JSValueCodingKey.swift b/Sources/Shared/JSValueCoder/JSValueCodingKey.swift index 808fe9e..1424161 100644 --- a/Sources/Shared/JSValueCoder/JSValueCodingKey.swift +++ b/Sources/Shared/JSValueCoder/JSValueCodingKey.swift @@ -2,7 +2,7 @@ // JSValueCodingKey.swift // // -// Created by ErrorErrorError on 11/5/23. +// Created by MochiTeam on 11/5/23. // // diff --git a/Sources/Shared/JSValueCoder/JSValueDecoder.swift b/Sources/Shared/JSValueCoder/JSValueDecoder.swift index 52802ae..cdc50d5 100644 --- a/Sources/Shared/JSValueCoder/JSValueDecoder.swift +++ b/Sources/Shared/JSValueCoder/JSValueDecoder.swift @@ -2,7 +2,7 @@ // JSValueDecoder.swift // // -// Created by ErrorErrorError on 11/4/23. +// Created by MochiTeam on 11/4/23. // // from https://github.com/theolampert/JSValueCoder diff --git a/Sources/Shared/JSValueCoder/JSValueEncoder.swift b/Sources/Shared/JSValueCoder/JSValueEncoder.swift index 94228dd..8f5e9c8 100644 --- a/Sources/Shared/JSValueCoder/JSValueEncoder.swift +++ b/Sources/Shared/JSValueCoder/JSValueEncoder.swift @@ -2,7 +2,7 @@ // JSValueEncoder.swift // // -// Created by ErrorErrorError on 11/4/23. +// Created by MochiTeam on 11/4/23. // // from https://github.com/theolampert/JSValueCoder diff --git a/Sources/Shared/SharedModels/EpisodeMetadata.swift b/Sources/Shared/SharedModels/EpisodeMetadata.swift index 0b49fdd..ea84f06 100644 --- a/Sources/Shared/SharedModels/EpisodeMetadata.swift +++ b/Sources/Shared/SharedModels/EpisodeMetadata.swift @@ -2,7 +2,7 @@ // EpisodeMetadata.swift // // -// Created by DeNeRr on 20.04.2024. +// Created by MochiTeam on 20.04.2024. // import Foundation diff --git a/Sources/Shared/SharedModels/Extensions/Entry+.swift b/Sources/Shared/SharedModels/Extensions/Entry+.swift index 204c3c8..f707849 100644 --- a/Sources/Shared/SharedModels/Extensions/Entry+.swift +++ b/Sources/Shared/SharedModels/Extensions/Entry+.swift @@ -2,7 +2,7 @@ // Entry+.swift // // -// Created by ErrorErrorError on 1/2/24. +// Created by MochiTeam on 1/2/24. // // diff --git a/Sources/Shared/SharedModels/Image.swift b/Sources/Shared/SharedModels/Image.swift index edf8b1b..7d041f5 100644 --- a/Sources/Shared/SharedModels/Image.swift +++ b/Sources/Shared/SharedModels/Image.swift @@ -2,7 +2,7 @@ // Image.swift // // -// Created by ErrorErrorError on 4/18/23. +// Created by MochiTeam on 4/18/23. // // diff --git a/Sources/Shared/SharedModels/LibraryDirectory.swift b/Sources/Shared/SharedModels/LibraryDirectory.swift index fb61793..f44426c 100644 --- a/Sources/Shared/SharedModels/LibraryDirectory.swift +++ b/Sources/Shared/SharedModels/LibraryDirectory.swift @@ -2,7 +2,7 @@ // LibraryDirectory.swift // // -// Created by DeNeRr on 24.04.2024. +// Created by MochiTeam on 24.04.2024. // import Foundation diff --git a/Sources/Shared/SharedModels/Meta.swift b/Sources/Shared/SharedModels/Meta.swift index a136ea6..a820f77 100644 --- a/Sources/Shared/SharedModels/Meta.swift +++ b/Sources/Shared/SharedModels/Meta.swift @@ -2,7 +2,7 @@ // Meta.swift // // -// Created by ErrorErrorError on 4/5/23. +// Created by MochiTeam on 4/5/23. // // diff --git a/Sources/Shared/SharedModels/Playlist.swift b/Sources/Shared/SharedModels/Playlist.swift index 1651e61..3a41d44 100644 --- a/Sources/Shared/SharedModels/Playlist.swift +++ b/Sources/Shared/SharedModels/Playlist.swift @@ -2,7 +2,7 @@ // Playlist.swift // // -// Created by ErrorErrorError on 5/29/23. +// Created by MochiTeam on 5/29/23. // // diff --git a/Sources/Shared/SharedModels/PlaylistCache.swift b/Sources/Shared/SharedModels/PlaylistCache.swift index 6a37dd7..3900004 100644 --- a/Sources/Shared/SharedModels/PlaylistCache.swift +++ b/Sources/Shared/SharedModels/PlaylistCache.swift @@ -2,7 +2,7 @@ // PlaylistCache.swift // // -// Created by DeNeRr on 17.04.2024. +// Created by MochiTeam on 17.04.2024. // import Foundation diff --git a/Sources/Shared/SharedModels/RepoModuleID.swift b/Sources/Shared/SharedModels/RepoModuleID.swift index ed22952..1ca2dda 100644 --- a/Sources/Shared/SharedModels/RepoModuleID.swift +++ b/Sources/Shared/SharedModels/RepoModuleID.swift @@ -2,7 +2,7 @@ // RepoModuleID.swift // // -// Created by ErrorErrorError on 6/2/23. +// Created by MochiTeam on 6/2/23. // // @@ -26,7 +26,7 @@ public struct RepoModuleID: Hashable, Sendable { extension Repo.ID { // Follow reverse domain name notation public var displayIdentifier: String { - // "dev.errorerrorerror.mochi.repo.local" for local storage + // "dev.MochiTeam.mochi.repo.local" for local storage rawValue.host?.split(separator: ".").reversed().joined(separator: ".").lowercased() ?? rawValue.absoluteString } } diff --git a/Sources/Shared/SharedModels/Text.swift b/Sources/Shared/SharedModels/Text.swift index fcfe1ab..18d3887 100644 --- a/Sources/Shared/SharedModels/Text.swift +++ b/Sources/Shared/SharedModels/Text.swift @@ -2,7 +2,7 @@ // Text.swift // // -// Created by ErrorErrorError on 4/18/23. +// Created by MochiTeam on 4/18/23. // // diff --git a/Sources/Shared/SharedModels/Utilities/Loadable.swift b/Sources/Shared/SharedModels/Utilities/Loadable.swift index eb6158d..30d9c14 100644 --- a/Sources/Shared/SharedModels/Utilities/Loadable.swift +++ b/Sources/Shared/SharedModels/Utilities/Loadable.swift @@ -2,7 +2,7 @@ // Loadable.swift // // -// Created by ErrorErrorError on 4/5/23. +// Created by MochiTeam on 4/5/23. // // diff --git a/Sources/Shared/SharedModels/Utilities/Paging.swift b/Sources/Shared/SharedModels/Utilities/Paging.swift index 8f0e18d..f623fbe 100644 --- a/Sources/Shared/SharedModels/Utilities/Paging.swift +++ b/Sources/Shared/SharedModels/Utilities/Paging.swift @@ -2,7 +2,7 @@ // Paging.swift // // -// Created by ErrorErrorError on 4/18/23. +// Created by MochiTeam on 4/18/23. // // diff --git a/Sources/Shared/SharedModels/Video.swift b/Sources/Shared/SharedModels/Video.swift index 58cb711..7365ca9 100644 --- a/Sources/Shared/SharedModels/Video.swift +++ b/Sources/Shared/SharedModels/Video.swift @@ -2,7 +2,7 @@ // Video.swift // // -// Created by ErrorErrorError on 4/18/23. +// Created by MochiTeam on 4/18/23. // // diff --git a/Sources/Shared/Styling/NavStack.swift b/Sources/Shared/Styling/NavStack.swift index 596a406..ef85d36 100644 --- a/Sources/Shared/Styling/NavStack.swift +++ b/Sources/Shared/Styling/NavStack.swift @@ -2,7 +2,7 @@ // NavStack.swift // // -// Created by ErrorErrorError on 5/20/23. +// Created by MochiTeam on 5/20/23. // // diff --git a/Sources/Shared/Styling/Popups.swift b/Sources/Shared/Styling/Popups.swift index 46b2e88..df36f81 100644 --- a/Sources/Shared/Styling/Popups.swift +++ b/Sources/Shared/Styling/Popups.swift @@ -2,7 +2,7 @@ // Popups.swift // // -// Created by ErrorErrorError on 4/20/23. +// Created by MochiTeam on 4/20/23. // // diff --git a/Sources/Shared/Styling/ScaledButtonStyle.swift b/Sources/Shared/Styling/ScaledButtonStyle.swift index 1fb4f0c..3a250ec 100644 --- a/Sources/Shared/Styling/ScaledButtonStyle.swift +++ b/Sources/Shared/Styling/ScaledButtonStyle.swift @@ -2,7 +2,7 @@ // ScaledButtonStyle.swift // // -// Created by ErrorErrorError on 10/12/23. +// Created by MochiTeam on 10/12/23. // // diff --git a/Sources/Shared/Styling/Settings/SettingsGroup.swift b/Sources/Shared/Styling/Settings/SettingsGroup.swift index 41b6170..60e4f36 100644 --- a/Sources/Shared/Styling/Settings/SettingsGroup.swift +++ b/Sources/Shared/Styling/Settings/SettingsGroup.swift @@ -2,7 +2,7 @@ // SettingsGroup.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // diff --git a/Sources/Shared/Styling/Settings/SettingsRow.swift b/Sources/Shared/Styling/Settings/SettingsRow.swift index cffafed..4e8615f 100644 --- a/Sources/Shared/Styling/Settings/SettingsRow.swift +++ b/Sources/Shared/Styling/Settings/SettingsRow.swift @@ -2,7 +2,7 @@ // SettingsRow.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // diff --git a/Sources/Shared/Styling/SheetView.swift b/Sources/Shared/Styling/SheetView.swift index f5c41b0..8af2dd1 100644 --- a/Sources/Shared/Styling/SheetView.swift +++ b/Sources/Shared/Styling/SheetView.swift @@ -2,7 +2,7 @@ // SheetView.swift // // -// Created by ErrorErrorError on 5/31/23. +// Created by MochiTeam on 5/31/23. // // diff --git a/Sources/Shared/Styling/StatusView.swift b/Sources/Shared/Styling/StatusView.swift index d40ef41..c48f758 100644 --- a/Sources/Shared/Styling/StatusView.swift +++ b/Sources/Shared/Styling/StatusView.swift @@ -2,7 +2,7 @@ // StatusView.swift // // -// Created by ErrorErrorError on 12/14/23. +// Created by MochiTeam on 12/14/23. // // diff --git a/Sources/Shared/Styling/ThemeModifier.swift b/Sources/Shared/Styling/ThemeModifier.swift index 2aed733..a2de2e4 100644 --- a/Sources/Shared/Styling/ThemeModifier.swift +++ b/Sources/Shared/Styling/ThemeModifier.swift @@ -2,7 +2,7 @@ // ThemeModifier.swift // // -// Created by ErrorErrorError on 10/11/23. +// Created by MochiTeam on 10/11/23. // // diff --git a/Sources/Shared/Styling/TopBar.swift b/Sources/Shared/Styling/TopBar.swift index 532c9ff..69937d8 100644 --- a/Sources/Shared/Styling/TopBar.swift +++ b/Sources/Shared/Styling/TopBar.swift @@ -2,7 +2,7 @@ // TopBar.swift // // -// Created by ErrorErrorError on 4/25/23. +// Created by MochiTeam on 4/25/23. // // diff --git a/Sources/Shared/Styling/_Exported.swift b/Sources/Shared/Styling/_Exported.swift index 60412e5..c423a33 100644 --- a/Sources/Shared/Styling/_Exported.swift +++ b/Sources/Shared/Styling/_Exported.swift @@ -2,7 +2,7 @@ // _Exported.swift // // -// Created by ErrorErrorError on 10/10/23. +// Created by MochiTeam on 10/10/23. // // diff --git a/Sources/Shared/Styling/macOS/NSWindow+.swift b/Sources/Shared/Styling/macOS/NSWindow+.swift index 612311e..d3d7851 100644 --- a/Sources/Shared/Styling/macOS/NSWindow+.swift +++ b/Sources/Shared/Styling/macOS/NSWindow+.swift @@ -2,7 +2,7 @@ // NSWindow+.swift // Mochi // -// Created by ErrorErrorError on 11/23/23. +// Created by MochiTeam on 11/23/23. // // diff --git a/Sources/Shared/ViewComponents/ChipView.swift b/Sources/Shared/ViewComponents/ChipView.swift index 2742f9d..6bf7a29 100644 --- a/Sources/Shared/ViewComponents/ChipView.swift +++ b/Sources/Shared/ViewComponents/ChipView.swift @@ -2,7 +2,7 @@ // ChipView.swift // // -// Created by ErrorErrorError on 7/23/23. +// Created by MochiTeam on 7/23/23. // // diff --git a/Sources/Shared/ViewComponents/CircularProgressView.swift b/Sources/Shared/ViewComponents/CircularProgressView.swift index 5528321..c097712 100644 --- a/Sources/Shared/ViewComponents/CircularProgressView.swift +++ b/Sources/Shared/ViewComponents/CircularProgressView.swift @@ -2,7 +2,7 @@ // CircularProgressView.swift // // -// Created by ErrorErrorError on 5/5/23. +// Created by MochiTeam on 5/5/23. // // diff --git a/Sources/Shared/ViewComponents/DynamicStack.swift b/Sources/Shared/ViewComponents/DynamicStack.swift index de6057b..42f58c4 100644 --- a/Sources/Shared/ViewComponents/DynamicStack.swift +++ b/Sources/Shared/ViewComponents/DynamicStack.swift @@ -2,7 +2,7 @@ // DynamicStack.swift // // -// Created by ErrorErrorError on 6/7/23. +// Created by MochiTeam on 6/7/23. // // diff --git a/Sources/Shared/ViewComponents/ElasticParallaxView.swift b/Sources/Shared/ViewComponents/ElasticParallaxView.swift index 691129d..4fd3016 100644 --- a/Sources/Shared/ViewComponents/ElasticParallaxView.swift +++ b/Sources/Shared/ViewComponents/ElasticParallaxView.swift @@ -2,7 +2,7 @@ // ElasticParallaxView.swift // // -// Created by ErrorErrorError on 5/19/23. +// Created by MochiTeam on 5/19/23. // // diff --git a/Sources/Shared/ViewComponents/ExpandableText.swift b/Sources/Shared/ViewComponents/ExpandableText.swift index 09e01ed..2de687b 100644 --- a/Sources/Shared/ViewComponents/ExpandableText.swift +++ b/Sources/Shared/ViewComponents/ExpandableText.swift @@ -2,7 +2,7 @@ // ExpandableText.swift // // -// Created by ErrorErrorError on 5/23/23. +// Created by MochiTeam on 5/23/23. // // diff --git a/Sources/Shared/ViewComponents/Extensions/Color+Ext.swift b/Sources/Shared/ViewComponents/Extensions/Color+Ext.swift index d4cfc9b..6d13731 100644 --- a/Sources/Shared/ViewComponents/Extensions/Color+Ext.swift +++ b/Sources/Shared/ViewComponents/Extensions/Color+Ext.swift @@ -2,7 +2,7 @@ // Color+Ext.swift // // -// Created by ErrorErrorError on 5/21/23. +// Created by MochiTeam on 5/21/23. // // diff --git a/Sources/Shared/ViewComponents/Extensions/Gradient+Easing.swift b/Sources/Shared/ViewComponents/Extensions/Gradient+Easing.swift index f02015b..9ae4ca9 100644 --- a/Sources/Shared/ViewComponents/Extensions/Gradient+Easing.swift +++ b/Sources/Shared/ViewComponents/Extensions/Gradient+Easing.swift @@ -2,7 +2,7 @@ // Gradient+Easing.swift // // -// Created by ErrorErrorError on 5/20/23. +// Created by MochiTeam on 5/20/23. // // // diff --git a/Sources/Shared/ViewComponents/Extensions/PlatformColor+Ext.swift b/Sources/Shared/ViewComponents/Extensions/PlatformColor+Ext.swift index 3013f09..c294509 100644 --- a/Sources/Shared/ViewComponents/Extensions/PlatformColor+Ext.swift +++ b/Sources/Shared/ViewComponents/Extensions/PlatformColor+Ext.swift @@ -2,7 +2,7 @@ // PlatformColor+Ext.swift // // -// Created by ErrorErrorError on 5/21/23. +// Created by MochiTeam on 5/21/23. // // diff --git a/Sources/Shared/ViewComponents/Extensions/Shape+Ext.swift b/Sources/Shared/ViewComponents/Extensions/Shape+Ext.swift index 93aba72..204c331 100644 --- a/Sources/Shared/ViewComponents/Extensions/Shape+Ext.swift +++ b/Sources/Shared/ViewComponents/Extensions/Shape+Ext.swift @@ -2,7 +2,7 @@ // Shape+Ext.swift // // -// Created by ErrorErrorError on 10/4/23. +// Created by MochiTeam on 10/4/23. // // diff --git a/Sources/Shared/ViewComponents/Extensions/View+Squircle.swift b/Sources/Shared/ViewComponents/Extensions/View+Squircle.swift index 62a746d..93fbf18 100644 --- a/Sources/Shared/ViewComponents/Extensions/View+Squircle.swift +++ b/Sources/Shared/ViewComponents/Extensions/View+Squircle.swift @@ -2,7 +2,7 @@ // View+Squircle.swift // // -// Created by ErrorErrorError on 5/29/23. +// Created by MochiTeam on 5/29/23. // // diff --git a/Sources/Shared/ViewComponents/FillAspectImage.swift b/Sources/Shared/ViewComponents/FillAspectImage.swift index 47185d7..ccc9bc0 100644 --- a/Sources/Shared/ViewComponents/FillAspectImage.swift +++ b/Sources/Shared/ViewComponents/FillAspectImage.swift @@ -2,7 +2,7 @@ // FillAspectImage.swift // // -// Created by ErrorErrorError on 10/25/22. +// Created by MochiTeam on 10/25/22. // // diff --git a/Sources/Shared/ViewComponents/InsetValue+Values.swift b/Sources/Shared/ViewComponents/InsetValue+Values.swift index 2ccbc72..9d17312 100644 --- a/Sources/Shared/ViewComponents/InsetValue+Values.swift +++ b/Sources/Shared/ViewComponents/InsetValue+Values.swift @@ -2,7 +2,7 @@ // InsetValue+Values.swift // // -// Created by ErrorErrorError on 4/19/23. +// Created by MochiTeam on 4/19/23. // // diff --git a/Sources/Shared/ViewComponents/InsetValue.swift b/Sources/Shared/ViewComponents/InsetValue.swift index f58b4bf..a03bc31 100644 --- a/Sources/Shared/ViewComponents/InsetValue.swift +++ b/Sources/Shared/ViewComponents/InsetValue.swift @@ -2,7 +2,7 @@ // InsetValue.swift // // -// Created by ErrorErrorError on 4/18/23. +// Created by MochiTeam on 4/18/23. // // diff --git a/Sources/Shared/ViewComponents/LazyView.swift b/Sources/Shared/ViewComponents/LazyView.swift index 55af459..25a4071 100644 --- a/Sources/Shared/ViewComponents/LazyView.swift +++ b/Sources/Shared/ViewComponents/LazyView.swift @@ -2,7 +2,7 @@ // LazyView.swift // // -// Created by ErrorErrorError on 10/6/23. +// Created by MochiTeam on 10/6/23. // // diff --git a/Sources/Shared/ViewComponents/LoadableView.swift b/Sources/Shared/ViewComponents/LoadableView.swift index 8e1b750..befdf23 100644 --- a/Sources/Shared/ViewComponents/LoadableView.swift +++ b/Sources/Shared/ViewComponents/LoadableView.swift @@ -2,7 +2,7 @@ // LoadableView.swift // // -// Created by ErrorErrorError on 1/7/23. +// Created by MochiTeam on 1/7/23. // // diff --git a/Sources/Shared/ViewComponents/NukeImage.swift b/Sources/Shared/ViewComponents/NukeImage.swift index 469cdef..6ae474e 100644 --- a/Sources/Shared/ViewComponents/NukeImage.swift +++ b/Sources/Shared/ViewComponents/NukeImage.swift @@ -2,7 +2,7 @@ // NukeImage.swift // // -// Created by ErrorErrorError on 10/10/23. +// Created by MochiTeam on 10/10/23. // // diff --git a/Sources/Shared/ViewComponents/OnInitialTask.swift b/Sources/Shared/ViewComponents/OnInitialTask.swift index 943c8ec..cab6669 100644 --- a/Sources/Shared/ViewComponents/OnInitialTask.swift +++ b/Sources/Shared/ViewComponents/OnInitialTask.swift @@ -2,7 +2,7 @@ // OnInitialTask.swift // // -// Created by ErrorErrorError on 12/15/23. +// Created by MochiTeam on 12/15/23. // // diff --git a/Sources/Shared/ViewComponents/PlatformViewRepresentable.swift b/Sources/Shared/ViewComponents/PlatformViewRepresentable.swift index b9e29c6..b2e5d7a 100644 --- a/Sources/Shared/ViewComponents/PlatformViewRepresentable.swift +++ b/Sources/Shared/ViewComponents/PlatformViewRepresentable.swift @@ -2,7 +2,7 @@ // PlatformViewRepresentable.swift // // -// Created by ErrorErrorError on 10/12/22. +// Created by MochiTeam on 10/12/22. // import SwiftUI diff --git a/Sources/Shared/ViewComponents/Refreshable.swift b/Sources/Shared/ViewComponents/Refreshable.swift index 8fb9fb9..aee4305 100644 --- a/Sources/Shared/ViewComponents/Refreshable.swift +++ b/Sources/Shared/ViewComponents/Refreshable.swift @@ -2,7 +2,7 @@ // Refreshable.swift // // -// Created by ErrorErrorError on 12/15/23. +// Created by MochiTeam on 12/15/23. // // diff --git a/Sources/Shared/ViewComponents/ScrollViewTracker.swift b/Sources/Shared/ViewComponents/ScrollViewTracker.swift index 7dd982a..41b2105 100644 --- a/Sources/Shared/ViewComponents/ScrollViewTracker.swift +++ b/Sources/Shared/ViewComponents/ScrollViewTracker.swift @@ -2,7 +2,7 @@ // ScrollViewTracker.swift // // -// Created by ErrorErrorError on 12/12/23. +// Created by MochiTeam on 12/12/23. // // Source: https://github.com/danielsaidi/ScrollKit/blob/main/Sources/ScrollKit/ScrollViewWithOffsetTracking.swift diff --git a/Sources/Shared/ViewComponents/SheetDetent.swift b/Sources/Shared/ViewComponents/SheetDetent.swift index 9d51529..d835360 100644 --- a/Sources/Shared/ViewComponents/SheetDetent.swift +++ b/Sources/Shared/ViewComponents/SheetDetent.swift @@ -2,7 +2,7 @@ // SheetDetent.swift // // -// Created by ErrorErrorError on 10/5/23. +// Created by MochiTeam on 10/5/23. // // diff --git a/Sources/Shared/ViewComponents/SnapScroll.swift b/Sources/Shared/ViewComponents/SnapScroll.swift index 13a5547..35b3545 100644 --- a/Sources/Shared/ViewComponents/SnapScroll.swift +++ b/Sources/Shared/ViewComponents/SnapScroll.swift @@ -2,7 +2,7 @@ // SnapScroll.swift // // -// Created by ErrorErrorError on 4/21/23. +// Created by MochiTeam on 4/21/23. // // diff --git a/Sources/Shared/ViewComponents/Swipable.swift b/Sources/Shared/ViewComponents/Swipable.swift index 04c67a9..acbaead 100644 --- a/Sources/Shared/ViewComponents/Swipable.swift +++ b/Sources/Shared/ViewComponents/Swipable.swift @@ -2,7 +2,7 @@ // Swipable.swift // // -// Created by ErrorErrorError on 6/27/23. +// Created by MochiTeam on 6/27/23. // // diff --git a/Sources/Shared/ViewComponents/View+ReadSize.swift b/Sources/Shared/ViewComponents/View+ReadSize.swift index 8f7f054..6ab1bd5 100644 --- a/Sources/Shared/ViewComponents/View+ReadSize.swift +++ b/Sources/Shared/ViewComponents/View+ReadSize.swift @@ -2,7 +2,7 @@ // View+ReadSize.swift // // -// Created by ErrorErrorError on 4/21/23. +// Created by MochiTeam on 4/21/23. // // diff --git a/Sources/Shared/ViewComponents/iOS/View+HomeIndicator.swift b/Sources/Shared/ViewComponents/iOS/View+HomeIndicator.swift index bccc523..62c1907 100644 --- a/Sources/Shared/ViewComponents/iOS/View+HomeIndicator.swift +++ b/Sources/Shared/ViewComponents/iOS/View+HomeIndicator.swift @@ -2,7 +2,7 @@ // View+HomeIndicator.swift // // -// Created by ErrorErrorError on 6/27/23. +// Created by MochiTeam on 6/27/23. // // diff --git a/Sources/Shared/ViewComponents/macOS/ToolbarAccessory.swift b/Sources/Shared/ViewComponents/macOS/ToolbarAccessory.swift index 3f6a9dc..2996530 100644 --- a/Sources/Shared/ViewComponents/macOS/ToolbarAccessory.swift +++ b/Sources/Shared/ViewComponents/macOS/ToolbarAccessory.swift @@ -2,7 +2,7 @@ // ToolbarAccessory.swift // // -// Created by ErrorErrorError on 11/28/23. +// Created by MochiTeam on 11/28/23. // // diff --git a/Tests/CoreDBTests/CoreDBTests.swift b/Tests/CoreDBTests/CoreDBTests.swift index 8f246ca..68e8a75 100644 --- a/Tests/CoreDBTests/CoreDBTests.swift +++ b/Tests/CoreDBTests/CoreDBTests.swift @@ -2,7 +2,7 @@ // CoreDBTests.swift // // -// Created by ErrorErrorError on 5/17/23. +// Created by MochiTeam on 5/17/23. // // diff --git a/Tests/CoreDBTests/Models.swift b/Tests/CoreDBTests/Models.swift index 1a5792c..bebd1a4 100644 --- a/Tests/CoreDBTests/Models.swift +++ b/Tests/CoreDBTests/Models.swift @@ -2,7 +2,7 @@ // Models.swift // // -// Created by ErrorErrorError on 5/19/23. +// Created by MochiTeam on 5/19/23. // // diff --git a/Tests/DatabaseClientTests/DatabaseClientTests.swift b/Tests/DatabaseClientTests/DatabaseClientTests.swift index 823a4b5..6376c34 100644 --- a/Tests/DatabaseClientTests/DatabaseClientTests.swift +++ b/Tests/DatabaseClientTests/DatabaseClientTests.swift @@ -2,7 +2,7 @@ // DatabaseClientTests.swift // // -// Created by ErrorErrorError on 5/13/23. +// Created by MochiTeam on 5/13/23. // // diff --git a/Tests/JSValueCoderTests/JSValueCoderTests.swift b/Tests/JSValueCoderTests/JSValueCoderTests.swift index 777adaa..3a69c16 100644 --- a/Tests/JSValueCoderTests/JSValueCoderTests.swift +++ b/Tests/JSValueCoderTests/JSValueCoderTests.swift @@ -2,7 +2,7 @@ // JSValueCoderTests.swift // // -// Created by ErrorErrorError on 11/7/23. +// Created by MochiTeam on 11/7/23. // // diff --git a/Tests/ModuleClientTests/JSRunnerTests.swift b/Tests/ModuleClientTests/JSRunnerTests.swift index 4d65f14..f465926 100644 --- a/Tests/ModuleClientTests/JSRunnerTests.swift +++ b/Tests/ModuleClientTests/JSRunnerTests.swift @@ -2,7 +2,7 @@ // JSRunnerTests.swift // // -// Created by ErrorErrorError on 11/8/23. +// Created by MochiTeam on 11/8/23. // // diff --git a/cog.toml b/cog.toml index b0bb1c4..8223722 100644 --- a/cog.toml +++ b/cog.toml @@ -14,5 +14,5 @@ owner = "Mochi-Team" # intended to map git signature to remote username # and generate changelog links to their remote profiles authors = [ - { signature = "ErrorErrrorError", username = "errorerrorerror" } + { signature = "ErrorErrrorError", username = "MochiTeam" } ] diff --git a/fastlane/Appfile b/fastlane/Appfile index a8aba97..4764529 100644 --- a/fastlane/Appfile +++ b/fastlane/Appfile @@ -1,4 +1,4 @@ -app_identifier("dev.errorerrorerror.mochi") # The bundle identifier of your app +app_identifier("dev.MochiTeam.mochi") # The bundle identifier of your app # apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username From c6a2f5ccef37ded7c6cda6b83165c5b247020c3e Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Fri, 24 May 2024 22:14:13 +0200 Subject: [PATCH 21/45] feat: added a way to cancel ongoing download with context menu --- App/Mochi.xcodeproj/project.pbxproj | 4 +- .../Clients/OfflineManagerClient/Client.swift | 2 + .../Clients/OfflineManagerClient/Live.swift | 13 +++ .../DownloadQueueFeature+Reducer.swift | 5 ++ .../DownloadQueueFeature+View.swift | 90 +++++++++++-------- .../DownloadQueue/DownloadQueueFeature.swift | 1 + 6 files changed, 75 insertions(+), 40 deletions(-) diff --git a/App/Mochi.xcodeproj/project.pbxproj b/App/Mochi.xcodeproj/project.pbxproj index 8c209fb..5a670ef 100644 --- a/App/Mochi.xcodeproj/project.pbxproj +++ b/App/Mochi.xcodeproj/project.pbxproj @@ -376,7 +376,7 @@ CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; - DEVELOPMENT_TEAM = GYXF583PFT; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -418,7 +418,7 @@ CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; - DEVELOPMENT_TEAM = GYXF583PFT; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; diff --git a/Sources/Clients/OfflineManagerClient/Client.swift b/Sources/Clients/OfflineManagerClient/Client.swift index 0c0f581..748f934 100644 --- a/Sources/Clients/OfflineManagerClient/Client.swift +++ b/Sources/Clients/OfflineManagerClient/Client.swift @@ -20,6 +20,7 @@ public struct OfflineManagerClient { public var cache: @Sendable (CacheAsset) async throws -> Void public var remove: @Sendable (RemoveType, String, String?) async throws -> Void public var togglePause: @Sendable (Int) async throws -> Void + public var cancel: @Sendable (Int) async throws -> Void public var observeDownloading: @Sendable () -> AsyncStream<[DownloadingItem]> } @@ -31,6 +32,7 @@ extension OfflineManagerClient: TestDependencyKey { cache: unimplemented("\(Self.self).cache"), remove: unimplemented("\(Self.self).remove"), togglePause: unimplemented("\(Self.self).togglePause"), + cancel: unimplemented("\(Self.self).cancel"), observeDownloading: unimplemented("\(Self.self).observeDownloading") ) } diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift index afef99d..b2f4d16 100644 --- a/Sources/Clients/OfflineManagerClient/Live.swift +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -62,6 +62,9 @@ extension OfflineManagerClient: DependencyKey { togglePause: { taskId in downloadManager.togglePauseDownload(taskId) }, + cancel: { taskId in + downloadManager.cancelDownload(taskId) + }, observeDownloading: { .init { continuation in let cancellable = Task.detached { @@ -178,6 +181,16 @@ private class OfflineDownloadManager: NSObject { } } + func cancelDownload(_ taskId: Int) { + downloadSession.getAllTasks { taskArray in + taskArray.first(where: { $0.taskIdentifier == taskId })?.cancel() + if let idx = self.downloadingItems.firstIndex(where: { $0.taskId == taskId }) { + self.downloadingItems.remove(at: idx) + } + NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: ["type": Notification.Name.AssetDownloadStateChanged, "taskId": taskId, "status": OfflineManagerClient.StatusType.cancelled]) + } + } + func restorePendingDownloads() { downloadSession.getAllTasks { tasksArray in for task in tasksArray { diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift index eb3f68f..c9a1810 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+Reducer.swift @@ -19,6 +19,11 @@ extension DownloadQueueFeature: Reducer { await send(.internal(.updateDownloadingItems(items))) } } + + case let .view(.didTapCancelDownload(item)): + return .run { send in + try await offlineManagerClient.cancel(item.taskId) + } case let .view(.pause(item)): return .run { send in diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift index 0d74ce4..a46850e 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift @@ -37,51 +37,65 @@ extension DownloadQueueFeature.View: View { } Spacer() - switch item.status { - case .suspended: - CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: Theme.pastelRed.opacity(0.4), width: 4, blurRadius: 0)) { - Image(systemName: "play.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(6) - .foregroundStyle(Theme.pastelRed) - } - .onTapGesture { - viewStore.send(.pause(item)) - } - .frame(width: 30, height: 30) - .animation(.easeInOut, value: item.status) - case .finished: - Image(systemName: "checkmark.circle.fill") + switch item.status { + case .suspended: + CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: Theme.pastelRed.opacity(0.4), width: 4, blurRadius: 0)) { + Image(systemName: "play.fill") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 29, height: 29) + .padding(6) .foregroundStyle(Theme.pastelRed) - .animation(.easeInOut, value: item.status) - case .cancelled: - EmptyView() - case .downloading: - CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: Theme.pastelRed, width: 4, blurRadius: 0)) { - Image(systemName: "pause.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(6) - .foregroundStyle(Theme.pastelRed) - } - .frame(width: 30, height: 30) - .contentShape(Rectangle()) - .onTapGesture { - viewStore.send(.pause(item)) - } + } + .onTapGesture { + viewStore.send(.pause(item)) + } + .frame(width: 30, height: 30) + .animation(.easeInOut, value: item.status) + case .finished: + Image(systemName: "checkmark.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 29, height: 29) + .foregroundStyle(Theme.pastelRed) + .animation(.easeInOut, value: item.status) + case .cancelled: + Image(systemName: "xmark.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 29, height: 29) + .foregroundStyle(Color.secondary.opacity(0.4)) .animation(.easeInOut, value: item.status) - case .error: - Image(systemName: "exclamationmark.circle.fill") + case .downloading: + CircularProgressView(progress: item.percentComplete, barStyle: .init(fill: Theme.pastelRed, width: 4, blurRadius: 0)) { + Image(systemName: "pause.fill") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 29, height: 29) + .padding(6) .foregroundStyle(Theme.pastelRed) - .animation(.easeInOut, value: item.status) - } + } + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + .onTapGesture { + viewStore.send(.pause(item)) + } + .animation(.easeInOut, value: item.status) + case .error: + Image(systemName: "exclamationmark.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 29, height: 29) + .foregroundStyle(Theme.pastelRed) + .animation(.easeInOut, value: item.status) + } + } + .contentShape(Rectangle()) + .contextMenu { + Button(role: .destructive) { + viewStore.send(.didTapCancelDownload(item)) + } label: { + Label("Cancel Download", systemImage: "xmark") + } + .buttonStyle(.plain) } } } diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature.swift index 4f08077..40844b3 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature.swift @@ -30,6 +30,7 @@ public struct DownloadQueueFeature: Feature { @dynamicMemberLookup public enum ViewAction: SendableAction { case didAppear + case didTapCancelDownload(OfflineManagerClient.DownloadingItem) case pause(OfflineManagerClient.DownloadingItem) } From 4542a945a7783a6bdc056efa8a69937892130984 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sat, 25 May 2024 00:10:31 +0200 Subject: [PATCH 22/45] feat: added copy to clipboard, fixed pages not showing sometines --- .../PlaylistDetailsFeature.swift | 7 +- .../Features/Settings/Components/Logs.swift | 79 +++++++++++++------ .../Platforms/SettingsFeature+iOS.swift | 4 +- 3 files changed, 60 insertions(+), 30 deletions(-) diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift index e88d29f..5cb49dc 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift @@ -77,7 +77,12 @@ public struct PlaylistDetailsFeature: Feature { if let group = content.groups.value?.first(where: { $0.default ?? false }) ?? content.groups.value?.first, let variant = group.variants.value?.first { if let epId = playlistHistory.value?.epId { - if let page = variant.pagings.value?.first(where: { ($0.items.value ?? []).contains(where: { $0.id.rawValue == epId }) }), + if let page = variant.pagings.value?.first(where: { + if let output = $0.items.value { + return output.contains(where: { $0.id.rawValue == epId }) + } + return true + }), let item = page.items.value?.first(where: { $0.id.rawValue == epId }) { return .resume(group.id, variant.id, page.id, item.id, item.title ?? "", playlistHistory.value?.timestamp ?? 0.0) } diff --git a/Sources/Features/Settings/Components/Logs.swift b/Sources/Features/Settings/Components/Logs.swift index aaed777..15464d5 100644 --- a/Sources/Features/Settings/Components/Logs.swift +++ b/Sources/Features/Settings/Components/Logs.swift @@ -155,6 +155,8 @@ extension Logs { public let store: StoreOf @Dependency(\.dateFormatter) var dateFormatter + + @SwiftUI.State var showCopyAlert = false @MainActor public init(store: StoreOf) { @@ -162,38 +164,52 @@ extension Logs { } @MainActor public var body: some SwiftUI.View { - ScrollView(.vertical) { - LazyVStack(spacing: 12) { - WithViewStore(store, observe: \.selected) { viewStore in - if viewStore.logsEmpty { - Text("No logs available.") - } else { - _VariadicView.Tree(Layout()) { - switch viewStore.state { - case let .system(events): - ForEach(events, id: \.timestamp) { event in - eventRow( - level: event.level.rawValue, - levelColor: event.level.color, - timeStamp: event.timestamp, - message: event.message - ) - } - case let .module(_, _, events): - ForEach(events, id: \.timestamp) { event in - eventRow( - level: event.level.rawValue, - levelColor: event.level.color, - timeStamp: event.timestamp, - message: event.body - ) + ZStack(alignment: .bottom) { + ScrollView(.vertical) { + LazyVStack(spacing: 12) { + WithViewStore(store, observe: \.selected) { viewStore in + if viewStore.logsEmpty { + Text("No logs available.") + } else { + _VariadicView.Tree(Layout()) { + switch viewStore.state { + case let .system(events): + ForEach(events, id: \.timestamp) { event in + eventRow( + level: event.level.rawValue, + levelColor: event.level.color, + timeStamp: event.timestamp, + message: event.message + ) + } + case let .module(_, _, events): + ForEach(events, id: \.timestamp) { event in + eventRow( + level: event.level.rawValue, + levelColor: event.level.color, + timeStamp: event.timestamp, + message: event.body + ) + } } } } } } + .padding() + } + if showCopyAlert { + VStack { + Text("Copied to clipboard!") + } + .transition(.opacity) + .padding() + .background(Theme.pastelOrange) + .foregroundColor(.white) + .clipShape(RoundedCorners(12)) + .shadow(radius: 10) + .offset(y: -20) } - .padding() } .moduleListsSheet( store.scope( @@ -300,6 +316,17 @@ extension Logs { Text(message) .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) + .onLongPressGesture { + UIPasteboard.general.setValue(message, forPasteboardType: "public.plain-text") + withAnimation { + showCopyAlert = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + self.showCopyAlert = false + } + } + } } .frame(maxWidth: .infinity) } diff --git a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift index e7568cd..95fabbc 100644 --- a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift +++ b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift @@ -44,9 +44,7 @@ private struct VersionView: View { Text( """ Design and developed by \ - [@MochiTeam](https://MochiTeam.dev) \ - & \ - [contributors](https://github.com/Mochi-Team/mochi/contributors) + [the community](https://mochisite.vercel.app) """ ) .multilineTextAlignment(.center) From e7b13151da757f3c82f24321672c0e50037825c2 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sat, 25 May 2024 20:30:03 +0200 Subject: [PATCH 23/45] fix: request now is being sent with headers, download selection is now reset when the option before is changed, canceled download file is now deleted --- Sources/Clients/OfflineManagerClient/Live.swift | 8 +++++++- Sources/Features/ContentCore/DownloadSelection.swift | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift index b2f4d16..7539784 100644 --- a/Sources/Clients/OfflineManagerClient/Live.swift +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -185,6 +185,9 @@ private class OfflineDownloadManager: NSObject { downloadSession.getAllTasks { taskArray in taskArray.first(where: { $0.taskIdentifier == taskId })?.cancel() if let idx = self.downloadingItems.firstIndex(where: { $0.taskId == taskId }) { + if let location = self.downloadingItems[idx].location { + try? self.fileClient.remove(location); + } self.downloadingItems.remove(at: idx) } NotificationCenter.default.post(name: .AssetDownloadTaskChanged, object: nil, userInfo: ["type": Notification.Name.AssetDownloadStateChanged, "taskId": taskId, "status": OfflineManagerClient.StatusType.cancelled]) @@ -278,9 +281,12 @@ extension OfflineDownloadManager { var m3u8: String var urlString = req.query["url"]!.replacingOccurrences(of: ">>", with: "&") - let rq = URLRequest(url: URL(string: urlString)!) + var rq = URLRequest(url: URL(string: urlString)!) var headers = req.headers headers.removeValue(forKey: .host) + headers.forEach { (key, value) in + rq.addValue(value, forHTTPHeaderField: key.rawValue) + } let (data, _) = try await URLSession.shared.data(for: rq) guard let string = String(data: data, encoding: .utf8) else { throw OfflineManagerClient.Error.failedToGenerateHLS diff --git a/Sources/Features/ContentCore/DownloadSelection.swift b/Sources/Features/ContentCore/DownloadSelection.swift index 1fc075d..298fd13 100644 --- a/Sources/Features/ContentCore/DownloadSelection.swift +++ b/Sources/Features/ContentCore/DownloadSelection.swift @@ -94,6 +94,9 @@ public struct DownloadSelection: Reducer { case let .selectSource(source): state.selectedSource = source + state.serverResponse = .pending + state.selectedQuality = nil + state.selectedSubtitle = nil case let .selectQuality(quality): state.selectedQuality = quality @@ -103,6 +106,8 @@ public struct DownloadSelection: Reducer { case let .selectServer(server): state.serverResponse = .loading + state.selectedQuality = nil + state.selectedSubtitle = nil guard let source = state.selectedSource else { return .none } From 73023516dc705ce3afc6791c38466046c7742c71 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Sun, 26 May 2024 19:29:30 +0200 Subject: [PATCH 24/45] fix: subtitles now pass headers --- Sources/Clients/OfflineManagerClient/Live.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift index 7539784..41245fc 100644 --- a/Sources/Clients/OfflineManagerClient/Live.swift +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -309,7 +309,13 @@ extension OfflineDownloadManager { await server.appendRoute("GET /subs.m3u8", handler: { req in func setupSubM3U8(_ url: URL) async throws -> String { - let (data, _) = try await URLSession.shared.data(for: .init(url: url)) + var rq = URLRequest(url: URL(string: url.absoluteString)!) + var headers = req.headers + headers.removeValue(forKey: .host) + headers.forEach { (key, value) in + rq.addValue(value, forHTTPHeaderField: key.rawValue) + } + let (data, _) = try await URLSession.shared.data(for: rq) let vttString = String(data: data , encoding: .utf8)! let lastTimeStampString = ( From e63c1f26967baa5872f4a2ac8418ca8242ae929a Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Thu, 6 Jun 2024 23:23:17 +0200 Subject: [PATCH 25/45] chore: update package --- Package.swift | 2 +- Package/Sources/Dependencies/CustomDump.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index d9d99c2..c9e666b 100644 --- a/Package.swift +++ b/Package.swift @@ -1013,7 +1013,7 @@ import Foundation struct CustomDump: PackageDependency { var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.2.1") } } // diff --git a/Package/Sources/Dependencies/CustomDump.swift b/Package/Sources/Dependencies/CustomDump.swift index 40a76b2..cd736e7 100644 --- a/Package/Sources/Dependencies/CustomDump.swift +++ b/Package/Sources/Dependencies/CustomDump.swift @@ -10,6 +10,6 @@ import Foundation struct CustomDump: PackageDependency { var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.2.1") } } From 7b770d4cd36b7b55ca51975586e4b4f522e1d660 Mon Sep 17 00:00:00 2001 From: Daniel Bady Date: Thu, 6 Jun 2024 23:25:34 +0200 Subject: [PATCH 26/45] fix: fix last watched episode sometimes not being updated --- Sources/Features/ContentCore/ContentCore.swift | 2 +- .../VideoPlayer/VideoPlayerFeature+Reducer.swift | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index 8147bf9..0965171 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -113,7 +113,7 @@ public struct ContentCore: Reducer { let item = state.item(groupId: groupId, variantId: variantId, pageId: pageId, itemId: itemId).value return .run { _ in if let item { - try? await playlistHistoryClient.updateEpId(.init( + try await playlistHistoryClient.updateEpId(.init( rmp: .init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), episode: .init(id: item.id.rawValue, title: item.title ?? "Unknown", thumbnail: item.thumbnail ?? playlist.posterImage ?? playlist.bannerImage), playlistName: playlist.title, diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift index 41e9bc4..bc33ff9 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+Reducer.swift @@ -22,6 +22,7 @@ private enum Cancellables: Hashable, CaseIterable { case delayCloseTab case fetchingSources case fetchingServer + case updateTimestamp } // MARK: - VideoPlayerFeature + Reducer @@ -43,10 +44,18 @@ extension VideoPlayerFeature: Reducer { state.content.fetchContent(.page(state.selected.groupId, state.selected.variantId, state.selected.pageId)) .map { .internal(.content($0)) }, .run { send in - for await status in playerClient.observe() { - if let progress = status.playback?.progress { - try? await playlistHistoryClient.updateTimestamp(.init(repoId: repoModule.repoId.absoluteString, moduleId: repoModule.moduleId.rawValue, playlistId: groupId), progress) + try await withTaskCancellation(id: Cancellables.updateTimestamp) { + while (true) { + try await Task.sleep(nanoseconds: 1_000_000_000) + let status = playerClient.get() + if let progress = status.playback?.progress { + try? await playlistHistoryClient.updateTimestamp(.init(repoId: repoModule.repoId.absoluteString, moduleId: repoModule.moduleId.rawValue, playlistId: groupId), progress) + } } + } + }, + .run { send in + for await status in playerClient.observe() { await send(.internal(.playerStatusUpdate(status))) } } @@ -311,7 +320,6 @@ extension VideoPlayerFeature.State { fetchSourcesIfNecessary(), .run { _ in await playerClient.clear() - try? await playlistHistoryClient.updateTimestamp(.init(repoId: repoModule.repoId.absoluteString, moduleId: repoModule.moduleId.rawValue, playlistId: groupId.rawValue), 0) } ) } From 32f9687579592ae7135855b11a3cdafab5b9c8cd Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 18:18:15 +0100 Subject: [PATCH 27/45] Add files via upload --- App/Mochi.xcodeproj/project.pbxproj | 22 +- .../xcshareddata/swiftpm/Package.resolved | 302 ++++++++++++++++++ .../UserInterfaceState.xcuserstate | Bin 0 -> 126736 bytes .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 + .../xcschemes/xcschememanagement.plist | 56 ++++ 5 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 App/Mochi.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 App/Mochi.xcodeproj/project.xcworkspace/xcuserdata/babyyoda777.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 App/Mochi.xcodeproj/xcuserdata/babyyoda777.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist create mode 100644 App/Mochi.xcodeproj/xcuserdata/babyyoda777.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/App/Mochi.xcodeproj/project.pbxproj b/App/Mochi.xcodeproj/project.pbxproj index 5a670ef..6368b10 100644 --- a/App/Mochi.xcodeproj/project.pbxproj +++ b/App/Mochi.xcodeproj/project.pbxproj @@ -164,6 +164,9 @@ Base, ); mainGroup = 13C18B8829CE6CC100C14F26; + packageReferences = ( + BB94AE842C14B2DF004E1ADB /* XCRemoteSwiftPackageReference "pillarbox-apple" */, + ); productRefGroup = 13C18B9229CE6CC200C14F26 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -373,10 +376,10 @@ CODE_SIGN_ENTITLEMENTS = Shared/mochi.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 8; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = Z994R8374W; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -415,10 +418,10 @@ CODE_SIGN_ENTITLEMENTS = Shared/mochi.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 8; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = Z994R8374W; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -471,6 +474,17 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + BB94AE842C14B2DF004E1ADB /* XCRemoteSwiftPackageReference "pillarbox-apple" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SRGSSR/pillarbox-apple"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 13F11CBF2B11617D006FFF63 /* App */ = { isa = XCSwiftPackageProductDependency; diff --git a/App/Mochi.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/Mochi.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..394275f --- /dev/null +++ b/App/Mochi.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,302 @@ +{ + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, + { + "identity" : "comscore-swift-package-manager", + "kind" : "remoteSourceControl", + "location" : "https://github.com/comScore/Comscore-Swift-Package-Manager.git", + "state" : { + "revision" : "c2f74c7cc02f8bb01b51c08297f7cbc486f3ca65", + "version" : "6.12.3" + } + }, + { + "identity" : "cwlcatchexception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "state" : { + "revision" : "3ef6999c73b6938cc0da422f2c912d0158abb0a0", + "version" : "2.2.0" + } + }, + { + "identity" : "cwlpreconditiontesting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state" : { + "revision" : "2ef56b2caf25f55fa7eef8784c30d5a767550f54", + "version" : "2.2.1" + } + }, + { + "identity" : "difference", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzysztofzablocki/Difference.git", + "state" : { + "revision" : "02fe1111edc8318c4f8a0da96336fcbcc201f38b", + "version" : "1.0.1" + } + }, + { + "identity" : "fluidgradient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Cindori/FluidGradient.git", + "state" : { + "revision" : "9ddda4cf23671ef0228e88681ec6210cb3e0d7f7", + "version" : "1.0.0" + } + }, + { + "identity" : "flyingfox", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/FlyingFox.git", + "state" : { + "revision" : "c9e0d358e6fcab4b4a4589bc88d110875c96d739", + "version" : "0.14.0" + } + }, + { + "identity" : "iosv5", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CommandersAct/iOSV5.git", + "state" : { + "revision" : "b65eb27cde41d3c40b05520f8713d55a52821553", + "version" : "5.4.9" + } + }, + { + "identity" : "nimble", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Quick/Nimble.git", + "state" : { + "revision" : "1c49fc1243018f81a7ea99cb5e0985b00096e9f4", + "version" : "13.3.0" + } + }, + { + "identity" : "nuke", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Nuke.git", + "state" : { + "revision" : "3f666f120b63ea7de57d42e9a7c9b47f8e7a290b", + "version" : "12.1.6" + } + }, + { + "identity" : "pillarbox-apple", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SRGSSR/pillarbox-apple", + "state" : { + "revision" : "59afca0cd1a8ddbba75eee1f63b699b7b9262b44", + "version" : "2.0.0" + } + }, + { + "identity" : "semaphore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/Semaphore", + "state" : { + "revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28", + "version" : "0.0.8" + } + }, + { + "identity" : "semver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kutchie-pelaez/Semver.git", + "state" : { + "revision" : "2b515fb1b5fc653e5f2140386f57b873853661e2", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "8d712376c99fc0267aa0e41fea732babe365270a", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "3568f01377c6c668aad40d066acf97ce670a1dad", + "version" : "1.5.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "350e1e119babe8525f9bd155b76640a5de270184", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d533cd18b0b456b106694a9899f917ee595f2666", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", + "version" : "1.5.4" + } + }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing", + "state" : { + "revision" : "a0e7d73f462c1c38c59dc40a3969ac40cea42950", + "version" : "0.13.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "swiftbackports", + "kind" : "remoteSourceControl", + "location" : "https://github.com/shaps80/SwiftBackports", + "state" : { + "revision" : "ddca6a237c1ba2291d5a3cc47ec8480ce6e9f805", + "version" : "1.0.3" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "028487d4a8a291b2fe1b4392b5425b6172056148", + "version" : "2.7.2" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "2ec6c3a15293efff6083966b38439a4004f25565", + "version" : "1.3.0" + } + }, + { + "identity" : "swiftuibackports", + "kind" : "remoteSourceControl", + "location" : "https://github.com/shaps80/SwiftUIBackports.git", + "state" : { + "revision" : "f5f23b016eeda6642a0fe1020241af19c9c05556", + "version" : "2.8.1" + } + }, + { + "identity" : "timelanecombine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/icanzilb/TimelaneCombine.git", + "state" : { + "revision" : "e6837bcbb19332866d5e37d501c05d68fbf985f2", + "version" : "2.0.0" + } + }, + { + "identity" : "timelanecore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/icanzilb/TimelaneCore", + "state" : { + "revision" : "c554d6d61be14bd7acb8b10161fe5d9dc20d7fbc", + "version" : "2.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", + "version" : "1.1.2" + } + }, + { + "identity" : "xmlcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CoreOffice/XMLCoder.git", + "state" : { + "revision" : "b1e944cbd0ef33787b13f639a5418d55b3bed501", + "version" : "0.17.1" + } + } + ], + "version" : 2 +} diff --git a/App/Mochi.xcodeproj/project.xcworkspace/xcuserdata/babyyoda777.xcuserdatad/UserInterfaceState.xcuserstate b/App/Mochi.xcodeproj/project.xcworkspace/xcuserdata/babyyoda777.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..4eeeea3552dad581b3b5209e68253dab46737c29 GIT binary patch literal 126736 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 q9*F1OEg5c=n=i5 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 + + diff --git a/App/Mochi.xcodeproj/xcuserdata/babyyoda777.xcuserdatad/xcschemes/xcschememanagement.plist b/App/Mochi.xcodeproj/xcuserdata/babyyoda777.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..425d768 --- /dev/null +++ b/App/Mochi.xcodeproj/xcuserdata/babyyoda777.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,56 @@ + + + + + SchemeUserState + + Mochi.xcscheme_^#shared#^_ + + orderHint + 0 + + Parsing (Playground) 1.xcscheme + + isShown + + orderHint + 19 + + Parsing (Playground) 2.xcscheme + + isShown + + orderHint + 20 + + Parsing (Playground).xcscheme + + isShown + + orderHint + 18 + + Tagged (Playground) 1.xcscheme + + isShown + + orderHint + 22 + + Tagged (Playground) 2.xcscheme + + isShown + + orderHint + 23 + + Tagged (Playground).xcscheme + + isShown + + orderHint + 21 + + + + From a5142ca9eeca24e79677f8d7f03735265e68a306 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 18:25:56 +0100 Subject: [PATCH 28/45] Some changes --- .github/workflows/.lint 2.yml.icloud | Bin 0 -> 158 bytes .../UserInterfaceState.xcuserstate | Bin 126736 -> 130009 bytes App/Shared/AppDelegate 2.swift | 52 +++++++++++ App/iOS/PreferenceHostingController 2.swift | 67 ++++++++++++++ Package/Sources/Dependencies/CustomDump.swift | 2 +- .../Clients/OfflineManagerClient/Live.swift | 4 +- .../Clients/OfflineManagerClient/Models.swift | 4 +- .../ContentCore/ContentCore+View.swift | 84 +++++++++++------- .../Features/ContentCore/ContentCore.swift | 2 +- .../DownloadQueueFeature+View.swift | 30 +++---- .../VideoPlayer/Components/ProgressBar.swift | 51 +++++++---- .../VideoPlayer/VideoPlayerFeature+View.swift | 18 ---- 12 files changed, 227 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/.lint 2.yml.icloud create mode 100644 App/Shared/AppDelegate 2.swift create mode 100644 App/iOS/PreferenceHostingController 2.swift diff --git a/.github/workflows/.lint 2.yml.icloud b/.github/workflows/.lint 2.yml.icloud new file mode 100644 index 0000000000000000000000000000000000000000..2840799e2dfcf1a98ec85e2858ade1f0870daee7 GIT binary patch literal 158 zcmYc)$jK}&F)+By$i&RT$`<1n92(@~mzbOComv?$AOPmNW#*&?XI4RkB;Z0psm1xF zMaiill?5QFsGQ8a5(Oi@%G?}5rbqDtGFTM`rKXqWBo=Y-%jkQBMlgT@BO`=nV29E$ GsvH1q6e@xM literal 0 HcmV?d00001 diff --git a/App/Mochi.xcodeproj/project.xcworkspace/xcuserdata/babyyoda777.xcuserdatad/UserInterfaceState.xcuserstate b/App/Mochi.xcodeproj/project.xcworkspace/xcuserdata/babyyoda777.xcuserdatad/UserInterfaceState.xcuserstate index 4eeeea3552dad581b3b5209e68253dab46737c29..2a7a7fe58e560729a33f147f897aa1e148d3e94e 100644 GIT binary patch literal 130009 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]Li9`u#zX9I_6Rejs4f;>DJ_tlB72hAWJu3-wF($ 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 literal 126736 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 q9*F1OEg5c=n=i5 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 Bool { + store.send(.internal(.appDelegate(.didFinishLaunching))) + UserDefaults.standard.register(defaults: [ + "userSettings.fastForwardAmount": 15, + "userSettings.fastBackwardAmount": 5 + ]) + return true + } +} + +#elseif canImport(AppKit) +import AppKit + +class AppDelegate: NSObject, NSApplicationDelegate { + let store = Store( + initialState: .init(), + reducer: { AppFeature() } + ) + + func applicationDidFinishLaunching(_: Notification) { + store.send(.internal(.appDelegate(.didFinishLaunching))) + } + + func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { + .terminateNow + } +} +#endif diff --git a/App/iOS/PreferenceHostingController 2.swift b/App/iOS/PreferenceHostingController 2.swift new file mode 100644 index 0000000..df94d3c --- /dev/null +++ b/App/iOS/PreferenceHostingController 2.swift @@ -0,0 +1,67 @@ +// +// PreferenceHostingController.swift +// mochi +// +// Created by ErrorErrorError on 6/27/23. +// +// + +#if canImport(UIKit) +import Foundation +import SwiftUI +import UIKit +import ViewComponents + +final class PreferenceHostingController: UIHostingController>, OpaquePreferenceHostingController { + override var prefersHomeIndicatorAutoHidden: Bool { _homeIndicatorAutoHidden } + + var _homeIndicatorAutoHidden = false { + didSet { + setNeedsUpdateOfHomeIndicatorAutoHidden() + } + } + + private let box: Box + + init(rootView: @escaping () -> Root) { + self.box = .init() + super.init(rootView: .init(box: box, content: rootView)) + box.object = self + } + + @available(*, unavailable) + dynamic required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +struct BoxedView: View { + let box: Box + + init(box: Box, content: @escaping () -> Content) { + self.content = content + self.box = box + } + + let content: () -> Content + + var body: some View { + content() + .onPreferenceChange(HomeIndicatorAutoHiddenPreferenceKey.self) { isHidden in + box.object?._homeIndicatorAutoHidden = isHidden + } + } +} + +final class Box { + weak var object: OpaquePreferenceHostingController? +} + +@MainActor +protocol OpaquePreferenceProperties { + var _homeIndicatorAutoHidden: Bool { get set } +} + +@MainActor +protocol OpaquePreferenceHostingController: OpaquePreferenceProperties, UIViewController {} +#endif diff --git a/Package/Sources/Dependencies/CustomDump.swift b/Package/Sources/Dependencies/CustomDump.swift index cd736e7..40a76b2 100644 --- a/Package/Sources/Dependencies/CustomDump.swift +++ b/Package/Sources/Dependencies/CustomDump.swift @@ -10,6 +10,6 @@ import Foundation struct CustomDump: PackageDependency { var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.2.1") + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") } } diff --git a/Sources/Clients/OfflineManagerClient/Live.swift b/Sources/Clients/OfflineManagerClient/Live.swift index 41245fc..4ea46d3 100644 --- a/Sources/Clients/OfflineManagerClient/Live.swift +++ b/Sources/Clients/OfflineManagerClient/Live.swift @@ -69,7 +69,7 @@ extension OfflineManagerClient: DependencyKey { .init { continuation in let cancellable = Task.detached { var values = downloadManager.downloadingItems.compactMap { - DownloadingItem(id: $0.metadata.link.url, percentComplete: $0.percentage, image: $0.playlist.posterImage ?? $0.playlist.bannerImage ?? URL(string: "")!, playlistName: $0.playlist.title ?? "", title: $0.episode.title ?? "Unknown Title", epNumber: 0, taskId: $0.taskId, status: $0.status) + DownloadingItem(id: $0.metadata.link.url, percentComplete: $0.percentage, image: $0.playlist.posterImage ?? $0.playlist.bannerImage ?? URL(string: "")!, playlistName: $0.playlist.title ?? "", title: $0.episode.title ?? "Unknown Title", epNumber: $0.episode.number, taskId: $0.taskId, status: $0.status) } continuation.yield(values) @@ -134,7 +134,7 @@ private class OfflineDownloadManager: NSObject { let playlist = asset.playlist let avAsset = AVURLAsset(url: URL(string: "http://localhost:64390/download.m3u?url=\(asset.episodeMetadata.link.url.absoluteString.replacingOccurrences(of: "&", with: ">>"))\(!asset.episodeMetadata.subtitles.isEmpty ? "&subs=\(String(data: try JSONEncoder().encode(asset.episodeMetadata.subtitles), encoding: .utf8)!)" : "")")!, options: options) let preferredMediaSelection = try await avAsset.load(.preferredMediaSelection) - + print(asset.episodeMetadata.link.url.absoluteString) guard let downloadTask = downloadSession.aggregateAssetDownloadTask(with: avAsset, mediaSelections: [preferredMediaSelection], assetTitle: asset.playlist.title ?? "Unknown Title", diff --git a/Sources/Clients/OfflineManagerClient/Models.swift b/Sources/Clients/OfflineManagerClient/Models.swift index 09bd1ad..50da23d 100644 --- a/Sources/Clients/OfflineManagerClient/Models.swift +++ b/Sources/Clients/OfflineManagerClient/Models.swift @@ -62,11 +62,11 @@ extension OfflineManagerClient { public let image: URL public let playlistName: String public let title: String - public let epNumber: Int + public let epNumber: Double public let taskId: Int public var status: StatusType - public init(id: URL, percentComplete: Double, image: URL, playlistName: String, title: String, epNumber: Int, taskId: Int, status: StatusType) { + public init(id: URL, percentComplete: Double, image: URL, playlistName: String, title: String, epNumber: Double, taskId: Int, status: StatusType) { self.id = id self.percentComplete = percentComplete self.image = image diff --git a/Sources/Features/ContentCore/ContentCore+View.swift b/Sources/Features/ContentCore/ContentCore+View.swift index ff80acf..42c3903 100644 --- a/Sources/Features/ContentCore/ContentCore+View.swift +++ b/Sources/Features/ContentCore/ContentCore+View.swift @@ -22,7 +22,7 @@ extension ContentCore { @ObservedObject private var viewStore: ViewStoreOf private let contentType: Playlist.PlaylistType - + @MainActor public init( store: StoreOf, @@ -206,10 +206,56 @@ extension ContentCore { ForEach(items.value ?? Self.placeholderItems, id: \.number) { item in let isDownloaded = viewStore.downloadedEpisodes.contains(item.id.rawValue.replacingOccurrences(of: "/", with: "\\")) VStack(alignment: .leading, spacing: 0) { - FillAspectImage(url: item.thumbnail ?? viewStore.playlist.posterImage) - .aspectRatio(16 / 9, contentMode: .fit) - .cornerRadius(12) - + ZStack { + FillAspectImage(url: item.thumbnail ?? viewStore.playlist.posterImage) + .aspectRatio(16 / 9, contentMode: .fit) + .cornerRadius(12) + + VStack { + HStack { + Spacer() + if isDownloaded == true { + Image(systemName: "checkmark") + .font(.system(size: 20, weight: .black)) + .foregroundColor(.white) + .padding(7) + .background(.green) + .clipShape(Circle()) + .padding(.top, 7.5) + .padding(.trailing, 7.5) + + } + else if isDownloaded == false{ + Button(action: { + store.send(.didTapDownloadPlaylist(item)) + + }) { + Image(systemName: "arrow.down") + .font(.system(size: 20, weight: .black)) + .foregroundColor(.black) + .padding(7) + .background(.white) + .clipShape(Circle()) + } + .padding(.top, 7.5) + .padding(.trailing, 7.5) + + } + } + Spacer() + } + } + .contextMenu { + if isDownloaded { + Button(role: .destructive) { + store.send(.didTapRemoveDownloadedPlaylist(item)) + } label: { + Label("Remove episode", systemImage: "trash") + } + .buttonStyle(.plain) + } + } + Spacer() .frame(height: 8) @@ -241,22 +287,6 @@ extension ContentCore { } } .id(item.id) - .contextMenu { - Button() { - store.send(.didTapDownloadPlaylist(item)) - } label: { - Label("Download episode", systemImage: "square.and.arrow.down") - } - .buttonStyle(.plain) - if isDownloaded { - Button(role: .destructive) { - store.send(.didTapRemoveDownloadedPlaylist(item)) - } label: { - Label("Remove episode", systemImage: "trash") - } - .buttonStyle(.plain) - } - } } .frame(maxHeight: .infinity, alignment: .top) } @@ -512,15 +542,3 @@ extension Playlist.PlaylistType { } // MARK: - ContentListingView_Previews - -#Preview { - ContentCore.View( - store: .init( - 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 0965171..e2536d4 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -113,7 +113,7 @@ public struct ContentCore: Reducer { let item = state.item(groupId: groupId, variantId: variantId, pageId: pageId, itemId: itemId).value return .run { _ in if let item { - try await playlistHistoryClient.updateEpId(.init( + try await playlistHistoryClient.updateEpId(.init( rmp: .init(repoId: repoModuleId.repoId.absoluteString, moduleId: repoModuleId.moduleId.rawValue, playlistId: playlist.id.rawValue), episode: .init(id: item.id.rawValue, title: item.title ?? "Unknown", thumbnail: item.thumbnail ?? playlist.posterImage ?? playlist.bannerImage), playlistName: playlist.title, diff --git a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift index a46850e..ab289bf 100644 --- a/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift +++ b/Sources/Features/DownloadQueue/DownloadQueueFeature+View.swift @@ -25,7 +25,7 @@ extension DownloadQueueFeature.View: View { .frame(height: 80) VStack(alignment: .leading, spacing: 6) { - Text(item.title) + Text("\(item.title)") .lineLimit(3) .font(.headline.weight(.medium)) .multilineTextAlignment(.leading) @@ -110,17 +110,17 @@ extension DownloadQueueFeature.View: View { } import OfflineManagerClient -#Preview { - DownloadQueueFeature.View( - store: .init( - initialState: .init( - downloadQueue: [ - OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0, image: URL(string: "https://fastly.picsum.photos/id/306/200/300.jpg?hmac=T-FQeWIc7YbLbcYdpyDGypNif0btJ8n5P4ozBJx8WgE")!, playlistName: "downloading", title: "Test 3", epNumber: 1, taskId: 0, status: .downloading), - OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 1, image: URL(string: "https://fastly.picsum.photos/id/1006/200/300.jpg?hmac=8H_lylM_UA6ot7bOUTm-ZzZkGKHmdjC-QU4yB3Xo5aQ")!, playlistName: "finished", title: "Test 2", epNumber: 2, taskId: 1, status: .finished), - OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0.35, image: URL(string: "https://fastly.picsum.photos/id/978/200/300.jpg?hmac=sP2_huC-v5a6cNxpdmxp1FPInoDET7j7O3GoftdaEJk")!, playlistName: "suspended", title: "Test 1", epNumber: 3, taskId: 2, status: .suspended) - ] - ), - reducer: { EmptyReducer() } - ) - ) -} +//#Preview { +// DownloadQueueFeature.View( +// store: .init( +// initialState: .init( +// downloadQueue: [ +// OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0, image: URL(string: "https://fastly.picsum.photos/id/306/200/300.jpg?hmac=T-FQeWIc7YbLbcYdpyDGypNif0btJ8n5P4ozBJx8WgE")!, playlistName: "downloading", title: "Test 3", epNumber: 1, taskId: 0, status: .downloading), +// OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 1, image: URL(string: "https://fastly.picsum.photos/id/1006/200/300.jpg?hmac=8H_lylM_UA6ot7bOUTm-ZzZkGKHmdjC-QU4yB3Xo5aQ")!, playlistName: "finished", title: "Test 2", epNumber: 2, taskId: 1, status: .finished), +// OfflineManagerClient.DownloadingItem(id: URL(string: "_blank")!, percentComplete: 0.35, image: URL(string: "https://fastly.picsum.photos/id/978/200/300.jpg?hmac=sP2_huC-v5a6cNxpdmxp1FPInoDET7j7O3GoftdaEJk")!, playlistName: "suspended", title: "Test 1", epNumber: 3, taskId: 2, status: .suspended) +// ] +// ), +// reducer: { EmptyReducer() } +// ) +// ) +//} diff --git a/Sources/Features/VideoPlayer/Components/ProgressBar.swift b/Sources/Features/VideoPlayer/Components/ProgressBar.swift index f498e01..1f7ff6e 100644 --- a/Sources/Features/VideoPlayer/Components/ProgressBar.swift +++ b/Sources/Features/VideoPlayer/Components/ProgressBar.swift @@ -59,8 +59,9 @@ struct ProgressBar: View { } return false } - + private static let defaultEmptyTime = "--:--" + private static let defaultLiveVideo = "LIVE" private static let defaultZeroTime = "00:00" init(store: Store) { @@ -116,27 +117,47 @@ struct ProgressBar: View { .frame(maxWidth: .infinity) .frame(height: 24) - Text("\(progressDisplayTime) / \(durationDisplayTime)") - .font(.caption.monospacedDigit()) - .foregroundColor(.white) + if viewState.state?.totalDuration.isInfinite == true { + Text("\(durationDisplayTime)") + .font(.caption.monospacedDigit().weight(.bold)) + .foregroundColor(.red) + } + else if viewState.state?.totalDuration.isNaN == true { + Text("\(durationDisplayTime)") + .font(.caption.monospacedDigit().weight(.bold)) + .foregroundColor(.red) + } + else{ + Text("\(progressDisplayTime) / \(durationDisplayTime)") + .font(.caption.monospacedDigit()) + .foregroundColor(.white) + } + + } .disabled(!canUseControls) .preferredColorScheme(.dark) } - private var progressDisplayTime: String { - if canUseControls { - formatter.playbackTimestamp(progress * (viewState.state?.totalDuration ?? .zero)) ?? Self.defaultZeroTime - } else { - Self.defaultEmptyTime + private var progressDisplayTime: String { + if canUseControls { + formatter.playbackTimestamp(progress * (viewState.state?.totalDuration ?? .zero)) ?? Self.defaultZeroTime + } else { + Self.defaultEmptyTime + } } - } private var durationDisplayTime: String { - if canUseControls { - formatter.playbackTimestamp(viewState.state?.totalDuration ?? .zero) ?? Self.defaultZeroTime - } else { - Self.defaultEmptyTime - } + if canUseControls { + formatter.playbackTimestamp(viewState.state?.totalDuration ?? .zero) ?? Self.defaultZeroTime + } else if viewState.state?.totalDuration.isInfinite == true { + Self.defaultLiveVideo + } + else if viewState.state?.totalDuration.isNaN == true { + Self.defaultLiveVideo + } + else { + Self.defaultEmptyTime + } } } diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift index 42f183e..776ebcb 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift @@ -807,21 +807,3 @@ extension VideoPlayerFeature.View { } } -#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) -} From f600bdce71069fd476c980b59fea79f536da5f21 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 18:47:40 +0100 Subject: [PATCH 29/45] Create build.yml --- .github/workflows/build.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e43b2e1 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +name: IPA Build +on: + - "push" + - "workflow_dispatch" +jobs: + build: + + runs-on: macos-13 + + strategy: + matrix: + platform: ['iOS', 'macOS'] + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + submodules: recursive + - name: Fetch Commit Info + id: commitinfo + run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Build ${{ matrix.platform }} + run: xcodebuild -xcodeproj "./App/Mochi.xcodeproj" -scheme Mochi -configuration Release -destination generic/platform=${{ matrix.platform }} archive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO + - name: Package ${{ matrix.platform }} + run: ./Misc/scripts/package.sh "build/Mochi.${{ matrix.platform }}.xcarchive" ${{ matrix.platform }} "Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}" + - name: Upload ${{ matrix.platform }} Symbols + run: ./Misc/scripts/upload_symbols.sh ${{ secrets.APPCENTER_TOKEN }} + - name: Upload ${{ matrix.platform }} Artifacts + uses: actions/upload-artifact@v2 + with: + name: Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }} + path: build/* + if-no-files-found: error From f5bd75f7d3f886171b035ab88c758713ef25e29d Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 18:50:09 +0100 Subject: [PATCH 30/45] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e43b2e1..553acd2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: with: xcode-version: latest-stable - name: Build ${{ matrix.platform }} - run: xcodebuild -xcodeproj "./App/Mochi.xcodeproj" -scheme Mochi -configuration Release -destination generic/platform=${{ matrix.platform }} archive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO + run: xcodebuild -project "./App/Mochi.xcodeproj" -scheme Mochi -configuration Release -destination generic/platform=${{ matrix.platform }} archive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO - name: Package ${{ matrix.platform }} run: ./Misc/scripts/package.sh "build/Mochi.${{ matrix.platform }}.xcarchive" ${{ matrix.platform }} "Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}" - name: Upload ${{ matrix.platform }} Symbols From 4890ddd9874f0d3ec757f9c6f2e491907bb4f4f1 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 18:53:55 +0100 Subject: [PATCH 31/45] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 553acd2..71b54ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - platform: ['iOS', 'macOS'] + platform: ['iOS'] steps: - uses: actions/checkout@v2 From bc183e363ad60f9070725832c5feef3f418b0d72 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 18:59:11 +0100 Subject: [PATCH 32/45] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71b54ee..8e64909 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: with: xcode-version: latest-stable - name: Build ${{ matrix.platform }} - run: xcodebuild -project "./App/Mochi.xcodeproj" -scheme Mochi -configuration Release -destination generic/platform=${{ matrix.platform }} archive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO + run: xcodebuild clean -project "./App/Mochi.xcodeproj" -scheme Mochi -configuration Release -destination generic/platform=${{ matrix.platform }} archive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO -verbose - name: Package ${{ matrix.platform }} run: ./Misc/scripts/package.sh "build/Mochi.${{ matrix.platform }}.xcarchive" ${{ matrix.platform }} "Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}" - name: Upload ${{ matrix.platform }} Symbols From 9a956cf0557637b3afbb0fa12e038d6f95a0ad22 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:05:30 +0100 Subject: [PATCH 33/45] Update build.yml --- .github/workflows/build.yml | 40 +++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8e64909..48c8354 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,34 +1,58 @@ name: IPA Build + on: - - "push" - - "workflow_dispatch" + push: + branches: + - main + workflow_dispatch: + jobs: build: - - runs-on: macos-13 - + + runs-on: macos-14 + strategy: matrix: platform: ['iOS'] - + steps: - - uses: actions/checkout@v2 + - name: Checkout code + uses: actions/checkout@v2 with: fetch-depth: 0 submodules: recursive + - name: Fetch Commit Info id: commitinfo run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" + - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable + + - name: Resolve Swift Package Dependencies + run: | + swift package resolve + - name: Build ${{ matrix.platform }} - run: xcodebuild clean -project "./App/Mochi.xcodeproj" -scheme Mochi -configuration Release -destination generic/platform=${{ matrix.platform }} archive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO -verbose + run: | + xcodebuild clean -project "./App/Mochi.xcodeproj" \ + -scheme Mochi \ + -configuration Release \ + -destination generic/platform=${{ matrix.platform }} \ + archive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + -verbose + - name: Package ${{ matrix.platform }} run: ./Misc/scripts/package.sh "build/Mochi.${{ matrix.platform }}.xcarchive" ${{ matrix.platform }} "Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}" + - name: Upload ${{ matrix.platform }} Symbols run: ./Misc/scripts/upload_symbols.sh ${{ secrets.APPCENTER_TOKEN }} + - name: Upload ${{ matrix.platform }} Artifacts uses: actions/upload-artifact@v2 with: From 7e66cb45d7fc40d23271bb6f8d21c65d1e9559d1 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:10:22 +0100 Subject: [PATCH 34/45] Update build.yml --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48c8354..0331a73 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,6 +45,7 @@ jobs: CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ + -skipMacroValidation \ -verbose - name: Package ${{ matrix.platform }} From 99755f371c3de4b7d83204750ec33487c1fe7821 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:25:59 +0100 Subject: [PATCH 35/45] Update build.yml --- .github/workflows/build.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0331a73..7d71efb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: run: | swift package resolve - - name: Build ${{ matrix.platform }} + - name: Build IPA run: | xcodebuild clean -project "./App/Mochi.xcodeproj" \ -scheme Mochi \ @@ -47,16 +47,13 @@ jobs: CODE_SIGNING_ALLOWED=NO \ -skipMacroValidation \ -verbose + xcodebuild -exportArchive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" \ + -exportOptionsPlist exportOptions.plist \ + -exportPath "build" - - name: Package ${{ matrix.platform }} - run: ./Misc/scripts/package.sh "build/Mochi.${{ matrix.platform }}.xcarchive" ${{ matrix.platform }} "Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}" - - - name: Upload ${{ matrix.platform }} Symbols - run: ./Misc/scripts/upload_symbols.sh ${{ secrets.APPCENTER_TOKEN }} - - - name: Upload ${{ matrix.platform }} Artifacts + - name: Upload IPA Artifact uses: actions/upload-artifact@v2 with: - name: Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }} - path: build/* + name: Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}.ipa + path: build/*.ipa if-no-files-found: error From 2f1ec57c4a3cb4400f3e596811bcc40ae5de935c Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:45:57 +0100 Subject: [PATCH 36/45] Create exportOptions.plist --- exportOptions.plist | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 exportOptions.plist diff --git a/exportOptions.plist b/exportOptions.plist new file mode 100644 index 0000000..f7982a3 --- /dev/null +++ b/exportOptions.plist @@ -0,0 +1,18 @@ + + + + + method + development + signingStyle + manual + stripSwiftSymbols + + compileBitcode + + uploadBitcode + + uploadSymbols + + + From cae08d8255f81245760d5214db563c3d5f2de279 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:46:28 +0100 Subject: [PATCH 37/45] Update build.yml --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d71efb..1939bee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: IPA Build +name: Unsigned IPA Build on: push: @@ -35,7 +35,7 @@ jobs: run: | swift package resolve - - name: Build IPA + - name: Build Unsigned IPA run: | xcodebuild clean -project "./App/Mochi.xcodeproj" \ -scheme Mochi \ @@ -51,7 +51,7 @@ jobs: -exportOptionsPlist exportOptions.plist \ -exportPath "build" - - name: Upload IPA Artifact + - name: Upload Unsigned IPA Artifact uses: actions/upload-artifact@v2 with: name: Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}.ipa From dde9d37eda66fb5a4e3274b54f9b2f3faef671dd Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:48:41 +0100 Subject: [PATCH 38/45] Update build.yml --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1939bee..3b242ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,8 @@ jobs: xcodebuild -exportArchive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" \ -exportOptionsPlist exportOptions.plist \ -exportPath "build" - + - name: Package ${{ matrix.platform }} + run: ./package.sh "build/Mochi.${{ matrix.platform }}.xcarchive" ${{ matrix.platform }} "Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}" - name: Upload Unsigned IPA Artifact uses: actions/upload-artifact@v2 with: From ce4cd62b33809beca82def0f4496cea0e834aebe Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Wed, 3 Jul 2024 07:34:01 +0100 Subject: [PATCH 39/45] Update build.yml --- .github/workflows/build.yml | 60 ++++++++++++++----------------------- 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b242ec..1b031af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,60 +1,44 @@ -name: Unsigned IPA Build +name: Build and Export Unsigned IPA on: push: branches: - main - workflow_dispatch: + pull_request: + branches: + - main jobs: build: - - runs-on: macos-14 - - strategy: - matrix: - platform: ['iOS'] + runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v2 + + - name: Set up Ruby + uses: actions/setup-ruby@v1 with: - fetch-depth: 0 - submodules: recursive + ruby-version: '2.7' - - name: Fetch Commit Info - id: commitinfo - run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" + - name: Install CocoaPods + run: | + gem install cocoapods + pod install --project-directory=App - - name: Setup Xcode + - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable + xcode-version: '15.0.0' - - name: Resolve Swift Package Dependencies + - name: Build and Export IPA run: | - swift package resolve + xcodebuild clean -project App/Mochi.xcodeproj -scheme Mochi -configuration Release + xcodebuild archive -project App/Mochi.xcodeproj -scheme Mochi -configuration Release -archivePath $PWD/build/Mochi.xcarchive + xcodebuild -exportArchive -archivePath $PWD/build/Mochi.xcarchive -exportOptionsPlist App/exportOptions.plist -exportPath $PWD/build - - name: Build Unsigned IPA - run: | - xcodebuild clean -project "./App/Mochi.xcodeproj" \ - -scheme Mochi \ - -configuration Release \ - -destination generic/platform=${{ matrix.platform }} \ - archive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - -skipMacroValidation \ - -verbose - xcodebuild -exportArchive -archivePath "build/Mochi.${{ matrix.platform }}.xcarchive" \ - -exportOptionsPlist exportOptions.plist \ - -exportPath "build" - - name: Package ${{ matrix.platform }} - run: ./package.sh "build/Mochi.${{ matrix.platform }}.xcarchive" ${{ matrix.platform }} "Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}" - - name: Upload Unsigned IPA Artifact + - name: Upload IPA as Artifact uses: actions/upload-artifact@v2 with: - name: Mochi.${{ steps.commitinfo.outputs.sha_short }}.${{ matrix.platform }}.ipa - path: build/*.ipa - if-no-files-found: error + name: Mochi.ipa + path: build/Mochi.ipa From bc46ce4004c065e079803395bc34c62cbf22ee69 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Wed, 3 Jul 2024 07:35:44 +0100 Subject: [PATCH 40/45] Create main.yml --- .github/workflows/main.yml | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..1b031af --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,44 @@ +name: Build and Export Unsigned IPA + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Ruby + uses: actions/setup-ruby@v1 + with: + ruby-version: '2.7' + + - name: Install CocoaPods + run: | + gem install cocoapods + pod install --project-directory=App + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.0.0' + + - name: Build and Export IPA + run: | + xcodebuild clean -project App/Mochi.xcodeproj -scheme Mochi -configuration Release + xcodebuild archive -project App/Mochi.xcodeproj -scheme Mochi -configuration Release -archivePath $PWD/build/Mochi.xcarchive + xcodebuild -exportArchive -archivePath $PWD/build/Mochi.xcarchive -exportOptionsPlist App/exportOptions.plist -exportPath $PWD/build + + - name: Upload IPA as Artifact + uses: actions/upload-artifact@v2 + with: + name: Mochi.ipa + path: build/Mochi.ipa From f5f89e0c3533cc989e538d7abdb09b38ae51ce71 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Wed, 3 Jul 2024 07:38:33 +0100 Subject: [PATCH 41/45] Delete .github/workflows directory --- .github/workflows/.lint 2.yml.icloud | Bin 158 -> 0 bytes .github/workflows/build.yml | 44 --------------------------- .github/workflows/lint.yml | 32 ------------------- .github/workflows/main.yml | 44 --------------------------- 4 files changed, 120 deletions(-) delete mode 100644 .github/workflows/.lint 2.yml.icloud delete mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/.lint 2.yml.icloud b/.github/workflows/.lint 2.yml.icloud deleted file mode 100644 index 2840799e2dfcf1a98ec85e2858ade1f0870daee7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158 zcmYc)$jK}&F)+By$i&RT$`<1n92(@~mzbOComv?$AOPmNW#*&?XI4RkB;Z0psm1xF zMaiill?5QFsGQ8a5(Oi@%G?}5rbqDtGFTM`rKXqWBo=Y-%jkQBMlgT@BO`=nV29E$ GsvH1q6e@xM diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 1b031af..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Build and Export Unsigned IPA - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - build: - runs-on: macos-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Ruby - uses: actions/setup-ruby@v1 - with: - ruby-version: '2.7' - - - name: Install CocoaPods - run: | - gem install cocoapods - pod install --project-directory=App - - - name: Set up Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '15.0.0' - - - name: Build and Export IPA - run: | - xcodebuild clean -project App/Mochi.xcodeproj -scheme Mochi -configuration Release - xcodebuild archive -project App/Mochi.xcodeproj -scheme Mochi -configuration Release -archivePath $PWD/build/Mochi.xcarchive - xcodebuild -exportArchive -archivePath $PWD/build/Mochi.xcarchive -exportOptionsPlist App/exportOptions.plist -exportPath $PWD/build - - - name: Upload IPA as Artifact - uses: actions/upload-artifact@v2 - with: - name: Mochi.ipa - path: build/Mochi.ipa diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 4c5c33e..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: lint - -on: - push: - branches: - - '*' - pull_request: - branches: - - '*' - -jobs: - run-swiftlint: - runs-on: ubuntu-latest - container: - image: ghcr.io/realm/swiftlint:0.54.0 - steps: - - uses: actions/checkout@v3 - - name: Lint code using SwiftLint - run: | - swiftlint --version - swiftlint lint --reporter github-actions-logging - - run-swiftformat: - runs-on: ubuntu-latest - container: - image: ghcr.io/nicklockwood/swiftformat:0.52.11 - steps: - - uses: actions/checkout@v3 - - name: Lint code using SwiftFormat - run: | - swiftformat --version - swiftformat --config .swiftformat.yml --lint --quiet --reporter github-actions-log . diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 1b031af..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Build and Export Unsigned IPA - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - build: - runs-on: macos-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Ruby - uses: actions/setup-ruby@v1 - with: - ruby-version: '2.7' - - - name: Install CocoaPods - run: | - gem install cocoapods - pod install --project-directory=App - - - name: Set up Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '15.0.0' - - - name: Build and Export IPA - run: | - xcodebuild clean -project App/Mochi.xcodeproj -scheme Mochi -configuration Release - xcodebuild archive -project App/Mochi.xcodeproj -scheme Mochi -configuration Release -archivePath $PWD/build/Mochi.xcarchive - xcodebuild -exportArchive -archivePath $PWD/build/Mochi.xcarchive -exportOptionsPlist App/exportOptions.plist -exportPath $PWD/build - - - name: Upload IPA as Artifact - uses: actions/upload-artifact@v2 - with: - name: Mochi.ipa - path: build/Mochi.ipa From 2fb2c43aecc98004e467f1c9a26b0fa9d71b69d1 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Wed, 3 Jul 2024 07:39:01 +0100 Subject: [PATCH 42/45] Create main.yml --- .github/workflows/main.yml | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..1b031af --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,44 @@ +name: Build and Export Unsigned IPA + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Ruby + uses: actions/setup-ruby@v1 + with: + ruby-version: '2.7' + + - name: Install CocoaPods + run: | + gem install cocoapods + pod install --project-directory=App + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.0.0' + + - name: Build and Export IPA + run: | + xcodebuild clean -project App/Mochi.xcodeproj -scheme Mochi -configuration Release + xcodebuild archive -project App/Mochi.xcodeproj -scheme Mochi -configuration Release -archivePath $PWD/build/Mochi.xcarchive + xcodebuild -exportArchive -archivePath $PWD/build/Mochi.xcarchive -exportOptionsPlist App/exportOptions.plist -exportPath $PWD/build + + - name: Upload IPA as Artifact + uses: actions/upload-artifact@v2 + with: + name: Mochi.ipa + path: build/Mochi.ipa From 8c2311b02b9fa724259616609eb09f07a8034356 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Wed, 3 Jul 2024 07:39:37 +0100 Subject: [PATCH 43/45] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b8643f..69d3030 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This application is currently a work-in-progress, so expect bugs and things brea ## Features - [X] Ad-Free, forever - [X] Source-based modular system (powered by JavaScriptCore) -- [ ] Offline downloads and local data support +- [X] Offline downloads and local data support - [ ] iCloud sync support - [ ] Tracker support - [ ] Integrated with Apple ecosystem From c92d76d5761434a83eeb69ccc55cb6bd0dccce97 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Wed, 3 Jul 2024 07:44:23 +0100 Subject: [PATCH 44/45] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 69d3030..3df4c83 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This application is currently a work-in-progress, so expect bugs and things brea *TBA (using TestFlight)* ## Module development -*TBA* +https://mochisite.verce.app ## Contribution Any contribution is greatly appreciated. This application's structure is based on [swift-composable-architecture](https://github.com/pointfreeco/swift-composable-architecture). From 8305f28870fa260a6d1c4e1f4fc3511e752221e9 Mon Sep 17 00:00:00 2001 From: Muhammad Shah <80623330+Babyyoda777@users.noreply.github.com> Date: Thu, 4 Jul 2024 17:07:34 +0100 Subject: [PATCH 45/45] Create codemagic.yaml --- codemagic.yaml | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 codemagic.yaml diff --git a/codemagic.yaml b/codemagic.yaml new file mode 100644 index 0000000..7c8c8ac --- /dev/null +++ b/codemagic.yaml @@ -0,0 +1,53 @@ +scripts: + - name: Get Flutter packages + script: | + flutter pub get + - name: Install pods + script: | + cd $CM_BUILD_DIR/ios + pod install + - name: Build the .app + script: | + # build using workspace + xcodebuild build \ + -project "$CM_BUILD_DIR/App/Mochi.xcodeproj" \ + -scheme MochiScheme \ + -skipMacroValidation \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + - name: Create a Payload directory and move the .app file into it + script: | + # Locate the .app in the specified location + BUILD_OUTPUT_DIR=$(find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/*" -type d -name "*.app" -print -quit) + echo "Build output directory: $BUILD_OUTPUT_DIR" + + if [ -d "$BUILD_OUTPUT_DIR" ]; then + # Create Payload directory + mkdir -p Payload + + # Move the .app file to the Payload directory + mv "$BUILD_OUTPUT_DIR" Payload/ + if [ $? -eq 0 ]; then + echo "App moved to Payload directory successfully." + else + echo "Failed to move app to Payload directory." + exit 1 + fi + + # Zip the Payload directory + zip -r RunnerTest.ipa Payload + if [ $? -eq 0 ]; then + echo "IPA file created successfully: RunnerTest.ipa" + else + echo "Failed to create IPA file." + exit 1 + fi + else + echo "Error: .app file not found in the specified location." + exit 1 + fi +artifacts: + - $HOME/Library/Developer/Xcode/DerivedData/**/Build/**/*.app + - /Users/builder/clone/build/ios/ipa/*.ipa + - /Users/builder/clone/*.ipa