diff --git a/PreferencesView/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/PreferencesView/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/PreferencesView/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/PreferencesView/Package.resolved b/PreferencesView/Package.resolved new file mode 100644 index 000000000..940a94e28 --- /dev/null +++ b/PreferencesView/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swizzleswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MarioIannotta/SwizzleSwift", + "state" : { + "branch" : "master", + "revision" : "e2d31c646182bf94a496b173c6ee5ad191230e9a" + } + } + ], + "version" : 2 +} diff --git a/PreferencesView/Package.swift b/PreferencesView/Package.swift new file mode 100644 index 000000000..f3061dabb --- /dev/null +++ b/PreferencesView/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "PreferencesView", + platforms: [ + .iOS(.v15), + .tvOS(.v15), + ], + products: [ + .library( + name: "PreferencesView", + targets: ["PreferencesView"] + ), + ], + dependencies: [ + .package(url: "https://github.com/MarioIannotta/SwizzleSwift", branch: "master"), + ], + targets: [ + .target( + name: "PreferencesView", + dependencies: [.product(name: "SwizzleSwift", package: "SwizzleSwift")] + ), + ] +) diff --git a/PreferencesView/README.md b/PreferencesView/README.md new file mode 100644 index 000000000..1ee52b25c --- /dev/null +++ b/PreferencesView/README.md @@ -0,0 +1 @@ +# PreferencesView \ No newline at end of file diff --git a/Shared/Objects/SelectorType.swift b/PreferencesView/Sources/PreferencesView/Box.swift similarity index 76% rename from Shared/Objects/SelectorType.swift rename to PreferencesView/Sources/PreferencesView/Box.swift index 4f7f9f6e8..dc054e087 100644 --- a/Shared/Objects/SelectorType.swift +++ b/PreferencesView/Sources/PreferencesView/Box.swift @@ -6,9 +6,9 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Foundation +class Box { -enum SelectorType { - case single - case multi + weak var value: UIPreferencesHostingController? + + init() {} } diff --git a/Swiftfin/Objects/KeyCommandAction.swift b/PreferencesView/Sources/PreferencesView/KeyCommandAction.swift similarity index 76% rename from Swiftfin/Objects/KeyCommandAction.swift rename to PreferencesView/Sources/PreferencesView/KeyCommandAction.swift index c3a49814f..1227f481a 100644 --- a/Swiftfin/Objects/KeyCommandAction.swift +++ b/PreferencesView/Sources/PreferencesView/KeyCommandAction.swift @@ -6,23 +6,25 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Foundation import UIKit -struct KeyCommandAction { +public struct KeyCommandAction { let title: String + let subtitle: String? let input: String let modifierFlags: UIKeyModifierFlags let action: () -> Void - init( + public init( title: String, + subtitle: String? = nil, input: String, modifierFlags: UIKeyModifierFlags = [], action: @escaping () -> Void ) { self.title = title + self.subtitle = subtitle self.input = input self.modifierFlags = modifierFlags self.action = action @@ -31,7 +33,7 @@ struct KeyCommandAction { extension KeyCommandAction: Equatable { - static func == (lhs: KeyCommandAction, rhs: KeyCommandAction) -> Bool { + public static func == (lhs: KeyCommandAction, rhs: KeyCommandAction) -> Bool { lhs.input == rhs.input } } diff --git a/PreferencesView/Sources/PreferencesView/KeyCommandsBuilder.swift b/PreferencesView/Sources/PreferencesView/KeyCommandsBuilder.swift new file mode 100644 index 000000000..f287d5fa2 --- /dev/null +++ b/PreferencesView/Sources/PreferencesView/KeyCommandsBuilder.swift @@ -0,0 +1,37 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +@resultBuilder +public enum KeyCommandsBuilder { + + public static func buildBlock(_ components: [KeyCommandAction]...) -> [KeyCommandAction] { + components.flatMap { $0 } + } + + public static func buildExpression(_ expression: KeyCommandAction) -> [KeyCommandAction] { + [expression] + } + + public static func buildOptional(_ component: [KeyCommandAction]?) -> [KeyCommandAction] { + component ?? [] + } + + public static func buildEither(first component: [KeyCommandAction]) -> [KeyCommandAction] { + component + } + + public static func buildEither(second component: [KeyCommandAction]) -> [KeyCommandAction] { + component + } + + public static func buildArray(_ components: [[KeyCommandAction]]) -> [KeyCommandAction] { + components.flatMap { $0 } + } +} diff --git a/PreferencesView/Sources/PreferencesView/PreferenceKeys.swift b/PreferencesView/Sources/PreferencesView/PreferenceKeys.swift new file mode 100644 index 000000000..75a1404f4 --- /dev/null +++ b/PreferencesView/Sources/PreferencesView/PreferenceKeys.swift @@ -0,0 +1,43 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +#if os(iOS) +struct KeyCommandsPreferenceKey: PreferenceKey { + + static var defaultValue: [KeyCommandAction] = [] + + static func reduce(value: inout [KeyCommandAction], nextValue: () -> [KeyCommandAction]) { + value.append(contentsOf: nextValue()) + } +} + +struct PreferredScreenEdgesDeferringSystemGesturesPreferenceKey: PreferenceKey { + + static var defaultValue: UIRectEdge = [.left, .right] + + static func reduce(value: inout UIRectEdge, nextValue: () -> UIRectEdge) {} +} + +struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey { + + static var defaultValue: Bool = false + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = nextValue() || value + } +} + +struct SupportedOrientationsPreferenceKey: PreferenceKey { + + static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown + + static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) {} +} +#endif diff --git a/PreferencesView/Sources/PreferencesView/PreferencesView.swift b/PreferencesView/Sources/PreferencesView/PreferencesView.swift new file mode 100644 index 000000000..2ed71ce90 --- /dev/null +++ b/PreferencesView/Sources/PreferencesView/PreferencesView.swift @@ -0,0 +1,26 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI +import SwizzleSwift + +public struct PreferencesView: UIViewControllerRepresentable { + + private var content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + _ = UIViewController.swizzlePreferences + self.content = content + } + + public func makeUIViewController(context: Context) -> UIPreferencesHostingController { + UIPreferencesHostingController(content: content) + } + + public func updateUIViewController(_ uiViewController: UIPreferencesHostingController, context: Context) {} +} diff --git a/PreferencesView/Sources/PreferencesView/UIPreferencesHostingController.swift b/PreferencesView/Sources/PreferencesView/UIPreferencesHostingController.swift new file mode 100644 index 000000000..4fc79d1c6 --- /dev/null +++ b/PreferencesView/Sources/PreferencesView/UIPreferencesHostingController.swift @@ -0,0 +1,113 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +public class UIPreferencesHostingController: UIHostingController { + + init(@ViewBuilder content: @escaping () -> Content) { + let box = Box() + let rootView = AnyView( + content() + #if os(iOS) + .onPreferenceChange(KeyCommandsPreferenceKey.self) { + box.value?._keyCommandActions = $0 + } + .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { + box.value?._prefersHomeIndicatorAutoHidden = $0 + } + .onPreferenceChange(PreferredScreenEdgesDeferringSystemGesturesPreferenceKey.self) { + box.value?._preferredScreenEdgesDeferringSystemGestures = $0 + } + .onPreferenceChange(SupportedOrientationsPreferenceKey.self) { + box.value?._orientations = $0 + } + #endif + ) + + super.init(rootView: rootView) + + box.value = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + #if os(iOS) + + // MARK: Key Commands + + private var _keyCommandActions: [KeyCommandAction] = [] { + willSet { + _keyCommands = newValue.map { action in + let keyCommand = UIKeyCommand( + title: action.title, + action: #selector(keyCommandHit), + input: String(action.input), + modifierFlags: action.modifierFlags + ) + + keyCommand.subtitle = action.subtitle + keyCommand.wantsPriorityOverSystemBehavior = true + + return keyCommand + } + } + } + + private var _keyCommands: [UIKeyCommand] = [] + + override public var keyCommands: [UIKeyCommand]? { + _keyCommands + } + + @objc + private func keyCommandHit(keyCommand: UIKeyCommand) { + guard let action = _keyCommandActions + .first(where: { $0.input == keyCommand.input && $0.modifierFlags == keyCommand.modifierFlags }) else { return } + action.action() + } + + // MARK: Orientation + + var _orientations: UIInterfaceOrientationMask = .all { + didSet { + if #available(iOS 16, *) { + setNeedsUpdateOfSupportedInterfaceOrientations() + } + } + } + + override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { + _orientations + } + + // MARK: Defer Edges + + private var _preferredScreenEdgesDeferringSystemGestures: UIRectEdge = [.left, .right] { + didSet { setNeedsUpdateOfScreenEdgesDeferringSystemGestures() } + } + + override public var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { + _preferredScreenEdgesDeferringSystemGestures + } + + // MARK: Home Indicator Auto Hidden + + private var _prefersHomeIndicatorAutoHidden = false { + didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } + } + + override public var prefersHomeIndicatorAutoHidden: Bool { + _prefersHomeIndicatorAutoHidden + } + + #endif +} diff --git a/PreferencesView/Sources/PreferencesView/UIViewController+Swizzling.swift b/PreferencesView/Sources/PreferencesView/UIViewController+Swizzling.swift new file mode 100644 index 000000000..b35c85c38 --- /dev/null +++ b/PreferencesView/Sources/PreferencesView/UIViewController+Swizzling.swift @@ -0,0 +1,74 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwizzleSwift +import UIKit + +extension UIViewController { + + // MARK: Swizzle + + // only swizzle once + static var swizzlePreferences = { + Swizzle(UIViewController.self) { + #if os(iOS) + #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) + #selector(getter: supportedInterfaceOrientations) <-> #selector(swizzled_supportedInterfaceOrientations) + #endif + } + }() + + // MARK: Swizzles + + #if os(iOS) + + @objc + func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { + if self is UIPreferencesHostingController { + return nil + } else { + return search() + } + } + + @objc + func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { + if self is UIPreferencesHostingController { + return nil + } else { + return search() + } + } + + @objc + func swizzled_prefersHomeIndicatorAutoHidden() -> Bool { + search()?.prefersHomeIndicatorAutoHidden ?? false + } + + @objc + func swizzled_supportedInterfaceOrientations() -> UIInterfaceOrientationMask { + search()?._orientations ?? .all + } + #endif + + // MARK: Search + + private func search() -> UIPreferencesHostingController? { + if let result = children.compactMap({ $0 as? UIPreferencesHostingController }).first { + return result + } + + for child in children { + if let result = child.search() { + return result + } + } + + return nil + } +} diff --git a/PreferencesView/Sources/PreferencesView/ViewExtensions.swift b/PreferencesView/Sources/PreferencesView/ViewExtensions.swift new file mode 100644 index 000000000..68059ee0c --- /dev/null +++ b/PreferencesView/Sources/PreferencesView/ViewExtensions.swift @@ -0,0 +1,30 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +public extension View { + + #if os(iOS) + func keyCommands(@KeyCommandsBuilder _ commands: @escaping () -> [KeyCommandAction]) -> some View { + preference(key: KeyCommandsPreferenceKey.self, value: commands()) + } + + func preferredScreenEdgesDeferringSystemGestures(_ edges: UIRectEdge) -> some View { + preference(key: PreferredScreenEdgesDeferringSystemGesturesPreferenceKey.self, value: edges) + } + + func prefersHomeIndicatorAutoHidden(_ hidden: Bool) -> some View { + preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: hidden) + } + + func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { + preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) + } + #endif +} diff --git a/Shared/Components/AssertionFailureView.swift b/Shared/Components/AssertionFailureView.swift new file mode 100644 index 000000000..b98205315 --- /dev/null +++ b/Shared/Components/AssertionFailureView.swift @@ -0,0 +1,24 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct AssertionFailureView: View { + + let message: String + + init(_ message: String) { + self.message = message + + assertionFailure(message) + } + + var body: some View { + EmptyView() + } +} diff --git a/Shared/Components/ImageView.swift b/Shared/Components/ImageView.swift index dae9e6af2..fb1313c8c 100644 --- a/Shared/Components/ImageView.swift +++ b/Shared/Components/ImageView.swift @@ -13,47 +13,37 @@ import NukeUI import SwiftUI import UIKit -struct ImageSource: Hashable { - - let url: URL? - let blurHash: String? - - init(url: URL? = nil, blurHash: String? = nil) { - self.url = url - self.blurHash = blurHash - } -} +private let imagePipeline = ImagePipeline(configuration: .withDataCache) +// TODO: Binding inits? +// - instead of removing first source on failure, just safe index into sources struct ImageView: View { @State private var sources: [ImageSource] - private var image: (NukeUI.Image) -> any View + private var image: (Image) -> any View private var placeholder: (() -> any View)? private var failure: () -> any View - private var resizingMode: ImageResizingMode @ViewBuilder private func _placeholder(_ currentSource: ImageSource) -> some View { if let placeholder = placeholder { placeholder() .eraseToAnyView() - } else if let blurHash = currentSource.blurHash { - BlurHashView(blurHash: blurHash, size: .Square(length: 16)) } else { - DefaultPlaceholderView() + DefaultPlaceholderView(blurHash: currentSource.blurHash) } } var body: some View { if let currentSource = sources.first { - LazyImage(url: currentSource.url) { state in + LazyImage(url: currentSource.url, transaction: .init(animation: .linear)) { state in if state.isLoading { _placeholder(currentSource) } else if let _image = state.image { - image(_image.resizingMode(resizingMode)) - .eraseToAnyView() + _image + .resizable() } else if state.error != nil { failure() .eraseToAnyView() @@ -62,8 +52,7 @@ struct ImageView: View { } } } - .pipeline(ImagePipeline(configuration: .withDataCache)) - .id(currentSource) + .pipeline(imagePipeline) } else { failure() .eraseToAnyView() @@ -72,43 +61,44 @@ struct ImageView: View { } extension ImageView { + init(_ source: ImageSource) { self.init( - sources: [source], + sources: [source].compacted(using: \.url), image: { $0 }, placeholder: nil, - failure: { DefaultFailureView() }, - resizingMode: .aspectFill + failure: { DefaultFailureView() } ) } init(_ sources: [ImageSource]) { self.init( - sources: sources, + sources: sources.compacted(using: \.url), image: { $0 }, placeholder: nil, - failure: { DefaultFailureView() }, - resizingMode: .aspectFill + failure: { DefaultFailureView() } ) } init(_ source: URL?) { self.init( - sources: [ImageSource(url: source, blurHash: nil)], + sources: [ImageSource(url: source)], image: { $0 }, placeholder: nil, - failure: { DefaultFailureView() }, - resizingMode: .aspectFill + failure: { DefaultFailureView() } ) } init(_ sources: [URL?]) { + let imageSources = sources + .compactMap { $0 } + .map { ImageSource(url: $0) } + self.init( - sources: sources.map { ImageSource(url: $0, blurHash: nil) }, + sources: imageSources, image: { $0 }, placeholder: nil, - failure: { DefaultFailureView() }, - resizingMode: .aspectFill + failure: { DefaultFailureView() } ) } } @@ -117,7 +107,7 @@ extension ImageView { extension ImageView { - func image(@ViewBuilder _ content: @escaping (NukeUI.Image) -> any View) -> Self { + func image(@ViewBuilder _ content: @escaping (Image) -> any View) -> Self { copy(modifying: \.image, with: content) } @@ -128,10 +118,6 @@ extension ImageView { func failure(@ViewBuilder _ content: @escaping () -> any View) -> Self { copy(modifying: \.failure, with: content) } - - func resizingMode(_ resizingMode: ImageResizingMode) -> Self { - copy(modifying: \.resizingMode, with: resizingMode) - } } // MARK: Defaults @@ -147,9 +133,14 @@ extension ImageView { struct DefaultPlaceholderView: View { + let blurHash: String? + var body: some View { - Color.secondarySystemFill - .opacity(0.5) + if let blurHash { + BlurHashView(blurHash: blurHash, size: .Square(length: 8)) + } else { + Color.secondarySystemFill + } } } } diff --git a/Shared/Components/InitialFailureView.swift b/Shared/Components/InitialFailureView.swift deleted file mode 100644 index 8f7b8d876..000000000 --- a/Shared/Components/InitialFailureView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct InitialFailureView: View { - - let initials: String - - init(_ initials: String) { - self.initials = initials - } - - var body: some View { - ZStack { - Color.secondarySystemFill - .opacity(0.5) - - Text(initials) - .font(.largeTitle) - .foregroundColor(.secondary) - .accessibilityHidden(true) - } - } -} diff --git a/Shared/Components/MaxHeightText.swift b/Shared/Components/MaxHeightText.swift new file mode 100644 index 000000000..a31126858 --- /dev/null +++ b/Shared/Components/MaxHeightText.swift @@ -0,0 +1,38 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: anchor for scaleEffect? +// TODO: try an implementation that doesn't require passing in the height + +/// A `Text` wrapper that will scale down the underlying `Text` view +/// if the height is greater than the given `maxHeight`. +struct MaxHeightText: View { + + @State + private var scale = 1.0 + + let text: String + let maxHeight: CGFloat + + var body: some View { + Text(text) + .fixedSize(horizontal: false, vertical: true) + .hidden() + .overlay { + Text(text) + .scaleEffect(CGSize(width: scale, height: scale), anchor: .bottom) + } + .onSizeChanged { newSize in + if newSize.height > maxHeight { + scale = maxHeight / newSize.height + } + } + } +} diff --git a/Shared/Components/PosterIndicators/WatchedIndicator.swift b/Shared/Components/PosterIndicators/WatchedIndicator.swift index 015068960..46b83c347 100644 --- a/Shared/Components/PosterIndicators/WatchedIndicator.swift +++ b/Shared/Components/PosterIndicators/WatchedIndicator.swift @@ -19,7 +19,7 @@ struct WatchedIndicator: View { Image(systemName: "checkmark.circle.fill") .resizable() .frame(width: size, height: size) - .accentSymbolRendering(accentColor: .white) + .paletteOverlayRendering(color: .white) .padding(3) } } diff --git a/Shared/Components/Divider.swift b/Shared/Components/RowDivider.swift similarity index 78% rename from Shared/Components/Divider.swift rename to Shared/Components/RowDivider.swift index 81196569c..50e62ca45 100644 --- a/Shared/Components/Divider.swift +++ b/Shared/Components/RowDivider.swift @@ -8,11 +8,11 @@ import SwiftUI -struct Divider: View { +struct RowDivider: View { var body: some View { Color.secondarySystemFill - .frame(height: 0.5) - .padding(.horizontal) + .frame(height: 1) + .edgePadding(.horizontal) } } diff --git a/Shared/Components/SelectorView.swift b/Shared/Components/SelectorView.swift index 4c0ec7e57..e802eab4e 100644 --- a/Shared/Components/SelectorView.swift +++ b/Shared/Components/SelectorView.swift @@ -6,85 +6,106 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Defaults import SwiftUI -// TODO: Implement different behavior types, where selected/unselected -// items can appear in different sections +// TODO: Label generic not really necessary if just restricting to `Text` +// - go back to `any View` implementation instead -struct SelectorView: View { +enum SelectorType { + case single + case multi +} + +struct SelectorView: View { - @Default(.accentColor) + @Environment(\.accentColor) private var accentColor @Binding - private var selection: [Item] + private var selection: Set - private let allItems: [Item] - private var label: (Item) -> any View + private let sources: [Element] + private var label: (Element) -> Label private let type: SelectorType var body: some View { - List(allItems) { item in + List(sources, id: \.hashValue) { element in Button { switch type { case .single: - handleSingleSelect(with: item) + handleSingleSelect(with: element) case .multi: - handleMultiSelect(with: item) + handleMultiSelect(with: element) } } label: { HStack { - label(item).eraseToAnyView() + label(element) Spacer() - if selection.contains(where: { $0.id == item.id }) { + if selection.contains(element) { Image(systemName: "checkmark.circle.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) - .accentSymbolRendering() + .paletteOverlayRendering() } } } } } - private func handleSingleSelect(with item: Item) { - selection = [item] + private func handleSingleSelect(with element: Element) { + selection = [element] } - private func handleMultiSelect(with item: Item) { - if selection.contains(where: { $0.id == item.id }) { - selection.removeAll(where: { $0.id == item.id }) + private func handleMultiSelect(with element: Element) { + if selection.contains(element) { + selection.remove(element) } else { - selection.append(item) + selection.insert(element) } } } -extension SelectorView { +extension SelectorView where Label == Text { + + init(selection: Binding<[Element]>, sources: [Element], type: SelectorType) { + + let selectionBinding = Binding { + Set(selection.wrappedValue) + } set: { newValue in + selection.wrappedValue = sources.intersection(newValue) + } - init(selection: Binding<[Item]>, allItems: [Item], type: SelectorType) { self.init( - selection: selection, - allItems: allItems, + selection: selectionBinding, + sources: sources, label: { Text($0.displayTitle).foregroundColor(.primary) }, type: type ) } - init(selection: Binding, allItems: [Item]) { + init(selection: Binding, sources: [Element]) { + + let selectionBinding = Binding { + Set([selection.wrappedValue]) + } set: { newValue in + selection.wrappedValue = newValue.first! + } + self.init( - selection: .init(get: { [selection.wrappedValue] }, set: { selection.wrappedValue = $0[0] }), - allItems: allItems, + selection: selectionBinding, + sources: sources, label: { Text($0.displayTitle).foregroundColor(.primary) }, type: .single ) } +} + +extension SelectorView { - func label(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + func label(@ViewBuilder _ content: @escaping (Element) -> Label) -> Self { copy(modifying: \.label, with: content) } } diff --git a/Shared/Components/SeparatorHStack.swift b/Shared/Components/SeparatorHStack.swift index ec5370ae9..c9b732ced 100644 --- a/Shared/Components/SeparatorHStack.swift +++ b/Shared/Components/SeparatorHStack.swift @@ -10,25 +10,28 @@ import SwiftUI // https://movingparts.io/variadic-views-in-swiftui -struct SeparatorHStack: View { +/// An `HStack` that inserts an optional `separator` between views. +/// +/// - Note: Default spacing is removed. The separator view is responsible +/// for spacing. +struct SeparatorHStack: View { - private var content: () -> any View + private var content: () -> Content private var separator: () -> any View var body: some View { _VariadicView.Tree(SeparatorHStackLayout(separator: separator)) { content() - .eraseToAnyView() } } } extension SeparatorHStack { - init(@ViewBuilder _ content: @escaping () -> any View) { + init(@ViewBuilder _ content: @escaping () -> Content) { self.init( content: content, - separator: { Divider() } + separator: { RowDivider() } ) } @@ -37,37 +40,34 @@ extension SeparatorHStack { } } -struct SeparatorHStackLayout: _VariadicView_UnaryViewRoot { +extension SeparatorHStack { + + struct SeparatorHStackLayout: _VariadicView_UnaryViewRoot { - var separator: () -> any View + var separator: () -> any View - @ViewBuilder - func body(children: _VariadicView.Children) -> some View { + @ViewBuilder + func body(children: _VariadicView.Children) -> some View { - let last = children.last?.id + let last = children.last?.id - localHStack { - ForEach(children) { child in - child + localHStack { + ForEach(children) { child in + child - if child.id != last { - separator() - .eraseToAnyView() + if child.id != last { + separator() + .eraseToAnyView() + } } } } - } - @ViewBuilder - private func localHStack(@ViewBuilder content: @escaping () -> some View) -> some View { - #if os(tvOS) - HStack(spacing: 0) { - content() - } - #else - HStack { - content() + @ViewBuilder + private func localHStack(@ViewBuilder content: @escaping () -> some View) -> some View { + HStack(spacing: 0) { + content() + } } - #endif } } diff --git a/Shared/Components/TextPairView.swift b/Shared/Components/TextPairView.swift index 8882a40bd..03d484599 100644 --- a/Shared/Components/TextPairView.swift +++ b/Shared/Components/TextPairView.swift @@ -8,6 +8,9 @@ import SwiftUI +// TODO: steal from SwiftUI, rename to something like +// `LabeledContentView` with `label` and `value` + struct TextPairView: View { let leading: String @@ -29,7 +32,7 @@ extension TextPairView { init(_ textPair: TextPair) { self.init( - leading: textPair.displayTitle, + leading: textPair.title, trailing: textPair.subtitle ) } diff --git a/Shared/Components/TruncatedText.swift b/Shared/Components/TruncatedText.swift index afd72956d..bd3150f37 100644 --- a/Shared/Components/TruncatedText.swift +++ b/Shared/Components/TruncatedText.swift @@ -9,21 +9,30 @@ import Defaults import SwiftUI +// TODO: only allow `view` selection when truncated? + struct TruncatedText: View { - @Default(.accentColor) + enum SeeMoreType { + case button + case view + } + + @Environment(\.accentColor) private var accentColor @State private var isTruncated: Bool = false @State - private var fullSize: CGFloat = 0 + private var fullheight: CGFloat = 0 + private var isTruncatedBinding: Binding + private var onSeeMore: () -> Void + private let seeMoreText = "\u{2026} See More" + private var seeMoreType: SeeMoreType private let text: String - private var seeMoreAction: () -> Void - private let seeMoreText = "... See More" - var body: some View { + private var textView: some View { ZStack(alignment: .bottomTrailing) { Text(text) .inverseMask(alignment: .bottomTrailing) { @@ -54,9 +63,14 @@ struct TruncatedText: View { Text(seeMoreText) .foregroundColor(accentColor) #else - Button { - seeMoreAction() - } label: { + if seeMoreType == .button { + Button { + onSeeMore() + } label: { + Text(seeMoreText) + .foregroundColor(accentColor) + } + } else { Text(seeMoreText) .foregroundColor(accentColor) } @@ -66,16 +80,11 @@ struct TruncatedText: View { .background { ZStack { if !isTruncated { - if fullSize != 0 { + if fullheight != 0 { Text(text) - .background { - GeometryReader { proxy in - Color.clear - .onAppear { - if fullSize > proxy.size.height { - self.isTruncated = true - } - } + .onSizeChanged { newSize in + if fullheight > newSize.height { + isTruncated = true } } } @@ -83,18 +92,29 @@ struct TruncatedText: View { Text(text) .lineLimit(10) .fixedSize(horizontal: false, vertical: true) - .background { - GeometryReader { proxy in - Color.clear - .onAppear { - self.fullSize = proxy.size.height - } - } + .onSizeChanged { newSize in + fullheight = newSize.height } } } .hidden() } + .onChange(of: isTruncated) { newValue in + isTruncatedBinding.wrappedValue = newValue + } + } + + var body: some View { + if seeMoreType == .button { + textView + } else { + Button { + onSeeMore() + } label: { + textView + } + .buttonStyle(.plain) + } } } @@ -102,12 +122,22 @@ extension TruncatedText { init(_ text: String) { self.init( - text: text, - seeMoreAction: {} + isTruncatedBinding: .constant(false), + onSeeMore: {}, + seeMoreType: .button, + text: text ) } - func seeMoreAction(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.seeMoreAction, with: action) + func isTruncated(_ isTruncated: Binding) -> Self { + copy(modifying: \.isTruncatedBinding, with: isTruncated) + } + + func onSeeMore(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSeeMore, with: action) + } + + func seeMoreType(_ type: SeeMoreType) -> Self { + copy(modifying: \.seeMoreType, with: type) } } diff --git a/Shared/Components/TypeSystemNameView.swift b/Shared/Components/TypeSystemNameView.swift new file mode 100644 index 000000000..6d182d361 --- /dev/null +++ b/Shared/Components/TypeSystemNameView.swift @@ -0,0 +1,34 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct TypeSystemNameView: View { + + @State + private var contentSize: CGSize = .zero + + let item: Item + + var body: some View { + ZStack { + Color.secondarySystemFill + .opacity(0.5) + + if let typeSystemImage = item.typeSystemName { + Image(systemName: typeSystemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary) + .accessibilityHidden(true) + .frame(width: contentSize.width / 3.5, height: contentSize.height / 3) + } + } + .size($contentSize) + } +} diff --git a/Shared/Components/Wrapped View.swift b/Shared/Components/WrappedView.swift similarity index 78% rename from Shared/Components/Wrapped View.swift rename to Shared/Components/WrappedView.swift index a2e6246b8..bb9c2d90e 100644 --- a/Shared/Components/Wrapped View.swift +++ b/Shared/Components/WrappedView.swift @@ -8,12 +8,12 @@ import SwiftUI -struct WrappedView: View { +struct WrappedView: View { - let content: () -> any View + @ViewBuilder + let content: () -> Content var body: some View { content() - .eraseToAnyView() } } diff --git a/Shared/Coordinators/BasicLibraryCoordinator.swift b/Shared/Coordinators/BasicLibraryCoordinator.swift deleted file mode 100644 index ae1dbd1e0..000000000 --- a/Shared/Coordinators/BasicLibraryCoordinator.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI -import Stinsen -import SwiftUI - -// TODO: See if this and LibraryCoordinator can be merged, -// along with all corresponding views -final class BasicLibraryCoordinator: NavigationCoordinatable { - - struct Parameters { - let title: String? - let viewModel: PagingLibraryViewModel - } - - let stack = NavigationStack(initial: \BasicLibraryCoordinator.start) - - @Root - var start = makeStart - - #if os(iOS) - @Route(.push) - var item = makeItem - @Route(.push) - var library = makeLibrary - #endif - - #if os(tvOS) - @Route(.modal) - var item = makeItem - @Route(.modal) - var library = makeLibrary - #endif - - private let parameters: Parameters - - init(parameters: Parameters) { - self.parameters = parameters - } - - @ViewBuilder - func makeStart() -> some View { - BasicLibraryView(viewModel: parameters.viewModel) - #if os(iOS) - .if(parameters.title != nil) { view in - view.navigationTitle(parameters.title ?? .emptyDash) - } - #endif - } - - func makeItem(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(ItemCoordinator(item: item)) - } - - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator { - NavigationViewCoordinator(LibraryCoordinator(parameters: parameters)) - } -} diff --git a/Shared/Coordinators/CastAndCrewLibraryCoordinator.swift b/Shared/Coordinators/CastAndCrewLibraryCoordinator.swift deleted file mode 100644 index 415b6268a..000000000 --- a/Shared/Coordinators/CastAndCrewLibraryCoordinator.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI -import Stinsen -import SwiftUI - -final class CastAndCrewLibraryCoordinator: NavigationCoordinatable { - - let stack = NavigationStack(initial: \CastAndCrewLibraryCoordinator.start) - - @Root - var start = makeStart - @Route(.push) - var library = makeLibrary - - let people: [BaseItemPerson] - - init(people: [BaseItemPerson]) { - self.people = people - } - - @ViewBuilder - func makeStart() -> some View { - CastAndCrewLibraryView(people: people) - } - - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator { - LibraryCoordinator(parameters: parameters) - } -} diff --git a/Shared/Coordinators/FilterCoordinator.swift b/Shared/Coordinators/FilterCoordinator.swift index 7baf5c64e..9855be323 100644 --- a/Shared/Coordinators/FilterCoordinator.swift +++ b/Shared/Coordinators/FilterCoordinator.swift @@ -14,10 +14,8 @@ import SwiftUI final class FilterCoordinator: NavigationCoordinatable { struct Parameters { - let title: String + let type: ItemFilterType let viewModel: FilterViewModel - let filter: WritableKeyPath - let selectorType: SelectorType } let stack = NavigationStack(initial: \FilterCoordinator.start) @@ -36,12 +34,7 @@ final class FilterCoordinator: NavigationCoordinatable { #if os(tvOS) Text(verbatim: .emptyDash) #else - FilterView( - title: parameters.title, - viewModel: parameters.viewModel, - filter: parameters.filter, - selectorType: parameters.selectorType - ) + FilterView(viewModel: parameters.viewModel, type: parameters.type) #endif } } diff --git a/Shared/Coordinators/HomeCoordinator.swift b/Shared/Coordinators/HomeCoordinator.swift index 742855053..337dd2bef 100644 --- a/Shared/Coordinators/HomeCoordinator.swift +++ b/Shared/Coordinators/HomeCoordinator.swift @@ -24,15 +24,11 @@ final class HomeCoordinator: NavigationCoordinatable { @Route(.modal) var item = makeItem @Route(.modal) - var basicLibrary = makeBasicLibrary - @Route(.modal) var library = makeLibrary #else @Route(.push) var item = makeItem @Route(.push) - var basicLibrary = makeBasicLibrary - @Route(.push) var library = makeLibrary #endif @@ -45,29 +41,21 @@ final class HomeCoordinator: NavigationCoordinatable { NavigationViewCoordinator(ItemCoordinator(item: item)) } - func makeBasicLibrary(parameters: BasicLibraryCoordinator.Parameters) -> NavigationViewCoordinator { - NavigationViewCoordinator(BasicLibraryCoordinator(parameters: parameters)) - } - - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator { - NavigationViewCoordinator(LibraryCoordinator(parameters: parameters)) + func makeLibrary(viewModel: PagingLibraryViewModel) -> NavigationViewCoordinator> { + NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) } #else func makeItem(item: BaseItemDto) -> ItemCoordinator { ItemCoordinator(item: item) } - func makeBasicLibrary(parameters: BasicLibraryCoordinator.Parameters) -> BasicLibraryCoordinator { - BasicLibraryCoordinator(parameters: parameters) - } - - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator { - LibraryCoordinator(parameters: parameters) + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) } #endif @ViewBuilder func makeStart() -> some View { - HomeView(viewModel: .init()) + HomeView() } } diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index e4b293fe1..b672ff958 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -20,8 +20,6 @@ final class ItemCoordinator: NavigationCoordinatable { @Route(.push) var item = makeItem @Route(.push) - var basicLibrary = makeBasicLibrary - @Route(.push) var library = makeLibrary @Route(.push) var castAndCrew = makeCastAndCrew @@ -54,22 +52,19 @@ final class ItemCoordinator: NavigationCoordinatable { ItemCoordinator(item: item) } - func makeBasicLibrary(parameters: BasicLibraryCoordinator.Parameters) -> BasicLibraryCoordinator { - BasicLibraryCoordinator(parameters: parameters) - } - - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator { - LibraryCoordinator(parameters: parameters) + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) } - func makeCastAndCrew(people: [BaseItemPerson]) -> CastAndCrewLibraryCoordinator { - CastAndCrewLibraryCoordinator(people: people) + func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator { + let viewModel = PagingLibraryViewModel(people, parent: BaseItemDto(name: L10n.castAndCrew)) + return LibraryCoordinator(viewModel: viewModel) } func makeItemOverview(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(BasicNavigationViewCoordinator { + NavigationViewCoordinator { ItemOverviewView(item: item) - }) + } } func makeMediaSourceInfo(source: MediaSourceInfo) -> NavigationViewCoordinator { diff --git a/Shared/Coordinators/LibraryCoordinator.swift b/Shared/Coordinators/LibraryCoordinator.swift index 5a64d74b0..950263f25 100644 --- a/Shared/Coordinators/LibraryCoordinator.swift +++ b/Shared/Coordinators/LibraryCoordinator.swift @@ -12,29 +12,7 @@ import JellyfinAPI import Stinsen import SwiftUI -final class LibraryCoordinator: NavigationCoordinatable { - - struct Parameters { - let parent: LibraryParent? - let type: LibraryParentType - let filters: ItemFilters - - init( - parent: LibraryParent, - type: LibraryParentType, - filters: ItemFilters - ) { - self.parent = parent - self.type = type - self.filters = filters - } - - init(filters: ItemFilters) { - self.parent = nil - self.type = .library - self.filters = filters - } - } +final class LibraryCoordinator: NavigationCoordinatable { let stack = NavigationStack(initial: \LibraryCoordinator.start) @@ -55,28 +33,15 @@ final class LibraryCoordinator: NavigationCoordinatable { var filter = makeFilter #endif - private let parameters: Parameters + private let viewModel: PagingLibraryViewModel - init(parameters: Parameters) { - self.parameters = parameters + init(viewModel: PagingLibraryViewModel) { + self.viewModel = viewModel } @ViewBuilder func makeStart() -> some View { - if let parent = parameters.parent { - if parameters.filters == .init(), let id = parent.id, let storedFilters = Defaults[.libraryFilterStore][id] { - LibraryView(viewModel: LibraryViewModel(parent: parent, type: parameters.type, filters: storedFilters, saveFilters: true)) - } else { - LibraryView(viewModel: LibraryViewModel( - parent: parent, - type: parameters.type, - filters: parameters.filters, - saveFilters: false - )) - } - } else { - LibraryView(viewModel: LibraryViewModel(filters: parameters.filters, saveFilters: false)) - } + PagingLibraryView(viewModel: viewModel) } #if os(tvOS) @@ -84,16 +49,16 @@ final class LibraryCoordinator: NavigationCoordinatable { NavigationViewCoordinator(ItemCoordinator(item: item)) } - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator { - NavigationViewCoordinator(LibraryCoordinator(parameters: parameters)) + func makeLibrary(viewModel: PagingLibraryViewModel) -> NavigationViewCoordinator> { + NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) } #else func makeItem(item: BaseItemDto) -> ItemCoordinator { ItemCoordinator(item: item) } - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator { - LibraryCoordinator(parameters: parameters) + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) } func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator { diff --git a/Shared/Coordinators/LiveTVProgramsCoordinator.swift b/Shared/Coordinators/LiveTVProgramsCoordinator.swift index ad5cac715..cfb1d53cd 100644 --- a/Shared/Coordinators/LiveTVProgramsCoordinator.swift +++ b/Shared/Coordinators/LiveTVProgramsCoordinator.swift @@ -47,18 +47,18 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable { func makeStart() -> some View { let viewModel = LiveTVProgramsViewModel() - let channels = (1 ..< 20).map { _ in BaseItemDto.randomItem() } - - for channel in channels { - viewModel.channels[channel.id!] = channel - } - - viewModel.recommendedItems = channels.randomSample(count: 5) - viewModel.seriesItems = channels.randomSample(count: 5) - viewModel.movieItems = channels.randomSample(count: 5) - viewModel.sportsItems = channels.randomSample(count: 5) - viewModel.kidsItems = channels.randomSample(count: 5) - viewModel.newsItems = channels.randomSample(count: 5) +// let channels = (1 ..< 20).map { _ in BaseItemDto.randomItem() } +// +// for channel in channels { +// viewModel.channels[channel.id!] = channel +// } +// +// viewModel.recommendedItems = channels.randomSample(count: 5) +// viewModel.seriesItems = channels.randomSample(count: 5) +// viewModel.movieItems = channels.randomSample(count: 5) +// viewModel.sportsItems = channels.randomSample(count: 5) +// viewModel.kidsItems = channels.randomSample(count: 5) +// viewModel.newsItems = channels.randomSample(count: 5) return LiveTVProgramsView(viewModel: viewModel) } diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index 0082a2c81..f29bb6f76 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -34,7 +34,7 @@ final class MainCoordinator: NavigationCoordinatable { init() { - if Container.userSession.callAsFunction().authenticated { + if Container.userSession().authenticated { stack = NavigationStack(initial: \MainCoordinator.mainTab) } else { stack = NavigationStack(initial: \MainCoordinator.serverList) diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift index 936b6cd28..5aae4b332 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift @@ -30,7 +30,7 @@ final class MainCoordinator: NavigationCoordinatable { init() { - if Container.userSession.callAsFunction().authenticated { + if Container.userSession().authenticated { stack = NavigationStack(initial: \MainCoordinator.mainTab) } else { stack = NavigationStack(initial: \MainCoordinator.serverList) diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift index 4a7733c59..d85218bdc 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift @@ -7,6 +7,7 @@ // import Foundation +import JellyfinAPI import Stinsen import SwiftUI @@ -45,12 +46,9 @@ final class MainTabCoordinator: TabCoordinatable { } } - func makeTVShows() -> NavigationViewCoordinator { - let parameters = BasicLibraryCoordinator.Parameters( - title: nil, - viewModel: ItemTypeLibraryViewModel(itemTypes: [.series], filters: .init()) - ) - return NavigationViewCoordinator(BasicLibraryCoordinator(parameters: parameters)) + func makeTVShows() -> NavigationViewCoordinator> { + let viewModel = ItemTypeLibraryViewModel(itemTypes: [.series]) + return NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) } @ViewBuilder @@ -61,12 +59,9 @@ final class MainTabCoordinator: TabCoordinatable { } } - func makeMovies() -> NavigationViewCoordinator { - let parameters = BasicLibraryCoordinator.Parameters( - title: nil, - viewModel: ItemTypeLibraryViewModel(itemTypes: [.movie], filters: .init()) - ) - return NavigationViewCoordinator(BasicLibraryCoordinator(parameters: parameters)) + func makeMovies() -> NavigationViewCoordinator> { + let viewModel = ItemTypeLibraryViewModel(itemTypes: [.movie]) + return NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) } @ViewBuilder diff --git a/Shared/Coordinators/MediaCoordinator.swift b/Shared/Coordinators/MediaCoordinator.swift index a8380b9ed..53adec6ad 100644 --- a/Shared/Coordinators/MediaCoordinator.swift +++ b/Shared/Coordinators/MediaCoordinator.swift @@ -7,6 +7,7 @@ // import Foundation +import JellyfinAPI import Stinsen import SwiftUI @@ -29,13 +30,13 @@ final class MediaCoordinator: NavigationCoordinatable { #endif #if os(tvOS) - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator { - NavigationViewCoordinator(LibraryCoordinator(parameters: parameters)) + func makeLibrary(viewModel: PagingLibraryViewModel) -> NavigationViewCoordinator> { + NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) } #else - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator { - LibraryCoordinator(parameters: parameters) + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) } func makeLiveTV() -> LiveTVCoordinator { @@ -49,6 +50,6 @@ final class MediaCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { - MediaView(viewModel: .init()) + MediaView() } } diff --git a/Shared/Coordinators/SearchCoordinator.swift b/Shared/Coordinators/SearchCoordinator.swift index 39ef690bc..4f6f1e3f0 100644 --- a/Shared/Coordinators/SearchCoordinator.swift +++ b/Shared/Coordinators/SearchCoordinator.swift @@ -36,16 +36,16 @@ final class SearchCoordinator: NavigationCoordinatable { NavigationViewCoordinator(ItemCoordinator(item: item)) } - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator { - NavigationViewCoordinator(LibraryCoordinator(parameters: parameters)) + func makeLibrary(viewModel: PagingLibraryViewModel) -> NavigationViewCoordinator> { + NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) } #else func makeItem(item: BaseItemDto) -> ItemCoordinator { ItemCoordinator(item: item) } - func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator { - LibraryCoordinator(parameters: parameters) + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) } func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator { @@ -55,6 +55,6 @@ final class SearchCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { - SearchView(viewModel: .init()) + SearchView() } } diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index fac5bf7f5..b42597fbc 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -34,7 +34,7 @@ final class SettingsCoordinator: NavigationCoordinatable { @Route(.push) var experimentalSettings = makeExperimentalSettings @Route(.push) - var filterDrawerButtonSelector = makeFilterDrawerButtonSelector + var itemFilterDrawerSelector = makeItemFilterDrawerSelector @Route(.push) var indicatorSettings = makeIndicatorSettings @Route(.push) @@ -117,8 +117,8 @@ final class SettingsCoordinator: NavigationCoordinatable { } #endif - func makeFilterDrawerButtonSelector(selectedButtonsBinding: Binding<[FilterDrawerButtonSelection]>) -> some View { - FilterDrawerButtonSelectorView(selectedButtonsBinding: selectedButtonsBinding) + func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View { + OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) } func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator { diff --git a/Shared/Coordinators/VideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator.swift index b5dc25076..513b30e0d 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator.swift @@ -9,6 +9,7 @@ import Defaults import Foundation import JellyfinAPI +import PreferencesView import Stinsen import SwiftUI @@ -29,7 +30,12 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { func makeStart() -> some View { #if os(iOS) - PreferenceUIHostingControllerView { + // Some settings have to apply to the root PreferencesView and this + // one - separately. + // It is assumed that because Stinsen adds a lot of views that the + // PreferencesView isn't in the right place in the VC chain so that + // it can apply the settings, even SwiftUI settings. + PreferencesView { Group { if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { VideoPlayer(manager: self.videoPlayerManager) @@ -37,20 +43,20 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { NativeVideoPlayer(manager: self.videoPlayerManager) } } - .overrideViewPreference(.dark) + .preferredColorScheme(.dark) + .supportedOrientations(UIDevice.isPhone ? .landscape : .allButUpsideDown) } .ignoresSafeArea() - .hideSystemOverlays() - .onAppear { - AppDelegate.enterPlaybackOrientation() - } + .backport + .persistentSystemOverlays(.hidden) + .backport + .defersSystemGestures(on: .all) #else if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { - PreferenceUIHostingControllerView { + PreferencesView { VideoPlayer(manager: self.videoPlayerManager) } - .ignoresSafeArea() } else { NativeVideoPlayer(manager: self.videoPlayerManager) } diff --git a/Shared/Coordinators/VideoPlayerSettingsCoordinator.swift b/Shared/Coordinators/VideoPlayerSettingsCoordinator.swift index a4191418d..0133f055b 100644 --- a/Shared/Coordinators/VideoPlayerSettingsCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerSettingsCoordinator.swift @@ -26,13 +26,8 @@ final class VideoPlayerSettingsCoordinator: NavigationCoordinatable { var actionButtonSelector = makeActionButtonSelector #endif - #if os(tvOS) - - #endif - func makeFontPicker(selection: Binding) -> some View { FontPickerView(selection: selection) - .navigationTitle(L10n.subtitleFont) } #if os(iOS) @@ -40,18 +35,13 @@ final class VideoPlayerSettingsCoordinator: NavigationCoordinatable { @ViewBuilder func makeGestureSettings() -> some View { GestureSettingsView() - .navigationTitle("Gestures") } func makeActionButtonSelector(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) -> some View { - ActionButtonSelectorView(selectedButtonsBinding: selectedButtonsBinding) + ActionButtonSelectorView(selection: selectedButtonsBinding) } #endif - #if os(tvOS) - - #endif - @ViewBuilder func makeStart() -> some View { VideoPlayerSettingsView() diff --git a/Shared/Errors/ErrorMessage.swift b/Shared/Errors/ErrorMessage.swift index 90412d400..23ebbf2f0 100644 --- a/Shared/Errors/ErrorMessage.swift +++ b/Shared/Errors/ErrorMessage.swift @@ -9,6 +9,7 @@ import Foundation import JellyfinAPI +// TODO: remove struct ErrorMessage: Hashable, Identifiable { let code: Int? diff --git a/Shared/Extensions/Collection.swift b/Shared/Extensions/Collection.swift index 994fd29b4..85e9a28a9 100644 --- a/Shared/Extensions/Collection.swift +++ b/Shared/Extensions/Collection.swift @@ -14,8 +14,8 @@ extension Collection { Array(self) } - func sorted(using keyPath: KeyPath) -> [Element] { - sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) + var isNotEmpty: Bool { + !isEmpty } subscript(safe index: Index) -> Element? { diff --git a/Shared/Extensions/Edge.swift b/Shared/Extensions/Edge.swift new file mode 100644 index 000000000..bc505bf4d --- /dev/null +++ b/Shared/Extensions/Edge.swift @@ -0,0 +1,33 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension Edge.Set { + + var asUIRectEdge: UIRectEdge { + switch self { + case .top: + .top + case .leading: + .left + case .bottom: + .bottom + case .trailing: + .right + case .all: + .all + case .horizontal: + [.left, .right] + case .vertical: + [.top, .bottom] + default: + .all + } + } +} diff --git a/Shared/Extensions/EdgeInsets.swift b/Shared/Extensions/EdgeInsets.swift index 659fd0f31..d78bd9ddb 100644 --- a/Shared/Extensions/EdgeInsets.swift +++ b/Shared/Extensions/EdgeInsets.swift @@ -8,6 +8,45 @@ import SwiftUI +extension EdgeInsets { + + // TODO: tvOS + /// The default padding for View's against contextual edges, + /// typically the edges of the View's scene + static let defaultEdgePadding: CGFloat = { + #if os(tvOS) + 50 + #else + if UIDevice.isPad { + 24 + } else { + 16 + } + #endif + }() + + static let DefaultEdgeInsets: EdgeInsets = .init(defaultEdgePadding) + + init(_ constant: CGFloat) { + self.init(top: constant, leading: constant, bottom: constant, trailing: constant) + } + + init(vertical: CGFloat = 0, horizontal: CGFloat = 0) { + self.init(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal) + } +} + +extension NSDirectionalEdgeInsets { + + init(constant: CGFloat) { + self.init(top: constant, leading: constant, bottom: constant, trailing: constant) + } + + init(vertical: CGFloat = 0, horizontal: CGFloat = 0) { + self.init(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal) + } +} + extension UIEdgeInsets { var asEdgeInsets: EdgeInsets { diff --git a/Shared/Extensions/EnvironmentValue.swift b/Shared/Extensions/EnvironmentValue.swift deleted file mode 100644 index 66cfc20af..000000000 --- a/Shared/Extensions/EnvironmentValue.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: Look at name spacing -// TODO: Consistent naming: ...Key - -struct AudioOffset: EnvironmentKey { - static let defaultValue: Binding = .constant(0) -} - -struct AspectFilled: EnvironmentKey { - static let defaultValue: Binding = .constant(false) -} - -struct CurrentOverlayType: EnvironmentKey { - static let defaultValue: Binding = .constant(.main) -} - -struct IsScrubbing: EnvironmentKey { - static let defaultValue: Binding = .constant(false) -} - -struct PlaybackSpeedKey: EnvironmentKey { - static let defaultValue: Binding = .constant(1) -} - -struct SafeAreaInsetsKey: EnvironmentKey { - static var defaultValue: EdgeInsets { - UIApplication.shared.keyWindow?.safeAreaInsets.asEdgeInsets ?? .zero - } -} - -struct SubtitleOffset: EnvironmentKey { - static let defaultValue: Binding = .constant(0) -} - -struct IsPresentingOverlayKey: EnvironmentKey { - static let defaultValue: Binding = .constant(false) -} - -extension EnvironmentValues { - - var isPresentingOverlay: Binding { - get { self[IsPresentingOverlayKey.self] } - set { self[IsPresentingOverlayKey.self] = newValue } - } - - var audioOffset: Binding { - get { self[AudioOffset.self] } - set { self[AudioOffset.self] = newValue } - } - - var aspectFilled: Binding { - get { self[AspectFilled.self] } - set { self[AspectFilled.self] = newValue } - } - - var currentOverlayType: Binding { - get { self[CurrentOverlayType.self] } - set { self[CurrentOverlayType.self] = newValue } - } - - var isScrubbing: Binding { - get { self[IsScrubbing.self] } - set { self[IsScrubbing.self] = newValue } - } - - var playbackSpeed: Binding { - get { self[PlaybackSpeedKey.self] } - set { self[PlaybackSpeedKey.self] = newValue } - } - - var safeAreaInsets: EdgeInsets { - self[SafeAreaInsetsKey.self] - } - - var subtitleOffset: Binding { - get { self[SubtitleOffset.self] } - set { self[SubtitleOffset.self] = newValue } - } -} diff --git a/Shared/Extensions/EnvironmentValue/EnvironmentValue+Keys.swift b/Shared/Extensions/EnvironmentValue/EnvironmentValue+Keys.swift new file mode 100644 index 000000000..c7a2213e4 --- /dev/null +++ b/Shared/Extensions/EnvironmentValue/EnvironmentValue+Keys.swift @@ -0,0 +1,57 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension EnvironmentValues { + + struct AccentColorKey: EnvironmentKey { + static let defaultValue: Color = Defaults[.accentColor] + } + + struct AudioOffsetKey: EnvironmentKey { + static let defaultValue: Binding = .constant(0) + } + + struct AspectFilledKey: EnvironmentKey { + static let defaultValue: Binding = .constant(false) + } + + struct CurrentOverlayTypeKey: EnvironmentKey { + static let defaultValue: Binding = .constant(.main) + } + + struct IsScrubbingKey: EnvironmentKey { + static let defaultValue: Binding = .constant(false) + } + + struct PlaybackSpeedKey: EnvironmentKey { + static let defaultValue: Binding = .constant(1) + } + + // TODO: does this actually do anything useful? + // should instead use view safe area? + struct SafeAreaInsetsKey: EnvironmentKey { + static var defaultValue: EdgeInsets { + UIApplication.shared.keyWindow?.safeAreaInsets.asEdgeInsets ?? .zero + } + } + + struct ShowsLibraryFiltersKey: EnvironmentKey { + static let defaultValue: Bool = true + } + + struct SubtitleOffsetKey: EnvironmentKey { + static let defaultValue: Binding = .constant(0) + } + + struct IsPresentingOverlayKey: EnvironmentKey { + static let defaultValue: Binding = .constant(false) + } +} diff --git a/Shared/Extensions/EnvironmentValue/EnvironmentValue+Values.swift b/Shared/Extensions/EnvironmentValue/EnvironmentValue+Values.swift new file mode 100644 index 000000000..0bcee80aa --- /dev/null +++ b/Shared/Extensions/EnvironmentValue/EnvironmentValue+Values.swift @@ -0,0 +1,63 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension EnvironmentValues { + + var accentColor: Color { + get { self[AccentColorKey.self] } + set { self[AccentColorKey.self] = newValue } + } + + var audioOffset: Binding { + get { self[AudioOffsetKey.self] } + set { self[AudioOffsetKey.self] = newValue } + } + + var aspectFilled: Binding { + get { self[AspectFilledKey.self] } + set { self[AspectFilledKey.self] = newValue } + } + + var currentOverlayType: Binding { + get { self[CurrentOverlayTypeKey.self] } + set { self[CurrentOverlayTypeKey.self] = newValue } + } + + var isPresentingOverlay: Binding { + get { self[IsPresentingOverlayKey.self] } + set { self[IsPresentingOverlayKey.self] = newValue } + } + + var isScrubbing: Binding { + get { self[IsScrubbingKey.self] } + set { self[IsScrubbingKey.self] = newValue } + } + + var playbackSpeed: Binding { + get { self[PlaybackSpeedKey.self] } + set { self[PlaybackSpeedKey.self] = newValue } + } + + var safeAreaInsets: EdgeInsets { + self[SafeAreaInsetsKey.self] + } + + // TODO: remove and make a parameter instead, isn't necessarily an + // environment value + var showsLibraryFilters: Bool { + get { self[ShowsLibraryFiltersKey.self] } + set { self[ShowsLibraryFiltersKey.self] = newValue } + } + + var subtitleOffset: Binding { + get { self[SubtitleOffsetKey.self] } + set { self[SubtitleOffsetKey.self] = newValue } + } +} diff --git a/Shared/Extensions/Equatable.swift b/Shared/Extensions/Equatable.swift index 5594f6f3d..23ce2b585 100644 --- a/Shared/Extensions/Equatable.swift +++ b/Shared/Extensions/Equatable.swift @@ -10,14 +10,6 @@ import Foundation extension Equatable { - func random(in range: Range) -> [Self] { - Array(repeating: self, count: Int.random(in: range)) - } - - func repeating(count: Int) -> [Self] { - Array(repeating: self, count: count) - } - func mutating(_ keyPath: WritableKeyPath, with newValue: Value) -> Self { var copy = self copy[keyPath: keyPath] = newValue diff --git a/Shared/Extensions/ForEach.swift b/Shared/Extensions/ForEach.swift deleted file mode 100644 index 15830169b..000000000 --- a/Shared/Extensions/ForEach.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension ForEach where Content: View { - - @ViewBuilder - static func `let`( - _ data: Data?, - id: KeyPath, - @ViewBuilder content: @escaping (Data.Element) -> Content - ) -> some View { - if let data { - ForEach(data, id: id, content: content) - } else { - EmptyView() - } - } - - @ViewBuilder - static func `let`(_ data: Data?, @ViewBuilder content: @escaping (Data.Element) -> Content) -> some View where ID == Data.Element.ID, - Data.Element: Identifiable { - if let data { - ForEach(data, content: content) - } else { - EmptyView() - } - } -} diff --git a/Shared/Extensions/HorizontalAlignment.swift b/Shared/Extensions/HorizontalAlignment.swift index 2da7dc058..af0c947e0 100644 --- a/Shared/Extensions/HorizontalAlignment.swift +++ b/Shared/Extensions/HorizontalAlignment.swift @@ -17,4 +17,12 @@ extension HorizontalAlignment { } static let VideoPlayerTitleAlignmentGuide = HorizontalAlignment(VideoPlayerTitleAlignment.self) + + struct LibraryRowContentAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } + + static let LeadingLibraryRowContentAlignmentGuide = HorizontalAlignment(LibraryRowContentAlignment.self) } diff --git a/Shared/Extensions/Int.swift b/Shared/Extensions/Int.swift index 171161226..227c9e8cf 100644 --- a/Shared/Extensions/Int.swift +++ b/Shared/Extensions/Int.swift @@ -16,9 +16,9 @@ extension FixedWidthInteger { let seconds = self % 3600 % 60 let hourText = hours > 0 ? String(hours).appending(":") : "" - let minutesText = hours > 0 ? String(minutes).leftPad(toWidth: 2, withString: "0").appending(":") : String(minutes) + let minutesText = hours > 0 ? String(minutes).leftPad(maxWidth: 2, with: "0").appending(":") : String(minutes) .appending(":") - let secondsText = String(seconds).leftPad(toWidth: 2, withString: "0") + let secondsText = String(seconds).leftPad(maxWidth: 2, with: "0") return hourText .appending(minutesText) @@ -28,8 +28,8 @@ extension FixedWidthInteger { extension Int { - /// Format if the current value represents milliseconds - var millisecondFormat: String { + /// Label if the current value represents milliseconds + var millisecondLabel: String { let isNegative = self < 0 let value = abs(self) let seconds = "\(value / 1000)" @@ -42,8 +42,8 @@ extension Int { .prepending("-", if: isNegative) } - // Format if the current value represents seconds - var secondFormat: String { + /// Label if the current value represents seconds + var secondLabel: String { let isNegative = self < 0 let value = abs(self) let seconds = "\(value)" @@ -52,4 +52,12 @@ extension Int { .appending("s") .prepending("-", if: isNegative) } + + init?(_ source: CGFloat?) { + if let source = source { + self.init(source) + } else { + return nil + } + } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto+Images.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift similarity index 90% rename from Shared/Extensions/JellyfinAPI/BaseItemDto+Images.swift rename to Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift index bedb45f52..740843f99 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto+Images.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift @@ -81,14 +81,12 @@ extension BaseItemDto { maxHeight: Int?, itemID: String ) -> URL? { - - // TODO: See if the scaling is actually right so that it isn't so big let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) let scaleHeight = maxHeight == nil ? nil : UIScreen.main.scale(maxHeight!) guard let tag = getImageTag(for: type) else { return nil } - let client = Container.userSession.callAsFunction().client + let client = Container.userSession().client let parameters = Paths.GetItemImageParameters( maxWidth: scaleWidth, maxHeight: scaleHeight, @@ -115,19 +113,9 @@ extension BaseItemDto { } } - fileprivate func _imageSource(_ type: ImageType, maxWidth: Int?, maxHeight: Int?) -> ImageSource { + private func _imageSource(_ type: ImageType, maxWidth: Int?, maxHeight: Int?) -> ImageSource { let url = _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: id ?? "") let blurHash = blurHash(type) return ImageSource(url: url, blurHash: blurHash) } } - -fileprivate extension Int { - init?(_ source: CGFloat?) { - if let source = source { - self.init(source) - } else { - return nil - } - } -} diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift similarity index 85% rename from Shared/Extensions/JellyfinAPI/BaseItemDto+Poster.swift rename to Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift index b1c7ac076..8934a85ae 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift @@ -27,7 +27,7 @@ extension BaseItemDto: Poster { var subtitle: String? { switch type { case .episode: - return seasonEpisodeLocator + return seasonEpisodeLabel case .video: return extraType?.displayTitle default: @@ -44,10 +44,24 @@ extension BaseItemDto: Poster { } } + var typeSystemName: String? { + switch type { + case .episode, .movie, .series: + "film" + case .folder: + "folder.fill" + case .person: + "person.fill" + default: nil + } + } + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { switch type { case .episode: return seriesImageSource(.primary, maxWidth: maxWidth) + case .folder: + return ImageSource() default: return imageSource(.primary, maxWidth: maxWidth) } @@ -65,6 +79,8 @@ extension BaseItemDto: Poster { imageSource(.primary, maxWidth: maxWidth), ] } + case .folder: + return [imageSource(.primary, maxWidth: maxWidth)] case .video: return [imageSource(.primary, maxWidth: maxWidth)] default: diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift similarity index 87% rename from Shared/Extensions/JellyfinAPI/BaseItemDto+VideoPlayerViewModel.swift rename to Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift index fc1afc067..0e615566a 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift @@ -6,12 +6,10 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Combine import Defaults import Factory import Foundation import JellyfinAPI -import SwiftUI extension BaseItemDto { @@ -20,9 +18,9 @@ extension BaseItemDto { let currentVideoPlayerType = Defaults[.VideoPlayer.videoPlayerType] // TODO: fix bitrate settings let tempOverkillBitrate = 360_000_000 - let profile = DeviceProfileBuilder.buildProfile(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate) + let profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate) - let userSession = Container.userSession.callAsFunction() + let userSession = Container.userSession() let playbackInfo = PlaybackInfoDto(deviceProfile: profile) let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters( diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift similarity index 81% rename from Shared/Extensions/JellyfinAPI/BaseItemDto.swift rename to Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index 990b74e05..628ec7015 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -19,7 +19,11 @@ extension BaseItemDto: Displayable { } } -extension BaseItemDto: LibraryParent {} +extension BaseItemDto: LibraryParent { + var libraryType: BaseItemKind? { + type + } +} extension BaseItemDto { @@ -28,16 +32,19 @@ extension BaseItemDto { return L10n.episodeNumber(episodeNo) } + var itemGenres: [ItemGenre]? { + guard let genres else { return nil } + return genres.map(ItemGenre.init) + } + var runTimeSeconds: Int { let playbackPositionTicks = runTimeTicks ?? 0 return Int(playbackPositionTicks / 10_000_000) } - var seasonEpisodeLocator: String? { - if let seasonNo = parentIndexNumber, let episodeNo = indexNumber { - return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo)) - } - return nil + var seasonEpisodeLabel: String? { + guard let seasonNo = parentIndexNumber, let episodeNo = indexNumber else { return nil } + return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo)) } var startTimeSeconds: Int { @@ -47,8 +54,7 @@ extension BaseItemDto { // MARK: Calculations - // TODO: make computed var or function that takes allowed units - func getItemRuntime() -> String? { + var runTimeLabel: String? { let timeHMSFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated @@ -92,8 +98,8 @@ extension BaseItemDto { } func getLiveProgressPercentage() -> Double { - if let startDate = self.startDate, - let endDate = self.endDate + if let startDate, + let endDate { let start = startDate.timeIntervalSinceReferenceDate let end = endDate.timeIntervalSinceReferenceDate @@ -132,11 +138,11 @@ extension BaseItemDto { } var airDateLabel: String? { - guard let premiereDateFormatted = premiereDateFormatted else { return nil } + guard let premiereDateFormatted = premiereDateLabel else { return nil } return L10n.airWithDate(premiereDateFormatted) } - var premiereDateFormatted: String? { + var premiereDateLabel: String? { guard let premiereDate = premiereDate else { return nil } let dateFormatter = DateFormatter() @@ -153,7 +159,7 @@ extension BaseItemDto { var hasExternalLinks: Bool { guard let externalURLs else { return false } - return !externalURLs.isEmpty + return externalURLs.isNotEmpty } var hasRatings: Bool { @@ -188,8 +194,7 @@ extension BaseItemDto { ) let imageURL = Container - .userSession - .callAsFunction() + .userSession() .client .fullURL(with: request) @@ -228,26 +233,30 @@ extension BaseItemDto { } } - // TODO: Don't use spoof objects as a placeholder or no results + // TODO: move as extension on `BaseItemKind` + // TODO: remove when `collectionType` becomes an enum + func includedItemTypesForCollectionType() -> [BaseItemKind]? { - static var placeHolder: BaseItemDto { - .init( - id: "1", - name: "Placeholder", - overview: String(repeating: "a", count: 100) -// indexNumber: 20 - ) - } + guard let collectionType else { return nil } + + var itemTypes: [BaseItemKind]? + + switch collectionType { + case "movies": + itemTypes = [.movie] + case "tvshows": + itemTypes = [.series] + case "mixed": + itemTypes = [.movie, .series] + default: + itemTypes = nil + } - static func randomItem() -> BaseItemDto { - .init( - id: UUID().uuidString, - name: "Lorem Ipsum", - overview: "Lorem ipsum dolor sit amet" - ) + return itemTypes } - static var noResults: BaseItemDto { - .init(name: L10n.noResults) + /// Returns `originalTitle` if it is not the same as `displayTitle` + var alternateTitle: String? { + originalTitle != displayTitle ? originalTitle : nil } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemPerson+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift similarity index 90% rename from Shared/Extensions/JellyfinAPI/BaseItemPerson+Poster.swift rename to Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift index 32d9f2ae8..93900d32a 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemPerson+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift @@ -14,16 +14,20 @@ import UIKit extension BaseItemPerson: Poster { var subtitle: String? { - self.firstRole + firstRole } var showTitle: Bool { true } + var typeSystemName: String? { + "person.fill" + } + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { let scaleWidth = UIScreen.main.scale(maxWidth) - let client = Container.userSession.callAsFunction().client + let client = Container.userSession().client let imageRequestParameters = Paths.GetItemImageParameters( maxWidth: scaleWidth, tag: primaryImageTag diff --git a/Shared/Extensions/JellyfinAPI/BaseItemPerson.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift similarity index 94% rename from Shared/Extensions/JellyfinAPI/BaseItemPerson.swift rename to Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift index 077ac9a4e..a64cd0a3b 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemPerson.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift @@ -16,12 +16,14 @@ extension BaseItemPerson: Displayable { } } -extension BaseItemPerson: LibraryParent {} +extension BaseItemPerson: LibraryParent { + var libraryType: BaseItemKind? { + .person + } +} extension BaseItemPerson { - // MARK: First Role - // Jellyfin will grab all roles the person played in the show which makes the role // text too long. This will grab the first role which: // - assumes that the most important role is the first diff --git a/Shared/Extensions/JellyfinAPI/ChapterInfo.swift b/Shared/Extensions/JellyfinAPI/ChapterInfo.swift index b61203871..0658274bd 100644 --- a/Shared/Extensions/JellyfinAPI/ChapterInfo.swift +++ b/Shared/Extensions/JellyfinAPI/ChapterInfo.swift @@ -31,7 +31,11 @@ extension ChapterInfo { extension ChapterInfo { - struct FullInfo: Poster, Hashable { + struct FullInfo: Poster { + + var id: Int { + chapterInfo.hashValue + } let chapterInfo: ChapterInfo let imageSource: ImageSource @@ -41,6 +45,7 @@ extension ChapterInfo { chapterInfo.displayTitle } + let typeSystemName: String? = "film" var subtitle: String? var showTitle: Bool = true diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+NativeProfile.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+NativeProfile.swift new file mode 100644 index 000000000..963bf59b9 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+NativeProfile.swift @@ -0,0 +1,90 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI + +extension DeviceProfile { + + static func nativeProfile() -> DeviceProfile { + + var profile: DeviceProfile = .init() + + // Build direct play profiles + profile.directPlayProfiles = [ + // Apple limitation: no mp3 in mp4; avi only supports mjpeg with pcm + // Right now, mp4 restrictions can't be enforced because mp4, m4v, mov, 3gp,3g2 treated the same + DirectPlayProfile( + audioCodec: "flac,alac,aac,eac3,ac3,opus", + container: "mp4", + type: .video, + videoCodec: "hevc,h264,mpeg4" + ), + DirectPlayProfile( + audioCodec: "alac,aac,ac3", + container: "m4v", + type: .video, + videoCodec: "h264,mpeg4" + ), + DirectPlayProfile( + audioCodec: "alac,aac,eac3,ac3,mp3,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le", + container: "mov", + type: .video, + videoCodec: "hevc,h264,mpeg4,mjpeg" + ), + DirectPlayProfile( + audioCodec: "aac,eac3,ac3,mp3", + container: "mpegts", + type: .video, + videoCodec: "h264" + ), + DirectPlayProfile( + audioCodec: "aac,amr_nb", + container: "3gp,3g2", + type: .video, + videoCodec: "h264,mpeg4" + ), + DirectPlayProfile( + audioCodec: "pcm_s16le,pcm_mulaw", + container: "avi", + type: .video, + videoCodec: "mjpeg" + ), + ] + + // Build transcoding profiles + profile.transcodingProfiles = [ + TranscodingProfile( + audioCodec: "flac,alac,aac,eac3,ac3,opus", + isBreakOnNonKeyFrames: true, + container: "mp4", + context: .streaming, + maxAudioChannels: "8", + minSegments: 2, + protocol: "hls", + type: .video, + videoCodec: "hevc,h264,mpeg4" + ), + ] + + // Create subtitle profiles + profile.subtitleProfiles = [ + // FFmpeg can only convert bitmap to bitmap and text to text; burn in bitmap subs + SubtitleProfile(format: "pgssub", method: .encode), + SubtitleProfile(format: "dvdsub", method: .encode), + SubtitleProfile(format: "dvbsub", method: .encode), + SubtitleProfile(format: "xsub", method: .encode), + // According to Apple HLS authoring specs, WebVTT must be in a text file delivered via HLS + SubtitleProfile(format: "vtt", method: .hls), // webvtt + // Apple HLS authoring spec has closed captions in video segments and TTML in fmp4 + SubtitleProfile(format: "ttml", method: .embed), + SubtitleProfile(format: "cc_dec", method: .embed), + ] + + return profile + } +} diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SharedCodecProfiles.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SharedCodecProfiles.swift new file mode 100644 index 000000000..5203d52fa --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SharedCodecProfiles.swift @@ -0,0 +1,78 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI + +extension DeviceProfile { + + // For now, assume native and VLCKit support same codec conditions + static func sharedCodecProfiles() -> [CodecProfile] { + + var codecProfiles: [CodecProfile] = [] + + let h264CodecConditions: [ProfileCondition] = [ + ProfileCondition( + condition: .notEquals, + isRequired: false, + property: .isAnamorphic, + value: "true" + ), + ProfileCondition( + condition: .equalsAny, + isRequired: false, + property: .videoProfile, + value: "high|main|baseline|constrained baseline" + ), + ProfileCondition( + condition: .lessThanEqual, + isRequired: false, + property: .videoLevel, + value: "80" + ), + ProfileCondition( + condition: .notEquals, + isRequired: false, + property: .isInterlaced, + value: "true" + ), + ] + + codecProfiles.append(CodecProfile(applyConditions: h264CodecConditions, codec: "h264", type: .video)) + + let hevcCodecConditions: [ProfileCondition] = [ + ProfileCondition( + condition: .notEquals, + isRequired: false, + property: .isAnamorphic, + value: "true" + ), + ProfileCondition( + condition: .equalsAny, + isRequired: false, + property: .videoProfile, + value: "high|main|main 10" + ), + ProfileCondition( + condition: .lessThanEqual, + isRequired: false, + property: .videoLevel, + value: "175" + ), + ProfileCondition( + condition: .notEquals, + isRequired: false, + property: .isInterlaced, + value: "true" + ), + ] + + codecProfiles.append(CodecProfile(applyConditions: hevcCodecConditions, codec: "hevc", type: .video)) + + return codecProfiles + } +} diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SwiftfinProfile.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SwiftfinProfile.swift new file mode 100644 index 000000000..8d66a5839 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SwiftfinProfile.swift @@ -0,0 +1,98 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI + +extension DeviceProfile { + + static func swiftfinProfile() -> DeviceProfile { + + var profile: DeviceProfile = .init() + + // Build direct play profiles + profile.directPlayProfiles = [ + // Just make one profile because if VLCKit can't decode it in a certain container, ffmpeg probably can't decode it for + // transcode either + DirectPlayProfile( + // No need to list containers or videocodecs since if jellyfin server can detect it/ffmpeg can decode it, so can + // VLCKit + // However, list audiocodecs because ffmpeg can decode TrueHD/mlp but VLCKit cannot + audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le,pcm_u8,pcm_alaw,pcm_mulaw,pcm_bluray,pcm_dvd,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb", + type: .video + ), + ] + + // Build transcoding profiles + // The only cases where transcoding should occur: + // 1) TrueHD/mlp audio + // 2) When server forces transcode for bitrate reasons + profile.transcodingProfiles = [TranscodingProfile( + audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1", + // no PCM,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb in mp4 + isBreakOnNonKeyFrames: true, + container: "mp4", + context: .streaming, + maxAudioChannels: "8", + minSegments: 2, + protocol: "hls", + type: .video, + videoCodec: "hevc,h264,av1,vp9,vc1,mpeg4,h263,mpeg2video,mpeg1video,mjpeg" // vp8,msmpeg4v3,msmpeg4v2,msmpeg4v1,theora,ffv1,flv1,wmv3,wmv2,wmv1 + // not supported in mp4 + )] + + // Create subtitle profiles + profile.subtitleProfiles = [ + SubtitleProfile(format: "pgssub", method: .embed), // *pgs* normalized to pgssub; includes sup + SubtitleProfile(format: "dvdsub", method: .embed), + // *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case? + SubtitleProfile(format: "subrip", method: .embed), // srt + SubtitleProfile(format: "ass", method: .embed), + SubtitleProfile(format: "ssa", method: .embed), + SubtitleProfile(format: "vtt", method: .embed), // webvtt + SubtitleProfile(format: "mov_text", method: .embed), // MPEG-4 Timed Text + SubtitleProfile(format: "ttml", method: .embed), + SubtitleProfile(format: "text", method: .embed), // txt + SubtitleProfile(format: "dvbsub", method: .embed), + // dvb_subtitle normalized to dvbsub; burned in during transcode regardless? + SubtitleProfile(format: "libzvbi_teletextdec", method: .embed), // dvb_teletext + SubtitleProfile(format: "xsub", method: .embed), + SubtitleProfile(format: "vplayer", method: .embed), + SubtitleProfile(format: "subviewer", method: .embed), + SubtitleProfile(format: "subviewer1", method: .embed), + SubtitleProfile(format: "sami", method: .embed), // SMI + SubtitleProfile(format: "realtext", method: .embed), + SubtitleProfile(format: "pjs", method: .embed), // Phoenix Subtitle + SubtitleProfile(format: "mpl2", method: .embed), + SubtitleProfile(format: "jacosub", method: .embed), + SubtitleProfile(format: "cc_dec", method: .embed), // eia_608 + // Can be passed as external files; ones that jellyfin can encode to must come first + SubtitleProfile(format: "subrip", method: .external), // srt + SubtitleProfile(format: "ttml", method: .external), + SubtitleProfile(format: "vtt", method: .external), // webvtt + SubtitleProfile(format: "ass", method: .external), + SubtitleProfile(format: "ssa", method: .external), + SubtitleProfile(format: "pgssub", method: .external), + SubtitleProfile(format: "text", method: .external), // txt + SubtitleProfile(format: "dvbsub", method: .external), // dvb_subtitle normalized to dvbsub + SubtitleProfile(format: "libzvbi_teletextdec", method: .external), // dvb_teletext + SubtitleProfile(format: "dvdsub", method: .external), + // *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case? + SubtitleProfile(format: "xsub", method: .external), + SubtitleProfile(format: "vplayer", method: .external), + SubtitleProfile(format: "subviewer", method: .external), + SubtitleProfile(format: "subviewer1", method: .external), + SubtitleProfile(format: "sami", method: .external), // SMI + SubtitleProfile(format: "realtext", method: .external), + SubtitleProfile(format: "pjs", method: .external), // Phoenix Subtitle + SubtitleProfile(format: "mpl2", method: .external), + SubtitleProfile(format: "jacosub", method: .external), + ] + + return profile + } +} diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile.swift new file mode 100644 index 000000000..0bebd1910 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile.swift @@ -0,0 +1,38 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI + +extension DeviceProfile { + + static func build(for videoPlayer: VideoPlayerType, maxBitrate: Int? = nil) -> DeviceProfile { + + var deviceProfile: DeviceProfile + + switch videoPlayer { + case .native: + deviceProfile = nativeProfile() + case .swiftfin: + deviceProfile = swiftfinProfile() + } + + let codecProfiles: [CodecProfile] = sharedCodecProfiles() + let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", mimeType: "video/mp4", type: .video)] + + deviceProfile.codecProfiles = codecProfiles + deviceProfile.responseProfiles = responseProfiles + + if let maxBitrate { + deviceProfile.maxStaticBitrate = maxBitrate + deviceProfile.maxStreamingBitrate = maxBitrate + deviceProfile.musicStreamingTranscodingBitrate = maxBitrate + } + + return deviceProfile + } +} diff --git a/Shared/Extensions/JellyfinAPI/ItemFields.swift b/Shared/Extensions/JellyfinAPI/ItemFields.swift index 0a2aae6c3..1e299f98e 100644 --- a/Shared/Extensions/JellyfinAPI/ItemFields.swift +++ b/Shared/Extensions/JellyfinAPI/ItemFields.swift @@ -11,11 +11,20 @@ import JellyfinAPI extension ItemFields { - static let minimumCases: [ItemFields] = [ - .chapters, + /// The minimum cases to use when retrieving an item or items + /// for basic presentation. Depending on the context, using + /// more fields and including user data may also be necessary. + static let MinimumFields: [ItemFields] = [ .mediaSources, .overview, .parentID, .taglines, ] } + +extension Array where Element == ItemFields { + + static var MinimumFields: Self { + ItemFields.MinimumFields + } +} diff --git a/Shared/Extensions/JellyfinAPI/ItemFilter.swift b/Shared/Extensions/JellyfinAPI/ItemFilter+ItemTrait.swift similarity index 52% rename from Shared/Extensions/JellyfinAPI/ItemFilter.swift rename to Shared/Extensions/JellyfinAPI/ItemFilter+ItemTrait.swift index 56fe4dab6..a361200cd 100644 --- a/Shared/Extensions/JellyfinAPI/ItemFilter.swift +++ b/Shared/Extensions/JellyfinAPI/ItemFilter+ItemTrait.swift @@ -9,7 +9,23 @@ import Foundation import JellyfinAPI -extension ItemFilter: Displayable { +/// Aliased so the name `ItemFilter` can be repurposed. +/// +/// - Important: Make sure to use the correct `filters` parameter for item calls! +typealias ItemTrait = JellyfinAPI.ItemFilter + +extension ItemTrait: ItemFilter { + + var value: String { + rawValue + } + + init(from anyFilter: AnyItemFilter) { + self.init(rawValue: anyFilter.value)! + } +} + +extension ItemTrait: Displayable { // TODO: Localize var displayTitle: String { switch self { @@ -27,13 +43,14 @@ extension ItemFilter: Displayable { } } -extension ItemFilter { - - static var supportedCases: [ItemFilter] { - [.isUnplayed, .isPlayed, .isFavorite, .likes] - } +extension ItemTrait: SupportedCaseIterable { - var filter: ItemFilters.Filter { - .init(displayTitle: displayTitle, id: rawValue, filterName: rawValue) + static var supportedCases: [ItemTrait] { + [ + .isUnplayed, + .isPlayed, + .isFavorite, + .likes, + ] } } diff --git a/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift b/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift index 27464e446..37cb3ea7a 100644 --- a/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift +++ b/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift @@ -8,7 +8,13 @@ import Foundation -struct JellyfinAPIError: Error { +// TODO: rename to `ErrorMessage` and remove other implementation + +/// A basic error that holds a message, useful for debugging. +/// +/// - Important: Only really use for debugging. For practical errors, +/// statically define errors for each domain/context. +struct JellyfinAPIError: LocalizedError, Equatable { private let message: String @@ -16,7 +22,7 @@ struct JellyfinAPIError: Error { self.message = message } - var localizedDescription: String { + var errorDescription: String? { message } } diff --git a/Shared/Extensions/JellyfinAPI/MediaSourceInfo+ItemVideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/MediaSourceInfo+ItemVideoPlayerViewModel.swift index cf54b188f..b5da78539 100644 --- a/Shared/Extensions/JellyfinAPI/MediaSourceInfo+ItemVideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/MediaSourceInfo+ItemVideoPlayerViewModel.swift @@ -16,7 +16,7 @@ extension MediaSourceInfo { func videoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel { - let userSession = Container.userSession.callAsFunction() + let userSession = Container.userSession() let playbackURL: URL let streamType: StreamType diff --git a/Shared/Extensions/JellyfinAPI/MediaStream.swift b/Shared/Extensions/JellyfinAPI/MediaStream.swift index 2d5bd26d7..f6c6aeb94 100644 --- a/Shared/Extensions/JellyfinAPI/MediaStream.swift +++ b/Shared/Extensions/JellyfinAPI/MediaStream.swift @@ -13,12 +13,11 @@ import VLCUI extension MediaStream { - // TODO: Localize - static var none: MediaStream = .init(displayTitle: "None", index: -1) + static var none: MediaStream = .init(displayTitle: L10n.none, index: -1) var asPlaybackChild: VLCVideoPlayer.PlaybackChild? { guard let deliveryURL else { return nil } - let client = Container.userSession.callAsFunction().client + let client = Container.userSession().client let deliveryPath = deliveryURL.removingFirst(if: client.configuration.url.absoluteString.last == "/") let fullURL = client.fullURL(with: deliveryPath) @@ -46,122 +45,117 @@ extension MediaStream { (width ?? 0) > 1900 && type == .video } - var size: String? { - guard let height, let width else { return nil } - return "\(width)x\(height)" - } - // MARK: Property groups var metadataProperties: [TextPair] { var properties: [TextPair] = [] if let value = type { - properties.append(.init(displayTitle: "Type", subtitle: value.rawValue)) + properties.append(.init(title: "Type", subtitle: value.rawValue)) } if let value = codec { - properties.append(.init(displayTitle: "Codec", subtitle: value)) + properties.append(.init(title: "Codec", subtitle: value)) } if let value = codecTag { - properties.append(.init(displayTitle: "Codec Tag", subtitle: value)) + properties.append(.init(title: "Codec Tag", subtitle: value)) } if let value = language { - properties.append(.init(displayTitle: "Language", subtitle: value)) + properties.append(.init(title: "Language", subtitle: value)) } if let value = timeBase { - properties.append(.init(displayTitle: "Time Base", subtitle: value)) + properties.append(.init(title: "Time Base", subtitle: value)) } if let value = codecTimeBase { - properties.append(.init(displayTitle: "Codec Time Base", subtitle: value)) + properties.append(.init(title: "Codec Time Base", subtitle: value)) } if let value = videoRange { - properties.append(.init(displayTitle: "Video Range", subtitle: value)) + properties.append(.init(title: "Video Range", subtitle: value)) } if let value = isInterlaced { - properties.append(.init(displayTitle: "Interlaced", subtitle: value.description)) + properties.append(.init(title: "Interlaced", subtitle: value.description)) } if let value = isAVC { - properties.append(.init(displayTitle: "AVC", subtitle: value.description)) + properties.append(.init(title: "AVC", subtitle: value.description)) } if let value = channelLayout { - properties.append(.init(displayTitle: "Channel Layout", subtitle: value)) + properties.append(.init(title: "Channel Layout", subtitle: value)) } if let value = bitRate { - properties.append(.init(displayTitle: "Bitrate", subtitle: value.description)) + properties.append(.init(title: "Bitrate", subtitle: value.description)) } if let value = bitDepth { - properties.append(.init(displayTitle: "Bit Depth", subtitle: value.description)) + properties.append(.init(title: "Bit Depth", subtitle: value.description)) } if let value = refFrames { - properties.append(.init(displayTitle: "Reference Frames", subtitle: value.description)) + properties.append(.init(title: "Reference Frames", subtitle: value.description)) } if let value = packetLength { - properties.append(.init(displayTitle: "Packet Length", subtitle: value.description)) + properties.append(.init(title: "Packet Length", subtitle: value.description)) } if let value = channels { - properties.append(.init(displayTitle: "Channels", subtitle: value.description)) + properties.append(.init(title: "Channels", subtitle: value.description)) } if let value = sampleRate { - properties.append(.init(displayTitle: "Sample Rate", subtitle: value.description)) + properties.append(.init(title: "Sample Rate", subtitle: value.description)) } if let value = isDefault { - properties.append(.init(displayTitle: "Default", subtitle: value.description)) + properties.append(.init(title: "Default", subtitle: value.description)) } if let value = isForced { - properties.append(.init(displayTitle: "Forced", subtitle: value.description)) + properties.append(.init(title: "Forced", subtitle: value.description)) } if let value = averageFrameRate { - properties.append(.init(displayTitle: "Average Frame Rate", subtitle: value.description)) + properties.append(.init(title: "Average Frame Rate", subtitle: value.description)) } if let value = realFrameRate { - properties.append(.init(displayTitle: "Real Frame Rate", subtitle: value.description)) + properties.append(.init(title: "Real Frame Rate", subtitle: value.description)) } if let value = profile { - properties.append(.init(displayTitle: "Profile", subtitle: value)) + properties.append(.init(title: "Profile", subtitle: value)) } if let value = aspectRatio { - properties.append(.init(displayTitle: "Aspect Ratio", subtitle: value)) + properties.append(.init(title: "Aspect Ratio", subtitle: value)) } if let value = index { - properties.append(.init(displayTitle: "Index", subtitle: value.description)) + properties.append(.init(title: "Index", subtitle: value.description)) } if let value = score { - properties.append(.init(displayTitle: "Score", subtitle: value.description)) + properties.append(.init(title: "Score", subtitle: value.description)) } if let value = pixelFormat { - properties.append(.init(displayTitle: "Pixel Format", subtitle: value)) + properties.append(.init(title: "Pixel Format", subtitle: value)) } if let value = level { - properties.append(.init(displayTitle: "Level", subtitle: value.description)) + properties.append(.init(title: "Level", subtitle: value.description)) } if let value = isAnamorphic { - properties.append(.init(displayTitle: "Anamorphic", subtitle: value.description)) + properties.append(.init(title: "Anamorphic", subtitle: value.description)) } return properties @@ -171,19 +165,19 @@ extension MediaStream { var properties: [TextPair] = [] if let value = colorRange { - properties.append(.init(displayTitle: "Range", subtitle: value)) + properties.append(.init(title: "Range", subtitle: value)) } if let value = colorSpace { - properties.append(.init(displayTitle: "Space", subtitle: value)) + properties.append(.init(title: "Space", subtitle: value)) } if let value = colorTransfer { - properties.append(.init(displayTitle: "Transfer", subtitle: value)) + properties.append(.init(title: "Transfer", subtitle: value)) } if let value = colorPrimaries { - properties.append(.init(displayTitle: "Primaries", subtitle: value)) + properties.append(.init(title: "Primaries", subtitle: value)) } return properties @@ -193,27 +187,27 @@ extension MediaStream { var properties: [TextPair] = [] if let value = isExternal { - properties.append(.init(displayTitle: "External", subtitle: value.description)) + properties.append(.init(title: "External", subtitle: value.description)) } if let value = deliveryMethod { - properties.append(.init(displayTitle: "Delivery Method", subtitle: value.rawValue)) + properties.append(.init(title: "Delivery Method", subtitle: value.rawValue)) } if let value = deliveryURL { - properties.append(.init(displayTitle: "URL", subtitle: value)) + properties.append(.init(title: "URL", subtitle: value)) } if let value = deliveryURL { - properties.append(.init(displayTitle: "External URL", subtitle: value.description)) + properties.append(.init(title: "External URL", subtitle: value.description)) } if let value = isTextSubtitleStream { - properties.append(.init(displayTitle: "Text Subtitle", subtitle: value.description)) + properties.append(.init(title: "Text Subtitle", subtitle: value.description)) } if let value = path { - properties.append(.init(displayTitle: "Path", subtitle: value)) + properties.append(.init(title: "Path", subtitle: value)) } return properties @@ -222,6 +216,7 @@ extension MediaStream { extension [MediaStream] { + // TODO: explain why adjustment is necessary func adjustExternalSubtitleIndexes(audioStreamCount: Int) -> [MediaStream] { guard allSatisfy({ $0.type == .subtitle }) else { return self } let embeddedSubtitleCount = filter { !($0.isExternal ?? false) }.count @@ -239,6 +234,7 @@ extension [MediaStream] { return mediaStreams } + // TODO: explain why adjustment is necessary func adjustAudioForExternalSubtitles(externalMediaStreamCount: Int) -> [MediaStream] { guard allSatisfy({ $0.type == .audio }) else { return self } @@ -254,22 +250,22 @@ extension [MediaStream] { } var has4KVideo: Bool { - first(where: { $0.is4kVideo }) != nil + oneSatisfies { $0.is4kVideo } } var has51AudioChannelLayout: Bool { - first(where: { $0.is51AudioChannelLayout }) != nil + oneSatisfies { $0.is51AudioChannelLayout } } var has71AudioChannelLayout: Bool { - first(where: { $0.is71AudioChannelLayout }) != nil + oneSatisfies { $0.is71AudioChannelLayout } } var hasHDVideo: Bool { - first(where: { $0.isHDVideo }) != nil + oneSatisfies { $0.isHDVideo } } var hasSubtitles: Bool { - first(where: { $0.type == .subtitle }) != nil + oneSatisfies { $0.type == .subtitle } } } diff --git a/Shared/Extensions/JellyfinAPI/NameGuidPair.swift b/Shared/Extensions/JellyfinAPI/NameGuidPair.swift index f6dfd674c..f9b81bbfd 100644 --- a/Shared/Extensions/JellyfinAPI/NameGuidPair.swift +++ b/Shared/Extensions/JellyfinAPI/NameGuidPair.swift @@ -16,11 +16,10 @@ extension NameGuidPair: Displayable { } } -extension NameGuidPair: LibraryParent {} +// TODO: strong type studios and implement as `LibraryParent` +extension NameGuidPair: LibraryParent { -extension NameGuidPair { - - var filter: ItemFilters.Filter { - .init(displayTitle: displayTitle, id: id, filterName: displayTitle) + var libraryType: BaseItemKind? { + .studio } } diff --git a/Shared/Extensions/JellyfinAPI/APISortOrder.swift b/Shared/Extensions/JellyfinAPI/SortOrder.swift similarity index 61% rename from Shared/Extensions/JellyfinAPI/APISortOrder.swift rename to Shared/Extensions/JellyfinAPI/SortOrder.swift index 1b6f2a420..c68e659a2 100644 --- a/Shared/Extensions/JellyfinAPI/APISortOrder.swift +++ b/Shared/Extensions/JellyfinAPI/SortOrder.swift @@ -9,9 +9,10 @@ import Foundation import JellyfinAPI -typealias APISortOrder = JellyfinAPI.SortOrder +// Necessary to handle conflict with Foundation.SortOrder +typealias ItemSortOrder = JellyfinAPI.SortOrder -extension APISortOrder: Displayable { +extension ItemSortOrder: Displayable { // TODO: Localize var displayTitle: String { switch self { @@ -23,9 +24,13 @@ extension APISortOrder: Displayable { } } -extension APISortOrder { +extension ItemSortOrder: ItemFilter { - var filter: ItemFilters.Filter { - .init(displayTitle: displayTitle, id: rawValue, filterName: rawValue) + var value: String { + rawValue + } + + init(from anyFilter: AnyItemFilter) { + self.init(rawValue: anyFilter.value)! } } diff --git a/Shared/Extensions/NavigationCoordinatable.swift b/Shared/Extensions/NavigationCoordinatable.swift index 497593783..c199d4eac 100644 --- a/Shared/Extensions/NavigationCoordinatable.swift +++ b/Shared/Extensions/NavigationCoordinatable.swift @@ -7,6 +7,7 @@ // import Stinsen +import SwiftUI extension NavigationCoordinatable { @@ -14,3 +15,10 @@ extension NavigationCoordinatable { NavigationViewCoordinator(self) } } + +extension NavigationViewCoordinator { + + convenience init(@ViewBuilder content: @escaping () -> Content) { + self.init(BasicNavigationViewCoordinator(content)) + } +} diff --git a/Shared/Extensions/Sequence.swift b/Shared/Extensions/Sequence.swift new file mode 100644 index 000000000..c90c21765 --- /dev/null +++ b/Shared/Extensions/Sequence.swift @@ -0,0 +1,43 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +extension Sequence { + + func compacted(using keyPath: KeyPath) -> [Element] { + filter { $0[keyPath: keyPath] != nil } + } + + func intersection(_ other: some Sequence, using keyPath: KeyPath) -> [Element] { + filter { other.contains($0[keyPath: keyPath]) } + } + + /// Returns the elements of the sequence, sorted by comparing values + /// at the given `KeyPath` of `Element`. + func sorted(using keyPath: KeyPath) -> [Element] { + sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) + } + + func subtracting(_ other: some Sequence, using keyPath: KeyPath) -> [Element] { + filter { !other.contains($0[keyPath: keyPath]) } + } +} + +extension Sequence where Element: Equatable { + + /// Returns an array containing the elements of the sequence that + /// are also within the given sequence. + func intersection(_ other: some Sequence) -> [Element] { + filter { other.contains($0) } + } + + func subtracting(_ other: some Sequence) -> [Element] { + filter { !other.contains($0) } + } +} diff --git a/Shared/Extensions/String.swift b/Shared/Extensions/String.swift index 9217b6dc7..cc11dc6bf 100644 --- a/Shared/Extensions/String.swift +++ b/Shared/Extensions/String.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +// TODO: Remove this and strongly type instances if it makes sense. extension String: Displayable { var displayTitle: String { @@ -16,14 +17,11 @@ extension String: Displayable { } } -extension String: Identifiable { +extension String { - public var id: String { - self + static func + (lhs: String, rhs: Character) -> String { + lhs.appending(rhs) } -} - -extension String { func appending(_ element: String) -> String { self + element @@ -63,20 +61,11 @@ extension String { } catch { return self } } - func leftPad(toWidth width: Int, withString string: String?) -> String { - let paddingString = string ?? " " - - if self.count >= width { - return self - } - - let remainingLength: Int = width - self.count - var padString = String() - for _ in 0 ..< remainingLength { - padString += paddingString - } + func leftPad(maxWidth width: Int, with character: Character) -> String { + guard count < width else { return self } - return "\(padString)\(self)" + let padding = String(repeating: character, count: width - count) + return padding + self } var text: Text { @@ -84,24 +73,9 @@ extension String { } var initials: String { - let initials = self.split(separator: " ").compactMap(\.first) - return String(initials) - } - - func heightOfString(usingFont font: UIFont) -> CGFloat { - let fontAttributes = [NSAttributedString.Key.font: font] - let textSize = self.size(withAttributes: fontAttributes) - return textSize.height - } - - func widthOfString(usingFont font: UIFont) -> CGFloat { - let fontAttributes = [NSAttributedString.Key.font: font] - let textSize = self.size(withAttributes: fontAttributes) - return textSize.width - } - - var filter: ItemFilters.Filter { - .init(displayTitle: self, id: self, filterName: self) + split(separator: " ") + .compactMap(\.first) + .reduce("", +) } static var emptyDash = "--" diff --git a/Shared/Extensions/Set.swift b/Shared/Extensions/Task.swift similarity index 55% rename from Shared/Extensions/Set.swift rename to Shared/Extensions/Task.swift index 0d8fb890b..cff9cd90a 100644 --- a/Shared/Extensions/Set.swift +++ b/Shared/Extensions/Task.swift @@ -6,11 +6,17 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Combine import Foundation -extension Set { +extension Task { - func sorted(using keyPath: KeyPath) -> [Element] { - sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) + @inlinable + func asAnyCancellable() -> AnyCancellable { + AnyCancellable(cancel) + } + + func store(in set: inout Set) { + set.insert(asAnyCancellable()) } } diff --git a/Shared/Extensions/UIApplication.swift b/Shared/Extensions/UIApplication.swift index 5350ce556..738eecf7c 100644 --- a/Shared/Extensions/UIApplication.swift +++ b/Shared/Extensions/UIApplication.swift @@ -36,14 +36,4 @@ extension UIApplication { func setAppearance(_ newAppearance: UIUserInterfaceStyle) { keyWindow?.overrideUserInterfaceStyle = newAppearance } - - #if os(iOS) - func setNavigationBackButtonAccentColor(_ newColor: UIColor) { - let config = UIImage.SymbolConfiguration(paletteColors: [newColor.overlayColor, newColor]) - let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill", withConfiguration: config) - let barAppearance = UINavigationBar.appearance() - barAppearance.backIndicatorImage = backButtonBackgroundImage - barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage - } - #endif } diff --git a/Shared/Extensions/UIDevice.swift b/Shared/Extensions/UIDevice.swift index 836a4d962..246788772 100644 --- a/Shared/Extensions/UIDevice.swift +++ b/Shared/Extensions/UIDevice.swift @@ -14,7 +14,7 @@ extension UIDevice { current.identifierForVendor!.uuidString } - static var isIPad: Bool { + static var isPad: Bool { current.userInterfaceIdiom == .pad } @@ -31,7 +31,7 @@ extension UIDevice { #if os(tvOS) "tvOS" #else - if UIDevice.isIPad { + if UIDevice.isPad { return "iPadOS" } else { return "iOS" @@ -45,7 +45,7 @@ extension UIDevice { } static var isLandscape: Bool { - isIPad || current.orientation.isLandscape + isPad || current.orientation.isLandscape } static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) { diff --git a/Shared/Extensions/UIScreen.swift b/Shared/Extensions/UIScreen.swift index d09502c0a..70c5f8a79 100644 --- a/Shared/Extensions/UIScreen.swift +++ b/Shared/Extensions/UIScreen.swift @@ -17,10 +17,4 @@ extension UIScreen { func scale(_ x: CGFloat) -> Int { Int(nativeScale * x) } - - func maxChildren(width: CGFloat, height: CGFloat) -> Int { - let screenSize = bounds.height * bounds.width - let itemSize = width * height - return Int(screenSize / itemSize) - } } diff --git a/Shared/Extensions/UIScrollView.swift b/Shared/Extensions/UIScrollView.swift deleted file mode 100644 index a8962fbac..000000000 --- a/Shared/Extensions/UIScrollView.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import UIKit - -extension UIScrollView { - - func scrollToTop(animated: Bool = true) { - let desiredOffset = CGPoint(x: 0, y: 0) - setContentOffset(desiredOffset, animated: animated) - } -} diff --git a/Shared/Extensions/ViewExtensions/Backport.swift b/Shared/Extensions/ViewExtensions/Backport.swift new file mode 100644 index 000000000..16fe5c549 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Backport.swift @@ -0,0 +1,64 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct Backport { + + let content: Content +} + +extension Backport where Content: View { + + @ViewBuilder + func lineLimit(_ limit: Int, reservesSpace: Bool = false) -> some View { + if #available(iOS 16, tvOS 16, *) { + content + .lineLimit(limit, reservesSpace: reservesSpace) + } else { + ZStack(alignment: .top) { + Text(String(repeating: "\n", count: limit - 1)) + + content + .lineLimit(limit) + } + } + } + + #if os(iOS) + + // TODO: - remove comment when migrated away from Stinsen + // + // This doesn't seem to work on device, but does in the simulator. + // It is assumed that because Stinsen adds a lot of views that the + // PreferencesView isn't in the right place in the VC chain so that + // it can apply the settings, even SwiftUI's deferment. + @available(iOS 15.0, *) + @ViewBuilder + func defersSystemGestures(on edges: Edge.Set) -> some View { + if #available(iOS 16, *) { + content + .defersSystemGestures(on: edges) + } else { + content + .preferredScreenEdgesDeferringSystemGestures(edges.asUIRectEdge) + } + } + + @ViewBuilder + func persistentSystemOverlays(_ visibility: Visibility) -> some View { + if #available(iOS 16, *) { + content + .persistentSystemOverlays(visibility) + } else { + content + .prefersHomeIndicatorAutoHidden(visibility == .hidden ? true : false) + } + } + #endif +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/AfterLastDisappearModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/AfterLastDisappearModifier.swift new file mode 100644 index 000000000..a2f20c2ae --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Modifiers/AfterLastDisappearModifier.swift @@ -0,0 +1,29 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct AfterLastDisappearModifier: ViewModifier { + + @State + private var lastDisappear: Date? = nil + + let action: (TimeInterval) -> Void + + func body(content: Content) -> some View { + content + .onAppear { + guard let lastDisappear else { return } + let interval = Date.now.timeIntervalSince(lastDisappear) + action(interval) + } + .onDisappear { + lastDisappear = Date.now + } + } +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnFinalDisappearModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnFinalDisappearModifier.swift new file mode 100644 index 000000000..888dda70c --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Modifiers/OnFinalDisappearModifier.swift @@ -0,0 +1,39 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct OnFinalDisappearModifier: ViewModifier { + + @StateObject + private var observer: Observer + + init(action: @escaping () -> Void) { + _observer = StateObject(wrappedValue: Observer(action: action)) + } + + func body(content: Content) -> some View { + content + .background { + Color.clear + } + } + + private class Observer: ObservableObject { + + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + deinit { + action() + } + } +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/PaddingMultiplierModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnFirstAppearModifier.swift similarity index 58% rename from Shared/Extensions/ViewExtensions/Modifiers/PaddingMultiplierModifier.swift rename to Shared/Extensions/ViewExtensions/Modifiers/OnFirstAppearModifier.swift index 01505dcbc..c85402d0b 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/PaddingMultiplierModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/OnFirstAppearModifier.swift @@ -8,16 +8,19 @@ import SwiftUI -struct PaddingMultiplierModifier: ViewModifier { +struct OnFirstAppearModifier: ViewModifier { - let edges: Edge.Set - let multiplier: Int + @State + private var didAppear = false + + let action: () -> Void func body(content: Content) -> some View { content - .if(multiplier > 0) { view in - view.padding() - .padding(multiplier: multiplier - 1, edges) + .onAppear { + guard !didAppear else { return } + didAppear = true + action() } } } diff --git a/Shared/Extensions/ViewExtensions/Modifiers/PaletteOverlayRenderingModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/PaletteOverlayRenderingModifier.swift new file mode 100644 index 000000000..1d55378c6 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Modifiers/PaletteOverlayRenderingModifier.swift @@ -0,0 +1,27 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct PaletteOverlayRenderingModifier: ViewModifier { + + @Environment(\.accentColor) + private var accentColor + + let color: Color? + + private var _color: Color { + color ?? accentColor + } + + func body(content: Content) -> some View { + content + .symbolRenderingMode(.palette) + .foregroundStyle(_color.overlayColor, _color) + } +} diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 9dbc73762..35f3277ab 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -19,7 +19,8 @@ extension View { AnyView(self) } - func inverseMask(alignment: Alignment = .center, _ content: @escaping () -> some View) -> some View { + // TODO: rename `invertedMask`? + func inverseMask(alignment: Alignment = .center, @ViewBuilder _ content: @escaping () -> some View) -> some View { mask(alignment: alignment) { content() .foregroundColor(.black) @@ -29,10 +30,11 @@ extension View { } } - // From: https://www.avanderlee.com/swiftui/conditional-view-modifier/ + /// - Important: Do *not* use this modifier for dynamically showing/hiding views. + /// Instead, use a native `if` statement. @ViewBuilder @inlinable - func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + func `if`(_ condition: Bool, @ViewBuilder transform: (Self) -> Content) -> some View { if condition { transform(self) } else { @@ -40,9 +42,15 @@ extension View { } } + /// - Important: Do *not* use this modifier for dynamically showing/hiding views. + /// Instead, use a native `if/else` statement. @ViewBuilder @inlinable - func `if`(_ condition: Bool, transformIf: (Self) -> Content, transformElse: (Self) -> Content) -> some View { + func `if`( + _ condition: Bool, + @ViewBuilder transformIf: (Self) -> Content, + @ViewBuilder transformElse: (Self) -> Content + ) -> some View { if condition { transformIf(self) } else { @@ -50,32 +58,60 @@ extension View { } } - // TODO: Don't apply corner radius on tvOS because buttons handle themselves, add new modifier for setting corner radius of poster type + /// - Important: Do *not* use this modifier for dynamically showing/hiding views. + /// Instead, use a native `if let` statement. + @ViewBuilder + @inlinable + func ifLet( + _ value: Value?, + @ViewBuilder transform: (Self, Value) -> Content + ) -> some View { + if let value { + transform(self, value) + } else { + self + } + } + + /// - Important: Do *not* use this modifier for dynamically showing/hiding views. + /// Instead, use a native `if let/else` statement. + @ViewBuilder + @inlinable + func ifLet( + _ value: Value?, + @ViewBuilder transformIf: (Self, Value) -> Content, + @ViewBuilder transformElse: (Self) -> Content + ) -> some View { + if let value { + transformIf(self, value) + } else { + transformElse(self) + } + } + + /// Applies the aspect ratio and corner radius for the given `PosterType` @ViewBuilder func posterStyle(_ type: PosterType) -> some View { switch type { case .portrait: - aspectRatio(2 / 3, contentMode: .fit) + aspectRatio(2 / 3, contentMode: .fill) + #if !os(tvOS) .cornerRadius(ratio: 0.0375, of: \.width) + #endif case .landscape: - aspectRatio(1.77, contentMode: .fit) + aspectRatio(1.77, contentMode: .fill) + #if !os(tvOS) .cornerRadius(ratio: 1 / 30, of: \.width) + #endif } } - // TODO: switch to padding(multiplier: 2) + // TODO: remove @inlinable func padding2(_ edges: Edge.Set = .all) -> some View { padding(edges).padding(edges) } - /// Applies the default system padding a number of times with a multiplier - func padding(multiplier: Int, _ edges: Edge.Set = .all) -> some View { - precondition(multiplier > 0, "Multiplier must be > 0") - - return modifier(PaddingMultiplierModifier(edges: edges, multiplier: multiplier)) - } - func scrollViewOffset(_ scrollViewOffset: Binding) -> some View { modifier(ScrollViewOffsetModifier(scrollViewOffset: scrollViewOffset)) } @@ -101,7 +137,7 @@ extension View { clipShape(RoundedCorner(radius: radius, corners: corners)) } - /// Apply a corner radius as a ratio of a side of the view's size + /// Apply a corner radius as a ratio of a view's side func cornerRadius(ratio: CGFloat, of side: KeyPath, corners: UIRectCorner = .allCorners) -> some View { modifier(RatioCornerRadiusModifier(corners: corners, ratio: ratio, side: side)) } @@ -116,6 +152,14 @@ extension View { .onPreferenceChange(FramePreferenceKey.self, perform: onChange) } + func frame(_ binding: Binding) -> some View { + onFrameChanged { newFrame in + binding.wrappedValue = newFrame + } + } + + // TODO: have x/y tracked binding + func onLocationChanged(_ onChange: @escaping (CGPoint) -> Void) -> some View { background { GeometryReader { reader in @@ -129,6 +173,14 @@ extension View { .onPreferenceChange(LocationPreferenceKey.self, perform: onChange) } + func location(_ binding: Binding) -> some View { + onLocationChanged { newLocation in + binding.wrappedValue = newLocation + } + } + + // TODO: have width/height tracked binding + func onSizeChanged(_ onChange: @escaping (CGSize) -> Void) -> some View { background { GeometryReader { reader in @@ -139,22 +191,22 @@ extension View { .onPreferenceChange(SizePreferenceKey.self, perform: onChange) } + func size(_ binding: Binding) -> some View { + onSizeChanged { newSize in + binding.wrappedValue = newSize + } + } + func copy(modifying keyPath: WritableKeyPath, with newValue: Value) -> Self { var copy = self copy[keyPath: keyPath] = newValue return copy } - @ViewBuilder - func hideSystemOverlays() -> some View { - if #available(iOS 16, tvOS 16, *) { - persistentSystemOverlays(.hidden) - } else { - self - } - } - // TODO: rename isVisible + + /// - Important: Do not use this to add or remove a view from the view heirarchy. + /// Use a conditional statement instead. @inlinable func visible(_ isVisible: Bool) -> some View { opacity(isVisible ? 1 : 0) @@ -166,9 +218,13 @@ extension View { } } - func accentSymbolRendering(accentColor: Color = Defaults[.accentColor]) -> some View { - symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) + /// Applies the `.palette` symbol rendering mode and a foreground style + /// where the primary style is the passed `Color`'s `overlayColor` and the + /// secondary style is the passed `Color`. + /// + /// If `color == nil`, then `accentColor` from the environment is used. + func paletteOverlayRendering(color: Color? = nil) -> some View { + modifier(PaletteOverlayRenderingModifier(color: color)) } @ViewBuilder @@ -184,6 +240,7 @@ extension View { modifier(AttributeViewModifier(style: style)) } + // TODO: rename `blurredFullScreenCover` func blurFullScreenCover( isPresented: Binding, onDismiss: (() -> Void)? = nil, @@ -203,4 +260,45 @@ extension View { func onScenePhase(_ phase: ScenePhase, _ action: @escaping () -> Void) -> some View { modifier(ScenePhaseChangeModifier(phase: phase, action: action)) } + + func edgePadding(_ edges: Edge.Set = .all) -> some View { + padding(edges, EdgeInsets.defaultEdgePadding) + } + + var backport: Backport { + Backport(content: self) + } + + /// Perform an action on the final disappearance of a `View`. + func onFinalDisappear(perform action: @escaping () -> Void) -> some View { + modifier(OnFinalDisappearModifier(action: action)) + } + + /// Perform an action before the first appearance of a `View`. + func onFirstAppear(perform action: @escaping () -> Void) -> some View { + modifier(OnFirstAppearModifier(action: action)) + } + + /// Perform an action as a view appears given the time interval + /// from when this view last disappeared. + func afterLastDisappear(perform action: @escaping (TimeInterval) -> Void) -> some View { + modifier(AfterLastDisappearModifier(action: action)) + } + + func topBarTrailing(@ViewBuilder content: @escaping () -> some View) -> some View { + toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + content() + } + } + } + + func onNotification(_ name: NSNotification.Name, perform action: @escaping () -> Void) -> some View { + modifier( + OnReceiveNotificationModifier( + notification: name, + onReceive: action + ) + ) + } } diff --git a/Shared/Objects/EnumPicker.swift b/Shared/Objects/CaseIterablePicker.swift similarity index 52% rename from Shared/Objects/EnumPicker.swift rename to Shared/Objects/CaseIterablePicker.swift index 18013a20d..b6987b265 100644 --- a/Shared/Objects/EnumPicker.swift +++ b/Shared/Objects/CaseIterablePicker.swift @@ -8,7 +8,11 @@ import SwiftUI -struct EnumPicker: View { +/// A `View` that automatically generates a SwiftUI `Picker` if `Element` conforms to `CaseIterable`. +/// +/// If `Element` is optional, an additional `none` value is added to select `nil` that can be customized +/// by `.noneStyle()`. +struct CaseIterablePicker: View { enum NoneStyle: Displayable { @@ -21,60 +25,62 @@ struct EnumPicker: View { case .text: return L10n.none case let .dash(length): - precondition(length >= 1, "Dash must have length of at least 1.") + assert(length >= 1, "Dash must have length of at least 1.") return String(repeating: "-", count: length) case let .custom(text): - precondition(!text.isEmpty, "Custom text must have length of at least 1.") + assert(text.isNotEmpty, "Custom text must have length of at least 1.") return text } } } @Binding - private var selection: EnumType? + private var selection: Element? private let title: String - private let hasNil: Bool + private let hasNone: Bool private var noneStyle: NoneStyle var body: some View { Picker(title, selection: $selection) { - if hasNil { + if hasNone { Text(noneStyle.displayTitle) - .tag(nil as EnumType?) + .tag(nil as Element?) } - ForEach(EnumType.allCases.asArray, id: \.hashValue) { + ForEach(Element.allCases.asArray, id: \.hashValue) { Text($0.displayTitle) - .tag($0 as EnumType?) + .tag($0 as Element?) } } } } -extension EnumPicker { +extension CaseIterablePicker { - init(title: String, selection: Binding) { - self.title = title - self._selection = selection - self.hasNil = true - self.noneStyle = .text + init(title: String, selection: Binding) { + self.init( + selection: selection, + title: title, + hasNone: true, + noneStyle: .text + ) } - init(title: String, selection: Binding) { + init(title: String, selection: Binding) { self.title = title - let binding = Binding { + let binding = Binding { selection.wrappedValue } set: { newValue, _ in - assert(newValue != nil, "Should not have nil new value with non-optional binding") + precondition(newValue != nil, "Should not have nil new value with non-optional binding") selection.wrappedValue = newValue! } self._selection = binding - self.hasNil = false + self.hasNone = false self.noneStyle = .text } diff --git a/Shared/Objects/DeviceProfileBuilder.swift b/Shared/Objects/DeviceProfileBuilder.swift deleted file mode 100644 index 8415e00a9..000000000 --- a/Shared/Objects/DeviceProfileBuilder.swift +++ /dev/null @@ -1,256 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI - -enum DeviceProfileBuilder { - - static func buildProfile(for type: VideoPlayerType, maxBitrate: Int? = nil) -> DeviceProfile { - let maxStreamingBitrate = maxBitrate - let maxStaticBitrate = maxBitrate - let musicStreamingTranscodingBitrate = maxBitrate - var directPlayProfiles: [DirectPlayProfile] = [] - var transcodingProfiles: [TranscodingProfile] = [] - var codecProfiles: [CodecProfile] = [] - var subtitleProfiles: [SubtitleProfile] = [] - - switch type { - - case .swiftfin: - func buildProfileSwiftfin() { - // Build direct play profiles - directPlayProfiles = [ - // Just make one profile because if VLCKit can't decode it in a certain container, ffmpeg probably can't decode it for - // transcode either - DirectPlayProfile( - // No need to list containers or videocodecs since if jellyfin server can detect it/ffmpeg can decode it, so can - // VLCKit - // However, list audiocodecs because ffmpeg can decode TrueHD/mlp but VLCKit cannot - audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le,pcm_u8,pcm_alaw,pcm_mulaw,pcm_bluray,pcm_dvd,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb", - type: .video - ), - ] - - // Build transcoding profiles - // The only cases where transcoding should occur: - // 1) TrueHD/mlp audio - // 2) When server forces transcode for bitrate reasons - transcodingProfiles = [TranscodingProfile( - audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1", - // no PCM,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb in mp4 - isBreakOnNonKeyFrames: true, - container: "mp4", - context: .streaming, - maxAudioChannels: "8", - minSegments: 2, - protocol: "hls", - type: .video, - videoCodec: "hevc,h264,av1,vp9,vc1,mpeg4,h263,mpeg2video,mpeg1video,mjpeg" // vp8,msmpeg4v3,msmpeg4v2,msmpeg4v1,theora,ffv1,flv1,wmv3,wmv2,wmv1 - // not supported in mp4 - )] - - // Create subtitle profiles - subtitleProfiles = [ - SubtitleProfile(format: "pgssub", method: .embed), // *pgs* normalized to pgssub; includes sup - SubtitleProfile(format: "dvdsub", method: .embed), - // *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case? - SubtitleProfile(format: "subrip", method: .embed), // srt - SubtitleProfile(format: "ass", method: .embed), - SubtitleProfile(format: "ssa", method: .embed), - SubtitleProfile(format: "vtt", method: .embed), // webvtt - SubtitleProfile(format: "mov_text", method: .embed), // MPEG-4 Timed Text - SubtitleProfile(format: "ttml", method: .embed), - SubtitleProfile(format: "text", method: .embed), // txt - SubtitleProfile(format: "dvbsub", method: .embed), - // dvb_subtitle normalized to dvbsub; burned in during transcode regardless? - SubtitleProfile(format: "libzvbi_teletextdec", method: .embed), // dvb_teletext - SubtitleProfile(format: "xsub", method: .embed), - SubtitleProfile(format: "vplayer", method: .embed), - SubtitleProfile(format: "subviewer", method: .embed), - SubtitleProfile(format: "subviewer1", method: .embed), - SubtitleProfile(format: "sami", method: .embed), // SMI - SubtitleProfile(format: "realtext", method: .embed), - SubtitleProfile(format: "pjs", method: .embed), // Phoenix Subtitle - SubtitleProfile(format: "mpl2", method: .embed), - SubtitleProfile(format: "jacosub", method: .embed), - SubtitleProfile(format: "cc_dec", method: .embed), // eia_608 - // Can be passed as external files; ones that jellyfin can encode to must come first - SubtitleProfile(format: "subrip", method: .external), // srt - SubtitleProfile(format: "ttml", method: .external), - SubtitleProfile(format: "vtt", method: .external), // webvtt - SubtitleProfile(format: "ass", method: .external), - SubtitleProfile(format: "ssa", method: .external), - SubtitleProfile(format: "pgssub", method: .external), - SubtitleProfile(format: "text", method: .external), // txt - SubtitleProfile(format: "dvbsub", method: .external), // dvb_subtitle normalized to dvbsub - SubtitleProfile(format: "libzvbi_teletextdec", method: .external), // dvb_teletext - SubtitleProfile(format: "dvdsub", method: .external), - // *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case? - SubtitleProfile(format: "xsub", method: .external), - SubtitleProfile(format: "vplayer", method: .external), - SubtitleProfile(format: "subviewer", method: .external), - SubtitleProfile(format: "subviewer1", method: .external), - SubtitleProfile(format: "sami", method: .external), // SMI - SubtitleProfile(format: "realtext", method: .external), - SubtitleProfile(format: "pjs", method: .external), // Phoenix Subtitle - SubtitleProfile(format: "mpl2", method: .external), - SubtitleProfile(format: "jacosub", method: .external), - ] - } - buildProfileSwiftfin() - - case .native: - func buildProfileNative() { - // Build direct play profiles - directPlayProfiles = [ - // Apple limitation: no mp3 in mp4; avi only supports mjpeg with pcm - // Right now, mp4 restrictions can't be enforced because mp4, m4v, mov, 3gp,3g2 treated the same - DirectPlayProfile( - audioCodec: "flac,alac,aac,eac3,ac3,opus", - container: "mp4", - type: .video, - videoCodec: "hevc,h264,mpeg4" - ), - DirectPlayProfile( - audioCodec: "alac,aac,ac3", - container: "m4v", - type: .video, - videoCodec: "h264,mpeg4" - ), - DirectPlayProfile( - audioCodec: "alac,aac,eac3,ac3,mp3,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le", - container: "mov", - type: .video, - videoCodec: "hevc,h264,mpeg4,mjpeg" - ), - DirectPlayProfile( - audioCodec: "aac,eac3,ac3,mp3", - container: "mpegts", - type: .video, - videoCodec: "h264" - ), - DirectPlayProfile( - audioCodec: "aac,amr_nb", - container: "3gp,3g2", - type: .video, - videoCodec: "h264,mpeg4" - ), - DirectPlayProfile( - audioCodec: "pcm_s16le,pcm_mulaw", - container: "avi", - type: .video, - videoCodec: "mjpeg" - ), - ] - - // Build transcoding profiles - transcodingProfiles = [ - TranscodingProfile( - audioCodec: "flac,alac,aac,eac3,ac3,opus", - isBreakOnNonKeyFrames: true, - container: "mp4", - context: .streaming, - maxAudioChannels: "8", - minSegments: 2, - protocol: "hls", - type: .video, - videoCodec: "hevc,h264,mpeg4" - ), - ] - - // Create subtitle profiles - subtitleProfiles = [ - // FFmpeg can only convert bitmap to bitmap and text to text; burn in bitmap subs - SubtitleProfile(format: "pgssub", method: .encode), - SubtitleProfile(format: "dvdsub", method: .encode), - SubtitleProfile(format: "dvbsub", method: .encode), - SubtitleProfile(format: "xsub", method: .encode), - // According to Apple HLS authoring specs, WebVTT must be in a text file delivered via HLS - SubtitleProfile(format: "vtt", method: .hls), // webvtt - // Apple HLS authoring spec has closed captions in video segments and TTML in fmp4 - SubtitleProfile(format: "ttml", method: .embed), - SubtitleProfile(format: "cc_dec", method: .embed), - ] - } - buildProfileNative() - } - - // For now, assume native and VLCKit support same codec conditions: - let h264CodecConditions: [ProfileCondition] = [ - ProfileCondition( - condition: .notEquals, - isRequired: false, - property: .isAnamorphic, - value: "true" - ), - ProfileCondition( - condition: .equalsAny, - isRequired: false, - property: .videoProfile, - value: "high|main|baseline|constrained baseline" - ), - ProfileCondition( - condition: .lessThanEqual, - isRequired: false, - property: .videoLevel, - value: "80" - ), - ProfileCondition( - condition: .notEquals, - isRequired: false, - property: .isInterlaced, - value: "true" - ), - ] - - codecProfiles.append(CodecProfile(applyConditions: h264CodecConditions, codec: "h264", type: .video)) - - let hevcCodecConditions: [ProfileCondition] = [ - ProfileCondition( - condition: .notEquals, - isRequired: false, - property: .isAnamorphic, - value: "true" - ), - ProfileCondition( - condition: .equalsAny, - isRequired: false, - property: .videoProfile, - value: "high|main|main 10" - ), - ProfileCondition( - condition: .lessThanEqual, - isRequired: false, - property: .videoLevel, - value: "175" - ), - ProfileCondition( - condition: .notEquals, - isRequired: false, - property: .isInterlaced, - value: "true" - ), - ] - - codecProfiles.append(CodecProfile(applyConditions: hevcCodecConditions, codec: "hevc", type: .video)) - - let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", mimeType: "video/mp4", type: .video)] - - return .init( - codecProfiles: codecProfiles, - containerProfiles: [], - directPlayProfiles: directPlayProfiles, - maxStaticBitrate: maxStaticBitrate, - maxStreamingBitrate: maxStreamingBitrate, - musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate, - responseProfiles: responseProfiles, - subtitleProfiles: subtitleProfiles, - transcodingProfiles: transcodingProfiles - ) - } -} diff --git a/Shared/ViewModels/StaticLibraryViewModel.swift b/Shared/Objects/Eventful.swift similarity index 61% rename from Shared/ViewModels/StaticLibraryViewModel.swift rename to Shared/Objects/Eventful.swift index e4fd7a4d2..258d9f142 100644 --- a/Shared/ViewModels/StaticLibraryViewModel.swift +++ b/Shared/Objects/Eventful.swift @@ -6,14 +6,12 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Combine import Foundation -import JellyfinAPI -class StaticLibraryViewModel: PagingLibraryViewModel { +protocol Eventful { - init(items: [BaseItemDto]) { - super.init() + associatedtype Event - self.items.elements = items - } + var events: AnyPublisher { get } } diff --git a/Shared/Objects/FilterDrawerSelection.swift b/Shared/Objects/FilterDrawerSelection.swift deleted file mode 100644 index c520f490a..000000000 --- a/Shared/Objects/FilterDrawerSelection.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Foundation - -enum FilterDrawerButtonSelection: String, CaseIterable, Defaults.Serializable, Displayable, Identifiable { - - case filters - case genres - case order - case sort - - var displayTitle: String { - switch self { - case .filters: - return L10n.filters - case .genres: - return L10n.genres - case .order: - return L10n.order - case .sort: - return L10n.sort - } - } - - var id: String { - rawValue - } - - var itemFilter: WritableKeyPath { - switch self { - case .filters: - return \.filters - case .genres: - return \.genres - case .order: - return \.sortOrder - case .sort: - return \.sortBy - } - } - - var selectorType: SelectorType { - switch self { - case .filters, .genres: - return .multi - case .order, .sort: - return .single - } - } - - var itemFilterDefault: [ItemFilters.Filter] { - switch self { - case .filters: - return [] - case .genres: - return [] - case .order: - return [APISortOrder.ascending.filter] - case .sort: - return [SortBy.name.filter] - } - } - - func isItemsFilterActive(activeFilters: ItemFilters) -> Bool { - switch self { - case .filters: - return activeFilters.filters != self.itemFilterDefault - case .genres: - return activeFilters.genres != self.itemFilterDefault - case .order: - return activeFilters.sortOrder != self.itemFilterDefault - case .sort: - return activeFilters.sortBy != self.itemFilterDefault - } - } - - static var defaultFilterDrawerButtons: [FilterDrawerButtonSelection] { - [ - .filters, - .genres, - .order, - .sort, - ] - } -} diff --git a/Shared/Objects/GestureAction.swift b/Shared/Objects/GestureAction.swift index 2d2427085..eeb1ee6a5 100644 --- a/Shared/Objects/GestureAction.swift +++ b/Shared/Objects/GestureAction.swift @@ -9,6 +9,8 @@ import Defaults import Foundation +// TODO: split out into separate files under folder `GestureAction` + // Optional values aren't yet supported in Defaults // https://github.com/sindresorhus/Defaults/issues/54 diff --git a/Shared/Objects/HTTPScheme.swift b/Shared/Objects/ImageSource.swift similarity index 57% rename from Shared/Objects/HTTPScheme.swift rename to Shared/Objects/ImageSource.swift index 591123d83..1f5f24ee1 100644 --- a/Shared/Objects/HTTPScheme.swift +++ b/Shared/Objects/ImageSource.swift @@ -6,15 +6,18 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Defaults import Foundation -enum HTTPScheme: String, CaseIterable, Displayable, Defaults.Serializable { +struct ImageSource: Hashable { - case http - case https + let url: URL? + let blurHash: String? - var displayTitle: String { - rawValue + init( + url: URL? = nil, + blurHash: String? = nil + ) { + self.url = url + self.blurHash = blurHash } } diff --git a/Shared/Objects/ItemFilter/AnyItemFilter.swift b/Shared/Objects/ItemFilter/AnyItemFilter.swift new file mode 100644 index 000000000..412ba1e4a --- /dev/null +++ b/Shared/Objects/ItemFilter/AnyItemFilter.swift @@ -0,0 +1,29 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +/// A type-erased instance of an item filter. +struct AnyItemFilter: Displayable, Hashable, ItemFilter { + + let displayTitle: String + let value: String + + init( + displayTitle: String, + value: String + ) { + self.displayTitle = displayTitle + self.value = value + } + + init(from anyFilter: AnyItemFilter) { + self.displayTitle = anyFilter.displayTitle + self.value = anyFilter.value + } +} diff --git a/Shared/Objects/ItemFilter/ItemFilter.swift b/Shared/Objects/ItemFilter/ItemFilter.swift new file mode 100644 index 000000000..1f31c1667 --- /dev/null +++ b/Shared/Objects/ItemFilter/ItemFilter.swift @@ -0,0 +1,44 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation + +protocol ItemFilter: Displayable { + + var value: String { get } + + // TODO: Should this be optional if the concrete type + // can't be constructed? + init(from anyFilter: AnyItemFilter) +} + +extension ItemFilter { + + var asAnyItemFilter: AnyItemFilter { + .init(displayTitle: displayTitle, value: value) + } +} + +extension ItemFilter where Self: RawRepresentable { + + var value: String { + rawValue + } + + init(from anyFilter: AnyItemFilter) { + self.init(rawValue: anyFilter.value)! + } +} + +extension Array where Element: ItemFilter { + + var asAnyItemFilter: [AnyItemFilter] { + map(\.asAnyItemFilter) + } +} diff --git a/Shared/Objects/ItemFilter/ItemFilterCollection.swift b/Shared/Objects/ItemFilter/ItemFilterCollection.swift new file mode 100644 index 000000000..317bab8a2 --- /dev/null +++ b/Shared/Objects/ItemFilter/ItemFilterCollection.swift @@ -0,0 +1,56 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import JellyfinAPI + +/// A structure representing a collection of item filters +struct ItemFilterCollection: Codable, Defaults.Serializable, Hashable { + + var genres: [ItemGenre] = [] + var sortBy: [ItemSortBy] = [ItemSortBy.name] + var sortOrder: [ItemSortOrder] = [ItemSortOrder.ascending] + var tags: [ItemTag] = [] + var traits: [ItemTrait] = [] + var years: [ItemYear] = [] + + /// The default collection of filters + static let `default`: ItemFilterCollection = .init() + + static let favorites: ItemFilterCollection = .init( + traits: [ItemTrait.isFavorite] + ) + static let recent: ItemFilterCollection = .init( + sortBy: [ItemSortBy.dateAdded], + sortOrder: [ItemSortOrder.descending] + ) + + /// A collection that has all statically available values + static let all: ItemFilterCollection = .init( + sortBy: ItemSortBy.allCases, + sortOrder: ItemSortOrder.allCases, + traits: ItemTrait.supportedCases + ) + + var hasFilters: Bool { + self != Self.default + } + + var activeFilterCount: Int { + var count = 0 + + for filter in ItemFilterType.allCases { + if self[keyPath: filter.collectionAnyKeyPath] != Self.default[keyPath: filter.collectionAnyKeyPath] { + count += 1 + } + } + + return count + } +} diff --git a/Shared/Objects/ItemFilter/ItemFilterType.swift b/Shared/Objects/ItemFilter/ItemFilterType.swift new file mode 100644 index 000000000..94d06dd0d --- /dev/null +++ b/Shared/Objects/ItemFilter/ItemFilterType.swift @@ -0,0 +1,67 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation + +enum ItemFilterType: String, CaseIterable, Defaults.Serializable { + + case genres + case sortBy + case sortOrder + case tags + case traits + case years + + // TODO: rename to something indicating plurality instead of concrete type? + var selectorType: SelectorType { + switch self { + case .genres, .tags, .traits, .years: + return .multi + case .sortBy, .sortOrder: + return .single + } + } + + var collectionAnyKeyPath: KeyPath { + switch self { + case .genres: + \ItemFilterCollection.genres.asAnyItemFilter + case .sortBy: + \ItemFilterCollection.sortBy.asAnyItemFilter + case .sortOrder: + \ItemFilterCollection.sortOrder.asAnyItemFilter + case .tags: + \ItemFilterCollection.tags.asAnyItemFilter + case .traits: + \ItemFilterCollection.traits.asAnyItemFilter + case .years: + \ItemFilterCollection.years.asAnyItemFilter + } + } +} + +extension ItemFilterType: Displayable { + + var displayTitle: String { + switch self { + case .genres: + L10n.genres + case .sortBy: + L10n.sort + case .sortOrder: + L10n.order + case .tags: + L10n.tags + case .traits: + L10n.filters + case .years: + "Years" + } + } +} diff --git a/Shared/Objects/ItemFilter/ItemGenre.swift b/Shared/Objects/ItemFilter/ItemGenre.swift new file mode 100644 index 000000000..9433ab0ad --- /dev/null +++ b/Shared/Objects/ItemFilter/ItemGenre.swift @@ -0,0 +1,26 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +struct ItemGenre: Codable, ExpressibleByStringLiteral, Hashable, ItemFilter { + + let value: String + + var displayTitle: String { + value + } + + init(stringLiteral value: String) { + self.value = value + } + + init(from anyFilter: AnyItemFilter) { + self.value = anyFilter.value + } +} diff --git a/Shared/Objects/SortBy.swift b/Shared/Objects/ItemFilter/ItemSortBy.swift similarity index 71% rename from Shared/Objects/SortBy.swift rename to Shared/Objects/ItemFilter/ItemSortBy.swift index c2acbeb1b..f04649ca6 100644 --- a/Shared/Objects/SortBy.swift +++ b/Shared/Objects/ItemFilter/ItemSortBy.swift @@ -9,9 +9,9 @@ import Foundation import JellyfinAPI -// TODO: Move to jellyfin-api-swift +// TODO: Remove when JellyfinAPI generates 10.9.0 schema -enum SortBy: String, CaseIterable, Displayable { +enum ItemSortBy: String, CaseIterable, Displayable, Codable { case premiereDate = "PremiereDate" case name = "SortName" @@ -31,8 +31,15 @@ enum SortBy: String, CaseIterable, Displayable { return "Random" } } +} + +extension ItemSortBy: ItemFilter { + + var value: String { + rawValue + } - var filter: ItemFilters.Filter { - .init(displayTitle: displayTitle, id: rawValue, filterName: rawValue) + init(from anyFilter: AnyItemFilter) { + self.init(rawValue: anyFilter.value)! } } diff --git a/Shared/Objects/ItemFilter/ItemTag.swift b/Shared/Objects/ItemFilter/ItemTag.swift new file mode 100644 index 000000000..fc2b34221 --- /dev/null +++ b/Shared/Objects/ItemFilter/ItemTag.swift @@ -0,0 +1,26 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +struct ItemTag: Codable, ExpressibleByStringLiteral, Hashable, ItemFilter { + + let value: String + + var displayTitle: String { + value + } + + init(stringLiteral value: String) { + self.value = value + } + + init(from anyFilter: AnyItemFilter) { + self.value = anyFilter.value + } +} diff --git a/Shared/Objects/ItemFilter/ItemYear.swift b/Shared/Objects/ItemFilter/ItemYear.swift new file mode 100644 index 000000000..10abc7b98 --- /dev/null +++ b/Shared/Objects/ItemFilter/ItemYear.swift @@ -0,0 +1,30 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +struct ItemYear: Codable, ExpressibleByIntegerLiteral, Hashable, ItemFilter { + + let value: String + + var displayTitle: String { + value + } + + var intValue: Int { + Int(value)! + } + + init(integerLiteral value: IntegerLiteralType) { + self.value = "\(value)" + } + + init(from anyFilter: AnyItemFilter) { + self.value = anyFilter.value + } +} diff --git a/Shared/Objects/ItemFilters.swift b/Shared/Objects/ItemFilters.swift deleted file mode 100644 index 2d0ada40f..000000000 --- a/Shared/Objects/ItemFilters.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import Foundation -import JellyfinAPI - -struct ItemFilters: Codable, Defaults.Serializable, Hashable { - - var genres: [Filter] = [] - var filters: [Filter] = [] - var sortOrder: [Filter] = [APISortOrder.ascending.filter] - var sortBy: [Filter] = [SortBy.name.filter] - - static let favorites: ItemFilters = .init(filters: [ItemFilter.isFavorite.filter]) - static let recent: ItemFilters = .init(sortOrder: [APISortOrder.descending.filter], sortBy: [SortBy.dateAdded.filter]) - static let all: ItemFilters = .init( - filters: ItemFilter.supportedCases.map(\.filter), - sortOrder: APISortOrder.allCases.map(\.filter), - sortBy: SortBy.allCases.map(\.filter) - ) - - var hasFilters: Bool { - self != .init() - } - - // Type-erased object for use with WritableKeyPath - struct Filter: Codable, Defaults.Serializable, Displayable, Hashable, Identifiable { - var displayTitle: String - var id: String? - var filterName: String - } -} diff --git a/Shared/Objects/LibraryParent/LibraryParent.swift b/Shared/Objects/LibraryParent/LibraryParent.swift new file mode 100644 index 000000000..8621ae01d --- /dev/null +++ b/Shared/Objects/LibraryParent/LibraryParent.swift @@ -0,0 +1,22 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +protocol LibraryParent: Displayable, Identifiable { + + // Only called `libraryType` because `BaseItemPerson` has + // a different `type` property. However, people should have + // different views so this can be renamed when they do, or + // this protocol to be removed entirely and replace just with + // a concrete `BaseItemDto` + // + // edit: studios also implement `LibraryParent` - reconsider above comment + var libraryType: BaseItemKind? { get } +} diff --git a/Shared/Objects/LibraryParent.swift b/Shared/Objects/LibraryParent/TitledLibraryParent.swift similarity index 51% rename from Shared/Objects/LibraryParent.swift rename to Shared/Objects/LibraryParent/TitledLibraryParent.swift index 79a5aab99..9e4e3cdee 100644 --- a/Shared/Objects/LibraryParent.swift +++ b/Shared/Objects/LibraryParent/TitledLibraryParent.swift @@ -7,15 +7,12 @@ // import Foundation +import JellyfinAPI -protocol LibraryParent: Displayable { - var id: String? { get } -} +/// A basic structure conforming to `LibraryParent` that is meant to only define its `displayTitle` +struct TitledLibraryParent: LibraryParent { -// TODO: Remove so multiple people/studios can be used -enum LibraryParentType { - case library - case folders - case person - case studio + let displayTitle: String + let id: String? = nil + let libraryType: BaseItemKind? = nil } diff --git a/Shared/Objects/LibraryViewType.swift b/Shared/Objects/LibraryViewType.swift index a2a539b37..6a189d62d 100644 --- a/Shared/Objects/LibraryViewType.swift +++ b/Shared/Objects/LibraryViewType.swift @@ -8,19 +8,20 @@ import Defaults import Foundation +import UIKit enum LibraryViewType: String, CaseIterable, Displayable, Defaults.Serializable { case grid case list - // TODO: localize after organization + // TODO: localize var displayTitle: String { switch self { case .grid: - return "Grid" + "Grid" case .list: - return "List" + "List" } } } diff --git a/Shared/Objects/MenuPosterHStackModel.swift b/Shared/Objects/MenuPosterHStackModel.swift deleted file mode 100644 index dc4c8204a..000000000 --- a/Shared/Objects/MenuPosterHStackModel.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Foundation - -// TODO: Don't be specific to Poster, allow other types - -protocol MenuPosterHStackModel: ObservableObject { - associatedtype Section: Hashable, Displayable - associatedtype Item: Poster - - var menuSelection: Section? { get } - var menuSections: [Section: [Item]] { get set } - var menuSectionSort: (Section, Section) -> Bool { get } - - func select(section: Section) -} diff --git a/Shared/Objects/Poster.swift b/Shared/Objects/Poster.swift index 2b46e4490..21a829581 100644 --- a/Shared/Objects/Poster.swift +++ b/Shared/Objects/Poster.swift @@ -8,12 +8,14 @@ import Foundation -// TODO: find way to remove special `single` handling // TODO: remove `showTitle` and `subtitle` since the PosterButton can define custom supplementary views? -protocol Poster: Displayable, Hashable { +// TODO: instead of the below image functions, have variables that match `ImageType` +// - allows caller to choose images +protocol Poster: Displayable, Hashable, Identifiable { var subtitle: String? { get } var showTitle: Bool { get } + var typeSystemName: String? { get } func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] diff --git a/Shared/Objects/PosterType.swift b/Shared/Objects/PosterType.swift index 1dfb4db7e..8a7f9814f 100644 --- a/Shared/Objects/PosterType.swift +++ b/Shared/Objects/PosterType.swift @@ -9,40 +9,20 @@ import Defaults import SwiftUI +// TODO: Rename to `PosterDisplayType` or `PosterDisplay`? +// TODO: in Swift 5.10, nest under `Poster` (also when GitHub CI has Xcode 15.3) enum PosterType: String, CaseIterable, Displayable, Defaults.Serializable { - case portrait case landscape - - var width: CGFloat { - switch self { - case .portrait: - return Width.portrait - case .landscape: - return Width.landscape - } - } + case portrait // TODO: localize var displayTitle: String { switch self { - case .portrait: - return "Portrait" case .landscape: - return "Landscape" + "Landscape" + case .portrait: + "Portrait" } } - - // TODO: Make property of the enum type, not a nested type - enum Width { - #if os(tvOS) - static let portrait = 200.0 - - static let landscape = 350.0 - #else - static var portrait = 100.0 - - static var landscape = 200.0 - #endif - } } diff --git a/Shared/Objects/Stateful.swift b/Shared/Objects/Stateful.swift new file mode 100644 index 000000000..fcb5ebfdb --- /dev/null +++ b/Shared/Objects/Stateful.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +// TODO: documentation + +protocol Stateful: AnyObject { + + associatedtype Action + associatedtype State: Equatable + + var state: State { get set } + + /// Send an action to the `Stateful` object, which will + /// `respond` to the action and set the new state. + @MainActor + func send(_ action: Action) + + /// Respond to a sent action and return the new state + @MainActor + func respond(to action: Action) -> State +} + +extension Stateful { + + @MainActor + func send(_ action: Action) { + state = respond(to: action) + } +} diff --git a/Shared/Objects/SupportedCaseIterable.swift b/Shared/Objects/SupportedCaseIterable.swift new file mode 100644 index 000000000..46fc0684e --- /dev/null +++ b/Shared/Objects/SupportedCaseIterable.swift @@ -0,0 +1,20 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +/// A type that provides a collection of a subset of all of its values. +/// +/// Using types that conform to `CaseIterable` may contain values that +/// aren't supported or valid in certain scenarios. +protocol SupportedCaseIterable: CaseIterable { + + associatedtype SupportedCases: Collection = [Self] where Self == Self.SupportedCases.Element + + static var supportedCases: Self.SupportedCases { get } +} diff --git a/Shared/Objects/TextPair.swift b/Shared/Objects/TextPair.swift index 1f52d7299..8c9d95713 100644 --- a/Shared/Objects/TextPair.swift +++ b/Shared/Objects/TextPair.swift @@ -8,13 +8,15 @@ import Foundation -// TODO: better context naming than for "display" purposes - struct TextPair: Displayable, Identifiable { - let displayTitle: String + let title: String let subtitle: String + var displayTitle: String { + title + } + var id: String { displayTitle.appending(subtitle) } diff --git a/Shared/Objects/VideoPlayerActionButton.swift b/Shared/Objects/VideoPlayerActionButton.swift index af0a7a001..220a4313a 100644 --- a/Shared/Objects/VideoPlayerActionButton.swift +++ b/Shared/Objects/VideoPlayerActionButton.swift @@ -71,22 +71,18 @@ enum VideoPlayerActionButton: String, CaseIterable, Defaults.Serializable, Displ } } - static var defaultBarActionButtons: [VideoPlayerActionButton] { - [ - .aspectFill, - .autoPlay, - .playPreviousItem, - .playNextItem, - ] - } + static let defaultBarActionButtons: [VideoPlayerActionButton] = [ + .aspectFill, + .autoPlay, + .playPreviousItem, + .playNextItem, + ] - static var defaultMenuActionButtons: [VideoPlayerActionButton] { - [ - .audio, - .subtitles, - .playbackSpeed, - .chapters, - .advanced, - ] - } + static let defaultMenuActionButtons: [VideoPlayerActionButton] = [ + .audio, + .subtitles, + .playbackSpeed, + .chapters, + .advanced, + ] } diff --git a/Shared/Services/DownloadTask.swift b/Shared/Services/DownloadTask.swift index 4f8afd138..399d9a9d7 100644 --- a/Shared/Services/DownloadTask.swift +++ b/Shared/Services/DownloadTask.swift @@ -80,7 +80,7 @@ class DownloadTask: NSObject, ObservableObject { await MainActor.run { self.state = .error(error) - Container.downloadManager.callAsFunction() + Container.downloadManager() .remove(task: self) } return @@ -284,7 +284,7 @@ extension DownloadTask: URLSessionDownloadDelegate { DispatchQueue.main.async { self.state = .error(error) - Container.downloadManager.callAsFunction() + Container.downloadManager() .remove(task: self) } } @@ -295,7 +295,7 @@ extension DownloadTask: URLSessionDownloadDelegate { DispatchQueue.main.async { self.state = .error(error) - Container.downloadManager.callAsFunction() + Container.downloadManager() .remove(task: self) } } diff --git a/Shared/Services/NewSessionManager.swift b/Shared/Services/NewSessionManager.swift index 37d87ffb7..bf8cea1f9 100644 --- a/Shared/Services/NewSessionManager.swift +++ b/Shared/Services/NewSessionManager.swift @@ -35,7 +35,7 @@ final class SwiftfinSession { let client = JellyfinClient( configuration: .swiftfinConfiguration(url: server.currentURL), - sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger.callAsFunction()), + sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger()), accessToken: user.accessToken ) @@ -53,7 +53,7 @@ final class BasicServerSession { let client = JellyfinClient( configuration: .swiftfinConfiguration(url: server.currentURL), - sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger.callAsFunction()) + sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger()) ) self.client = client diff --git a/Shared/Services/PlaybackManager.swift b/Shared/Services/PlaybackManager.swift deleted file mode 100644 index cded478ee..000000000 --- a/Shared/Services/PlaybackManager.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Combine -import Factory -import Foundation -import JellyfinAPI - -final class PlaybackManager { - - static let service = Factory(scope: .singleton) { - .init() - } - - private var cancellables = Set() - -// func sendStartReport( -// _ request: ReportPlaybackStartRequest, -// onSuccess: @escaping () -> Void = {}, -// onFailure: @escaping (Error) -> Void = { _ in } -// ) { -// PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: request) -// .sink { completion in -// switch completion { -// case .finished: -// onSuccess() -// case let .failure(error): -// onFailure(error) -// } -// } receiveValue: { _ in -// } -// .store(in: &cancellables) -// } -// -// func sendProgressReport( -// _ request: ReportPlaybackProgressRequest, -// onSuccess: @escaping () -> Void = {}, -// onFailure: @escaping (Error) -> Void = { _ in } -// ) { -// PlaystateAPI.reportPlaybackProgress(reportPlaybackProgressRequest: request) -// .sink { completion in -// switch completion { -// case .finished: -// onSuccess() -// case let .failure(error): -// onFailure(error) -// } -// } receiveValue: { _ in -// } -// .store(in: &cancellables) -// } -// -// func sendStopReport( -// _ request: ReportPlaybackStoppedRequest, -// onSuccess: @escaping () -> Void = {}, -// onFailure: @escaping (Error) -> Void = { _ in } -// ) { -// PlaystateAPI.reportPlaybackStopped(reportPlaybackStoppedRequest: request) -// .sink { completion in -// switch completion { -// case .finished: -// onSuccess() -// case let .failure(error): -// onFailure(error) -// } -// } receiveValue: { _ in -// } -// .store(in: &cancellables) -// } -} diff --git a/Shared/Services/SwiftfinDefaults.swift b/Shared/Services/SwiftfinDefaults.swift index 69c6c71fb..b6bb55610 100644 --- a/Shared/Services/SwiftfinDefaults.swift +++ b/Shared/Services/SwiftfinDefaults.swift @@ -27,7 +27,7 @@ extension Defaults.Keys { static let lastServerUserID = Defaults.Key("lastServerUserID", suite: .universalSuite) // TODO: Replace with a cache - static let libraryFilterStore = Key<[String: ItemFilters]>("libraryFilterStore", default: [:], suite: .generalSuite) + static let libraryFilterStore = Key<[String: ItemFilterCollection]>("libraryFilterStore", default: [:], suite: .generalSuite) enum Customization { @@ -40,6 +40,8 @@ extension Defaults.Keys { static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: .generalSuite) static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", default: true, suite: .generalSuite) static let similarPosterType = Key("similarPosterType", default: .portrait, suite: .generalSuite) + + // TODO: have search poster type by types of items if applicable static let searchPosterType = Key("searchPosterType", default: .portrait, suite: .generalSuite) enum CinematicItemViewType { @@ -62,26 +64,48 @@ extension Defaults.Keys { enum Library { - static let gridPosterType = Key("libraryGridPosterType", default: .portrait, suite: .generalSuite) static let cinematicBackground: Key = .init( "Customization.Library.cinematicBackground", default: true, suite: .generalSuite ) - static let randomImage: Key = .init("libraryRandomImage", default: true, suite: .generalSuite) - static let showFavorites: Key = .init("libraryShowFavorites", default: true, suite: .generalSuite) - static let viewType = Key("libraryViewType", default: .grid, suite: .generalSuite) - } - - enum Filters { - static let libraryFilterDrawerButtons: Key<[FilterDrawerButtonSelection]> = .init( - "defaultLibraryFilterDrawerButtons", - default: FilterDrawerButtonSelection.defaultFilterDrawerButtons, + static let enabledDrawerFilters: Key<[ItemFilterType]> = .init( + "Library.enabledDrawerFilters", + default: ItemFilterType.allCases, + suite: .generalSuite + ) + static let viewType = Key( + "libraryViewType", + default: .grid, + suite: .generalSuite + ) + static let posterType = Key( + "libraryPosterType", + default: .portrait, + suite: .generalSuite + ) + static let listColumnCount = Key( + "listColumnCount", + default: 1, suite: .generalSuite ) - static let searchFilterDrawerButtons: Key<[FilterDrawerButtonSelection]> = .init( - "defaultSearchFilterDrawerButtons", - default: FilterDrawerButtonSelection.defaultFilterDrawerButtons, + static let randomImage: Key = .init( + "libraryRandomImage", + default: true, + suite: .generalSuite + ) + static let showFavorites: Key = .init( + "libraryShowFavorites", + default: true, + suite: .generalSuite + ) + } + + enum Search { + + static let enabledDrawerFilters: Key<[ItemFilterType]> = .init( + "Search.enabledDrawerFilters", + default: ItemFilterType.allCases, suite: .generalSuite ) } @@ -207,7 +231,6 @@ extension Defaults.Keys { ) static let forceDirectPlay = Key("forceDirectPlay", default: false, suite: .generalSuite) - static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: .generalSuite) static let liveTVForceDirectPlay = Key("liveTVForceDirectPlay", default: false, suite: .generalSuite) } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 03a4183d9..a34116813 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -30,7 +30,7 @@ internal enum L10n { internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres") /// All Media internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") - /// Represents the Appearance setting label + /// Appearance internal static let appearance = L10n.tr("Localizable", "appearance", fallback: "Appearance") /// App Icon internal static let appIcon = L10n.tr("Localizable", "appIcon", fallback: "App Icon") @@ -122,7 +122,7 @@ internal enum L10n { internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position") /// Customize internal static let customize = L10n.tr("Localizable", "customize", fallback: "Customize") - /// Represents the dark theme setting + /// Dark internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") @@ -222,7 +222,7 @@ internal enum L10n { } /// Library internal static let library = L10n.tr("Localizable", "library", fallback: "Library") - /// Represents the light theme setting + /// Light internal static let light = L10n.tr("Localizable", "light", fallback: "Light") /// List internal static let list = L10n.tr("Localizable", "list", fallback: "List") @@ -556,7 +556,7 @@ internal enum L10n { internal static let suggestions = L10n.tr("Localizable", "suggestions", fallback: "Suggestions") /// Switch User internal static let switchUser = L10n.tr("Localizable", "switchUser", fallback: "Switch User") - /// Represents the system theme setting + /// System internal static let system = L10n.tr("Localizable", "system", fallback: "System") /// System Control Gestures Enabled internal static let systemControlGesturesEnabled = L10n.tr("Localizable", "systemControlGesturesEnabled", fallback: "System Control Gestures Enabled") diff --git a/Shared/ViewModels/FilterViewModel.swift b/Shared/ViewModels/FilterViewModel.swift index c9a03a967..3f2f40ba4 100644 --- a/Shared/ViewModels/FilterViewModel.swift +++ b/Shared/ViewModels/FilterViewModel.swift @@ -13,35 +13,53 @@ import SwiftUI final class FilterViewModel: ViewModel { @Published - var allFilters: ItemFilters = .all + var currentFilters: ItemFilterCollection + @Published - var currentFilters: ItemFilters + var allFilters: ItemFilterCollection = .all - let parent: LibraryParent? + private let parent: (any LibraryParent)? init( - parent: LibraryParent?, - currentFilters: ItemFilters + parent: (any LibraryParent)? = nil, + currentFilters: ItemFilterCollection = .default ) { self.parent = parent self.currentFilters = currentFilters super.init() - - getQueryFilters() } - private func getQueryFilters() { - Task { - let parameters = Paths.GetQueryFiltersParameters( - userID: userSession.user.id, - parentID: parent?.id - ) - let request = Paths.getQueryFilters(parameters: parameters) - let response = try await userSession.client.send(request) - - await MainActor.run { - allFilters.genres = response.value.genres?.map(\.filter) ?? [] - } + /// Sets the query filters from the parent + func setQueryFilters() async { + let queryFilters = await getQueryFilters() + + await MainActor.run { + allFilters.genres = queryFilters.genres + allFilters.tags = queryFilters.tags + allFilters.years = queryFilters.years } } + + private func getQueryFilters() async -> (genres: [ItemGenre], tags: [ItemTag], years: [ItemYear]) { + let parameters = Paths.GetQueryFiltersLegacyParameters( + userID: userSession.user.id, + parentID: parent?.id as? String + ) + + let request = Paths.getQueryFiltersLegacy(parameters: parameters) + guard let response = try? await userSession.client.send(request) else { return ([], [], []) } + + let genres: [ItemGenre] = (response.value.genres ?? []) + .map(ItemGenre.init) + + let tags = (response.value.tags ?? []) + .map(ItemTag.init) + + // Manually sort so that most recent years are "first" + let years = (response.value.years ?? []) + .sorted(by: >) + .map(ItemYear.init) + + return (genres, tags, years) + } } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 62b09f183..55d035c35 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -9,183 +9,162 @@ import Combine import CoreStore import Factory -import Foundation import JellyfinAPI -import UIKit +import OrderedCollections -final class HomeViewModel: ViewModel { +final class HomeViewModel: ViewModel, Stateful { + + // MARK: Action + + enum Action { + case error(JellyfinAPIError) + case refresh + } + + // MARK: State + + enum State: Equatable { + case content + case error(JellyfinAPIError) + case initial + case refreshing + } @Published - var errorMessage: String? - @Published - var hasNextUp: Bool = false - @Published - var hasRecentlyAdded: Bool = false + var libraries: [LatestInLibraryViewModel] = [] @Published - var libraries: [BaseItemDto] = [] + var resumeItems: OrderedSet = [] + @Published - var resumeItems: [BaseItemDto] = [] + var state: State = .initial - override init() { - super.init() + private(set) var nextUpViewModel: NextUpLibraryViewModel = .init() + private(set) var recentlyAddedViewModel: RecentlyAddedLibraryViewModel = .init() - refresh() - } + private var refreshTask: AnyCancellable? - @objc - func refresh() { + func respond(to action: Action) -> State { + switch action { + case let .error(error): + return .error(error) + case .refresh: + cancellables.removeAll() - hasNextUp = false - hasRecentlyAdded = false - libraries = [] - resumeItems = [] + Task { [weak self] in + guard let self else { return } + do { - Task { - logger.debug("Refreshing home screen") + try await self.refresh() - await MainActor.run { - isLoading = true - } + guard !Task.isCancelled else { return } - refreshHasRecentlyAddedItems() - refreshResumeItems() - refreshHasNextUp() + await MainActor.run { + self.state = .content + } + } catch { + guard !Task.isCancelled else { return } - do { - try await refreshLibrariesLatest() - } catch { - await MainActor.run { - isLoading = false - errorMessage = error.localizedDescription + await MainActor.run { + self.send(.error(.init(error.localizedDescription))) + } } - - return } + .store(in: &cancellables) - await MainActor.run { - isLoading = false - errorMessage = nil - } + return .refreshing } } - // MARK: Libraries Latest Items - - private func refreshLibrariesLatest() async throws { - let userViewsPath = Paths.getUserViews(userID: userSession.user.id) - let response = try await userSession.client.send(userViewsPath) + private func refresh() async throws { - guard let allLibraries = response.value.items else { - await MainActor.run { - libraries = [] - } + Task { + await nextUpViewModel.send(.refresh) + } - return + Task { + await recentlyAddedViewModel.send(.refresh) } - let excludedLibraryIDs = await getExcludedLibraries() + let resumeItems = try await getResumeItems() + let libraries = try await getLibraries() - let newLibraries = allLibraries - .filter { $0.collectionType == "movies" || $0.collectionType == "tvshows" } - .filter { library in - !excludedLibraryIDs.contains(where: { $0 == library.id ?? "" }) - } + for library in libraries { + await library.send(.refresh) + } await MainActor.run { - libraries = newLibraries + self.resumeItems.elements = resumeItems + self.libraries = libraries } } - private func getExcludedLibraries() async -> [String] { - let currentUserPath = Paths.getCurrentUser - let response = try? await userSession.client.send(currentUserPath) - - return response?.value.configuration?.latestItemsExcludes ?? [] - } + private func getResumeItems() async throws -> [BaseItemDto] { + var parameters = Paths.GetResumeItemsParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.includeItemTypes = [.movie, .episode] + parameters.limit = 20 - // MARK: Recently Added Items + let request = Paths.getResumeItems(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) - private func refreshHasRecentlyAddedItems() { - Task { - let parameters = Paths.GetLatestMediaParameters( - includeItemTypes: [.movie, .series], - limit: 1 - ) - let request = Paths.getLatestMedia(userID: userSession.user.id, parameters: parameters) - let response = try await userSession.client.send(request) - - await MainActor.run { - hasRecentlyAdded = !response.value.isEmpty - } - } + return response.value.items ?? [] } - // MARK: Resume Items - - private func refreshResumeItems() { - Task { - let resumeParameters = Paths.GetResumeItemsParameters( - limit: 20, - fields: ItemFields.minimumCases, - enableUserData: true, - includeItemTypes: [.movie, .episode] - ) + private func getLibraries() async throws -> [LatestInLibraryViewModel] { - let request = Paths.getResumeItems(userID: userSession.user.id, parameters: resumeParameters) - let response = try await userSession.client.send(request) + let userViewsPath = Paths.getUserViews(userID: userSession.user.id) + async let userViews = userSession.client.send(userViewsPath) - guard let items = response.value.items else { return } + async let excludedLibraryIDs = getExcludedLibraries() - await MainActor.run { - resumeItems = items - } - } + return try await (userViews.value.items ?? []) + .intersection(["movies", "tvshows"], using: \.collectionType) + .subtracting(excludedLibraryIDs, using: \.id) + .map { LatestInLibraryViewModel(parent: $0) } } - func markItemUnplayed(_ item: BaseItemDto) { - guard resumeItems.contains(where: { $0.id == item.id! }) else { return } + // TODO: eventually a more robust user/server information retrieval system + // will be in place. Replace with using the data from the remove user + private func getExcludedLibraries() async throws -> [String] { + let currentUserPath = Paths.getCurrentUser + let response = try await userSession.client.send(currentUserPath) - Task { - let request = Paths.markUnplayedItem( - userID: userSession.user.id, - itemID: item.id! - ) - let _ = try await userSession.client.send(request) - - refreshResumeItems() - refreshHasNextUp() - } + return response.value.configuration?.latestItemsExcludes ?? [] } - func markItemPlayed(_ item: BaseItemDto) { - guard resumeItems.contains(where: { $0.id == item.id! }) else { return } - - Task { - let request = Paths.markPlayedItem( - userID: userSession.user.id, - itemID: item.id! - ) - let _ = try await userSession.client.send(request) - - refreshResumeItems() - refreshHasNextUp() - } + // TODO: fix + func markItemUnplayed(_ item: BaseItemDto) { +// guard resumeItems.contains(where: { $0.id == item.id! }) else { return } +// +// Task { +// let request = Paths.markUnplayedItem( +// userID: userSession.user.id, +// itemID: item.id! +// ) +// let _ = try await userSession.client.send(request) +// + //// refreshResumeItems() +// +// try await nextUpViewModel.refresh() +// try await recentlyAddedViewModel.refresh() +// } } - // MARK: Next Up Items - - private func refreshHasNextUp() { - Task { - let parameters = Paths.GetNextUpParameters( - userID: userSession.user.id, - limit: 1 - ) - let request = Paths.getNextUp(parameters: parameters) - let response = try await userSession.client.send(request) - - await MainActor.run { - hasNextUp = !(response.value.items?.isEmpty ?? true) - } - } + // TODO: fix + func markItemPlayed(_ item: BaseItemDto) { +// guard resumeItems.contains(where: { $0.id == item.id! }) else { return } +// +// Task { +// let request = Paths.markPlayedItem( +// userID: userSession.user.id, +// itemID: item.id! +// ) +// let _ = try await userSession.client.send(request) +// + //// refreshResumeItems() +// try await nextUpViewModel.refresh() +// try await recentlyAddedViewModel.refresh() +// } } } diff --git a/Shared/ViewModels/ItemTypeLibraryViewModel.swift b/Shared/ViewModels/ItemTypeLibraryViewModel.swift deleted file mode 100644 index 46dfab6a7..000000000 --- a/Shared/ViewModels/ItemTypeLibraryViewModel.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Combine -import Foundation -import Get -import JellyfinAPI - -final class ItemTypeLibraryViewModel: PagingLibraryViewModel { - - let itemTypes: [BaseItemKind] - let filterViewModel: FilterViewModel - - init(itemTypes: [BaseItemKind], filters: ItemFilters) { - self.itemTypes = itemTypes - self.filterViewModel = .init(parent: nil, currentFilters: filters) - super.init() - - filterViewModel.$currentFilters - .sink { newFilters in - self.requestItems(with: newFilters, replaceCurrentItems: true) - } - .store(in: &cancellables) - } - - private func requestItems(with filters: ItemFilters, replaceCurrentItems: Bool = false) { - - if replaceCurrentItems { - items = [] - currentPage = 0 - hasNextPage = true - } - - Task { - var parameters = self._getDefaultParams() - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let items = response.value.items, !items.isEmpty else { - hasNextPage = false - return - } - - await MainActor.run { - self.items.append(contentsOf: items) - } - } - } - - override func _getDefaultParams() -> Paths.GetItemsParameters? { - let filters = filterViewModel.currentFilters - let genreIDs = filters.genres.compactMap(\.id) - let sortBy: [String] = filters.sortBy.map(\.filterName).appending("IsFolder") - let sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending } - let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) } - - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - startIndex: currentPage * pageItemSize, - limit: pageItemSize, - isRecursive: true, - sortOrder: sortOrder, - fields: ItemFields.allCases, - includeItemTypes: itemTypes, - filters: itemFilters, - sortBy: sortBy, - enableUserData: true, - genreIDs: genreIDs - ) - - return parameters - } - - override func _requestNextPage() { - requestItems(with: filterViewModel.currentFilters) - } -} diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index eef6f7bef..4cee78653 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -12,6 +12,7 @@ import Foundation import JellyfinAPI import UIKit +// TODO: transition to `Stateful` class ItemViewModel: ViewModel { @Published @@ -123,7 +124,7 @@ class ItemViewModel: ViewModel { let parameters = Paths.GetSimilarItemsParameters( userID: userSession.user.id, limit: 20, - fields: ItemFields.minimumCases + fields: .MinimumFields ) let request = Paths.getSimilarItems( itemID: item.id!, diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift index f633dd800..9d18517e1 100644 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift @@ -11,13 +11,18 @@ import Defaults import Factory import Foundation import JellyfinAPI +import OrderedCollections -final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { +// TODO: use OrderedDictionary + +final class SeriesItemViewModel: ItemViewModel { @Published var menuSelection: BaseItemDto? @Published - var menuSections: [BaseItemDto: [BaseItemDto]] + var currentItems: OrderedSet = [] + + var menuSections: [BaseItemDto: OrderedSet] var menuSectionSort: (BaseItemDto, BaseItemDto) -> Bool override init(item: BaseItemDto) { @@ -48,7 +53,7 @@ final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { } guard let playButtonItem = playButtonItem, - let episodeLocator = playButtonItem.seasonEpisodeLocator else { return L10n.play } + let episodeLocator = playButtonItem.seasonEpisodeLabel else { return L10n.play } return episodeLocator } @@ -57,7 +62,7 @@ final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { Task { let parameters = Paths.GetNextUpParameters( userID: userSession.user.id, - fields: ItemFields.minimumCases, + fields: .MinimumFields, seriesID: item.id, enableUserData: true ) @@ -77,7 +82,7 @@ final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { let parameters = Paths.GetResumeItemsParameters( limit: 1, parentID: item.id, - fields: ItemFields.minimumCases + fields: .MinimumFields ) let request = Paths.getResumeItems(userID: userSession.user.id, parameters: parameters) let response = try await userSession.client.send(request) @@ -98,7 +103,7 @@ final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { isRecursive: true, sortOrder: [.ascending], parentID: item.id, - fields: ItemFields.minimumCases, + fields: .MinimumFields, includeItemTypes: [.episode] ) let request = Paths.getItems(parameters: parameters) @@ -117,8 +122,12 @@ final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { func select(section: BaseItemDto) { self.menuSelection = section - if let episodes = menuSections[section], episodes.isEmpty { - getEpisodesForSeason(section) + if let episodes = menuSections[section] { + if episodes.isEmpty { + getEpisodesForSeason(section) + } else { + self.currentItems = episodes + } } } @@ -132,11 +141,13 @@ final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { let response = try await userSession.client.send(request) guard let seasons = response.value.items else { return } + await MainActor.run { for season in seasons { self.menuSections[season] = [] } } + if let firstSeason = seasons.first { self.getEpisodesForSeason(firstSeason) await MainActor.run { @@ -146,11 +157,12 @@ final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { } } + // TODO: implement lazy loading private func getEpisodesForSeason(_ season: BaseItemDto) { Task { let parameters = Paths.GetEpisodesParameters( userID: userSession.user.id, - fields: ItemFields.minimumCases, + fields: .MinimumFields, seasonID: season.id!, isMissing: Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false, enableUserData: true @@ -160,7 +172,9 @@ final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel { await MainActor.run { if let items = response.value.items { - self.menuSections[season] = items + let newItems = OrderedSet(items) + self.menuSections[season] = newItems + self.currentItems = newItems } } } diff --git a/Shared/ViewModels/LatestInLibraryViewModel.swift b/Shared/ViewModels/LatestInLibraryViewModel.swift deleted file mode 100644 index 832faa52c..000000000 --- a/Shared/ViewModels/LatestInLibraryViewModel.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Combine -import Foundation -import JellyfinAPI - -final class LatestInLibraryViewModel: PagingLibraryViewModel { - - let parent: LibraryParent - - init(parent: LibraryParent) { - self.parent = parent - - super.init() - - _requestNextPage() - } - - override func _requestNextPage() { - Task { - - await MainActor.run { - self.isLoading = true - } - - let parameters = Paths.GetLatestMediaParameters( - parentID: self.parent.id, - fields: ItemFields.minimumCases, - enableUserData: true, - limit: self.pageItemSize * 3 - ) - let request = Paths.getLatestMedia(userID: userSession.user.id, parameters: parameters) - let response = try await userSession.client.send(request) - - let items = response.value - if items.isEmpty { - hasNextPage = false - return - } - - await MainActor.run { - self.isLoading = false - self.items.append(contentsOf: items) - } - } - } - - override public func getRandomItemFromLibrary() async throws -> BaseItemDtoQueryResult { - BaseItemDtoQueryResult(items: items.elements) - } - - func markPlayed(item: BaseItemDto) { - Task { - - let request = Paths.markPlayedItem( - userID: userSession.user.id, - itemID: item.id! - ) - let _ = try await userSession.client.send(request) - - await MainActor.run { - refresh() - } - } - } -} diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift deleted file mode 100644 index 19d5cf11f..000000000 --- a/Shared/ViewModels/LibraryViewModel.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import Factory -import Get -import JellyfinAPI -import SwiftUI -import UIKit - -// TODO: Look at refactoring -final class LibraryViewModel: PagingLibraryViewModel { - - let filterViewModel: FilterViewModel - - let parent: LibraryParent? - let type: LibraryParentType - private let saveFilters: Bool - - var libraryCoordinatorParameters: LibraryCoordinator.Parameters { - if let parent = parent { - return .init(parent: parent, type: type, filters: filterViewModel.currentFilters) - } else { - return .init(filters: filterViewModel.currentFilters) - } - } - - convenience init(filters: ItemFilters, saveFilters: Bool = false) { - self.init(parent: nil, type: .library, filters: filters, saveFilters: saveFilters) - } - - init( - parent: LibraryParent?, - type: LibraryParentType, - filters: ItemFilters = .init(), - saveFilters: Bool = false - ) { - self.parent = parent - self.type = type - self.filterViewModel = .init(parent: parent, currentFilters: filters) - self.saveFilters = saveFilters - super.init() - - filterViewModel.$currentFilters - .sink { newFilters in - self.requestItems(with: newFilters, replaceCurrentItems: true) - - if self.saveFilters, let id = self.parent?.id { - Defaults[.libraryFilterStore][id] = newFilters - } - } - .store(in: &cancellables) - } - - private func requestItems(with filters: ItemFilters, replaceCurrentItems: Bool = false) { - - if replaceCurrentItems { - self.items = [] - self.currentPage = 0 - self.hasNextPage = true - } - - var parameters = _getDefaultParams() - parameters?.limit = pageItemSize - parameters?.startIndex = currentPage * pageItemSize - parameters?.sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending } - parameters?.sortBy = filters.sortBy.map(\.filterName).appending("IsFolder") - - if filters.sortBy.first == SortBy.random.filter { - parameters?.excludeItemIDs = items.compactMap(\.id) - } - - Task { - await MainActor.run { - self.isLoading = true - } - - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let items = response.value.items, !items.isEmpty else { - self.hasNextPage = false - return - } - - await MainActor.run { - self.isLoading = false - self.items.append(contentsOf: items) - } - } - } - - override func _requestNextPage() { - requestItems(with: filterViewModel.currentFilters) - } - - override func _getDefaultParams() -> Paths.GetItemsParameters? { - - let filters = filterViewModel.currentFilters - var libraryID: String? - var personIDs: [String]? - var studioIDs: [String]? - let includeItemTypes: [BaseItemKind] - var recursive = true - - if let parent = parent { - switch type { - case .library, .folders: - libraryID = parent.id - case .person: - personIDs = [parent].compactMap(\.id) - case .studio: - studioIDs = [parent].compactMap(\.id) - } - } - - if filters.filters.contains(ItemFilter.isFavorite.filter) { - includeItemTypes = [.movie, .boxSet, .series, .season, .episode] - } else if type == .folders { - recursive = false - includeItemTypes = [.movie, .boxSet, .series, .folder, .collectionFolder] - } else { - includeItemTypes = [.movie, .boxSet, .series] - } - - let genreIDs = filters.genres.compactMap(\.id) - let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) } - - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - isRecursive: recursive, - parentID: libraryID, - fields: ItemFields.allCases, - includeItemTypes: includeItemTypes, - filters: itemFilters, - enableUserData: true, - personIDs: personIDs, - studioIDs: studioIDs, - genreIDs: genreIDs, - enableImages: true - ) - - return parameters - } -} diff --git a/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift new file mode 100644 index 000000000..40f0021c6 --- /dev/null +++ b/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift @@ -0,0 +1,125 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Defaults +import Get +import JellyfinAPI +import OrderedCollections +import SwiftUI + +final class ItemLibraryViewModel: PagingLibraryViewModel { + + // MARK: get + + override func get(page: Int) async throws -> [BaseItemDto] { + + let parameters = itemParameters(for: page) + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + // 1 - only care to keep collections that hold valid items + // 2 - if parent is type `folder`, then we are in a folder-view + // context so change `collectionFolder` types to `folder` + // for better view handling + let items = (response.value.items ?? []) + .filter { item in + if let collectionType = item.collectionType { + return ["movies", "tvshows", "mixed", "boxsets"].contains(collectionType) + } + + return true + } + .map { item in + if parent?.libraryType == .folder, item.type == .collectionFolder { + return item.mutating(\.type, with: .folder) + } + + return item + } + + return items + } + + // MARK: item parameters + + func itemParameters(for page: Int?) -> Paths.GetItemsByUserIDParameters { + + var libraryID: String? + var personIDs: [String]? + var studioIDs: [String]? + var includeItemTypes: [BaseItemKind] = [.movie, .series, .boxSet] + var isRecursive: Bool? = true + + if let libraryType = parent?.libraryType, let id = parent?.id { + switch libraryType { + case .collectionFolder: + libraryID = id + case .folder, .userView: + libraryID = id + isRecursive = nil + includeItemTypes = [.movie, .series, .boxSet, .folder, .collectionFolder] + case .person: + personIDs = [id] + case .studio: + studioIDs = [id] + default: () + } + } + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.includeItemTypes = includeItemTypes + parameters.isRecursive = isRecursive + parameters.parentID = libraryID + parameters.personIDs = personIDs + parameters.studioIDs = studioIDs + + // Page size + if let page { + parameters.limit = pageSize + parameters.startIndex = page * pageSize + } + + // Filters + if let filterViewModel { + let filters = filterViewModel.currentFilters + parameters.filters = filters.traits + parameters.genres = filters.genres.map(\.value) + parameters.sortBy = filters.sortBy.map(\.rawValue) + parameters.sortOrder = filters.sortOrder + parameters.tags = filters.tags.map(\.value) + parameters.years = filters.years.compactMap { Int($0.value) } + + // Random sort won't take into account previous items, so + // manual exclusion is necessary. This could possibly be + // a performance issue for loading pages after already loading + // many items, but there's nothing we can do about that. + if filters.sortBy.first == ItemSortBy.random { + parameters.excludeItemIDs = elements.compactMap(\.id) + } + } + + return parameters + } + + // MARK: getRandomItem + + override func getRandomItem() async -> BaseItemDto? { + + var parameters = itemParameters(for: nil) + parameters.limit = 1 + parameters.sortBy = [ItemSortBy.random.rawValue] + + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try? await userSession.client.send(request) + + return response?.value.items?.first + } +} diff --git a/Shared/ViewModels/LibraryViewModel/ItemTypeLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/ItemTypeLibraryViewModel.swift new file mode 100644 index 000000000..aee8af9c3 --- /dev/null +++ b/Shared/ViewModels/LibraryViewModel/ItemTypeLibraryViewModel.swift @@ -0,0 +1,50 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import Get +import JellyfinAPI + +// TODO: atow, this is only really used for tvOS tabs +final class ItemTypeLibraryViewModel: PagingLibraryViewModel { + + let itemTypes: [BaseItemKind] + + init(itemTypes: [BaseItemKind]) { + self.itemTypes = itemTypes + + super.init() + } + + override func get(page: Int) async throws -> [BaseItemDto] { + + let parameters = itemParameters(for: page) + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } + + func itemParameters(for page: Int?) -> Paths.GetItemsByUserIDParameters { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.includeItemTypes = itemTypes + parameters.isRecursive = true + + // Page size + if let page { + parameters.limit = pageSize + parameters.startIndex = page * pageSize + } + + return parameters + } +} diff --git a/Shared/ViewModels/LibraryViewModel/LatestInLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/LatestInLibraryViewModel.swift new file mode 100644 index 000000000..dd7231950 --- /dev/null +++ b/Shared/ViewModels/LibraryViewModel/LatestInLibraryViewModel.swift @@ -0,0 +1,33 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +final class LatestInLibraryViewModel: PagingLibraryViewModel, Identifiable { + + override func get(page: Int) async throws -> [BaseItemDto] { + + let parameters = parameters() + let request = Paths.getLatestMedia(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value + } + + private func parameters() -> Paths.GetLatestMediaParameters { + + var parameters = Paths.GetLatestMediaParameters() + parameters.parentID = parent?.id + parameters.fields = .MinimumFields + parameters.enableUserData = true + parameters.limit = pageSize + + return parameters + } +} diff --git a/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift new file mode 100644 index 000000000..20553ef06 --- /dev/null +++ b/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift @@ -0,0 +1,53 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI + +final class NextUpLibraryViewModel: PagingLibraryViewModel { + + init() { + super.init(parent: TitledLibraryParent(displayTitle: L10n.nextUp)) + } + + override func get(page: Int) async throws -> [BaseItemDto] { + + let parameters = parameters(for: page) + let request = Paths.getNextUp(parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } + + private func parameters(for page: Int) -> Paths.GetNextUpParameters { + + var parameters = Paths.GetNextUpParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.limit = pageSize + parameters.startIndex = page + parameters.userID = userSession.user.id + + return parameters + } + + // TODO: fix + func markPlayed(item: BaseItemDto) { +// Task { +// +// let request = Paths.markPlayedItem( +// userID: userSession.user.id, +// itemID: item.id! +// ) +// let _ = try await userSession.client.send(request) +// +// try await refresh() +// } + } +} diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift new file mode 100644 index 000000000..22067b3f2 --- /dev/null +++ b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift @@ -0,0 +1,296 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import Get +import JellyfinAPI +import OrderedCollections +import UIKit + +/// Magic number for page sizes +private let DefaultPageSize = 50 + +// TODO: frankly this is just generic because we also view `BaseItemPerson` elements +// and I don't want additional views for it. Is there a way we can transform a +// `BaseItemPerson` into a `BaseItemDto` and just use the concrete type? + +// TODO: how to indicate that this is performing some kind of background action (ie: RandomItem) +// *without* being in an explicit state? +// TODO: fix how `hasNextPage` is determined +// - some subclasses might not have "paging" and only have one call. This can be solved with +// a check if elements were actually appended to the set but that requires a redundant get +class PagingLibraryViewModel: ViewModel, Eventful, Stateful { + + // MARK: Event + + enum Event: Equatable { + case gotRandomItem(Element) + } + + // MARK: Action + + enum Action: Equatable { + case error(LibraryError) + case refresh + case getNextPage + case getRandomItem + } + + // MARK: State + + enum State: Equatable { + case content + case error(LibraryError) + case gettingNextPage + case initial + case refreshing + } + + // TODO: wrap Get HTTP and NSURL errors either here + // or in a general implementation + enum LibraryError: LocalizedError { + case unableToGetPage + case unableToGetRandomItem + + var errorDescription: String? { + switch self { + case .unableToGetPage: + "Unable to get page" + case .unableToGetRandomItem: + "Unable to get random item" + } + } + } + + @Published + final var elements: OrderedSet + @Published + final var state: State = .initial + + final let filterViewModel: FilterViewModel? + final let parent: (any LibraryParent)? + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + let pageSize: Int + private(set) final var currentPage = 0 + private(set) final var hasNextPage = true + + private let eventSubject: PassthroughSubject = .init() + private let isStatic: Bool + + // tasks + + private var filterQueryTask: AnyCancellable? + private var pagingTask: AnyCancellable? + private var randomItemTask: AnyCancellable? + + // MARK: init + + init( + _ data: some Collection, + parent: (any LibraryParent)? = nil, + pageSize: Int = DefaultPageSize + ) { + self.filterViewModel = nil + self.elements = OrderedSet(data) + self.isStatic = true + self.hasNextPage = false + self.pageSize = pageSize + self.parent = parent + } + + init( + parent: (any LibraryParent)? = nil, + filters: ItemFilterCollection? = nil, + pageSize: Int = DefaultPageSize + ) { + self.elements = OrderedSet() + self.isStatic = false + self.pageSize = pageSize + self.parent = parent + + if let filters { + self.filterViewModel = .init( + parent: parent, + currentFilters: filters + ) + } else { + self.filterViewModel = nil + } + + super.init() + + if let filterViewModel { + filterViewModel.$currentFilters + .dropFirst() // prevents a refresh on subscription + .debounce(for: 0.5, scheduler: RunLoop.main) + .removeDuplicates() + .sink { [weak self] _ in + guard let self else { return } + + Task { @MainActor in + self.send(.refresh) + } + } + .store(in: &cancellables) + } + } + + convenience init( + title: String, + filters: ItemFilterCollection = .default, + pageSize: Int = DefaultPageSize + ) { + self.init(parent: TitledLibraryParent(displayTitle: title), filters: filters, pageSize: pageSize) + } + + // MARK: respond + + @MainActor + func respond(to action: Action) -> State { + + if action == .refresh, isStatic { + return .content + } + + switch action { + case let .error(error): + + Task { @MainActor in + elements.removeAll() + } + + return .error(error) + case .refresh: + + filterQueryTask?.cancel() + pagingTask?.cancel() + randomItemTask?.cancel() + + filterQueryTask = Task { + await filterViewModel?.setQueryFilters() + } + .asAnyCancellable() + + pagingTask = Task { [weak self] in + guard let self else { return } + + do { + try await self.refresh() + + guard !Task.isCancelled else { return } + + await MainActor.run { + self.state = .content + } + } catch { + guard !Task.isCancelled else { return } + + await MainActor.run { + self.send(.error(.unableToGetPage)) + } + } + } + .asAnyCancellable() + + return .refreshing + case .getNextPage: + + guard hasNextPage else { return state } + + pagingTask = Task { [weak self] in + do { + try await self?.getNextPage() + + guard !Task.isCancelled else { return } + + await MainActor.run { + self?.state = .content + } + } catch { + guard !Task.isCancelled else { return } + + await MainActor.run { + self?.state = .error(.unableToGetPage) + } + } + } + .asAnyCancellable() + + return .gettingNextPage + case .getRandomItem: + + randomItemTask = Task { [weak self] in + do { + guard let randomItem = try await self?.getRandomItem() else { return } + + guard !Task.isCancelled else { return } + + self?.eventSubject.send(.gotRandomItem(randomItem)) + } catch { + // TODO: when a general toasting mechanism is implemented, add + // background errors for errors from other background tasks + } + } + .asAnyCancellable() + + return state + } + } + + // MARK: refresh + + final func refresh() async throws { + + currentPage = -1 + hasNextPage = true + + await MainActor.run { + elements.removeAll() + } + + try await getNextPage() + } + + /// Gets the next page of items or immediately returns if + /// there is not a next page. + /// + /// See `get(page:)` for the conditions that determine + /// if there is a next page or not. + final func getNextPage() async throws { + guard hasNextPage else { return } + + currentPage += 1 + + let pageItems = try await get(page: currentPage) + + hasNextPage = !(pageItems.count < DefaultPageSize) + + await MainActor.run { + elements.append(contentsOf: pageItems) + } + } + + /// Gets the items at the given page. If the number of items + /// is less than `DefaultPageSize`, then it is inferred that + /// there is not a next page and subsequent calls to `getNextPage` + /// will immediately return. + func get(page: Int) async throws -> [Element] { + [] + } + + func getRandomItem() async throws -> Element? { + elements.randomElement() + } +} diff --git a/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift b/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift new file mode 100644 index 000000000..7ea9bbaa4 --- /dev/null +++ b/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift @@ -0,0 +1,55 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI + +// TODO: verify this properly returns pages of items in correct date-added order +// *when* new episodes are added to a series? +final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel { + + // Necessary because this is paginated and also used on home view + init(customPageSize: Int? = nil) { + + if let customPageSize { + super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded), pageSize: customPageSize) + } else { + super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded)) + } + } + + override func get(page: Int) async throws -> [BaseItemDto] { + + let parameters = parameters(for: page) + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } + + private func parameters(for page: Int) -> Paths.GetItemsByUserIDParameters { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.includeItemTypes = [.movie, .series] + parameters.isRecursive = true + parameters.limit = pageSize + parameters.sortBy = [ItemSortBy.dateAdded.rawValue] + parameters.sortOrder = [.descending] + parameters.startIndex = page + + // Necessary to get an actual "next page" with this endpoint. + // Could be a performance issue for lots of items, but there's + // nothing we can do about it. + parameters.excludeItemIDs = elements.compactMap(\.id) + + return parameters + } +} diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index 127a43124..9b09b925b 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -77,7 +77,7 @@ final class LiveTVChannelsViewModel: ViewModel { startIndex: 0, limit: 100, enableImageTypes: [.primary], - fields: ItemFields.minimumCases, + fields: .MinimumFields, enableUserData: false, enableFavoriteSorting: true ) @@ -93,7 +93,7 @@ final class LiveTVChannelsViewModel: ViewModel { } private func getPrograms() { - guard !channels.isEmpty else { + guard channels.isNotEmpty else { logger.debug("Cannot get programs, channels list empty.") return } diff --git a/Shared/ViewModels/MediaItemViewModel.swift b/Shared/ViewModels/MediaItemViewModel.swift deleted file mode 100644 index f60bbea30..000000000 --- a/Shared/ViewModels/MediaItemViewModel.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Foundation -import JellyfinAPI - -final class MediaItemViewModel: ViewModel { - - @Published - var imageSources: [ImageSource]? - - let item: BaseItemDto - - init(item: BaseItemDto) { - self.item = item - super.init() - - if item.collectionType == "favorites" { - getRandomItemImageSource(with: [.isFavorite]) - } else if item.collectionType == "downloads" { - imageSources = nil - } else if !Defaults[.Customization.Library.randomImage] || item.collectionType == "liveTV" { - imageSources = [item.imageSource(.primary, maxWidth: 500)] - } else { - getRandomItemImageSource(with: nil) - } - } - - private func getRandomItemImageSource(with filters: [ItemFilter]?) { - Task { - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - limit: 1, - isRecursive: true, - parentID: item.id, - includeItemTypes: [.movie, .series], - filters: filters, - sortBy: ["Random"] - ) - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let item = response.value.items?.first else { return } - - await MainActor.run { - imageSources = [item.imageSource(.backdrop, maxWidth: 500)] - } - } - } -} - -extension MediaItemViewModel: Equatable { - - static func == (lhs: MediaItemViewModel, rhs: MediaItemViewModel) -> Bool { - lhs.item == rhs.item - } -} - -extension MediaItemViewModel: Hashable { - - func hash(into hasher: inout Hasher) { - hasher.combine(item) - } -} diff --git a/Shared/ViewModels/MediaViewModel.swift b/Shared/ViewModels/MediaViewModel.swift index a12a0f728..3ddec09d5 100644 --- a/Shared/ViewModels/MediaViewModel.swift +++ b/Shared/ViewModels/MediaViewModel.swift @@ -9,47 +9,153 @@ import Defaults import Foundation import JellyfinAPI +import OrderedCollections -final class MediaViewModel: ViewModel { +final class MediaViewModel: ViewModel, Stateful { + + // TODO: remove once collection types become an enum + static let supportedCollectionTypes: [String] = ["boxsets", "folders", "movies", "tvshows", "livetv"] + + enum MediaType: Displayable, Hashable { + case downloads + case favorites + case liveTV + case userView(BaseItemDto) + + var displayTitle: String { + switch self { + case .downloads: + return L10n.downloads + case .favorites: + return L10n.favorites + case .liveTV: + return L10n.liveTV + case let .userView(item): + return item.displayTitle + } + } + } + + // MARK: Action + + enum Action { + case error(JellyfinAPIError) + case refresh + } + + // MARK: State + + enum State: Equatable { + case content + case error(JellyfinAPIError) + case initial + case refreshing + } @Published - private var libraries: [BaseItemDto] = [] + var mediaItems: OrderedSet = [] + + @Published + var state: State = .initial + + func respond(to action: Action) -> State { + switch action { + case let .error(error): + return .error(error) + case .refresh: + cancellables.removeAll() - var libraryItems: [MediaItemViewModel] { - libraries.map { .init(item: $0) } - .prepending( - .init(item: .init(collectionType: "liveTV", name: L10n.liveTV)), - if: Defaults[.Experimental.liveTVAlphaEnabled] - ) - .prepending( - .init(item: .init(collectionType: "favorites", name: L10n.favorites)), - if: Defaults[.Customization.Library.showFavorites] - ) - .prepending( - .init(item: .init(collectionType: "downloads", name: L10n.downloads)), - if: Defaults[.Experimental.downloads] - ) + Task { + do { + try await refresh() + + await MainActor.run { + self.state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .store(in: &cancellables) + + return .refreshing + } } - private static let supportedCollectionTypes: [String] = ["boxsets", "folders", "movies", "tvshows", "unknown"] + private func refresh() async throws { - override init() { - super.init() + await MainActor.run { + mediaItems.removeAll() + } - requestLibraries() + let media = try await getUserViews() + .map(MediaType.userView) + .prepending(.favorites, if: Defaults[.Customization.Library.showFavorites]) + + await MainActor.run { + mediaItems.elements = media + } } - func requestLibraries() { - Task { - let request = Paths.getUserViews(userID: userSession.user.id) - let response = try await userSession.client.send(request) + private func getUserViews() async throws -> [BaseItemDto] { + + let userViewsPath = Paths.getUserViews(userID: userSession.user.id) + async let userViews = userSession.client.send(userViewsPath) + + async let excludedLibraryIDs = getExcludedLibraries() + + // folders has `type = UserView`, but we manually + // force it to `folders` for better view handling + let supportedUserViews = try await (userViews.value.items ?? []) + .intersection(Self.supportedCollectionTypes, using: \.collectionType) + .subtracting(excludedLibraryIDs, using: \.id) + .map { item in - guard let items = response.value.items else { return } - let supportedLibraries = items.filter { Self.supportedCollectionTypes.contains($0.collectionType ?? "unknown") } + if item.type == .userView, item.collectionType == "folders" { + return item.mutating(\.type, with: .folder) + } - await MainActor.run { - libraries = supportedLibraries + return item } + + return supportedUserViews + } + + private func getExcludedLibraries() async throws -> [String] { + let currentUserPath = Paths.getCurrentUser + let response = try await userSession.client.send(currentUserPath) + + return response.value.configuration?.myMediaExcludes ?? [] + } + + func randomItemImageSources(for mediaType: MediaType) async throws -> [ImageSource] { + + var parentID: String? + + if case let MediaType.userView(item) = mediaType { + parentID = item.id } + + var filters: [ItemTrait]? + + if mediaType == .favorites { + filters = [.isFavorite] + } + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.limit = 3 + parameters.isRecursive = true + parameters.parentID = parentID + parameters.includeItemTypes = [.movie, .series, .boxSet] + parameters.filters = filters + parameters.sortBy = [ItemSortBy.random.rawValue] + + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + return (response.value.items ?? []) + .map { $0.imageSource(.backdrop, maxWidth: 500) } } } diff --git a/Shared/ViewModels/NextUpLibraryViewModel.swift b/Shared/ViewModels/NextUpLibraryViewModel.swift deleted file mode 100644 index 5313030b3..000000000 --- a/Shared/ViewModels/NextUpLibraryViewModel.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Combine -import Foundation -import JellyfinAPI - -final class NextUpLibraryViewModel: PagingLibraryViewModel { - - override init() { - super.init() - - _requestNextPage() - } - - override func _requestNextPage() { - Task { - - await MainActor.run { - self.isLoading = true - } - - let parameters = Paths.GetNextUpParameters( - userID: userSession.user.id, - limit: pageItemSize, - fields: ItemFields.minimumCases, - enableUserData: true - ) - let request = Paths.getNextUp(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let items = response.value.items, !items.isEmpty else { - hasNextPage = false - return - } - - await MainActor.run { - self.isLoading = false - self.items.append(contentsOf: items) - } - } - } - - func markPlayed(item: BaseItemDto) { - Task { - - let request = Paths.markPlayedItem( - userID: userSession.user.id, - itemID: item.id! - ) - let _ = try await userSession.client.send(request) - - await MainActor.run { - refresh() - } - } - } -} diff --git a/Shared/ViewModels/PagingLibraryViewModel.swift b/Shared/ViewModels/PagingLibraryViewModel.swift deleted file mode 100644 index a7e182dc3..000000000 --- a/Shared/ViewModels/PagingLibraryViewModel.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Foundation -import Get -import JellyfinAPI -import OrderedCollections -import UIKit - -class PagingLibraryViewModel: ViewModel { - - @Default(.Customization.Library.gridPosterType) - private var libraryGridPosterType - - @Published - var items: OrderedSet = [] - - var currentPage = 0 - var hasNextPage = true - - var pageItemSize: Int { - let height = libraryGridPosterType == .portrait ? libraryGridPosterType.width * 1.5 : libraryGridPosterType.width / 1.77 - return UIScreen.main.maxChildren(width: libraryGridPosterType.width, height: height) - } - - public func getRandomItemFromLibrary() async throws -> BaseItemDtoQueryResult { - - var parameters = _getDefaultParams() - parameters?.limit = 1 - parameters?.sortBy = [SortBy.random.rawValue] - - await MainActor.run { - self.isLoading = true - } - - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - await MainActor.run { - self.isLoading = false - } - - return response.value - } - - func _getDefaultParams() -> Paths.GetItemsParameters? { - Paths.GetItemsParameters() - } - - func refresh() { - currentPage = 0 - hasNextPage = true - - items = [] - - requestNextPage() - } - - func requestNextPage() { - guard hasNextPage else { return } - currentPage += 1 - _requestNextPage() - } - - func _requestNextPage() {} -} diff --git a/Shared/ViewModels/RecentlyAddedViewModel.swift b/Shared/ViewModels/RecentlyAddedViewModel.swift deleted file mode 100644 index 06ba7a6c9..000000000 --- a/Shared/ViewModels/RecentlyAddedViewModel.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Combine -import Foundation -import JellyfinAPI - -final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel { - - override init() { - super.init() - - _requestNextPage() - } - - override func _requestNextPage() { - Task { - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - startIndex: currentPage * pageItemSize, - limit: pageItemSize, - isRecursive: true, - sortOrder: [.descending], - fields: ItemFields.allCases, - includeItemTypes: [.movie, .series], - sortBy: [SortBy.dateAdded.rawValue], - enableUserData: true - ) - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let items = response.value.items, !items.isEmpty else { - hasNextPage = false - return - } - - await MainActor.run { - self.items.append(contentsOf: items) - } - } - } -} diff --git a/Shared/ViewModels/SearchViewModel.swift b/Shared/ViewModels/SearchViewModel.swift index 9941c1971..27c5de658 100644 --- a/Shared/ViewModels/SearchViewModel.swift +++ b/Shared/ViewModels/SearchViewModel.swift @@ -11,149 +11,236 @@ import Foundation import JellyfinAPI import SwiftUI -final class SearchViewModel: ViewModel { +final class SearchViewModel: ViewModel, Stateful { + + // MARK: Action + + enum Action { + case error(JellyfinAPIError) + case getSuggestions + case search(query: String) + } + + // MARK: State + + enum State: Equatable { + case content + case error(JellyfinAPIError) + case initial + case searching + } - @Published - var movies: [BaseItemDto] = [] @Published var collections: [BaseItemDto] = [] @Published - var series: [BaseItemDto] = [] - @Published var episodes: [BaseItemDto] = [] @Published + var movies: [BaseItemDto] = [] + @Published var people: [BaseItemDto] = [] @Published + var series: [BaseItemDto] = [] + @Published var suggestions: [BaseItemDto] = [] + @Published + var state: State = .initial + + private var searchTask: AnyCancellable? + private var searchQuery: CurrentValueSubject = .init("") + let filterViewModel: FilterViewModel - private var searchTextSubject = CurrentValueSubject("") - private var searchCancellables = Set() - var noResults: Bool { - movies.isEmpty && - collections.isEmpty && - series.isEmpty && + var hasNoResults: Bool { + collections.isEmpty && episodes.isEmpty && - people.isEmpty + movies.isEmpty && + people.isEmpty && + series.isEmpty } + // MARK: init + override init() { - self.filterViewModel = .init(parent: nil, currentFilters: .init()) + self.filterViewModel = .init() super.init() - getSuggestions() - - searchTextSubject - .debounce(for: 0.5, scheduler: DispatchQueue.main) - .sink { newSearch in - - if newSearch.isEmpty { - self.movies = [] - self.collections = [] - self.series = [] - self.episodes = [] - self.people = [] + searchQuery + .debounce(for: 0.5, scheduler: RunLoop.main) + .sink { [weak self] query in + guard let self, query.isNotEmpty else { return } - return - } - - self._search(with: newSearch, filters: self.filterViewModel.currentFilters) + self.searchTask?.cancel() + self.search(query: query) } .store(in: &cancellables) filterViewModel.$currentFilters - .sink { newFilters in - self._search(with: self.searchTextSubject.value, filters: newFilters) + .debounce(for: 0.5, scheduler: RunLoop.main) + .filter { _ in self.searchQuery.value.isNotEmpty } + .sink { [weak self] _ in + guard let self else { return } + + guard searchQuery.value.isNotEmpty else { return } + + self.searchTask?.cancel() + self.search(query: searchQuery.value) } .store(in: &cancellables) } - func search(with query: String) { - searchTextSubject.send(query) - } + // MARK: respond + + func respond(to action: Action) -> State { + switch action { + case let .error(error): + return .error(error) + case let .search(query): + if query.isEmpty { + searchTask?.cancel() + searchTask = nil + searchQuery.send(query) + return .initial + } else { + searchQuery.send(query) + return .searching + } + case .getSuggestions: + Task { + await filterViewModel.setQueryFilters() + } + .store(in: &cancellables) - private func _search(with query: String, filters: ItemFilters) { - getItems(for: query, with: filters, type: .movie, keyPath: \.movies) - getItems(for: query, with: filters, type: .boxSet, keyPath: \.collections) - getItems(for: query, with: filters, type: .series, keyPath: \.series) - getItems(for: query, with: filters, type: .episode, keyPath: \.episodes) - getPeople(for: query, with: filters) - } + Task { + let suggestions = try await getSuggestions() - private func getItems( - for query: String, - with filters: ItemFilters, - type itemType: BaseItemKind, - keyPath: ReferenceWritableKeyPath - ) { - let genreIDs = filters.genres.compactMap(\.id) - let sortBy = filters.sortBy.map(\.filterName) - let sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending } - let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) } - - Task { - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - limit: 20, - isRecursive: true, - searchTerm: query, - sortOrder: sortOrder, - fields: ItemFields.allCases, - includeItemTypes: [itemType], - filters: itemFilters, - sortBy: sortBy, - enableUserData: true, - genreIDs: genreIDs, - enableImages: true - ) - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - await MainActor.run { - self[keyPath: keyPath] = response.value.items ?? [] + await MainActor.run { + self.suggestions = suggestions + } } + .store(in: &cancellables) + + return state } } - private func getPeople(for query: String?, with filters: ItemFilters) { - guard !filters.hasFilters else { - self.people = [] - return - } + // MARK: search + + private func search(query: String) { + searchTask = Task { + + do { + + let items = try await withThrowingTaskGroup( + of: (BaseItemKind, [BaseItemDto]).self, + returning: [BaseItemKind: [BaseItemDto]].self + ) { group in + + // Base items + let retrievingItemTypes: [BaseItemKind] = [ + .boxSet, + .episode, + .movie, + .series, + ] + + for type in retrievingItemTypes { + group.addTask { + let items = try await self.getItems(query: query, itemType: type) + return (type, items) + } + } + + // People + group.addTask { + let items = try await self.getPeople(query: query) + return (BaseItemKind.person, items) + } + + var result: [BaseItemKind: [BaseItemDto]] = [:] + + while let items = try await group.next() { + result[items.0] = items.1 + } + + return result + } + + guard !Task.isCancelled else { return } + + await MainActor.run { + self.collections = items[.boxSet] ?? [] + self.episodes = items[.episode] ?? [] + self.movies = items[.movie] ?? [] + self.people = items[.person] ?? [] + self.series = items[.series] ?? [] + + self.state = .content + } + } catch { - Task { - let parameters = Paths.GetPersonsParameters( - limit: 20, - searchTerm: query - ) - let request = Paths.getPersons(parameters: parameters) - let response = try await userSession.client.send(request) + guard !Task.isCancelled else { print("search was cancelled") + return + } - await MainActor.run { - people = response.value.items ?? [] + await MainActor.run { + self.send(.error(.init(error.localizedDescription))) + } } } + .asAnyCancellable() } - private func getSuggestions() { - Task { - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - limit: 10, - isRecursive: true, - includeItemTypes: [.movie, .series], - sortBy: ["IsFavoriteOrLiked", "Random"], - imageTypeLimit: 0, - enableTotalRecordCount: false, - enableImages: false - ) - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - await MainActor.run { - suggestions = response.value.items ?? [] - } - } + private func getItems(query: String, itemType: BaseItemKind) async throws -> [BaseItemDto] { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.includeItemTypes = [itemType] + parameters.isRecursive = true + parameters.limit = 20 + parameters.searchTerm = query + + // Filters + let filters = filterViewModel.currentFilters + parameters.filters = filters.traits + parameters.genres = filters.genres.map(\.value) + parameters.sortBy = filters.sortBy.map(\.rawValue) + parameters.sortOrder = filters.sortOrder + parameters.tags = filters.tags.map(\.value) + parameters.years = filters.years.map(\.intValue) + + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } + + private func getPeople(query: String) async throws -> [BaseItemDto] { + + var parameters = Paths.GetPersonsParameters() + parameters.limit = 20 + parameters.searchTerm = query + + let request = Paths.getPersons(parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } + + // MARK: suggestions + + private func getSuggestions() async throws -> [BaseItemDto] { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.includeItemTypes = [.movie, .series] + parameters.isRecursive = true + parameters.limit = 10 + parameters.sortBy = [ItemSortBy.random.rawValue] + + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] } } diff --git a/Shared/ViewModels/SpecialFeaturesViewModel.swift b/Shared/ViewModels/SpecialFeaturesViewModel.swift deleted file mode 100644 index 3eb3fd6d1..000000000 --- a/Shared/ViewModels/SpecialFeaturesViewModel.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI - -class SpecialFeaturesViewModel: ViewModel, MenuPosterHStackModel { - - @Published - var menuSelection: SpecialFeatureType? - @Published - var menuSections: [SpecialFeatureType: [BaseItemDto]] - var menuSectionSort: (SpecialFeatureType, SpecialFeatureType) -> Bool - - init(sections: [SpecialFeatureType: [BaseItemDto]]) { - let comparator: (SpecialFeatureType, SpecialFeatureType) -> Bool = { i, j in i.rawValue < j.rawValue } - self.menuSelection = Array(sections.keys).sorted(by: comparator).first! - self.menuSections = sections - self.menuSectionSort = comparator - } - - func select(section: SpecialFeatureType) { - self.menuSelection = section - } -} diff --git a/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift b/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift index 64d4c253b..f7fbd64fa 100644 --- a/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift +++ b/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift @@ -115,7 +115,7 @@ class VideoPlayerManager: ViewModel { let parameters = Paths.GetEpisodesParameters( userID: userSession.user.id, - fields: ItemFields.minimumCases, + fields: .MinimumFields, adjacentTo: item.id!, limit: 3 ) diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 78a73cb78..c9a51e81c 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -30,7 +30,7 @@ class VideoPlayerViewModel: ViewModel { var hlsPlaybackURL: URL { - let userSession = Container.userSession.callAsFunction() + let userSession = Container.userSession() let parameters = Paths.GetMasterHlsVideoPlaylistParameters( isStatic: true, diff --git a/Shared/ViewModels/ViewModel.swift b/Shared/ViewModels/ViewModel.swift index 694928b6b..5da770989 100644 --- a/Shared/ViewModels/ViewModel.swift +++ b/Shared/ViewModels/ViewModel.swift @@ -18,9 +18,11 @@ class ViewModel: ObservableObject { @Injected(Container.userSession) var userSession + // TODO: remove on transition to Stateful @Published var error: ErrorMessage? = nil + // TODO: remove on transition to Stateful @Published var isLoading = false diff --git a/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift index 4de4a9238..c459d3d58 100644 --- a/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift +++ b/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift @@ -6,128 +6,130 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import SwiftUI -import UIKit +// TODO: IMPLEMENT BUTTON OVERRIDES IN `PreferencesView` PACKAGE -// MARK: PreferenceUIHostingController - -class PreferenceUIHostingController: UIHostingController { - - init(@ViewBuilder wrappedView: @escaping () -> V) { - let box = Box() - super.init(rootView: AnyView( - wrappedView() - .onPreferenceChange(ViewPreferenceKey.self) { - box.value?._viewPreference = $0 - } - .onPreferenceChange(DidPressMenuPreferenceKey.self) { - box.value?.didPressMenuAction = $0 - } - .onPreferenceChange(DidPressSelectPreferenceKey.self) { - box.value?.didPressSelectAction = $0 - } - )) - box.value = self - - addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenuSelector)) - addButtonPressRecognizer(pressType: .select, action: #selector(didPressSelectSelector)) - } - - @objc - dynamic required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - super.modalPresentationStyle = .fullScreen - } - - private class Box { - weak var value: PreferenceUIHostingController? - init() {} - } - - public var _viewPreference: UIUserInterfaceStyle = .unspecified { - didSet { - overrideUserInterfaceStyle = _viewPreference - } - } - - var didPressMenuAction: ActionHolder = .init(action: {}) - var didPressSelectAction: ActionHolder = .init(action: {}) - - private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { - let pressRecognizer = UITapGestureRecognizer() - pressRecognizer.addTarget(self, action: action) - pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] - view.addGestureRecognizer(pressRecognizer) - } - - @objc - private func didPressMenuSelector() { - DispatchQueue.main.async { - self.didPressMenuAction.action() - } - } - - @objc - private func didPressSelectSelector() { - DispatchQueue.main.async { - self.didPressSelectAction.action() - } - } -} - -struct ActionHolder: Equatable { - - static func == (lhs: ActionHolder, rhs: ActionHolder) -> Bool { - lhs.uuid == rhs.uuid - } - - var action: () -> Void - let uuid = UUID().uuidString -} - -// MARK: Preference Keys - -struct ViewPreferenceKey: PreferenceKey { - typealias Value = UIUserInterfaceStyle - - static var defaultValue: UIUserInterfaceStyle = .unspecified - - static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { - value = nextValue() - } -} - -struct DidPressMenuPreferenceKey: PreferenceKey { - - static var defaultValue: ActionHolder = .init(action: {}) - - static func reduce(value: inout ActionHolder, nextValue: () -> ActionHolder) { - value = nextValue() - } -} - -struct DidPressSelectPreferenceKey: PreferenceKey { - - static var defaultValue: ActionHolder = .init(action: {}) - - static func reduce(value: inout ActionHolder, nextValue: () -> ActionHolder) { - value = nextValue() - } -} - -// MARK: Preference Key View Extension - -extension View { - - func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { - preference(key: ViewPreferenceKey.self, value: viewPreference) - } - - func onMenuPressed(_ action: @escaping () -> Void) -> some View { - preference(key: DidPressMenuPreferenceKey.self, value: ActionHolder(action: action)) - } - - func onSelectPressed(_ action: @escaping () -> Void) -> some View { - preference(key: DidPressSelectPreferenceKey.self, value: ActionHolder(action: action)) - } -} +// import SwiftUI +// import UIKit +// +//// MARK: PreferenceUIHostingController +// +// class PreferenceUIHostingController: UIHostingController { +// +// init(@ViewBuilder wrappedView: @escaping () -> V) { +// let box = Box() +// super.init(rootView: AnyView( +// wrappedView() +// .onPreferenceChange(ViewPreferenceKey.self) { +// box.value?._viewPreference = $0 +// } +// .onPreferenceChange(DidPressMenuPreferenceKey.self) { +// box.value?.didPressMenuAction = $0 +// } +// .onPreferenceChange(DidPressSelectPreferenceKey.self) { +// box.value?.didPressSelectAction = $0 +// } +// )) +// box.value = self +// +// addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenuSelector)) +// addButtonPressRecognizer(pressType: .select, action: #selector(didPressSelectSelector)) +// } +// +// @objc +// dynamic required init?(coder aDecoder: NSCoder) { +// super.init(coder: aDecoder) +// super.modalPresentationStyle = .fullScreen +// } +// +// private class Box { +// weak var value: PreferenceUIHostingController? +// init() {} +// } +// +// public var _viewPreference: UIUserInterfaceStyle = .unspecified { +// didSet { +// overrideUserInterfaceStyle = _viewPreference +// } +// } +// +// var didPressMenuAction: ActionHolder = .init(action: {}) +// var didPressSelectAction: ActionHolder = .init(action: {}) +// +// private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { +// let pressRecognizer = UITapGestureRecognizer() +// pressRecognizer.addTarget(self, action: action) +// pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] +// view.addGestureRecognizer(pressRecognizer) +// } +// +// @objc +// private func didPressMenuSelector() { +// DispatchQueue.main.async { +// self.didPressMenuAction.action() +// } +// } +// +// @objc +// private func didPressSelectSelector() { +// DispatchQueue.main.async { +// self.didPressSelectAction.action() +// } +// } +// } +// +// struct ActionHolder: Equatable { +// +// static func == (lhs: ActionHolder, rhs: ActionHolder) -> Bool { +// lhs.uuid == rhs.uuid +// } +// +// var action: () -> Void +// let uuid = UUID().uuidString +// } +// +//// MARK: Preference Keys +// +// struct ViewPreferenceKey: PreferenceKey { +// typealias Value = UIUserInterfaceStyle +// +// static var defaultValue: UIUserInterfaceStyle = .unspecified +// +// static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { +// value = nextValue() +// } +// } +// +// struct DidPressMenuPreferenceKey: PreferenceKey { +// +// static var defaultValue: ActionHolder = .init(action: {}) +// +// static func reduce(value: inout ActionHolder, nextValue: () -> ActionHolder) { +// value = nextValue() +// } +// } +// +// struct DidPressSelectPreferenceKey: PreferenceKey { +// +// static var defaultValue: ActionHolder = .init(action: {}) +// +// static func reduce(value: inout ActionHolder, nextValue: () -> ActionHolder) { +// value = nextValue() +// } +// } +// +//// MARK: Preference Key View Extension +// +// extension View { +// +// func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { +// preference(key: ViewPreferenceKey.self, value: viewPreference) +// } +// +// func onMenuPressed(_ action: @escaping () -> Void) -> some View { +// preference(key: DidPressMenuPreferenceKey.self, value: ActionHolder(action: action)) +// } +// +// func onSelectPressed(_ action: @escaping () -> Void) -> some View { +// preference(key: DidPressSelectPreferenceKey.self, value: ActionHolder(action: action)) +// } +// } diff --git a/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift index bcdf2868d..f4231d8b2 100644 --- a/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift +++ b/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift @@ -6,74 +6,77 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import SwiftUI -import SwizzleSwift -import UIKit +// TODO: IMPLEMENT BUTTON OVERRIDES IN `PreferencesView` PACKAGE -// MARK: - wrapper view - -/// Wrapper view that will apply swizzling to make iOS query the child view for preference settings. -/// Used in combination with PreferenceUIHostingController. -/// -/// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d -struct PreferenceUIHostingControllerView: UIViewControllerRepresentable { - init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { - _ = UIViewController.preferenceSwizzling - self.wrappedView = wrappedView - } - - var wrappedView: () -> Wrapped - - func makeUIViewController(context: Context) -> PreferenceUIHostingController { - PreferenceUIHostingController { wrappedView() } - } - - func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} -} - -// MARK: - swizzling uiviewcontroller extensions - -extension UIViewController { - static var preferenceSwizzling: Void = { - Swizzle(UIViewController.self) { -// #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) -// #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) - } - }() -} - -extension UIViewController { - @objc - func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { - if self is PreferenceUIHostingController { - // dont continue searching - return nil - } else { - return search() - } - } - - @objc - func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { - if self is PreferenceUIHostingController { - // dont continue searching - return nil - } else { - return search() - } - } - - private func search() -> PreferenceUIHostingController? { - if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { - return result - } - - for child in children { - if let result = child.search() { - return result - } - } - - return nil - } -} +// import SwiftUI +// import SwizzleSwift +// import UIKit +// +//// MARK: - wrapper view +// +///// Wrapper view that will apply swizzling to make iOS query the child view for preference settings. +///// Used in combination with PreferenceUIHostingController. +///// +///// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d +// struct PreferenceUIHostingControllerView: UIViewControllerRepresentable { +// init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { +// _ = UIViewController.preferenceSwizzling +// self.wrappedView = wrappedView +// } +// +// var wrappedView: () -> Wrapped +// +// func makeUIViewController(context: Context) -> PreferenceUIHostingController { +// PreferenceUIHostingController { wrappedView() } +// } +// +// func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} +// } +// +//// MARK: - swizzling uiviewcontroller extensions +// +// extension UIViewController { +// static var preferenceSwizzling: Void = { +// Swizzle(UIViewController.self) { +//// #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> +/// #selector(swizzled_childForScreenEdgesDeferringSystemGestures) +//// #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) +// } +// }() +// } +// +// extension UIViewController { +// @objc +// func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { +// if self is PreferenceUIHostingController { +// // dont continue searching +// return nil +// } else { +// return search() +// } +// } +// +// @objc +// func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { +// if self is PreferenceUIHostingController { +// // dont continue searching +// return nil +// } else { +// return search() +// } +// } +// +// private func search() -> PreferenceUIHostingController? { +// if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { +// return result +// } +// +// for child in children { +// if let result = child.search() { +// return result +// } +// } +// +// return nil +// } +// } diff --git a/Swiftfin tvOS/Components/CinematicBackgroundView.swift b/Swiftfin tvOS/Components/CinematicBackgroundView.swift index 479f47d14..c900b9dfa 100644 --- a/Swiftfin tvOS/Components/CinematicBackgroundView.swift +++ b/Swiftfin tvOS/Components/CinematicBackgroundView.swift @@ -33,6 +33,7 @@ struct CinematicBackgroundView: View { .failure { Color.clear } + .aspectRatio(contentMode: .fill) } } } diff --git a/Swiftfin tvOS/Components/CinematicItemSelector.swift b/Swiftfin tvOS/Components/CinematicItemSelector.swift index efd39e27f..f5c6b4da2 100644 --- a/Swiftfin tvOS/Components/CinematicItemSelector.swift +++ b/Swiftfin tvOS/Components/CinematicItemSelector.swift @@ -16,6 +16,8 @@ struct CinematicItemSelector: View { @State private var focusedItem: Item? + @State + private var posterHStackSize: CGSize = .zero @StateObject private var viewModel: CinematicBackgroundView.ViewModel = .init() @@ -32,9 +34,43 @@ struct CinematicItemSelector: View { var body: some View { ZStack(alignment: .bottomLeading) { + Color.clear + + VStack(alignment: .leading, spacing: 10) { + + Spacer() + + if let currentItem = viewModel.currentItem { + topContent(currentItem) + .eraseToAnyView() + .id(currentItem.hashValue) + .transition(.opacity) + } + + // By design, PosterHStack/CollectionHStack requires being in a ScrollView + ScrollView { + PosterHStack(type: .landscape, items: items) + .content(itemContent) + .imageOverlay(itemImageOverlay) + .contextMenu(itemContextMenu) + .trailing(trailingContent) + .onSelect(onSelect) + .focusedItem($focusedItem) + .size($posterHStackSize) + } + .frame(height: posterHStackSize.height) + .if(true) { view in + if #available(tvOS 16, *) { + view.scrollDisabled(true) + } else { + view + } + } + } + } + .background(alignment: .top) { ZStack { CinematicBackgroundView(viewModel: viewModel, initialItem: items.first) - .ignoresSafeArea() LinearGradient( stops: [ @@ -46,6 +82,7 @@ struct CinematicItemSelector: View { endPoint: .bottom ) } + .frame(height: UIScreen.main.bounds.height) .mask { LinearGradient( stops: [ @@ -56,23 +93,6 @@ struct CinematicItemSelector: View { endPoint: .bottom ) } - - VStack(alignment: .leading, spacing: 10) { - if let currentItem = viewModel.currentItem { - topContent(currentItem) - .eraseToAnyView() - .id(currentItem.hashValue) - .transition(.opacity) - } - - PosterHStack(type: .landscape, items: items) - .content(itemContent) - .imageOverlay(itemImageOverlay) - .contextMenu(itemContextMenu) - .trailing(trailingContent) - .onSelect(onSelect) - .focusedItem($focusedItem) - } } .frame(height: UIScreen.main.bounds.height - 75) .frame(maxWidth: .infinity) diff --git a/Swiftfin tvOS/Components/DotHStack.swift b/Swiftfin tvOS/Components/DotHStack.swift index 18085d3bc..191955931 100644 --- a/Swiftfin tvOS/Components/DotHStack.swift +++ b/Swiftfin tvOS/Components/DotHStack.swift @@ -8,10 +8,10 @@ import SwiftUI -struct DotHStack: View { +struct DotHStack: View { @ViewBuilder - var content: () -> any View + var content: () -> Content var body: some View { SeparatorHStack(content) diff --git a/Swiftfin tvOS/Components/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Components/LiveTVChannelItemElement.swift index 51ab37667..a4ea9f054 100644 --- a/Swiftfin tvOS/Components/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Components/LiveTVChannelItemElement.swift @@ -95,7 +95,7 @@ struct LiveTVChannelItemElement: View { color: Color.primary, font: Font.system(size: 20, weight: .bold, design: .default) ) - if !nextProgramsText.isEmpty { + if nextProgramsText.isNotEmpty { let nextItem = nextProgramsText[0] programLabel( timeText: nextItem.timeDisplay, diff --git a/Swiftfin tvOS/Components/NonePosterButton.swift b/Swiftfin tvOS/Components/NonePosterButton.swift index 86a22848f..e0a4604d2 100644 --- a/Swiftfin tvOS/Components/NonePosterButton.swift +++ b/Swiftfin tvOS/Components/NonePosterButton.swift @@ -30,7 +30,6 @@ struct NonePosterButton: View { } } .posterStyle(type) - .frame(width: type.width) } } .buttonStyle(.card) diff --git a/Swiftfin tvOS/Components/PagingLibraryView.swift b/Swiftfin tvOS/Components/PagingLibraryView.swift index 81344c66f..5f3f8dfca 100644 --- a/Swiftfin tvOS/Components/PagingLibraryView.swift +++ b/Swiftfin tvOS/Components/PagingLibraryView.swift @@ -6,54 +6,156 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionView +import CollectionVGrid import Defaults import JellyfinAPI import SwiftUI // TODO: Figure out proper tab bar handling with the collection offset +// TODO: list columns +// TODO: list row view (LibraryRow) -struct PagingLibraryView: View { +struct PagingLibraryView: View { @Default(.Customization.Library.cinematicBackground) private var cinematicBackground - @Default(.Customization.Library.gridPosterType) - private var libraryPosterType + @Default(.Customization.Library.posterType) + private var posterType + @Default(.Customization.Library.viewType) + private var viewType @Default(.Customization.showPosterLabels) private var showPosterLabels - @FocusState - private var focusedItem: BaseItemDto? + @EnvironmentObject + private var router: LibraryCoordinator.Router - @ObservedObject - private var viewModel: PagingLibraryViewModel + @State + private var focusedItem: Element? @State private var presentBackground = false @State - private var scrollViewOffset: CGPoint = .zero + private var layout: CollectionVGridLayout @StateObject - private var cinematicBackgroundViewModel: CinematicBackgroundView.ViewModel = .init() + private var viewModel: PagingLibraryViewModel - private var onSelect: (BaseItemDto) -> Void + @StateObject + private var cinematicBackgroundViewModel: CinematicBackgroundView.ViewModel = .init() - private func layout(layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { - switch libraryPosterType { - case .portrait: - return .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .fixedNumberOfColumns(7), - lineSpacing: 50 - ) - case .landscape: - return .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .adaptive(withMinItemSize: 400), - lineSpacing: 50, - itemSize: .estimated(400), - sectionInsets: .zero + init(viewModel: PagingLibraryViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + + let initialPosterType = Defaults[.Customization.Library.posterType] + let initialViewType = Defaults[.Customization.Library.viewType] + + self._layout = State( + initialValue: Self.makeLayout( + posterType: initialPosterType, + viewType: initialViewType ) + ) + } + + // MARK: onSelect + + private func onSelect(_ element: Element) { + switch element { + case let element as BaseItemDto: + select(item: element) + case let element as BaseItemPerson: + select(person: element) + default: + assertionFailure("Used an unexpected type within a `PagingLibaryView`?") + } + } + + private func select(item: BaseItemDto) { + switch item.type { + case .collectionFolder, .folder: + let viewModel = ItemLibraryViewModel(parent: item) + router.route(to: \.library, viewModel) + default: + router.route(to: \.item, item) + } + } + + private func select(person: BaseItemPerson) { + let viewModel = ItemLibraryViewModel(parent: person) + router.route(to: \.library, viewModel) + } + + // MARK: layout + + private static func makeLayout( + posterType: PosterType, + viewType: LibraryViewType + ) -> CollectionVGridLayout { + switch (posterType, viewType) { + case (.landscape, .grid): + .columns(5) + case (.portrait, .grid): + .columns(7, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + case (_, .list): + .columns(1) + } + } + + private func landscapeGridItemView(item: Element) -> some View { + PosterButton(item: item, type: .landscape) + .content { + if item.showTitle { + PosterButton.TitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) + } + } + .onFocusChanged { newValue in + if newValue { + focusedItem = item + } + } + .onSelect { + onSelect(item) + } + } + + private func portraitGridItemView(item: Element) -> some View { + PosterButton(item: item, type: .portrait) + .content { + if item.showTitle { + PosterButton.TitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) + } + } + .onFocusChanged { newValue in + if newValue { + focusedItem = item + } + } + .onSelect { + onSelect(item) + } + } + + private func listItemView(item: Element) -> some View { + Button(item.displayTitle) + } + + private var contentView: some View { + CollectionVGrid( + $viewModel.elements, + layout: layout + ) { item in + switch (posterType, viewType) { + case (.landscape, .grid): + landscapeGridItemView(item: item) + case (.portrait, .grid): + portraitGridItemView(item: item) + case (_, .list): + listItemView(item: item) + } } } @@ -65,25 +167,30 @@ struct PagingLibraryView: View { .blurred() } - CollectionView(items: viewModel.items.elements) { _, item, _ in - PosterButton(item: item, type: libraryPosterType) - .onSelect { - onSelect(item) + WrappedView { + Group { + switch viewModel.state { + case let .error(error): + Text(error.localizedDescription) + case .initial, .refreshing: + ProgressView() + case .gettingNextPage, .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + contentView + } } - .focused($focusedItem, equals: item) - } - .layout { _, layoutEnvironment in - layout(layoutEnvironment: layoutEnvironment) - } - .willReachEdge(insets: .init(top: 0, leading: 0, bottom: 600, trailing: 0)) { edge in - if !viewModel.isLoading && edge == .bottom { - viewModel.requestNextPage() } } - .scrollViewOffset($scrollViewOffset) } - .id(libraryPosterType.hashValue) - .id(showPosterLabels) + .ignoresSafeArea() + .navigationTitle(viewModel.parent?.displayTitle ?? "") + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } .onChange(of: focusedItem) { newValue in guard let newValue else { withAnimation { @@ -102,17 +209,3 @@ struct PagingLibraryView: View { } } } - -extension PagingLibraryView { - - init(viewModel: PagingLibraryViewModel) { - self.init( - viewModel: viewModel, - onSelect: { _ in } - ) - } - - func onSelect(_ action: @escaping (BaseItemDto) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin tvOS/Components/PosterButton.swift b/Swiftfin tvOS/Components/PosterButton.swift index bdca8f290..60f0f3fcf 100644 --- a/Swiftfin tvOS/Components/PosterButton.swift +++ b/Swiftfin tvOS/Components/PosterButton.swift @@ -19,7 +19,6 @@ struct PosterButton: View { private var item: Item private var type: PosterType - private var itemScale: CGFloat private var horizontalAlignment: HorizontalAlignment private var content: () -> any View private var imageOverlay: () -> any View @@ -31,8 +30,20 @@ struct PosterButton: View { // Only set if desiring focus changes private var onFocusChanged: ((Bool) -> Void)? - private var itemWidth: CGFloat { - type.width * itemScale + @ViewBuilder + private func poster(from item: Item) -> some View { + switch type { + case .portrait: + ImageView(item.portraitPosterImageSource(maxWidth: 500)) + .failure { + TypeSystemNameView(item: item) + } + case .landscape: + ImageView(item.landscapePosterImageSources(maxWidth: 500, single: singleImage)) + .failure { + TypeSystemNameView(item: item) + } + } } var body: some View { @@ -40,28 +51,15 @@ struct PosterButton: View { Button { onSelect() } label: { - Group { - switch type { - case .portrait: - ImageView(item.portraitPosterImageSource(maxWidth: itemWidth)) - .failure { - InitialFailureView(item.displayTitle.initials) - } - case .landscape: - ImageView(item.landscapePosterImageSources(maxWidth: itemWidth, single: singleImage)) - .failure { - InitialFailureView(item.displayTitle.initials) - } - } - } - .posterStyle(type) - .frame(width: itemWidth) - .overlay { + ZStack { + Color.clear + + poster(from: item) + imageOverlay() .eraseToAnyView() - .posterStyle(type) - .frame(width: itemWidth) } + .posterStyle(type) } .buttonStyle(.card) .contextMenu(menuItems: { @@ -69,11 +67,11 @@ struct PosterButton: View { .eraseToAnyView() }) .posterShadow() - .if(onFocusChanged != nil) { view in + .ifLet(onFocusChanged) { view, onFocusChanged in view .focused($isFocused) .onChange(of: isFocused) { newValue in - onFocusChanged?(newValue) + onFocusChanged(newValue) } } @@ -81,7 +79,6 @@ struct PosterButton: View { .eraseToAnyView() .zIndex(-1) } - .frame(width: itemWidth) } } @@ -91,9 +88,8 @@ extension PosterButton { self.init( item: item, type: type, - itemScale: 1, horizontalAlignment: .leading, - content: { DefaultContentView(item: item) }, + content: { TitleSubtitleContentView(item: item) }, imageOverlay: { DefaultOverlay(item: item) }, contextMenu: { EmptyView() }, onSelect: {}, @@ -109,10 +105,6 @@ extension PosterButton { copy(modifying: \.horizontalAlignment, with: alignment) } - func scaleItem(_ scale: CGFloat) -> Self { - copy(modifying: \.itemScale, with: scale) - } - func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { copy(modifying: \.content, with: content) } @@ -134,31 +126,49 @@ extension PosterButton { } } -// MARK: default content view +// TODO: Shared default content? extension PosterButton { - struct DefaultContentView: View { + // MARK: Default Content + + struct TitleContentView: View { + + let item: Item + + var body: some View { + Text(item.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + } + } + + struct SubtitleContentView: View { + + let item: Item + + var body: some View { + Text(item.subtitle ?? "") + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + } + + struct TitleSubtitleContentView: View { let item: Item var body: some View { VStack(alignment: .leading) { if item.showTitle { - Text(item.displayTitle) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .lineLimit(2) + TitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) } - if let description = item.subtitle { - Text(description) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(2) - } + SubtitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) } } } diff --git a/Swiftfin tvOS/Components/PosterHStack.swift b/Swiftfin tvOS/Components/PosterHStack.swift index 3d70f5d5e..a3ee7f841 100644 --- a/Swiftfin tvOS/Components/PosterHStack.swift +++ b/Swiftfin tvOS/Components/PosterHStack.swift @@ -6,14 +6,17 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import CollectionHStack +import OrderedCollections import SwiftUI +// TODO: trailing content refactor? + struct PosterHStack: View { private var title: String? private var type: PosterType - private var items: [Item] - private var itemScale: CGFloat + private var items: Binding> private var content: (Item) -> any View private var imageOverlay: (Item) -> any View private var contextMenu: (Item) -> any View @@ -24,9 +27,9 @@ struct PosterHStack: View { private var focusedItem: Binding? var body: some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 20) { - if let title = title { + if let title { HStack { Text(title) .font(.title2) @@ -38,27 +41,27 @@ struct PosterHStack: View { } } - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 30) { - ForEach(items, id: \.hashValue) { item in - PosterButton(item: item, type: type) - .scaleItem(itemScale) - .content { content(item).eraseToAnyView() } - .imageOverlay { imageOverlay(item).eraseToAnyView() } - .contextMenu { contextMenu(item).eraseToAnyView() } - .onSelect { onSelect(item) } - .if(focusedItem != nil) { view in - view.onFocusChanged { isFocused in - if isFocused { focusedItem?.wrappedValue = item } - } - } + CollectionHStack( + items, + columns: type == .landscape ? 4 : 7 + ) { item in + PosterButton(item: item, type: type) + .content { content(item).eraseToAnyView() } + .imageOverlay { imageOverlay(item).eraseToAnyView() } + .contextMenu { contextMenu(item).eraseToAnyView() } + .onSelect { onSelect(item) } + .ifLet(focusedItem) { view, focusedItem in + view.onFocusChanged { isFocused in + if isFocused { focusedItem.wrappedValue = item } + } } - - trailingContent() - .eraseToAnyView() - } - .padding(50) } + .clipsToBounds(false) + .dataPrefix(20) + .horizontalInset(EdgeInsets.defaultEdgePadding) + .verticalInsets(top: 20, bottom: 20) + .itemSpacing(EdgeInsets.defaultEdgePadding - 20) + .scrollBehavior(.continuousLeadingEdge) } .focusSection() .mask { @@ -84,14 +87,13 @@ extension PosterHStack { init( title: String? = nil, type: PosterType, - items: [Item] + items: Binding> ) { self.init( title: title, type: type, items: items, - itemScale: 1, - content: { PosterButton.DefaultContentView(item: $0) }, + content: { PosterButton.TitleSubtitleContentView(item: $0) }, imageOverlay: { PosterButton.DefaultOverlay(item: $0) }, contextMenu: { _ in EmptyView() }, trailingContent: { EmptyView() }, @@ -99,12 +101,17 @@ extension PosterHStack { focusedItem: nil ) } -} -extension PosterHStack { - - func scaleItems(_ scale: CGFloat) -> Self { - copy(modifying: \.itemScale, with: scale) + init>( + title: String? = nil, + type: PosterType, + items: S + ) { + self.init( + title: title, + type: type, + items: .constant(OrderedSet(items)) + ) } func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { diff --git a/Swiftfin tvOS/Components/SeeAllPosterButton.swift b/Swiftfin tvOS/Components/SeeAllPosterButton.swift index bebe18c34..50dc8b582 100644 --- a/Swiftfin tvOS/Components/SeeAllPosterButton.swift +++ b/Swiftfin tvOS/Components/SeeAllPosterButton.swift @@ -30,7 +30,6 @@ struct SeeAllPosterButton: View { } } .posterStyle(type) - .frame(width: type.width) } .buttonStyle(.card) } diff --git a/Swiftfin tvOS/Views/BasicLibraryView.swift b/Swiftfin tvOS/Views/BasicLibraryView.swift deleted file mode 100644 index 609c98a01..000000000 --- a/Swiftfin tvOS/Views/BasicLibraryView.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import Defaults -import JellyfinAPI -import SwiftUI - -struct BasicLibraryView: View { - - @EnvironmentObject - private var router: BasicLibraryCoordinator.Router - - @ObservedObject - var viewModel: PagingLibraryViewModel - - @ViewBuilder - private var loadingView: some View { - ProgressView() - } - - // TODO: add retry - @ViewBuilder - private var noResultsView: some View { - L10n.noResults.text - } - - @ViewBuilder - private var libraryItemsView: some View { - PagingLibraryView(viewModel: viewModel) - .onSelect { item in - router.route(to: \.item, item) - } - .ignoresSafeArea() - } - - var body: some View { - if viewModel.isLoading && viewModel.items.isEmpty { - loadingView - } else if viewModel.items.isEmpty { - noResultsView - } else { - libraryItemsView - } - } -} diff --git a/Swiftfin tvOS/Views/CastAndCrewLibraryView.swift b/Swiftfin tvOS/Views/CastAndCrewLibraryView.swift deleted file mode 100644 index 9e7f9f57e..000000000 --- a/Swiftfin tvOS/Views/CastAndCrewLibraryView.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import JellyfinAPI -import SwiftUI - -struct CastAndCrewLibraryView: View { - - @EnvironmentObject - private var router: CastAndCrewLibraryCoordinator.Router - - let people: [BaseItemPerson] - - @ViewBuilder - private var noResultsView: some View { - L10n.noResults.text - } - - @ViewBuilder - private var libraryGridView: some View { - CollectionView(items: people) { _, person, _ in - PosterButton(item: person, type: .portrait) - .onSelect { - router.route(to: \.library, .init(parent: person, type: .person, filters: .init())) - } - } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .fixedNumberOfColumns(7), - lineSpacing: 50 - ) - } - .configure { configuration in - configuration.showsVerticalScrollIndicator = false - } - } - - var body: some View { - Group { - if people.isEmpty { - noResultsView - } else { - libraryGridView - } - } - .ignoresSafeArea() - } -} diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift index 8d6f1275e..ad0ad3a65 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift @@ -17,7 +17,7 @@ extension HomeView { private var router: HomeCoordinator.Router @ObservedObject - var viewModel: ItemTypeLibraryViewModel + var viewModel: RecentlyAddedLibraryViewModel private func itemSelectorImageSource(for item: BaseItemDto) -> ImageSource { if item.type == .episode { @@ -36,10 +36,9 @@ extension HomeView { } var body: some View { - CinematicItemSelector(items: viewModel.items.prefix(20).asArray) + CinematicItemSelector(items: viewModel.elements.elements) .topContent { item in ImageView(itemSelectorImageSource(for: item)) - .resizingMode(.bottomLeft) .placeholder { EmptyView() } @@ -48,17 +47,11 @@ extension HomeView { .font(.largeTitle) .fontWeight(.semibold) } - .padding2(.leading) + .edgePadding(.leading) } .onSelect { item in router.route(to: \.item, item) } - .trailingContent { - SeeAllPosterButton(type: .landscape) - .onSelect { - router.route(to: \.basicLibrary, .init(title: L10n.recentlyAdded, viewModel: viewModel)) - } - } } } } diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift index 41c38100f..0ebc817d2 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift @@ -36,10 +36,9 @@ extension HomeView { } var body: some View { - CinematicItemSelector(items: viewModel.resumeItems) + CinematicItemSelector(items: viewModel.resumeItems.elements) .topContent { item in ImageView(itemSelectorImageSource(for: item)) - .resizingMode(.bottomLeft) .placeholder { EmptyView() } @@ -48,7 +47,9 @@ extension HomeView { .font(.largeTitle) .fontWeight(.semibold) } - .padding2(.leading) + .edgePadding(.leading) + .aspectRatio(contentMode: .fit) + .frame(height: 200, alignment: .bottomLeading) } .content { item in if let subtitle = item.subtitle { diff --git a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift index 7af50a0f1..9ab31ce7c 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift @@ -16,29 +16,19 @@ extension HomeView { @EnvironmentObject private var router: HomeCoordinator.Router - @StateObject + @ObservedObject var viewModel: LatestInLibraryViewModel var body: some View { - PosterHStack( - title: L10n.latestWithString(viewModel.parent.displayTitle), - type: .portrait, - items: viewModel.items.prefix(20).asArray - ) - .trailing { - SeeAllPosterButton(type: .portrait) - .onSelect { - router.route( - to: \.basicLibrary, - .init( - title: L10n.latestWithString(viewModel.parent.displayTitle), - viewModel: viewModel - ) - ) - } - } - .onSelect { item in - router.route(to: \.item, item) + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), + type: .portrait, + items: $viewModel.elements + ) + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift index 218358d4d..b2e0b260c 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift @@ -13,40 +13,26 @@ extension HomeView { struct NextUpView: View { + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + @EnvironmentObject private var router: HomeCoordinator.Router + @ObservedObject var viewModel: NextUpLibraryViewModel - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType - var body: some View { - PosterHStack( - title: L10n.nextUp, - type: nextUpPosterType, - items: viewModel.items.prefix(20).asArray - ) - .trailing { - Button { - router.route(to: \.basicLibrary, .init(title: L10n.nextUp, viewModel: viewModel)) - } label: { - HStack { - L10n.seeAll.text - Image(systemName: "chevron.right") - } - .font(.subheadline.bold()) + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.nextUp, + type: nextUpPosterType, + items: $viewModel.elements + ) + .onSelect { item in + router.route(to: \.item, item) } } - .onSelect { item in - router.route(to: \.item, item) - } - .trailing { - SeeAllPosterButton(type: nextUpPosterType) - .onSelect { - router.route(to: \.basicLibrary, .init(title: L10n.nextUp, viewModel: viewModel)) - } - } } } } diff --git a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift index 7ae986d1d..41c5bdeb4 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift @@ -13,28 +13,25 @@ extension HomeView { struct RecentlyAddedView: View { + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + @EnvironmentObject private var router: HomeCoordinator.Router - @ObservedObject - var viewModel: ItemTypeLibraryViewModel - @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType + @ObservedObject + var viewModel: RecentlyAddedLibraryViewModel var body: some View { - PosterHStack( - title: L10n.recentlyAdded, - type: recentlyAddedPosterType, - items: viewModel.items.prefix(20).asArray - ) - .onSelect { item in - router.route(to: \.item, item) - } - .trailing { - SeeAllPosterButton(type: recentlyAddedPosterType) - .onSelect { - router.route(to: \.basicLibrary, .init(title: L10n.recentlyAdded, viewModel: viewModel)) - } + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.recentlyAdded, + type: recentlyAddedPosterType, + items: $viewModel.elements + ) + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin tvOS/Views/HomeView/HomeContentView.swift b/Swiftfin tvOS/Views/HomeView/HomeContentView.swift deleted file mode 100644 index 6b0b73a5f..000000000 --- a/Swiftfin tvOS/Views/HomeView/HomeContentView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct ContentView: View { - - @EnvironmentObject - private var router: HomeCoordinator.Router - @ObservedObject - var viewModel: HomeViewModel - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - - if viewModel.resumeItems.isEmpty { - CinematicRecentlyAddedView(viewModel: .init( - itemTypes: [.movie, .series], - filters: .init(sortOrder: [APISortOrder.descending.filter], sortBy: [SortBy.dateAdded.filter]) - )) - - if viewModel.hasNextUp { - NextUpView(viewModel: .init()) - } - } else { - CinematicResumeView(viewModel: viewModel) - - if viewModel.hasNextUp { - NextUpView(viewModel: .init()) - } - - if viewModel.hasRecentlyAdded { - RecentlyAddedView(viewModel: .init( - itemTypes: [.movie, .series], - filters: .init(sortOrder: [APISortOrder.descending.filter], sortBy: [SortBy.dateAdded.filter]) - )) - } - } - - ForEach(viewModel.libraries, id: \.self) { library in - LatestInLibraryView(viewModel: .init(parent: library)) - } - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift b/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift index 6e98925eb..41ed54101 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift @@ -8,6 +8,8 @@ import SwiftUI +// TODO: make general `ErrorView` like iOS + extension HomeView { struct ErrorView: View { @@ -37,7 +39,7 @@ extension HomeView { .multilineTextAlignment(.center) Button { - viewModel.refresh() +// viewModel.refresh() } label: { L10n.retry.text .bold() diff --git a/Swiftfin tvOS/Views/HomeView/HomeView.swift b/Swiftfin tvOS/Views/HomeView/HomeView.swift index c064a4833..293d5cb59 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeView.swift @@ -16,23 +16,50 @@ struct HomeView: View { @EnvironmentObject private var router: HomeCoordinator.Router - @ObservedObject - var viewModel: HomeViewModel + + @StateObject + private var viewModel = HomeViewModel() + + private var contentView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + + if viewModel.resumeItems.isNotEmpty { + CinematicResumeView(viewModel: viewModel) + + NextUpView(viewModel: viewModel.nextUpViewModel) + + RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) + } else { + CinematicRecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) + + NextUpView(viewModel: viewModel.nextUpViewModel) + } + + ForEach(viewModel.libraries) { viewModel in + LatestInLibraryView(viewModel: viewModel) + } + } + } + } var body: some View { - Group { - if let errorMessage = viewModel.errorMessage { - ErrorView( - viewModel: viewModel, - errorMessage: .init(message: errorMessage) - ) - } else if viewModel.isLoading { - ProgressView() - } else { - ContentView(viewModel: viewModel) + WrappedView { + Group { + switch viewModel.state { + case .content: + contentView + case let .error(error): + Text(error.localizedDescription) + case .initial, .refreshing: + ProgressView() + } } + .transition(.opacity.animation(.linear(duration: 0.2))) + } + .onFirstAppear { + viewModel.send(.refresh) } - .edgesIgnoringSafeArea(.top) - .edgesIgnoringSafeArea(.horizontal) + .ignoresSafeArea() } } diff --git a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift index 46165b96c..5209c5bed 100644 --- a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift @@ -25,10 +25,16 @@ extension CollectionItemView { .frame(height: UIScreen.main.bounds.height - 150) .padding(.bottom, 50) - PosterHStack(title: L10n.items, type: .portrait, items: viewModel.collectionItems) + if viewModel.collectionItems.isNotEmpty { + PosterHStack( + title: L10n.items, + type: .portrait, + items: viewModel.collectionItems + ) .onSelect { item in router.route(to: \.item, item) } + } ItemView.AboutView(viewModel: viewModel) } diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift index fd4a6d9d9..be9192504 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift @@ -33,7 +33,7 @@ extension ItemView { .imageOverlay { EmptyView() } - .scaleItem(1.35) + .frame(height: 405) OverviewCard(item: viewModel.item) diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift index e2a22a08f..3699cd87f 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift @@ -23,7 +23,7 @@ extension ItemView { Group { if viewModel.isPlayed { Image(systemName: "checkmark.circle.fill") - .accentSymbolRendering(accentColor: .white) + .paletteOverlayRendering(color: .white) } else { Image(systemName: "checkmark.circle") } diff --git a/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift index 6c5756f31..c957ca372 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift @@ -22,20 +22,11 @@ extension ItemView { PosterHStack( title: L10n.castAndCrew, type: .portrait, - items: people.filter(\.isDisplayed).prefix(20).asArray + items: people.filter(\.isDisplayed) ) - .trailing { - if people.isEmpty { - NonePosterButton(type: .portrait) - } else { - SeeAllPosterButton(type: .portrait) - .onSelect { - router.route(to: \.castAndCrew, people) - } - } - } .onSelect { person in - router.route(to: \.library, .init(parent: person, type: .person, filters: .init())) + let viewModel = ItemLibraryViewModel(parent: person) + router.route(to: \.library, viewModel) } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift index 5a1c6372c..1f712ba8a 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift @@ -19,25 +19,20 @@ extension ItemView { @EnvironmentObject private var router: ItemCoordinator.Router - let items: [BaseItemDto] + + @StateObject + private var viewModel: PagingLibraryViewModel + + init(items: [BaseItemDto]) { + self._viewModel = StateObject(wrappedValue: PagingLibraryViewModel(items, parent: BaseItemDto(name: L10n.recommended))) + } var body: some View { PosterHStack( title: L10n.recommended, type: similarPosterType, - items: items + items: $viewModel.elements ) - .trailing { - if items.isEmpty { - NonePosterButton(type: similarPosterType) - } else { - SeeAllPosterButton(type: similarPosterType) - .onSelect { - let viewModel = StaticLibraryViewModel(items: items) - router.route(to: \.basicLibrary, .init(title: L10n.recommended, viewModel: viewModel)) - } - } - } .onSelect { item in router.route(to: \.item, item) } diff --git a/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift index 13f2e58e6..4ff3c460f 100644 --- a/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift @@ -25,13 +25,19 @@ extension EpisodeItemView { .frame(height: UIScreen.main.bounds.height - 150) .padding(.bottom, 50) - ItemView.CastAndCrewHStack(people: viewModel.item.people ?? []) + if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { + ItemView.CastAndCrewHStack(people: castAndCrew) + } if let seriesItem = viewModel.seriesItem { - PosterHStack(title: L10n.series, type: .portrait, items: [seriesItem]) - .onSelect { item in - router.route(to: \.item, item) - } + PosterHStack( + title: L10n.series, + type: .portrait, + items: [seriesItem] + ) + .onSelect { item in + router.route(to: \.item, item) + } } ItemView.AboutView(viewModel: viewModel) @@ -112,11 +118,11 @@ extension EpisodeItemView.ContentView { HStack { DotHStack { if let premiereYear = viewModel.item.premiereDateYear { - premiereYear.text + Text(premiereYear) } - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { - runtime.text + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { + Text(runtime) } } .font(.caption) diff --git a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift index edcb84019..ca265576b 100644 --- a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift @@ -25,13 +25,17 @@ extension MovieItemView { .frame(height: UIScreen.main.bounds.height - 150) .padding(.bottom, 50) - ItemView.CastAndCrewHStack(people: viewModel.item.people ?? []) + if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { + ItemView.CastAndCrewHStack(people: castAndCrew) + } - if !viewModel.specialFeatures.isEmpty { + if viewModel.specialFeatures.isNotEmpty { ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) } - ItemView.SimilarItemsHStack(items: viewModel.similarItems) + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } ItemView.AboutView(viewModel: viewModel) } diff --git a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift index 98bc722dc..8b70129db 100644 --- a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift @@ -67,7 +67,6 @@ extension ItemView { maxWidth: UIScreen.main.bounds.width * 0.4, maxHeight: 250 )) - .resizingMode(.bottomLeft) .placeholder { EmptyView() } @@ -79,7 +78,9 @@ extension ItemView { .multilineTextAlignment(.leading) .foregroundColor(.white) } + .aspectRatio(contentMode: .fit) .padding(.bottom) + .frame(maxHeight: 250, alignment: .bottomLeading) if let tagline = viewModel.item.taglines?.first { Text(tagline) @@ -97,15 +98,15 @@ extension ItemView { DotHStack { if let firstGenre = viewModel.item.genres?.first { - firstGenre.text + Text(firstGenre) } if let premiereYear = viewModel.item.premiereDateYear { - premiereYear.text + Text(premiereYear) } - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { - runtime.text + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { + Text(runtime) } } .font(.caption) diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift index f18c39df7..5fd8e9997 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift @@ -29,7 +29,6 @@ struct EpisodeCard: View { type: .landscape, singleImage: true ) - .scaleItem(1.57) .content { Button { router.route(to: \.item, episode) @@ -67,7 +66,7 @@ struct EpisodeCard: View { .fontWeight(.medium) .foregroundColor(.jellyfinPurple) } - .frame(width: 510, height: 220) + .aspectRatio(510 / 220, contentMode: .fill) .padding() } .buttonStyle(.card) diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift index 8e5a763cb..9c88e0f2b 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import CollectionHStack import Introspect import JellyfinAPI import SwiftUI @@ -50,8 +51,8 @@ extension SeriesEpisodeSelector { ForEach(viewModel.menuSections.keys.sorted(by: { viewModel.menuSectionSort($0, $1) }), id: \.self) { season in Button { Text(season.displayTitle) + .font(.headline) .fontWeight(.semibold) - .fixedSize() .padding(.vertical, 10) .padding(.horizontal, 20) .if(viewModel.menuSelection == season) { text in @@ -60,8 +61,7 @@ extension SeriesEpisodeSelector { .foregroundColor(.black) } } - .buttonStyle(.plain) - .id(season) + .buttonStyle(.card) .focused($focusedSeason, equals: season) } } @@ -103,35 +103,15 @@ extension SeriesEpisodeSelector { @State private var wrappedScrollView: UIScrollView? - private var items: [BaseItemDto] { - guard let selection = viewModel.menuSelection, - let items = viewModel.menuSections[selection] else { return [.noResults] } - return items - } - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 40) { - if !items.isEmpty { - ForEach(items, id: \.self) { episode in - EpisodeCard(episode: episode) - .focused($focusedEpisodeID, equals: episode.id) - } - } else if viewModel.isLoading { - ForEach(1 ..< 10) { i in - EpisodeCard(episode: .placeHolder) - .redacted(reason: .placeholder) - .focused($focusedEpisodeID, equals: "\(i)") - } - } else { - EpisodeCard(episode: .noResults) - .focused($focusedEpisodeID, equals: "no-results") - } - } - .padding(.horizontal, 50) - .padding(.bottom, 50) - .padding(.top) + var contentView: some View { + CollectionHStack( + $viewModel.currentItems, + columns: 3.5 + ) { item in + EpisodeCard(episode: item) + .focused($focusedEpisodeID, equals: item.id) } + .verticalInsets(top: 20, bottom: 20) .mask { VStack(spacing: 0) { Color.white @@ -148,24 +128,30 @@ extension SeriesEpisodeSelector { } } .transition(.opacity) + .focusSection() .focusGuide( focusGuide, tag: "episodes", onContentFocus: { focusedEpisodeID = lastFocusedEpisodeID }, top: "seasons" ) - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } .onChange(of: viewModel.menuSelection) { _ in - lastFocusedEpisodeID = items.first?.id + lastFocusedEpisodeID = viewModel.currentItems.first?.id } .onChange(of: focusedEpisodeID) { episodeIndex in guard let episodeIndex = episodeIndex else { return } lastFocusedEpisodeID = episodeIndex } - .onChange(of: viewModel.menuSections) { _ in - lastFocusedEpisodeID = items.first?.id + .onChange(of: viewModel.currentItems) { _ in + lastFocusedEpisodeID = viewModel.currentItems.first?.id + } + } + + var body: some View { + if viewModel.currentItems.isEmpty { + EmptyView() + } else { + contentView } } } diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift index 22b1b0e79..beccadbb5 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift @@ -31,9 +31,13 @@ extension SeriesItemView { SeriesEpisodeSelector(viewModel: viewModel) .environmentObject(focusGuide) - ItemView.CastAndCrewHStack(people: viewModel.item.people ?? []) + if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { + ItemView.CastAndCrewHStack(people: castAndCrew) + } - ItemView.SimilarItemsHStack(items: viewModel.similarItems) + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } ItemView.AboutView(viewModel: viewModel) } diff --git a/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift deleted file mode 100644 index b3dff1ef6..000000000 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import Defaults -import JellyfinAPI -import SwiftUI - -struct LibraryView: View { - - @EnvironmentObject - private var router: LibraryCoordinator.Router - - @ObservedObject - var viewModel: LibraryViewModel - - @ViewBuilder - private var loadingView: some View { - ProgressView() - } - - // TODO: add retry - @ViewBuilder - private var noResultsView: some View { - L10n.noResults.text - } - - private func baseItemOnSelect(_ item: BaseItemDto) { - if let baseParent = viewModel.parent as? BaseItemDto { - if baseParent.collectionType == "folders" { - router.route(to: \.library, .init(parent: item, type: .folders, filters: .init())) - } else if item.type == .folder { - router.route(to: \.library, .init(parent: item, type: .library, filters: .init())) - } else { - router.route(to: \.item, item) - } - } else { - router.route(to: \.item, item) - } - } - - @ViewBuilder - private var libraryItemsView: some View { - PagingLibraryView(viewModel: viewModel) - .onSelect { item in - baseItemOnSelect(item) - } - .ignoresSafeArea() - } - - var body: some View { - if viewModel.isLoading && viewModel.items.isEmpty { - loadingView - } else if viewModel.items.isEmpty { - noResultsView - } else { - libraryItemsView - } - } -} diff --git a/Swiftfin tvOS/Views/LiveTVProgramsView.swift b/Swiftfin tvOS/Views/LiveTVProgramsView.swift index 2db91a290..321e8a3a0 100644 --- a/Swiftfin tvOS/Views/LiveTVProgramsView.swift +++ b/Swiftfin tvOS/Views/LiveTVProgramsView.swift @@ -21,7 +21,7 @@ struct LiveTVProgramsView: View { var body: some View { ScrollView { LazyVStack(alignment: .leading) { - if !viewModel.recommendedItems.isEmpty { + if viewModel.recommendedItems.isNotEmpty { let items = viewModel.recommendedItems Text("On Now") .font(.headline) @@ -46,7 +46,7 @@ struct LiveTVProgramsView: View { } }.frame(height: 350) } - if !viewModel.seriesItems.isEmpty { + if viewModel.seriesItems.isNotEmpty { let items = viewModel.seriesItems Text("Shows") .font(.headline) @@ -71,7 +71,7 @@ struct LiveTVProgramsView: View { } }.frame(height: 350) } - if !viewModel.movieItems.isEmpty { + if viewModel.movieItems.isNotEmpty { let items = viewModel.movieItems Text("Movies") .font(.headline) @@ -96,7 +96,7 @@ struct LiveTVProgramsView: View { } }.frame(height: 350) } - if !viewModel.sportsItems.isEmpty { + if viewModel.sportsItems.isNotEmpty { let items = viewModel.sportsItems Text("Sports") .font(.headline) @@ -121,7 +121,7 @@ struct LiveTVProgramsView: View { } }.frame(height: 350) } - if !viewModel.kidsItems.isEmpty { + if viewModel.kidsItems.isNotEmpty { let items = viewModel.kidsItems Text("Kids") .font(.headline) @@ -146,7 +146,7 @@ struct LiveTVProgramsView: View { } }.frame(height: 350) } - if !viewModel.newsItems.isEmpty { + if viewModel.newsItems.isNotEmpty { let items = viewModel.newsItems Text("News") .font(.headline) diff --git a/Swiftfin tvOS/Views/MediaSourceInfoView.swift b/Swiftfin tvOS/Views/MediaSourceInfoView.swift index 6b4f2355e..c811e979e 100644 --- a/Swiftfin tvOS/Views/MediaSourceInfoView.swift +++ b/Swiftfin tvOS/Views/MediaSourceInfoView.swift @@ -31,7 +31,7 @@ struct MediaSourceInfoView: View { HStack { Form { if let videoStreams = source.videoStreams, - !videoStreams.isEmpty + videoStreams.isNotEmpty { Section(L10n.video) { ForEach(videoStreams, id: \.self) { stream in @@ -44,7 +44,7 @@ struct MediaSourceInfoView: View { } if let audioStreams = source.audioStreams, - !audioStreams.isEmpty + audioStreams.isNotEmpty { Section(L10n.audio) { ForEach(audioStreams, id: \.self) { stream in @@ -57,7 +57,7 @@ struct MediaSourceInfoView: View { } if let subtitleStreams = source.subtitleStreams, - !subtitleStreams.isEmpty + subtitleStreams.isNotEmpty { Section(L10n.subtitle) { ForEach(subtitleStreams, id: \.self) { stream in @@ -80,7 +80,7 @@ struct MediaSourceInfoView: View { } } - if !lastSelectedMediaStream.colorProperties.isEmpty { + if lastSelectedMediaStream.colorProperties.isNotEmpty { Section(L10n.color) { ForEach(lastSelectedMediaStream.colorProperties) { property in Button { @@ -90,7 +90,7 @@ struct MediaSourceInfoView: View { } } - if !lastSelectedMediaStream.deliveryProperties.isEmpty { + if lastSelectedMediaStream.deliveryProperties.isNotEmpty { Section(L10n.delivery) { ForEach(lastSelectedMediaStream.deliveryProperties) { property in Button { diff --git a/Swiftfin tvOS/Views/MediaView.swift b/Swiftfin tvOS/Views/MediaView.swift index ea7689397..94fff238f 100644 --- a/Swiftfin tvOS/Views/MediaView.swift +++ b/Swiftfin tvOS/Views/MediaView.swift @@ -6,7 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionView +import CollectionVGrid import Defaults import JellyfinAPI import Stinsen @@ -15,100 +15,138 @@ import SwiftUI struct MediaView: View { @EnvironmentObject - private var tabRouter: MainCoordinator.Router + private var mainRouter: MainCoordinator.Router @EnvironmentObject private var router: MediaCoordinator.Router - @ObservedObject - var viewModel: MediaViewModel + @StateObject + private var viewModel = MediaViewModel() - var body: some View { - CollectionView(items: viewModel.libraryItems) { _, viewModel, _ in - LibraryCard(viewModel: viewModel) + private var contentView: some View { + CollectionVGrid( + $viewModel.mediaItems, + layout: .columns(4, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + ) { mediaType in + MediaItem(viewModel: viewModel, type: mediaType) .onSelect { - switch viewModel.item.collectionType { - case "favorites": - router.route(to: \.library, .init(parent: viewModel.item, type: .library, filters: .favorites)) - case "folders": - router.route(to: \.library, .init(parent: viewModel.item, type: .folders, filters: .init())) - case "liveTV": - tabRouter.root(\.liveTV) - default: - router.route(to: \.library, .init(parent: viewModel.item, type: .library, filters: .init())) + switch mediaType { + case .downloads: () + case .favorites: + let viewModel = ItemLibraryViewModel( + title: L10n.favorites, + filters: .favorites + ) + router.route(to: \.library, viewModel) + case .liveTV: + mainRouter.root(\.liveTV) + case let .userView(item): + let viewModel = ItemLibraryViewModel( + parent: item, + filters: .default + ) + router.route(to: \.library, viewModel) } } } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .adaptive(withMinItemSize: 400), - lineSpacing: 50, - itemSize: .estimated(400), - sectionInsets: .zero - ) + } + + var body: some View { + WrappedView { + Group { + switch viewModel.state { + case .content: + contentView + case let .error(error): + Text(error.localizedDescription) + case .initial, .refreshing: + ProgressView() + } + } + .transition(.opacity.animation(.linear(duration: 0.2))) } .ignoresSafeArea() + .onFirstAppear { + viewModel.send(.refresh) + } } } extension MediaView { - struct LibraryCard: View { + // TODO: custom view for folders and tv (allow customization?) + struct MediaItem: View { + + @Default(.Customization.Library.randomImage) + private var useRandomImage @ObservedObject - var viewModel: MediaItemViewModel + var viewModel: MediaViewModel + + @State + private var imageSources: [ImageSource] = [] private var onSelect: () -> Void + private let mediaType: MediaViewModel.MediaType + + init(viewModel: MediaViewModel, type: MediaViewModel.MediaType) { + self.viewModel = viewModel + self.onSelect = {} + self.mediaType = type + } - private var itemWidth: CGFloat { - PosterType.landscape.width * (UIDevice.isPhone ? 0.85 : 1) + private func setImageSources() { + Task { @MainActor in + if useRandomImage { + self.imageSources = try await viewModel.randomItemImageSources(for: mediaType) + return + } + + if case let MediaViewModel.MediaType.userView(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } + } } var body: some View { Button { onSelect() } label: { - Group { - if let imageSources = viewModel.imageSources { - ImageView(imageSources) - } else { - ImageView(nil) - } - } - .overlay { - if Defaults[.Customization.Library.randomImage] || - viewModel.item.collectionType == "favorites" + ZStack { + Color.clear + + ImageView(imageSources) + .id(imageSources.hashValue) + + if useRandomImage || + mediaType == .favorites || + mediaType == .downloads { ZStack { Color.black .opacity(0.5) - Text(viewModel.item.displayTitle) + Text(mediaType.displayTitle) .foregroundColor(.white) .font(.title2) .fontWeight(.semibold) - .lineLimit(2) + .lineLimit(1) .multilineTextAlignment(.center) .frame(alignment: .center) } } } .posterStyle(.landscape) - .frame(width: itemWidth) } .buttonStyle(.card) + .onFirstAppear(perform: setImageSources) + .onChange(of: useRandomImage) { _ in + setImageSources() + } } } } -extension MediaView.LibraryCard { - - init(viewModel: MediaItemViewModel) { - self.init( - viewModel: viewModel, - onSelect: {} - ) - } +extension MediaView.MediaItem { func onSelect(_ action: @escaping () -> Void) -> Self { copy(modifying: \.onSelect, with: action) diff --git a/Swiftfin tvOS/Views/SearchView.swift b/Swiftfin tvOS/Views/SearchView.swift index 2706ec811..18a790b65 100644 --- a/Swiftfin tvOS/Views/SearchView.swift +++ b/Swiftfin tvOS/Views/SearchView.swift @@ -6,50 +6,66 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Defaults import JellyfinAPI import SwiftUI struct SearchView: View { + @Default(.Customization.searchPosterType) + private var searchPosterType + @EnvironmentObject private var router: SearchCoordinator.Router - @ObservedObject - var viewModel: SearchViewModel + @StateObject + private var viewModel = SearchViewModel() @State - private var searchText = "" + private var searchQuery = "" + + private var suggestionsView: some View { + VStack(spacing: 20) { + ForEach(viewModel.suggestions) { item in + Button(item.displayTitle) { + searchQuery = item.displayTitle + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } - @ViewBuilder private var resultsView: some View { ScrollView(showsIndicators: false) { VStack(spacing: 20) { - if !viewModel.movies.isEmpty { - itemsSection(title: L10n.movies, keyPath: \.movies) + if viewModel.movies.isNotEmpty { + itemsSection(title: L10n.movies, keyPath: \.movies, posterType: searchPosterType) } - if !viewModel.collections.isEmpty { - itemsSection(title: L10n.collections, keyPath: \.collections) + if viewModel.series.isNotEmpty { + itemsSection(title: L10n.tvShows, keyPath: \.series, posterType: searchPosterType) } - if !viewModel.series.isEmpty { - itemsSection(title: L10n.tvShows, keyPath: \.series) + if viewModel.collections.isNotEmpty { + itemsSection(title: L10n.collections, keyPath: \.collections, posterType: searchPosterType) } - if !viewModel.episodes.isEmpty { - itemsSection(title: L10n.episodes, keyPath: \.episodes) + if viewModel.episodes.isNotEmpty { + itemsSection(title: L10n.episodes, keyPath: \.episodes, posterType: searchPosterType) } - if !viewModel.people.isEmpty { - itemsSection(title: L10n.people, keyPath: \.people) + if viewModel.people.isNotEmpty { + itemsSection(title: L10n.people, keyPath: \.people, posterType: .portrait) } } - }.ignoresSafeArea(edges: [.bottom, .horizontal]) + } } - private func baseItemOnSelect(_ item: BaseItemDto) { + private func select(_ item: BaseItemDto) { if item.type == .person { - router.route(to: \.library, .init(parent: item, type: .person, filters: .init())) + let viewModel = ItemLibraryViewModel(parent: item) + router.route(to: \.library, viewModel) } else { router.route(to: \.item, item) } @@ -58,31 +74,44 @@ struct SearchView: View { @ViewBuilder private func itemsSection( title: String, - keyPath: ReferenceWritableKeyPath + keyPath: ReferenceWritableKeyPath, + posterType: PosterType ) -> some View { PosterHStack( title: title, - type: .portrait, + type: posterType, items: viewModel[keyPath: keyPath] ) - .onSelect { item in - baseItemOnSelect(item) - } + .onSelect(select) } var body: some View { - Group { - if searchText.isEmpty { - EmptyView() - } else if !viewModel.isLoading && viewModel.noResults { - L10n.noResults.text - } else { - resultsView + WrappedView { + Group { + switch viewModel.state { + case let .error(error): + Text(error.localizedDescription) + case .initial: + suggestionsView + case .content: + if viewModel.hasNoResults { + L10n.noResults.text + } else { + resultsView + } + case .searching: + ProgressView() + } } + .transition(.opacity.animation(.linear(duration: 0.2))) + } + .ignoresSafeArea(edges: [.bottom, .horizontal]) + .onFirstAppear { + viewModel.send(.getSuggestions) } - .onChange(of: searchText) { newText in - viewModel.search(with: newText) + .onChange(of: searchQuery) { newValue in + viewModel.send(.search(query: newValue)) } - .searchable(text: $searchText, prompt: L10n.search) + .searchable(text: $searchQuery, prompt: L10n.search) } } diff --git a/Swiftfin tvOS/Views/ServerListView.swift b/Swiftfin tvOS/Views/ServerListView.swift index 2e5541576..e3f5c096d 100644 --- a/Swiftfin tvOS/Views/ServerListView.swift +++ b/Swiftfin tvOS/Views/ServerListView.swift @@ -98,9 +98,9 @@ struct ServerListView: View { // var body: some View { // innerBody // .navigationTitle(L10n.servers) -// .if(!viewModel.servers.isEmpty) { view in +// .if(viewModel.servers.isNotEmpty) { view in // view.toolbar { -// ToolbarItem(placement: .navigationBarTrailing) { +// ToolbarItem(placement: .topBarTrailing) { // SFSymbolButton(systemName: "plus.circle.fill") // .onSelect { // router.route(to: \.connectToServer) diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift index 296aa8afc..fad753f9c 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift @@ -28,8 +28,8 @@ struct CustomizeViewsSettings: View { private var similarPosterType @Default(.Customization.searchPosterType) private var searchPosterType - @Default(.Customization.Library.gridPosterType) - private var libraryGridPosterType + @Default(.Customization.Library.viewType) + private var libraryViewType @Default(.Customization.Library.cinematicBackground) private var cinematicBackground @@ -79,7 +79,7 @@ struct CustomizeViewsSettings: View { InlineEnumToggle(title: L10n.search, selection: $searchPosterType) - InlineEnumToggle(title: L10n.library, selection: $libraryGridPosterType) + InlineEnumToggle(title: L10n.library, selection: $libraryViewType) } header: { Text("Posters") diff --git a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift index b7cdd5819..aba22d076 100644 --- a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -16,8 +16,6 @@ struct ExperimentalSettingsView: View { @Default(.Experimental.syncSubtitleStateWithAdjacent) private var syncSubtitleStateWithAdjacent - @Default(.Experimental.liveTVAlphaEnabled) - private var liveTVAlphaEnabled @Default(.Experimental.liveTVForceDirectPlay) private var liveTVForceDirectPlay @@ -42,8 +40,6 @@ struct ExperimentalSettingsView: View { Section { - Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) - Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) } header: { diff --git a/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift index 3110a3e7f..019424d7e 100644 --- a/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift @@ -45,7 +45,7 @@ struct VideoPlayerSettingsView: View { Section { ChevronButton( title: "Resume Offset", - subtitle: resumeOffset.secondFormat + subtitle: resumeOffset.secondLabel ) .onSelect { isPresentingResumeOffsetStepper = true @@ -79,7 +79,7 @@ struct VideoPlayerSettingsView: View { step: 1 ) .valueFormatter { - $0.secondFormat + $0.secondLabel } .onCloseSelected { isPresentingResumeOffsetStepper = false diff --git a/Swiftfin tvOS/Views/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift index 6aa5b9fce..08bfe8387 100644 --- a/Swiftfin tvOS/Views/UserListView.swift +++ b/Swiftfin tvOS/Views/UserListView.swift @@ -84,9 +84,9 @@ struct UserListView: View { } } .navigationTitle(viewModel.server.name) - .if(!viewModel.users.isEmpty) { view in + .if(viewModel.users.isNotEmpty) { view in view.toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { Button { router.route(to: \.userSignIn, viewModel.server) } label: { diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift index e2c2715dc..808a039ef 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift @@ -30,7 +30,7 @@ extension VideoPlayer.Overlay { @ViewBuilder private var chaptersButton: some View { - if !viewModel.chapters.isEmpty { + if viewModel.chapters.isNotEmpty { ActionButtons.Chapters() } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift index 931c7e793..d6f6be9f8 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift @@ -64,39 +64,39 @@ extension VideoPlayer { isPresentingOverlay = false } } - .onSelectPressed { - currentOverlayType = .main - isPresentingOverlay = true - overlayTimer.start(5) - } - .onMenuPressed { - - overlayTimer.start(5) - confirmCloseWorkItem?.cancel() - - if isPresentingOverlay && currentOverlayType == .confirmClose { - proxy.stop() - router.dismissCoordinator() - } else if isPresentingOverlay && currentOverlayType == .smallMenu { - currentOverlayType = .main - } else { - withAnimation { - currentOverlayType = .confirmClose - isPresentingOverlay = true - } - - let task = DispatchWorkItem { - withAnimation { - isPresentingOverlay = false - overlayTimer.stop() - } - } - - confirmCloseWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) - } - } +// .onSelectPressed { +// currentOverlayType = .main +// isPresentingOverlay = true +// overlayTimer.start(5) +// } +// .onMenuPressed { +// +// overlayTimer.start(5) +// confirmCloseWorkItem?.cancel() +// +// if isPresentingOverlay && currentOverlayType == .confirmClose { +// proxy.stop() +// router.dismissCoordinator() +// } else if isPresentingOverlay && currentOverlayType == .smallMenu { +// currentOverlayType = .main +// } else { +// withAnimation { +// currentOverlayType = .confirmClose +// isPresentingOverlay = true +// } +// +// let task = DispatchWorkItem { +// withAnimation { +// isPresentingOverlay = false +// overlayTimer.stop() +// } +// } +// +// confirmCloseWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// } } } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift index fc2454012..b13e8c73b 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift @@ -104,7 +104,7 @@ extension VideoPlayer { ScrollView(.horizontal, showsIndicators: false) { HStack { - if !viewModel.subtitleStreams.isEmpty { + if viewModel.subtitleStreams.isNotEmpty { SectionButton( section: .subtitles, focused: $focusedSection, @@ -112,7 +112,7 @@ extension VideoPlayer { ) } - if !viewModel.audioStreams.isEmpty { + if viewModel.audioStreams.isNotEmpty { SectionButton( section: .audio, focused: $focusedSection, diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index ba95b6353..62964db55 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -3,19 +3,16 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; - 4E8B34EA2AB91B6E0018F305 /* FilterDrawerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* FilterDrawerSelection.swift */; }; - 4E8B34EB2AB91B6E0018F305 /* FilterDrawerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* FilterDrawerSelection.swift */; }; - 4EAA35BB2AB9699B00D840DD /* FilterDrawerButtonSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EAA35BA2AB9699B00D840DD /* FilterDrawerButtonSelectorView.swift */; }; - 4F1282B12A7F3E8F005BCA29 /* RandomItemButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1282B02A7F3E8F005BCA29 /* RandomItemButton.swift */; }; + 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; + 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; - 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; }; 53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; }; @@ -30,7 +27,7 @@ 535870672669D21700D05A09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870662669D21700D05A09 /* Assets.xcassets */; }; 5358707E2669D64F00D05A09 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; 535870912669D7A800D05A09 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 535870902669D7A800D05A09 /* Introspect */; }; - 535870AD2669D8DD00D05A09 /* ItemFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilters.swift */; }; + 535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilterCollection.swift */; }; 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */; }; 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */; }; @@ -65,7 +62,6 @@ 53913C1426D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BED26D323FE00EB3286 /* Localizable.strings */; }; 5398514526B64DA100101B49 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5398514426B64DA100101B49 /* SettingsView.swift */; }; 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */; }; - 53A83C33268A309300DF3D92 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A83C32268A309300DF3D92 /* LibraryView.swift */; }; 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53ABFDDB267972BF00886593 /* TVServices.framework */; }; 53ABFDE4267974EF00886593 /* MediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* MediaViewModel.swift */; }; 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; @@ -75,7 +71,6 @@ 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ABFDEA2679753200886593 /* ConnectToServerView.swift */; }; 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A3F268A49C2002ABD4E /* ItemView.swift */; }; 53EE24E6265060780068F029 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* SearchView.swift */; }; - 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */; }; 62133890265F83A900A81A2A /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213388F265F83A900A81A2A /* MediaView.swift */; }; 621338932660107500A81A2A /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* String.swift */; }; 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; @@ -138,8 +133,8 @@ 62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URL.swift */; }; 62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* SearchViewModel.swift */; }; 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* SearchViewModel.swift */; }; - 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */; }; - 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */; }; + 62E632E0267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* ItemLibraryViewModel.swift */; }; + 62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* ItemLibraryViewModel.swift */; }; 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */; }; 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */; }; 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */; }; @@ -151,8 +146,6 @@ 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */; }; 6334175B287DDFB9000603CE /* QuickConnectSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */; }; 6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */; }; - 8BF1BD842AE93DFB00C3B271 /* LatestInLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF1BD832AE93DFB00C3B271 /* LatestInLibraryViewModel.swift */; }; - 8BF1BD852AE93DFB00C3B271 /* LatestInLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF1BD832AE93DFB00C3B271 /* LatestInLibraryViewModel.swift */; }; AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; BD0BA22B2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; }; BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; }; @@ -184,9 +177,15 @@ E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; - E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; }; + E1047E2327E5880000CB0D4A /* TypeSystemNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* TypeSystemNameView.swift */; }; E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */; }; E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */; }; + E104DC8D2B9D8979008F506D /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC8C2B9D8979008F506D /* CollectionHStack */; }; + E104DC902B9D8995008F506D /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC8F2B9D8995008F506D /* CollectionVGrid */; }; + E104DC922B9D89A2008F506D /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC912B9D89A2008F506D /* CollectionHStack */; }; + E104DC942B9D89A2008F506D /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC932B9D89A2008F506D /* CollectionVGrid */; }; + E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104DC952B9E7E29008F506D /* AssertionFailureView.swift */; }; + E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104DC952B9E7E29008F506D /* AssertionFailureView.swift */; }; E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; }; E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E10706112942F57D00646DAF /* PulseLogHandler */; }; E10706142942F57D00646DAF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E10706132942F57D00646DAF /* PulseUI */; }; @@ -197,6 +196,8 @@ E10E842A29A587110064EA49 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842929A587110064EA49 /* LoadingView.swift */; }; E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842B29A589860064EA49 /* NonePosterButton.swift */; }; E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSize.swift */; }; + E11042752B8013DF00821020 /* Stateful.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11042742B8013DF00821020 /* Stateful.swift */; }; + E11042762B8013DF00821020 /* Stateful.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11042742B8013DF00821020 /* Stateful.swift */; }; E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */; }; E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */; }; E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111D8F728D03BF900400001 /* PagingLibraryView.swift */; }; @@ -207,11 +208,14 @@ E113132B28BDB4B500930F75 /* NavBarDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113132A28BDB4B500930F75 /* NavBarDrawerView.swift */; }; E113132F28BDB66A00930F75 /* NavBarDrawerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113132E28BDB66A00930F75 /* NavBarDrawerModifier.swift */; }; E113133228BDC72000930F75 /* FilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133128BDC72000930F75 /* FilterView.swift */; }; - E113133428BE988200930F75 /* FilterDrawerHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133328BE988200930F75 /* FilterDrawerHStack.swift */; }; + E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133328BE988200930F75 /* NavigationBarFilterDrawer.swift */; }; E113133628BE98AA00930F75 /* FilterDrawerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133528BE98AA00930F75 /* FilterDrawerButton.swift */; }; E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133728BEADBA00930F75 /* LibraryParent.swift */; }; E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133928BEB71D00930F75 /* FilterViewModel.swift */; }; E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133928BEB71D00930F75 /* FilterViewModel.swift */; }; + E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A62B5A178D009CAAAA /* CollectionHStack */; }; + E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A92B5A179A009CAAAA /* CollectionVGrid */; }; + E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E114DB322B1944FA00B75FB3 /* CollectionVGrid */; }; E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; }; E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; }; E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; }; @@ -223,6 +227,12 @@ E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; + E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF762B8513B40045C54A /* ItemGenre.swift */; }; + E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF762B8513B40045C54A /* ItemGenre.swift */; }; + E11BDF7A2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */; }; + E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */; }; + E11BDF972B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; }; + E11BDF982B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; }; E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; E11CEB8B28998552003E74C7 /* iOSViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8A28998552003E74C7 /* iOSViewExtensions.swift */; }; E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; }; @@ -240,13 +250,12 @@ E12376B02A33D6AE001F5B44 /* AboutViewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12376AF2A33D6AE001F5B44 /* AboutViewCard.swift */; }; E12376B12A33DB33001F5B44 /* MediaSourceInfoCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */; }; E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12376B22A33DFAC001F5B44 /* ItemOverviewView.swift */; }; - E1267D3E271A1F46003C492E /* PreferenceUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */; }; E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */; }; E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */; }; E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428728F0831F00796AC6 /* SplitTimestamp.swift */; }; E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428F28F0BDC300796AC6 /* TimeStampType.swift */; }; E129429328F2845000796AC6 /* SliderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429228F2845000796AC6 /* SliderType.swift */; }; - E129429828F4785200796AC6 /* EnumPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429728F4785200796AC6 /* EnumPicker.swift */; }; + E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429728F4785200796AC6 /* CaseIterablePicker.swift */; }; E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429A28F4A5E300796AC6 /* PlaybackSettingsView.swift */; }; E12A9EF829499E0100731C3A /* JellyfinClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12A9EF729499E0100731C3A /* JellyfinClient.swift */; }; E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12A9EF729499E0100731C3A /* JellyfinClient.swift */; }; @@ -254,14 +263,9 @@ E12CC1AE28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */; }; E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */; }; E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1B028D1008F00678D5D /* NextUpView.swift */; }; - E12CC1B528D1124400678D5D /* BasicLibraryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1B428D1124400678D5D /* BasicLibraryCoordinator.swift */; }; - E12CC1B628D1124400678D5D /* BasicLibraryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1B428D1124400678D5D /* BasicLibraryCoordinator.swift */; }; - E12CC1B928D11A1D00678D5D /* BasicLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1B828D11A1D00678D5D /* BasicLibraryView.swift */; }; E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1BA28D11E1000678D5D /* RecentlyAddedViewModel.swift */; }; E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1BA28D11E1000678D5D /* RecentlyAddedViewModel.swift */; }; E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1BD28D11F4500678D5D /* RecentlyAddedView.swift */; }; - E12CC1BF28D1260600678D5D /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; - E12CC1C128D12B0A00678D5D /* BasicLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C028D12B0A00678D5D /* BasicLibraryView.swift */; }; E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */; }; E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C628D12FD600678D5D /* CinematicRecentlyAddedView.swift */; }; E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C828D132B800678D5D /* RecentlyAddedView.swift */; }; @@ -273,8 +277,6 @@ E12F038C28F8B0B100976CC3 /* EdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */; }; E13316FE2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */; }; E13316FF2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */; }; - E13317012ADE4B8D009BF865 /* PaddingMultiplierModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13317002ADE4B8D009BF865 /* PaddingMultiplierModifier.swift */; }; - E13317022ADE4B8D009BF865 /* PaddingMultiplierModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13317002ADE4B8D009BF865 /* PaddingMultiplierModifier.swift */; }; E133328829538D8D00EE76AB /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328729538D8D00EE76AB /* Files.swift */; }; E133328929538D8D00EE76AB /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328729538D8D00EE76AB /* Files.swift */; }; E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328C2953AE4B00EE76AB /* CircularProgressView.swift */; }; @@ -310,8 +312,7 @@ E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3FB2717EAE8009D4DAF /* UserListView.swift */; }; E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; - E13F05F128BC9016003499D2 /* LibraryItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */; }; - E13F05F328BC9016003499D2 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05F028BC9016003499D2 /* LibraryView.swift */; }; + E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EF28BC9016003499D2 /* LibraryRow.swift */; }; E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */; }; E1401CA22938122C00E8B599 /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA12938122C00E8B599 /* AppIcons.swift */; }; E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA4293813F400E8B599 /* InvertedDarkAppIcon.swift */; }; @@ -319,21 +320,26 @@ E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA82938140700E8B599 /* DarkAppIcon.swift */; }; E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CAA2938140A00E8B599 /* LightAppIcon.swift */; }; E1401CB129386C9200E8B599 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CB029386C9200E8B599 /* UIColor.swift */; }; - E1401D45293A952300E8B599 /* MediaItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401D44293A952300E8B599 /* MediaItemViewModel.swift */; }; E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */; }; - E148128528C15472003B8787 /* APISortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* APISortOrder.swift */; }; - E148128628C15475003B8787 /* APISortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* APISortOrder.swift */; }; - E148128828C154BF003B8787 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilter.swift */; }; - E148128928C154BF003B8787 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilter.swift */; }; - E148128B28C15526003B8787 /* SortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128A28C15526003B8787 /* SortBy.swift */; }; + E148128528C15472003B8787 /* SortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* SortOrder.swift */; }; + E148128628C15475003B8787 /* SortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* SortOrder.swift */; }; + E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */; }; + E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */; }; + E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128A28C15526003B8787 /* ItemSortBy.swift */; }; E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; - E14A08CD28E68729004FC984 /* MenuPosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CC28E68729004FC984 /* MenuPosterHStack.swift */; }; E14CB6862A9FF62A001586C6 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E14CB6852A9FF62A001586C6 /* JellyfinAPI */; }; E14CB6882A9FF71F001586C6 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E14CB6872A9FF71F001586C6 /* JellyfinAPI */; }; + E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; }; + E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */; }; + E14EDEC82B8FB65F000F00A4 /* ItemFilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */; }; + E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */; }; + E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDECB2B8FB709000F00A4 /* ItemYear.swift */; }; + E14EDECD2B8FB709000F00A4 /* ItemYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EDECB2B8FB709000F00A4 /* ItemYear.swift */; }; E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E15210552946DF1B00375CC2 /* PulseLogHandler */; }; E15210582946DF1B00375CC2 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E15210572946DF1B00375CC2 /* PulseUI */; }; E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; }; E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; }; + E1523F822B132C350062821A /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1523F812B132C350062821A /* CollectionHStack */; }; E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546776289AF46E00087E35 /* CollectionItemView.swift */; }; E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546779289AF48200087E35 /* CollectionItemContentView.swift */; }; E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549655296CA2EF00C4EF88 /* DownloadTask.swift */; }; @@ -346,8 +352,6 @@ E1549665296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */; }; E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */; }; E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */; }; - E1549668296CA2EF00C4EF88 /* PlaybackManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965A296CA2EF00C4EF88 /* PlaybackManager.swift */; }; - E1549669296CA2EF00C4EF88 /* PlaybackManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965A296CA2EF00C4EF88 /* PlaybackManager.swift */; }; E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965B296CA2EF00C4EF88 /* DownloadManager.swift */; }; E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965B296CA2EF00C4EF88 /* DownloadManager.swift */; }; E154966E296CA2EF00C4EF88 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965D296CA2EF00C4EF88 /* LogManager.swift */; }; @@ -367,12 +371,12 @@ E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F328875037002A7A66 /* ItemViewType.swift */; }; E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EF4C402911B783008CC695 /* StreamType.swift */; }; - E1575E63293E77B5001665B1 /* EnumPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429728F4785200796AC6 /* EnumPicker.swift */; }; + E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429728F4785200796AC6 /* CaseIterablePicker.swift */; }; E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1575E66293E77B5001665B1 /* Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A60288F32DB00CB80AA /* Poster.swift */; }; E1575E67293E77B5001665B1 /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133728BEADBA00930F75 /* LibraryParent.swift */; }; - E1575E69293E77B5001665B1 /* SortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128A28C15526003B8787 /* SortBy.swift */; }; + E1575E69293E77B5001665B1 /* ItemSortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128A28C15526003B8787 /* ItemSortBy.swift */; }; E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */; }; E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55128C119D400311DFE /* Displayable.swift */; }; E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429228F2845000796AC6 /* SliderType.swift */; }; @@ -381,21 +385,16 @@ E1575E70293E77B5001665B1 /* TextPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528428FD191A00600579 /* TextPair.swift */; }; E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; E1575E72293E77B5001665B1 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429229340B8300D1041A /* Utilities.swift */; }; - E1575E73293E77B5001665B1 /* SelectorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB54E28C1197700311DFE /* SelectorType.swift */; }; E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */; }; E1575E75293E77B5001665B1 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756352936856700976E1F /* VideoPlayerType.swift */; }; - E1575E77293E77B5001665B1 /* MenuPosterHStackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656B28E78C1700592A73 /* MenuPosterHStackModel.swift */; }; E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */; }; - E1575E79293E77B5001665B1 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428F28F0BDC300796AC6 /* TimeStampType.swift */; }; - E1575E7B293E77B5001665B1 /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E306CC28EF6E8000537998 /* TimerProxy.swift */; }; E1575E7D293E77B5001665B1 /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; - E1575E7E293E77B5001665B1 /* ItemFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilters.swift */; }; + E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilterCollection.swift */; }; E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; - E1575E83293E784A001665B1 /* MediaItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401D44293A952300E8B599 /* MediaItemViewModel.swift */; }; E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA62938140300E8B599 /* PrimaryAppIcon.swift */; }; E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA82938140700E8B599 /* DarkAppIcon.swift */; }; E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA12938122C00E8B599 /* AppIcons.swift */; }; @@ -409,11 +408,10 @@ E1575E93293E7B1E001665B1 /* Float.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756312935642A00976E1F /* Float.swift */; }; E1575E94293E7B1E001665B1 /* VerticalAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528128FD126C00600579 /* VerticalAlignment.swift */; }; E1575E95293E7B1E001665B1 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; }; - E1575E96293E7B1E001665B1 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollView.swift */; }; E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplication.swift */; }; E1575E99293E7B1E001665B1 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CB029386C9200E8B599 /* UIColor.swift */; }; E1575E9A293E7B1E001665B1 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1644028BB301900323B0A /* Array.swift */; }; - E1575E9B293E7B1E001665B1 /* EnvironmentValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DEAC128EFCF590058F196 /* EnvironmentValue.swift */; }; + E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DEAC128EFCF590058F196 /* EnvironmentValue+Keys.swift */; }; E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* Collection.swift */; }; E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B33EAF28EA890D0073B0FD /* Equatable.swift */; }; E1575E9F293E7B1E001665B1 /* Int.swift in Sources */ = {isa = PBXBuildFile; fileRef = E139CC1E28EC83E400688DE2 /* Int.swift */; }; @@ -422,18 +420,22 @@ E1575EA2293E7B1E001665B1 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* Color.swift */; }; E1575EA3293E7B1E001665B1 /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDevice.swift */; }; E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1575EA5293E7D40001665B1 /* VideoPlayer.swift */; }; + E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1579EA62B97DC1500A31CA1 /* Eventful.swift */; }; + E1579EA82B97DC1500A31CA1 /* Eventful.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1579EA62B97DC1500A31CA1 /* Eventful.swift */; }; E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1581E26291EF59800D6C640 /* SplitContentView.swift */; }; E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */; }; - E158C8D32A31967600C527C5 /* ForEach.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158C8D22A31967600C527C5 /* ForEach.swift */; }; + E15D4F052B1B0C3C00442DB8 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E15D4F042B1B0C3C00442DB8 /* PreferencesView */; }; + E15D4F072B1B12C300442DB8 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D4F062B1B12C300442DB8 /* Backport.swift */; }; + E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D4F062B1B12C300442DB8 /* Backport.swift */; }; + E15D4F0A2B1BD88900442DB8 /* Edge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D4F092B1BD88900442DB8 /* Edge.swift */; }; + E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D4F092B1BD88900442DB8 /* Edge.swift */; }; E168BD10289A4162001A6922 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD08289A4162001A6922 /* HomeView.swift */; }; - E168BD11289A4162001A6922 /* HomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD09289A4162001A6922 /* HomeContentView.swift */; }; E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */; }; E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */; }; - E168BD15289A4162001A6922 /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0F289A4162001A6922 /* HomeErrorView.swift */; }; E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */; }; E16AA60828A364A6009A983C /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16AA60728A364A6009A983C /* PosterButton.swift */; }; E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16AF11B292C98A7001422A8 /* GestureSettingsView.swift */; }; - E16DEAC228EFCF590058F196 /* EnvironmentValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DEAC128EFCF590058F196 /* EnvironmentValue.swift */; }; + E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DEAC128EFCF590058F196 /* EnvironmentValue+Keys.swift */; }; E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D0E1294CC8000017224C /* VideoPlayer+Actions.swift */; }; E170D0E4294CC8AB0017224C /* VideoPlayer+KeyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D0E3294CC8AB0017224C /* VideoPlayer+KeyCommands.swift */; }; E170D103294CE8BF0017224C /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D102294CE8BF0017224C /* LoadingView.swift */; }; @@ -458,7 +460,6 @@ E17AC96F2954EE4B003D2BC2 /* DownloadListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */; }; E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */; }; E17AC9732955007A003D2BC2 /* DownloadTaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */; }; - E17FB54F28C1197700311DFE /* SelectorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB54E28C1197700311DFE /* SelectorType.swift */; }; E17FB55228C119D400311DFE /* Displayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55128C119D400311DFE /* Displayable.swift */; }; E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */; }; E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */; }; @@ -472,6 +473,8 @@ E187A60229AB28F0008387E6 /* RotateContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E187A60129AB28F0008387E6 /* RotateContentView.swift */; }; E187A60329AB28F0008387E6 /* RotateContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E187A60129AB28F0008387E6 /* RotateContentView.swift */; }; E187A60529AD2E25008387E6 /* StepperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E187A60429AD2E25008387E6 /* StepperView.swift */; }; + E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = E187F7662B8E6A1C005400FE /* EnvironmentValue+Values.swift */; }; + E187F7682B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = E187F7662B8E6A1C005400FE /* EnvironmentValue+Values.swift */; }; E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */; }; E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */; }; E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A17EF298C68B700C22F62 /* Overlay.swift */; }; @@ -492,7 +495,6 @@ E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDto.swift */; }; E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */; }; - E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A4288746AF0022598C /* RefreshableScrollView.swift */; }; E18E01AB288746AF0022598C /* PillHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A5288746AF0022598C /* PillHStack.swift */; }; E18E01AD288746AF0022598C /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A7288746AF0022598C /* DotHStack.swift */; }; E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */; }; @@ -518,17 +520,15 @@ E18E01F1288747230022598C /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D8288747230022598C /* PlayButton.swift */; }; E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D9288747230022598C /* ActionButtonHStack.swift */; }; E18E01FA288747580022598C /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01F3288747580022598C /* AboutAppView.swift */; }; - E18E0204288749200022598C /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* Divider.swift */; }; + E18E0204288749200022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; }; E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeStyleModifier.swift */; }; E18E0208288749200022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; E18E021C2887492B0022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeStyleModifier.swift */; }; - E18E021E2887492B0022598C /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* Divider.swift */; }; - E18E021F2887492B0022598C /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; }; + E18E021E2887492B0022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; }; + E18E021F2887492B0022598C /* TypeSystemNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* TypeSystemNameView.swift */; }; E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; - E18E023A288749540022598C /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollView.swift */; }; - E19169CE272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */; }; E1921B7628E63306003A5238 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7528E63306003A5238 /* GestureView.swift */; }; E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E192607F28D28AAD002314B4 /* UserProfileButton.swift */; }; @@ -574,9 +574,10 @@ E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A16C9C2889AF1E00EA4679 /* AboutView.swift */; }; E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplication.swift */; }; E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */; }; - E1A42E4C28CBD39300A14DCB /* HomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4B28CBD39300A14DCB /* HomeContentView.swift */; }; E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */; }; E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; }; + E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E1A7B1642B9A9F7800152546 /* PreferencesView */; }; + E1A7B1662B9ADAD300152546 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButton.swift */; }; E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */; }; @@ -589,10 +590,10 @@ E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */; }; E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; - E1B5784128F8AFCB00D42911 /* Wrapped View.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* Wrapped View.swift */; }; - E1B5784228F8AFCB00D42911 /* Wrapped View.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* Wrapped View.swift */; }; - E1B5861229E32EEF00E45D6E /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Set.swift */; }; - E1B5861329E32EEF00E45D6E /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Set.swift */; }; + E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* WrappedView.swift */; }; + E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* WrappedView.swift */; }; + E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Sequence.swift */; }; + E1B5861329E32EEF00E45D6E /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Sequence.swift */; }; E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A629577BCE004B26CF /* Pulse */; }; E1B5F7A929577BCE004B26CF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A829577BCE004B26CF /* PulseLogHandler */; }; E1B5F7AB29577BCE004B26CF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AA29577BCE004B26CF /* PulseUI */; }; @@ -636,15 +637,20 @@ E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */; }; E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */; }; E1CFE28028FA606800B7D34C /* ChapterTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CFE27F28FA606800B7D34C /* ChapterTrack.swift */; }; - E1D3043228D175CE00587289 /* StaticLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043128D175CE00587289 /* StaticLibraryViewModel.swift */; }; - E1D3043328D175CE00587289 /* StaticLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043128D175CE00587289 /* StaticLibraryViewModel.swift */; }; E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043428D1763100587289 /* SeeAllButton.swift */; }; - E1D3043A28D189C500587289 /* CastAndCrewLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043928D189C500587289 /* CastAndCrewLibraryView.swift */; }; - E1D3043C28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043B28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift */; }; - E1D3043D28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043B28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift */; }; - E1D3043F28D18F5700587289 /* CastAndCrewLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043E28D18F5700587289 /* CastAndCrewLibraryView.swift */; }; - E1D3044228D1976600587289 /* CastAndCrewItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3044128D1976600587289 /* CastAndCrewItemRow.swift */; }; E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */; }; + E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F472B9C648E00343D2B /* MaxHeightText.swift */; }; + E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F472B9C648E00343D2B /* MaxHeightText.swift */; }; + E1D37F4B2B9CEA5C00343D2B /* ImageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */; }; + E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */; }; + E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */; }; + E1D37F4F2B9CEDC400343D2B /* DeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */; }; + E1D37F522B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */; }; + E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */; }; + E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */; }; + E1D37F562B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */; }; + E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */; }; + E1D37F592B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */; }; E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; }; E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */; }; @@ -661,9 +667,6 @@ E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429429346C6400D1041A /* BasicStepper.swift */; }; E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */; }; E1DA654C28E69B0500592A73 /* SpecialFeatureType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */; }; - E1DA656928E78B5900592A73 /* SpecialFeaturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656828E78B5900592A73 /* SpecialFeaturesViewModel.swift */; }; - E1DA656A28E78B5900592A73 /* SpecialFeaturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656828E78B5900592A73 /* SpecialFeaturesViewModel.swift */; }; - E1DA656C28E78C1700592A73 /* MenuPosterHStackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656B28E78C1700592A73 /* MenuPosterHStackModel.swift */; }; E1DA656F28E78C9900592A73 /* SeriesEpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */; }; E1DABAFA2A270E62008AC34A /* OverviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAF92A270E62008AC34A /* OverviewCard.swift */; }; E1DABAFC2A270EE7008AC34A /* MediaSourcesCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAFB2A270EE7008AC34A /* MediaSourcesCard.swift */; }; @@ -681,6 +684,12 @@ E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9843296DECB600982F06 /* ProgressIndicator.swift */; }; E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; + E1DD55372B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; + E1DD55382B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; + E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE2B492B97ECB900F6715F /* ErrorView.swift */; }; + E1DE2B4C2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */; }; + E1DE2B4D2B9838B200F6715F /* PaletteOverlayRenderingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */; }; + E1DE84142B9531C1008CCE21 /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */; }; E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */; }; E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */; }; E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1643928BAC2EF00323B0A /* SearchView.swift */; }; @@ -689,8 +698,13 @@ E1E1644128BB301900323B0A /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1644028BB301900323B0A /* Array.swift */; }; E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */; }; E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */; }; + E1E2F83F2B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */; }; + E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */; }; + E1E2F8422B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */; }; + E1E2F8432B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */; }; + E1E2F8452B757E3400B75998 /* AfterLastDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */; }; + E1E2F8462B757E3400B75998 /* AfterLastDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */; }; E1E306CD28EF6E8000537998 /* TimerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E306CC28EF6E8000537998 /* TimerProxy.swift */; }; - E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */; }; E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* VideoPlayerSettingsView.swift */; }; E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */; }; E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */; }; @@ -720,13 +734,16 @@ E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */; }; E1EBCB42278BD174009FE6E9 /* TruncatedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */; }; E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */; }; + E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED91142B95897500802036 /* LatestInLibraryViewModel.swift */; }; + E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED91142B95897500802036 /* LatestInLibraryViewModel.swift */; }; + E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED91172B95993300802036 /* TitledLibraryParent.swift */; }; + E1ED91192B95993300802036 /* TitledLibraryParent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED91172B95993300802036 /* TitledLibraryParent.swift */; }; E1EF473A289A0F610034046B /* TruncatedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */; }; E1EF4C412911B783008CC695 /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EF4C402911B783008CC695 /* StreamType.swift */; }; E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */; }; E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */; }; E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E1FAD1C52A0375BA007F5521 /* UDPBroadcast */; }; - E1FBDB6629D0F336003DD5E2 /* KeyCommandAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FBDB6529D0F336003DD5E2 /* KeyCommandAction.swift */; }; E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; @@ -766,12 +783,9 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; - 4E8B34E92AB91B6E0018F305 /* FilterDrawerSelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterDrawerSelection.swift; sourceTree = ""; }; - 4EAA35BA2AB9699B00D840DD /* FilterDrawerButtonSelectorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterDrawerButtonSelectorView.swift; sourceTree = ""; }; - 4F1282B02A7F3E8F005BCA29 /* RandomItemButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomItemButton.swift; sourceTree = ""; }; + 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlainNavigationLinkButton.swift; sourceTree = ""; }; - 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileBuilder.swift; sourceTree = ""; }; 531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; @@ -782,7 +796,7 @@ 535870622669D21600D05A09 /* SwiftfinApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfinApp.swift; sourceTree = ""; }; 535870662669D21700D05A09 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 535870702669D21700D05A09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 535870AC2669D8DD00D05A09 /* ItemFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilters.swift; sourceTree = ""; }; + 535870AC2669D8DD00D05A09 /* ItemFilterCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilterCollection.swift; sourceTree = ""; }; 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; 5362E4A7267D4067000E2F71 /* GoogleCast.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleCast.framework; path = "../../Downloads/GoogleCastSDK-ios-4.6.0_dynamic/GoogleCast.framework"; sourceTree = ""; }; 5362E4AA267D40AD000E2F71 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; @@ -821,13 +835,11 @@ 53913BEE26D323FE00EB3286 /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = Localizable.strings; sourceTree = ""; }; 5398514426B64DA100101B49 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 53A83C32268A309300DF3D92 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 53ABFDDB267972BF00886593 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.0.sdk/System/Library/Frameworks/TVServices.framework; sourceTree = DEVELOPER_DIR; }; 53ABFDEA2679753200886593 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; 53CD2A3F268A49C2002ABD4E /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Carthage/Build/MobileVLCKit.xcframework; sourceTree = ""; }; 53EE24E5265060780068F029 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; - 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingSwizzling.swift; sourceTree = ""; }; 6213388F265F83A900A81A2A /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; 621338922660107500A81A2A /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; @@ -881,7 +893,7 @@ 62C83B07288C6A630004ED0C /* FontPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontPickerView.swift; sourceTree = ""; }; 62E1DCC2273CE19800C9AE76 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 62E632DB267D2E130063E547 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; - 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; + 62E632DF267D30CA0063E547 /* ItemLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLibraryViewModel.swift; sourceTree = ""; }; 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemViewModel.swift; sourceTree = ""; }; 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeItemViewModel.swift; sourceTree = ""; }; 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemViewModel.swift; sourceTree = ""; }; @@ -890,7 +902,6 @@ 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectSettingsView.swift; sourceTree = ""; }; 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectSettingsViewModel.swift; sourceTree = ""; }; 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = UDPBroadcast.xcframework; path = Carthage/Build/UDPBroadcast.xcframework; sourceTree = ""; }; - 8BF1BD832AE93DFB00C3B271 /* LatestInLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestInLibraryViewModel.swift; sourceTree = ""; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineVideoPlayerManager.swift; sourceTree = ""; }; BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadVideoPlayerManager.swift; sourceTree = ""; }; @@ -912,14 +923,16 @@ C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; - E1047E2227E5880000CB0D4A /* InitialFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialFailureView.swift; sourceTree = ""; }; + E1047E2227E5880000CB0D4A /* TypeSystemNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeSystemNameView.swift; sourceTree = ""; }; E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; + E104DC952B9E7E29008F506D /* AssertionFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertionFailureView.swift; sourceTree = ""; }; E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = ""; }; E1092F4B29106F9F00163F57 /* GestureAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureAction.swift; sourceTree = ""; }; E10E842929A587110064EA49 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; E10E842B29A589860064EA49 /* NonePosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonePosterButton.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSize.swift; sourceTree = ""; }; + E11042742B8013DF00821020 /* Stateful.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stateful.swift; sourceTree = ""; }; E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingLibraryViewModel.swift; sourceTree = ""; }; E111D8F728D03BF900400001 /* PagingLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingLibraryView.swift; sourceTree = ""; }; E111D8F928D0400900400001 /* PagingLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingLibraryView.swift; sourceTree = ""; }; @@ -929,7 +942,7 @@ E113132A28BDB4B500930F75 /* NavBarDrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarDrawerView.swift; sourceTree = ""; }; E113132E28BDB66A00930F75 /* NavBarDrawerModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarDrawerModifier.swift; sourceTree = ""; }; E113133128BDC72000930F75 /* FilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterView.swift; sourceTree = ""; }; - E113133328BE988200930F75 /* FilterDrawerHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDrawerHStack.swift; sourceTree = ""; }; + E113133328BE988200930F75 /* NavigationBarFilterDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarFilterDrawer.swift; sourceTree = ""; }; E113133528BE98AA00930F75 /* FilterDrawerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDrawerButton.swift; sourceTree = ""; }; E113133728BEADBA00930F75 /* LibraryParent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryParent.swift; sourceTree = ""; }; E113133928BEB71D00930F75 /* FilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterViewModel.swift; sourceTree = ""; }; @@ -940,6 +953,9 @@ E11895AE2893840F0042947B /* NavBarOffsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarOffsetView.swift; sourceTree = ""; }; E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundParallaxHeaderModifier.swift; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; + E11BDF762B8513B40045C54A /* ItemGenre.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemGenre.swift; sourceTree = ""; }; + E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedCaseIterable.swift; sourceTree = ""; }; + E11BDF962B865F550045C54A /* ItemTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTag.swift; sourceTree = ""; }; E11CEB8A28998552003E74C7 /* iOSViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSViewExtensions.swift; sourceTree = ""; }; E11CEB8C28999B4A003E74C7 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = ""; }; E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; @@ -949,21 +965,17 @@ E12376AD2A33D680001F5B44 /* AboutViewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewCard.swift; sourceTree = ""; }; E12376AF2A33D6AE001F5B44 /* AboutViewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewCard.swift; sourceTree = ""; }; E12376B22A33DFAC001F5B44 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = ""; }; - E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnReceiveNotificationModifier.swift; sourceTree = ""; }; E129428728F0831F00796AC6 /* SplitTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTimestamp.swift; sourceTree = ""; }; E129428F28F0BDC300796AC6 /* TimeStampType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeStampType.swift; sourceTree = ""; }; E129429228F2845000796AC6 /* SliderType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderType.swift; sourceTree = ""; }; - E129429728F4785200796AC6 /* EnumPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPicker.swift; sourceTree = ""; }; + E129429728F4785200796AC6 /* CaseIterablePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseIterablePicker.swift; sourceTree = ""; }; E129429A28F4A5E300796AC6 /* PlaybackSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSettingsView.swift; sourceTree = ""; }; E12A9EF729499E0100731C3A /* JellyfinClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinClient.swift; sourceTree = ""; }; E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpLibraryViewModel.swift; sourceTree = ""; }; E12CC1B028D1008F00678D5D /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; - E12CC1B428D1124400678D5D /* BasicLibraryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicLibraryCoordinator.swift; sourceTree = ""; }; - E12CC1B828D11A1D00678D5D /* BasicLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicLibraryView.swift; sourceTree = ""; }; E12CC1BA28D11E1000678D5D /* RecentlyAddedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedViewModel.swift; sourceTree = ""; }; E12CC1BD28D11F4500678D5D /* RecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedView.swift; sourceTree = ""; }; - E12CC1C028D12B0A00678D5D /* BasicLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicLibraryView.swift; sourceTree = ""; }; E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeeAllPosterButton.swift; sourceTree = ""; }; E12CC1C628D12FD600678D5D /* CinematicRecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicRecentlyAddedView.swift; sourceTree = ""; }; E12CC1C828D132B800678D5D /* RecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedView.swift; sourceTree = ""; }; @@ -974,7 +986,6 @@ E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPickerView.swift; sourceTree = ""; }; E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeInsets.swift; sourceTree = ""; }; E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatioCornerRadiusModifier.swift; sourceTree = ""; }; - E13317002ADE4B8D009BF865 /* PaddingMultiplierModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingMultiplierModifier.swift; sourceTree = ""; }; E133328729538D8D00EE76AB /* Files.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Files.swift; sourceTree = ""; }; E133328C2953AE4B00EE76AB /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; E133328E2953B71000EE76AB /* DownloadTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskView.swift; sourceTree = ""; }; @@ -998,8 +1009,7 @@ E13DD3FB2717EAE8009D4DAF /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = ""; }; E13F05EB28BC9000003499D2 /* LibraryViewType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryViewType.swift; sourceTree = ""; }; - E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryItemRow.swift; sourceTree = ""; }; - E13F05F028BC9016003499D2 /* LibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + E13F05EF28BC9016003499D2 /* LibraryRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = ""; }; E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconSelectorView.swift; sourceTree = ""; }; E1401CA12938122C00E8B599 /* AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcons.swift; sourceTree = ""; }; E1401CA4293813F400E8B599 /* InvertedDarkAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedDarkAppIcon.swift; sourceTree = ""; }; @@ -1007,12 +1017,13 @@ E1401CA82938140700E8B599 /* DarkAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarkAppIcon.swift; sourceTree = ""; }; E1401CAA2938140A00E8B599 /* LightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightAppIcon.swift; sourceTree = ""; }; E1401CB029386C9200E8B599 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; - E1401D44293A952300E8B599 /* MediaItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItemViewModel.swift; sourceTree = ""; }; - E148128428C15472003B8787 /* APISortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APISortOrder.swift; sourceTree = ""; }; - E148128728C154BF003B8787 /* ItemFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; - E148128A28C15526003B8787 /* SortBy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortBy.swift; sourceTree = ""; }; + E148128428C15472003B8787 /* SortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortOrder.swift; sourceTree = ""; }; + E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemFilter+ItemTrait.swift"; sourceTree = ""; }; + E148128A28C15526003B8787 /* ItemSortBy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSortBy.swift; sourceTree = ""; }; E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; - E14A08CC28E68729004FC984 /* MenuPosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPosterHStack.swift; sourceTree = ""; }; + E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyItemFilter.swift; sourceTree = ""; }; + E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilterType.swift; sourceTree = ""; }; + E14EDECB2B8FB709000F00A4 /* ItemYear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemYear.swift; sourceTree = ""; }; E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedLightAppIcon.swift; sourceTree = ""; }; E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = ""; }; E1546779289AF48200087E35 /* CollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = ""; }; @@ -1021,7 +1032,6 @@ E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewSessionManager.swift; sourceTree = ""; }; E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinStore.swift; sourceTree = ""; }; E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinNotifications.swift; sourceTree = ""; }; - E154965A296CA2EF00C4EF88 /* PlaybackManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackManager.swift; sourceTree = ""; }; E154965B296CA2EF00C4EF88 /* DownloadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; E154965D296CA2EF00C4EF88 /* LogManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineEnumToggle.swift; sourceTree = ""; }; @@ -1034,18 +1044,18 @@ E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayerSettingsView.swift; sourceTree = ""; }; E15756352936856700976E1F /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = ""; }; E1575EA5293E7D40001665B1 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; + E1579EA62B97DC1500A31CA1 /* Eventful.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eventful.swift; sourceTree = ""; }; E1581E26291EF59800D6C640 /* SplitContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitContentView.swift; sourceTree = ""; }; E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceInfoView.swift; sourceTree = ""; }; - E158C8D22A31967600C527C5 /* ForEach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForEach.swift; sourceTree = ""; }; + E15D4F062B1B12C300442DB8 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; + E15D4F092B1BD88900442DB8 /* Edge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Edge.swift; sourceTree = ""; }; E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; - E168BD09289A4162001A6922 /* HomeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = ""; }; E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; - E168BD0F289A4162001A6922 /* HomeErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeaturesHStack.swift; sourceTree = ""; }; E16AA60728A364A6009A983C /* PosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = ""; }; E16AF11B292C98A7001422A8 /* GestureSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureSettingsView.swift; sourceTree = ""; }; - E16DEAC128EFCF590058F196 /* EnvironmentValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValue.swift; sourceTree = ""; }; + E16DEAC128EFCF590058F196 /* EnvironmentValue+Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValue+Keys.swift"; sourceTree = ""; }; E170D0E1294CC8000017224C /* VideoPlayer+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayer+Actions.swift"; sourceTree = ""; }; E170D0E3294CC8AB0017224C /* VideoPlayer+KeyCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayer+KeyCommands.swift"; sourceTree = ""; }; E170D102294CE8BF0017224C /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; @@ -1067,7 +1077,6 @@ E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListViewModel.swift; sourceTree = ""; }; E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListCoordinator.swift; sourceTree = ""; }; E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskButton.swift; sourceTree = ""; }; - E17FB54E28C1197700311DFE /* SelectorType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectorType.swift; sourceTree = ""; }; E17FB55128C119D400311DFE /* Displayable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Displayable.swift; sourceTree = ""; }; E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = ""; }; E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = ""; }; @@ -1078,6 +1087,7 @@ E185920928CEF23A00326F80 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = ""; }; E187A60129AB28F0008387E6 /* RotateContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotateContentView.swift; sourceTree = ""; }; E187A60429AD2E25008387E6 /* StepperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepperView.swift; sourceTree = ""; }; + E187F7662B8E6A1C005400FE /* EnvironmentValue+Values.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValue+Values.swift"; sourceTree = ""; }; E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Poster.swift"; sourceTree = ""; }; E18A17EF298C68B700C22F62 /* Overlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overlay.swift; sourceTree = ""; }; E18A17F1298C68BB00C22F62 /* MainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainOverlay.swift; sourceTree = ""; }; @@ -1090,7 +1100,6 @@ E18CE0B128A229E70092E7F1 /* UserDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDto.swift; sourceTree = ""; }; E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer.swift; sourceTree = ""; }; E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectCoordinator.swift; sourceTree = ""; }; - E18E01A4288746AF0022598C /* RefreshableScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; E18E01A5288746AF0022598C /* PillHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PillHStack.swift; sourceTree = ""; }; E18E01A7288746AF0022598C /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = ""; }; E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSEpisodeContentView.swift; sourceTree = ""; }; @@ -1116,11 +1125,9 @@ E18E01D8288747230022598C /* PlayButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = ""; }; E18E01D9288747230022598C /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = ""; }; E18E01F3288747580022598C /* AboutAppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = ""; }; - E18E01FF288749200022598C /* Divider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Divider.swift; sourceTree = ""; }; + E18E01FF288749200022598C /* RowDivider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowDivider.swift; sourceTree = ""; }; E18E0202288749200022598C /* AttributeStyleModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeStyleModifier.swift; sourceTree = ""; }; E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = ""; }; - E18E0239288749540022598C /* UIScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = ""; }; - E19169CD272514760085832A /* HTTPScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPScheme.swift; sourceTree = ""; }; E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureHStack.swift; sourceTree = ""; }; E1921B7528E63306003A5238 /* GestureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureView.swift; sourceTree = ""; }; E192607F28D28AAD002314B4 /* UserProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileButton.swift; sourceTree = ""; }; @@ -1144,7 +1151,6 @@ E1A16C9C2889AF1E00EA4679 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; E1A2C153279A7D5A005EC829 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = ""; }; - E1A42E4B28CBD39300A14DCB /* HomeContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = ""; }; E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; E1AA331C2782541500F6439C /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; @@ -1157,8 +1163,8 @@ E1B33ED028EB860A0073B0FD /* LargePlaybackButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargePlaybackButtons.swift; sourceTree = ""; }; E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentLogHandler.swift; sourceTree = ""; }; E1B490462967E2E500D3EDCE /* CoreStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreStore.swift; sourceTree = ""; }; - E1B5784028F8AFCB00D42911 /* Wrapped View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Wrapped View.swift"; sourceTree = ""; }; - E1B5861129E32EEF00E45D6E /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = ""; }; + E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = ""; }; + E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = ""; }; E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerActionButton.swift; sourceTree = ""; }; E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonSelectorView.swift; sourceTree = ""; }; @@ -1197,13 +1203,14 @@ E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectOrientationModifier.swift; sourceTree = ""; }; E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; E1CFE27F28FA606800B7D34C /* ChapterTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterTrack.swift; sourceTree = ""; }; - E1D3043128D175CE00587289 /* StaticLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLibraryViewModel.swift; sourceTree = ""; }; E1D3043428D1763100587289 /* SeeAllButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeeAllButton.swift; sourceTree = ""; }; - E1D3043928D189C500587289 /* CastAndCrewLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewLibraryView.swift; sourceTree = ""; }; - E1D3043B28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewLibraryCoordinator.swift; sourceTree = ""; }; - E1D3043E28D18F5700587289 /* CastAndCrewLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewLibraryView.swift; sourceTree = ""; }; - E1D3044128D1976600587289 /* CastAndCrewItemRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewItemRow.swift; sourceTree = ""; }; E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewTypeToggle.swift; sourceTree = ""; }; + E1D37F472B9C648E00343D2B /* MaxHeightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxHeightText.swift; sourceTree = ""; }; + E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSource.swift; sourceTree = ""; }; + E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = ""; }; + E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+SharedCodecProfiles.swift"; sourceTree = ""; }; + E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+NativeProfile.swift"; sourceTree = ""; }; + E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+SwiftfinProfile.swift"; sourceTree = ""; }; E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsCoordinator.swift; sourceTree = ""; }; @@ -1219,8 +1226,6 @@ E1D8429429346C6400D1041A /* BasicStepper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicStepper.swift; sourceTree = ""; }; E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayer.swift; sourceTree = ""; }; E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureType.swift; sourceTree = ""; }; - E1DA656828E78B5900592A73 /* SpecialFeaturesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeaturesViewModel.swift; sourceTree = ""; }; - E1DA656B28E78C1700592A73 /* MenuPosterHStackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPosterHStackModel.swift; sourceTree = ""; }; E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesEpisodeSelector.swift; sourceTree = ""; }; E1DABAF92A270E62008AC34A /* OverviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewCard.swift; sourceTree = ""; }; E1DABAFB2A270EE7008AC34A /* MediaSourcesCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourcesCard.swift; sourceTree = ""; }; @@ -1230,13 +1235,19 @@ E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedIndicator.swift; sourceTree = ""; }; E1DC9843296DECB600982F06 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteIndicator.swift; sourceTree = ""; }; + E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; + E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; + E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteOverlayRenderingModifier.swift; sourceTree = ""; }; + E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIGestureRecognizer.swift; sourceTree = ""; }; E1E1643928BAC2EF00323B0A /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; E1E1643D28BB074000323B0A /* SelectorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectorView.swift; sourceTree = ""; }; E1E1644028BB301900323B0A /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceKeys.swift; sourceTree = ""; }; + E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFinalDisappearModifier.swift; sourceTree = ""; }; + E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFirstAppearModifier.swift; sourceTree = ""; }; + E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterLastDisappearModifier.swift; sourceTree = ""; }; E1E306CC28EF6E8000537998 /* TimerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerProxy.swift; sourceTree = ""; }; - E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = ""; }; E1E5D5472783CCF900692DFE /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = ""; }; E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; @@ -1262,11 +1273,12 @@ E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerManager.swift; sourceTree = ""; }; E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedText.swift; sourceTree = ""; }; E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = ""; }; + E1ED91142B95897500802036 /* LatestInLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestInLibraryViewModel.swift; sourceTree = ""; }; + E1ED91172B95993300802036 /* TitledLibraryParent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitledLibraryParent.swift; sourceTree = ""; }; E1EF4C402911B783008CC695 /* StreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamType.swift; sourceTree = ""; }; E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = ""; }; E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemView.swift; sourceTree = ""; }; E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemContentView.swift; sourceTree = ""; }; - E1FBDB6529D0F336003DD5E2 /* KeyCommandAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCommandAction.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; @@ -1299,6 +1311,7 @@ E1B5F7AB29577BCE004B26CF /* PulseUI in Frameworks */, E1B5F7A929577BCE004B26CF /* PulseLogHandler in Frameworks */, 62666E1B27E501D400EC0ECD /* CoreGraphics.framework in Frameworks */, + E104DC922B9D89A2008F506D /* CollectionHStack in Frameworks */, E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */, 62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */, 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */, @@ -1308,6 +1321,8 @@ 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */, 62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */, E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */, + E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */, + E104DC942B9D89A2008F506D /* CollectionVGrid in Frameworks */, 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */, 62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */, @@ -1327,7 +1342,9 @@ 62666DFF27E5016400EC0ECD /* CFNetwork.framework in Frameworks */, E13DD3D327168E65009D4DAF /* Defaults in Frameworks */, E1002B682793CFBA00E47059 /* Algorithms in Frameworks */, + E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */, 62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */, + E104DC902B9D8995008F506D /* CollectionVGrid in Frameworks */, E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, 62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */, @@ -1336,6 +1353,8 @@ E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */, E10706102942F57D00646DAF /* Pulse in Frameworks */, E192608328D2D0DB002314B4 /* Factory in Frameworks */, + E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */, + E1523F822B132C350062821A /* CollectionHStack in Frameworks */, E10706142942F57D00646DAF /* PulseUI in Frameworks */, 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */, 62666E0227E5016D00EC0ECD /* CoreGraphics.framework in Frameworks */, @@ -1348,6 +1367,7 @@ E14CB6862A9FF62A001586C6 /* JellyfinAPI in Frameworks */, 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */, E18A8E7A28D5FEDF00333B9A /* VLCUI in Frameworks */, + E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */, 53352571265EA0A0006CCA86 /* Introspect in Frameworks */, E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */, 62666E0427E5017500EC0ECD /* CoreText.framework in Frameworks */, @@ -1355,10 +1375,12 @@ 62666E0E27E501AF00EC0ECD /* Security.framework in Frameworks */, E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */, E1DC9821296DDBE600982F06 /* CollectionView in Frameworks */, + E104DC8D2B9D8979008F506D /* CollectionHStack in Frameworks */, 62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */, 62666DFD27E5014F00EC0ECD /* AudioToolbox.framework in Frameworks */, E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */, 62666E0D27E501AA00EC0ECD /* QuartzCore.framework in Frameworks */, + E15D4F052B1B0C3C00442DB8 /* PreferencesView in Frameworks */, E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */, 62666E3F27E5040300EC0ECD /* SystemConfiguration.framework in Frameworks */, 62666E3927E502CE00EC0ECD /* SwizzleSwift in Frameworks */, @@ -1376,22 +1398,6 @@ path = ServerDiscovery; sourceTree = ""; }; - 4EAA35B82AB9694000D840DD /* FilterDrawerSettingsView */ = { - isa = PBXGroup; - children = ( - 4EAA35B92AB9694D00D840DD /* Components */, - ); - path = FilterDrawerSettingsView; - sourceTree = ""; - }; - 4EAA35B92AB9694D00D840DD /* Components */ = { - isa = PBXGroup; - children = ( - 4EAA35BA2AB9699B00D840DD /* FilterDrawerButtonSelectorView.swift */, - ); - path = Components; - sourceTree = ""; - }; 5310694F2684E7EE00CFFDBA /* VideoPlayer */ = { isa = PBXGroup; children = ( @@ -1406,33 +1412,25 @@ 532175392671BCED005491E6 /* ViewModels */ = { isa = PBXGroup; children = ( - BD0BA2292AD6501300306A8D /* VideoPlayerManager */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, - C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */, E107BB9127880A4000354E07 /* ItemViewModel */, - 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */, + E1EDA8D52B924CA500F9A57E /* LibraryViewModel */, C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */, 625CB5742678C33500530A6E /* MediaViewModel.swift */, - E1401D44293A952300E8B599 /* MediaItemViewModel.swift */, - E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */, - E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */, 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */, - E12CC1BA28D11E1000678D5D /* RecentlyAddedViewModel.swift */, 62E632DB267D2E130063E547 /* SearchViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, - E1DA656828E78B5900592A73 /* SpecialFeaturesViewModel.swift */, - E1D3043128D175CE00587289 /* StaticLibraryViewModel.swift */, E13DD3F82717E961009D4DAF /* UserListViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, + BD0BA2292AD6501300306A8D /* VideoPlayerManager */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, - 8BF1BD832AE93DFB00C3B271 /* LatestInLibraryViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -1518,17 +1516,15 @@ isa = PBXGroup; children = ( E1D4BF802719D22800A11E64 /* AppAppearance.swift */, - 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, + E129429728F4785200796AC6 /* CaseIterablePicker.swift */, E17FB55128C119D400311DFE /* Displayable.swift */, - E129429728F4785200796AC6 /* EnumPicker.swift */, - 4E8B34E92AB91B6E0018F305 /* FilterDrawerSelection.swift */, + E1579EA62B97DC1500A31CA1 /* Eventful.swift */, E1092F4B29106F9F00163F57 /* GestureAction.swift */, - E19169CD272514760085832A /* HTTPScheme.swift */, - 535870AC2669D8DD00D05A09 /* ItemFilters.swift */, + E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */, + E14EDECA2B8FB66F000F00A4 /* ItemFilter */, E1C925F328875037002A7A66 /* ItemViewType.swift */, - E113133728BEADBA00930F75 /* LibraryParent.swift */, + E1DE2B4E2B983F3200F6715F /* LibraryParent */, E13F05EB28BC9000003499D2 /* LibraryViewType.swift */, - E1DA656B28E78C1700592A73 /* MenuPosterHStackModel.swift */, E1AA331E2782639D00F6439C /* OverlayType.swift */, E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */, E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, @@ -1536,11 +1532,11 @@ E1CCF12D28ABF989006CAC9E /* PosterType.swift */, E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */, E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */, - E17FB54E28C1197700311DFE /* SelectorType.swift */, E129429228F2845000796AC6 /* SliderType.swift */, - E148128A28C15526003B8787 /* SortBy.swift */, E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */, + E11042742B8013DF00821020 /* Stateful.swift */, E1EF4C402911B783008CC695 /* StreamType.swift */, + E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */, E1A1528428FD191A00600579 /* TextPair.swift */, E1E306CC28EF6E8000537998 /* TimerProxy.swift */, E129428F28F0BDC300796AC6 /* TimeStampType.swift */, @@ -1796,37 +1792,24 @@ E1A1528728FD229500600579 /* ChevronButton.swift */, E133328C2953AE4B00EE76AB /* CircularProgressView.swift */, E18E01A7288746AF0022598C /* DotHStack.swift */, - E1FE69AF28C2DA4A0021BC93 /* FilterDrawerHStack */, + E1DE2B492B97ECB900F6715F /* ErrorView.swift */, E1921B7528E63306003A5238 /* GestureView.swift */, E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */, - E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */, - E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */, - E14A08CC28E68729004FC984 /* MenuPosterHStack.swift */, - E111D8F728D03BF900400001 /* PagingLibraryView.swift */, + E1FE69AF28C2DA4A0021BC93 /* NavigationBarFilterDrawer */, + E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */, E18E01A5288746AF0022598C /* PillHStack.swift */, E16AA60728A364A6009A983C /* PosterButton.swift */, E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */, E1AA331C2782541500F6439C /* PrimaryButton.swift */, - E18E01A4288746AF0022598C /* RefreshableScrollView.swift */, E1D3043428D1763100587289 /* SeeAllButton.swift */, E1D5C39728DF914100CDBEFB /* Slider */, E1581E26291EF59800D6C640 /* SplitContentView.swift */, E157562F29355B7900976E1F /* UpdateView.swift */, E192607F28D28AAD002314B4 /* UserProfileButton.swift */, - 4F1282B02A7F3E8F005BCA29 /* RandomItemButton.swift */, ); path = Components; sourceTree = ""; }; - 5D64683B277B15E4009E09AE /* PreferenceUIHosting */ = { - isa = PBXGroup; - children = ( - E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */, - 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */, - ); - path = PreferenceUIHosting; - sourceTree = ""; - }; 621338912660106C00A81A2A /* Extensions */ = { isa = PBXGroup; children = ( @@ -1837,26 +1820,26 @@ 6267B3D526710B8900A7371D /* Collection.swift */, E173DA5126D04AAF00CC4EB7 /* Color.swift */, E1B490462967E2E500D3EDCE /* CoreStore.swift */, + E15D4F092B1BD88900442DB8 /* Edge.swift */, E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */, - E16DEAC128EFCF590058F196 /* EnvironmentValue.swift */, + E187F7652B8E6A08005400FE /* EnvironmentValue */, E1B33EAF28EA890D0073B0FD /* Equatable.swift */, E133328729538D8D00EE76AB /* Files.swift */, E15756312935642A00976E1F /* Float.swift */, E11CEB8C28999B4A003E74C7 /* Font.swift */, - E158C8D22A31967600C527C5 /* ForEach.swift */, E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */, E139CC1E28EC83E400688DE2 /* Int.swift */, E1AD105226D96D5F003E4A08 /* JellyfinAPI */, E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, - E1B5861129E32EEF00E45D6E /* Set.swift */, + E1B5861129E32EEF00E45D6E /* Sequence.swift */, 621338922660107500A81A2A /* String.swift */, + E1DD55362B6EE533007501C0 /* Task.swift */, E1A2C153279A7D5A005EC829 /* UIApplication.swift */, E1401CB029386C9200E8B599 /* UIColor.swift */, E13DD3C727164B1E009D4DAF /* UIDevice.swift */, E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */, E1937A3D288F0D3D00CB80AA /* UIScreen.swift */, - E18E0239288749540022598C /* UIScrollView.swift */, 62E1DCC2273CE19800C9AE76 /* URL.swift */, E1C812C4277A90B200918266 /* URLComponents.swift */, E17AC9692954D00E003D2BC2 /* URLResponse.swift */, @@ -1878,9 +1861,7 @@ isa = PBXGroup; children = ( E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */, - E12CC1B428D1124400678D5D /* BasicLibraryCoordinator.swift */, E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */, - E1D3043B28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift */, 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */, E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */, E13332902953B91000EE76AB /* DownloadTaskCoordinator.swift */, @@ -2004,6 +1985,7 @@ isa = PBXGroup; children = ( E170D101294CE4C10017224C /* Modifiers */, + E15D4F062B1B12C300442DB8 /* Backport.swift */, E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */, 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */, ); @@ -2060,14 +2042,11 @@ isa = PBXGroup; children = ( E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */, - E12CC1C028D12B0A00678D5D /* BasicLibraryView.swift */, - E1D3043E28D18F5700587289 /* CastAndCrewLibraryView.swift */, 53ABFDEA2679753200886593 /* ConnectToServerView.swift */, E154967B296CBB1A00C4EF88 /* FontPickerView.swift */, E1A42E4D28CBD3B200A14DCB /* HomeView */, E12376B22A33DFAC001F5B44 /* ItemOverviewView.swift */, E193D54E271942C000900D82 /* ItemView */, - 53A83C32268A309300DF3D92 /* LibraryView.swift */, C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */, C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */, C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */, @@ -2119,7 +2098,6 @@ children = ( E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */, 5377CBF4263B596A003A4E83 /* SwiftfinApp.swift */, - 5D64683B277B15E4009E09AE /* PreferenceUIHosting */, ); path = App; sourceTree = ""; @@ -2130,8 +2108,6 @@ E18E01F3288747580022598C /* AboutAppView.swift */, E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */, E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */, - E12CC1B828D11A1D00678D5D /* BasicLibraryView.swift */, - E1D3044028D1974700587289 /* CastAndCrewLibraryView */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */, E13332922953BA9400EE76AB /* DownloadTaskView */, @@ -2140,7 +2116,6 @@ E168BD07289A4162001A6922 /* HomeView */, E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */, E14F7D0A26DB3714007C3AE6 /* ItemView */, - E13F05F028BC9016003499D2 /* LibraryView.swift */, C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */, C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */, C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */, @@ -2149,6 +2124,7 @@ E170D104294D21FA0017224C /* MediaSourceInfoView.swift */, E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, 6213388F265F83A900A81A2A /* MediaView.swift */, + E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, 53EE24E5265060780068F029 /* SearchView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, @@ -2174,6 +2150,21 @@ path = AppIcons; sourceTree = ""; }; + E14EDECA2B8FB66F000F00A4 /* ItemFilter */ = { + isa = PBXGroup; + children = ( + E14EDEC42B8FB64E000F00A4 /* AnyItemFilter.swift */, + 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */, + 535870AC2669D8DD00D05A09 /* ItemFilterCollection.swift */, + E14EDEC72B8FB65F000F00A4 /* ItemFilterType.swift */, + E11BDF762B8513B40045C54A /* ItemGenre.swift */, + E148128A28C15526003B8787 /* ItemSortBy.swift */, + E11BDF962B865F550045C54A /* ItemTag.swift */, + E14EDECB2B8FB709000F00A4 /* ItemYear.swift */, + ); + path = ItemFilter; + sourceTree = ""; + }; E14F7D0A26DB3714007C3AE6 /* ItemView */ = { isa = PBXGroup; children = ( @@ -2201,7 +2192,6 @@ E1549655296CA2EF00C4EF88 /* DownloadTask.swift */, E154965D296CA2EF00C4EF88 /* LogManager.swift */, E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */, - E154965A296CA2EF00C4EF88 /* PlaybackManager.swift */, E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */, E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */, E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */, @@ -2222,8 +2212,6 @@ isa = PBXGroup; children = ( E168BD0A289A4162001A6922 /* Components */, - E168BD09289A4162001A6922 /* HomeContentView.swift */, - E168BD0F289A4162001A6922 /* HomeErrorView.swift */, E168BD08289A4162001A6922 /* HomeView.swift */, ); path = HomeView; @@ -2243,14 +2231,17 @@ E170D101294CE4C10017224C /* Modifiers */ = { isa = PBXGroup; children = ( + E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */, E18E0202288749200022598C /* AttributeStyleModifier.swift */, E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, + E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */, + E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */, E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */, - E13317002ADE4B8D009BF865 /* PaddingMultiplierModifier.swift */, + E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */, E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */, - E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */, E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */, + E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */, ); path = Modifiers; sourceTree = ""; @@ -2290,6 +2281,15 @@ path = Objects; sourceTree = ""; }; + E187F7652B8E6A08005400FE /* EnvironmentValue */ = { + isa = PBXGroup; + children = ( + E16DEAC128EFCF590058F196 /* EnvironmentValue+Keys.swift */, + E187F7662B8E6A1C005400FE /* EnvironmentValue+Values.swift */, + ); + path = EnvironmentValue; + sourceTree = ""; + }; E18A17F3298C68BF00C22F62 /* Overlays */ = { isa = PBXGroup; children = ( @@ -2517,7 +2517,6 @@ isa = PBXGroup; children = ( E12CC1C328D12D6300678D5D /* Components */, - E1A42E4B28CBD39300A14DCB /* HomeContentView.swift */, E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */, 531690E6267ABD79005D8AB9 /* HomeView.swift */, ); @@ -2527,24 +2526,21 @@ E1AD105226D96D5F003E4A08 /* JellyfinAPI */ = { isa = PBXGroup; children = ( - E148128428C15472003B8787 /* APISortOrder.swift */, - E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */, - E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */, - E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */, - E18A8E7C28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift */, - 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */, - E118959C289312020042947B /* BaseItemPerson+Poster.swift */, + E1D37F5B2B9CF02600343D2B /* BaseItemDto */, + E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */, E1002B632793CEE700E47059 /* ChapterInfo.swift */, + E1D37F502B9CEF1300343D2B /* DeviceProfile */, E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, E1D842902933F87500D1041A /* ItemFields.swift */, - E148128728C154BF003B8787 /* ItemFilter.swift */, + E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */, E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */, + E12A9EF729499E0100731C3A /* JellyfinClient.swift */, E1D8428E2933F2D900D1041A /* MediaSourceInfo.swift */, E18A8E7F28D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift */, E122A9122788EAAD0060FA63 /* MediaStream.swift */, E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, + E148128428C15472003B8787 /* SortOrder.swift */, E18CE0B128A229E70092E7F1 /* UserDto.swift */, - E12A9EF729499E0100731C3A /* JellyfinClient.swift */, ); path = JellyfinAPI; sourceTree = ""; @@ -2552,19 +2548,21 @@ E1AD105326D96F5A003E4A08 /* Components */ = { isa = PBXGroup; children = ( + E104DC952B9E7E29008F506D /* AssertionFailureView.swift */, E18E0203288749200022598C /* BlurView.swift */, - E18E01FF288749200022598C /* Divider.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, - E1047E2227E5880000CB0D4A /* InitialFailureView.swift */, + E1D37F472B9C648E00343D2B /* MaxHeightText.swift */, 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */, E1DC983F296DEBA500982F06 /* PosterIndicators */, E1FE69A628C29B720021BC93 /* ProgressBar.swift */, E187A60129AB28F0008387E6 /* RotateContentView.swift */, + E18E01FF288749200022598C /* RowDivider.swift */, E1E1643D28BB074000323B0A /* SelectorView.swift */, E1356E0129A7309D00382563 /* SeparatorHStack.swift */, E1A1528928FD22F600600579 /* TextPairView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */, - E1B5784028F8AFCB00D42911 /* Wrapped View.swift */, + E1047E2227E5880000CB0D4A /* TypeSystemNameView.swift */, + E1B5784028F8AFCB00D42911 /* WrappedView.swift */, ); path = Components; sourceTree = ""; @@ -2653,13 +2651,35 @@ path = Components; sourceTree = ""; }; - E1D3044028D1974700587289 /* CastAndCrewLibraryView */ = { + E1D37F502B9CEF1300343D2B /* DeviceProfile */ = { isa = PBXGroup; children = ( - E1D3044128D1976600587289 /* CastAndCrewItemRow.swift */, - E1D3043928D189C500587289 /* CastAndCrewLibraryView.swift */, + E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */, + E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */, + E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */, + E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */, + ); + path = DeviceProfile; + sourceTree = ""; + }; + E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */ = { + isa = PBXGroup; + children = ( + 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */, + E118959C289312020042947B /* BaseItemPerson+Poster.swift */, + ); + path = BaseItemPerson; + sourceTree = ""; + }; + E1D37F5B2B9CF02600343D2B /* BaseItemDto */ = { + isa = PBXGroup; + children = ( + E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */, + E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */, + E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */, + E18A8E7C28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift */, ); - path = CastAndCrewLibraryView; + path = BaseItemDto; sourceTree = ""; }; E1D5C39728DF914100CDBEFB /* Slider */ = { @@ -2716,17 +2736,23 @@ E1DD1127271E7D15005BE12F /* Objects */ = { isa = PBXGroup; children = ( - E1FBDB6529D0F336003DD5E2 /* KeyCommandAction.swift */, - E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */, E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */, ); path = Objects; sourceTree = ""; }; + E1DE2B4E2B983F3200F6715F /* LibraryParent */ = { + isa = PBXGroup; + children = ( + E113133728BEADBA00930F75 /* LibraryParent.swift */, + E1ED91172B95993300802036 /* TitledLibraryParent.swift */, + ); + path = LibraryParent; + sourceTree = ""; + }; E1E5D54A2783E26100692DFE /* SettingsView */ = { isa = PBXGroup; children = ( - 4EAA35B82AB9694000D840DD /* FilterDrawerSettingsView */, 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */, E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */, E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */, @@ -2764,6 +2790,37 @@ path = ActionButtons; sourceTree = ""; }; + E1E93E5D2B92DE6100D8A16A /* Components */ = { + isa = PBXGroup; + children = ( + E13F05EF28BC9016003499D2 /* LibraryRow.swift */, + E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */, + ); + path = Components; + sourceTree = ""; + }; + E1EDA8D52B924CA500F9A57E /* LibraryViewModel */ = { + isa = PBXGroup; + children = ( + 62E632DF267D30CA0063E547 /* ItemLibraryViewModel.swift */, + C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */, + E1ED91142B95897500802036 /* LatestInLibraryViewModel.swift */, + E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */, + E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */, + E12CC1BA28D11E1000678D5D /* RecentlyAddedViewModel.swift */, + ); + path = LibraryViewModel; + sourceTree = ""; + }; + E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */ = { + isa = PBXGroup; + children = ( + E1E93E5D2B92DE6100D8A16A /* Components */, + E111D8F728D03BF900400001 /* PagingLibraryView.swift */, + ); + path = PagingLibraryView; + sourceTree = ""; + }; E1FA891C289A302600176FEB /* CollectionItemView */ = { isa = PBXGroup; children = ( @@ -2782,13 +2839,13 @@ path = Errors; sourceTree = ""; }; - E1FE69AF28C2DA4A0021BC93 /* FilterDrawerHStack */ = { + E1FE69AF28C2DA4A0021BC93 /* NavigationBarFilterDrawer */ = { isa = PBXGroup; children = ( E113133528BE98AA00930F75 /* FilterDrawerButton.swift */, - E113133328BE988200930F75 /* FilterDrawerHStack.swift */, + E113133328BE988200930F75 /* NavigationBarFilterDrawer.swift */, ); - path = FilterDrawerHStack; + path = NavigationBarFilterDrawer; sourceTree = ""; }; /* End PBXGroup section */ @@ -2831,6 +2888,9 @@ E1DC981D296DD91900982F06 /* CollectionView */, E18443CA2A037773002DDDC8 /* UDPBroadcast */, E14CB6872A9FF71F001586C6 /* JellyfinAPI */, + E1A7B1642B9A9F7800152546 /* PreferencesView */, + E104DC912B9D89A2008F506D /* CollectionHStack */, + E104DC932B9D89A2008F506D /* CollectionVGrid */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; @@ -2873,6 +2933,13 @@ E1DC9820296DDBE600982F06 /* CollectionView */, E1FAD1C52A0375BA007F5521 /* UDPBroadcast */, E14CB6852A9FF62A001586C6 /* JellyfinAPI */, + E1523F812B132C350062821A /* CollectionHStack */, + E114DB322B1944FA00B75FB3 /* CollectionVGrid */, + E15D4F042B1B0C3C00442DB8 /* PreferencesView */, + E113A2A62B5A178D009CAAAA /* CollectionHStack */, + E113A2A92B5A179A009CAAAA /* CollectionVGrid */, + E104DC8C2B9D8979008F506D /* CollectionHStack */, + E104DC8F2B9D8995008F506D /* CollectionVGrid */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -2941,6 +3008,9 @@ E1DC981F296DDBE600982F06 /* XCRemoteSwiftPackageReference "CollectionView" */, E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */, E14CB6842A9FF62A001586C6 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, + E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */, + E104DC8B2B9D8979008F506D /* XCRemoteSwiftPackageReference "CollectionHStack" */, + E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -3089,20 +3159,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */, E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */, C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, - E18E021E2887492B0022598C /* Divider.swift in Sources */, + E18E021E2887492B0022598C /* RowDivider.swift in Sources */, E1DC983E296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, - E1575E7E293E77B5001665B1 /* ItemFilters.swift in Sources */, + E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */, + E1D37F592B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */, 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, E1575E99293E7B1E001665B1 /* UIColor.swift in Sources */, E1575E92293E7B1E001665B1 /* CGSize.swift in Sources */, - E1575E96293E7B1E001665B1 /* UIScrollView.swift in Sources */, E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, @@ -3110,6 +3181,7 @@ E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, E1549663296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, + E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */, E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, E187A60529AD2E25008387E6 /* StepperView.swift in Sources */, @@ -3118,13 +3190,13 @@ E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */, - E1D3043F28D18F5700587289 /* CastAndCrewLibraryView.swift in Sources */, - E1575E63293E77B5001665B1 /* EnumPicker.swift in Sources */, + E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */, E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */, E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */, + E1DD55382B6EE533007501C0 /* Task.swift in Sources */, E1575EA1293E7B1E001665B1 /* String.swift in Sources */, E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, @@ -3134,7 +3206,6 @@ E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */, E1E6C45629B130F50064123F /* ChapterOverlay.swift in Sources */, - E12CC1C128D12B0A00678D5D /* BasicLibraryView.swift in Sources */, E1549665296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */, E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */, E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */, @@ -3153,14 +3224,15 @@ E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */, E18A8E8128D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */, + E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, - E1D3043328D175CE00587289 /* StaticLibraryViewModel.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */, + E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */, E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */, E12376B12A33DB33001F5B44 /* MediaSourceInfoCoordinator.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, @@ -3176,7 +3248,7 @@ E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E1575E93293E7B1E001665B1 /* Float.swift in Sources */, - E1B5784228F8AFCB00D42911 /* Wrapped View.swift in Sources */, + E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */, E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */, E17AC96B2954D00E003D2BC2 /* URLResponse.swift in Sources */, @@ -3184,13 +3256,14 @@ E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */, E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */, E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */, - 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */, - 53A83C33268A309300DF3D92 /* LibraryView.swift in Sources */, + E1ED91192B95993300802036 /* TitledLibraryParent.swift in Sources */, + 62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 5398514526B64DA100101B49 /* SettingsView.swift in Sources */, E193D54B271941D300900D82 /* ServerListView.swift in Sources */, E1575E91293E7B1E001665B1 /* URL.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, + E1E2F8432B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */, E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */, C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */, @@ -3198,10 +3271,10 @@ 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */, + E1E2F8462B757E3400B75998 /* AfterLastDisappearModifier.swift in Sources */, E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */, E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */, E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */, - E1A42E4C28CBD39300A14DCB /* HomeContentView.swift in Sources */, E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */, E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */, E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */, @@ -3209,55 +3282,60 @@ E12376B02A33D6AE001F5B44 /* AboutViewCard.swift in Sources */, E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */, E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */, + E1579EA82B97DC1500A31CA1 /* Eventful.swift in Sources */, E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */, E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */, E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */, C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */, E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */, E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */, + E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E10E842A29A587110064EA49 /* LoadingView.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */, E13316FF2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */, - E148128928C154BF003B8787 /* ItemFilter.swift in Sources */, + E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, E43918672AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */, E154966F296CA2EF00C4EF88 /* LogManager.swift in Sources */, + E1D37F562B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */, E1575E75293E77B5001665B1 /* LibraryViewType.swift in Sources */, E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E12E30F1296383810022FAC9 /* SplitFormWindowView.swift in Sources */, - E1575E7B293E77B5001665B1 /* HTTPScheme.swift in Sources */, E1356E0429A731EB00382563 /* SeparatorHStack.swift in Sources */, - E1575E69293E77B5001665B1 /* SortBy.swift in Sources */, + E1575E69293E77B5001665B1 /* ItemSortBy.swift in Sources */, E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */, E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */, - E1DA656A28E78B5900592A73 /* SpecialFeaturesViewModel.swift in Sources */, E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, + E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */, E1DABAFC2A270EE7008AC34A /* MediaSourcesCard.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, + E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, + E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, + E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */, BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */, E1575EA2293E7B1E001665B1 /* Color.swift in Sources */, - E1575E77293E77B5001665B1 /* MenuPosterHStackModel.swift in Sources */, E12E30F5296392EC0022FAC9 /* EnumPickerView.swift in Sources */, E1575E72293E77B5001665B1 /* Utilities.swift in Sources */, E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */, E1E6C45129B104850064123F /* Button.swift in Sources */, E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, E1A1528B28FD22F600600579 /* TextPairView.swift in Sources */, + E11042762B8013DF00821020 /* Stateful.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, E1575E66293E77B5001665B1 /* Poster.swift in Sources */, - E18E021F2887492B0022598C /* InitialFailureView.swift in Sources */, - E1D3043D28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift in Sources */, + E18E021F2887492B0022598C /* TypeSystemNameView.swift in Sources */, + E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, E1575E8C293E7B1E001665B1 /* UIScreen.swift in Sources */, E1575E88293E7A00001665B1 /* LightAppIcon.swift in Sources */, E1549678296CB22B00C4EF88 /* InlineEnumToggle.swift in Sources */, @@ -3273,12 +3351,11 @@ E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */, E1E6C44B29AED2B70064123F /* HorizontalAlignment.swift in Sources */, - E158C8D32A31967600C527C5 /* ForEach.swift in Sources */, E193D549271941CC00900D82 /* UserSignInView.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, - E148128628C15475003B8787 /* APISortOrder.swift in Sources */, + E148128628C15475003B8787 /* SortOrder.swift in Sources */, E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, - E1575E9B293E7B1E001665B1 /* EnvironmentValue.swift in Sources */, + E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */, E133328929538D8D00EE76AB /* Files.swift in Sources */, E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */, E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */, @@ -3287,14 +3364,13 @@ E18E02232887492B0022598C /* ImageView.swift in Sources */, E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */, E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */, - E1575E73293E77B5001665B1 /* SelectorType.swift in Sources */, E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, - E12CC1B628D1124400678D5D /* BasicLibraryCoordinator.swift in Sources */, E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */, E11E374E293E7F08009EF240 /* MediaSourceInfo.swift in Sources */, E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */, E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */, E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */, + E11BDF982B865F550045C54A /* ItemTag.swift in Sources */, E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */, E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */, @@ -3304,6 +3380,7 @@ E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */, E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */, + E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */, C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */, @@ -3311,7 +3388,6 @@ E1575E90293E7B1E001665B1 /* EdgeInsets.swift in Sources */, E1E6C43D29AECC310064123F /* BarActionButtons.swift in Sources */, E1E6C44529AECCF20064123F /* PlayNextItemActionButton.swift in Sources */, - 8BF1BD852AE93DFB00C3B271 /* LatestInLibraryViewModel.swift in Sources */, 6264E88D273850380081A12A /* Strings.swift in Sources */, E1C926102887565C002A7A66 /* PlayButton.swift in Sources */, E1575E67293E77B5001665B1 /* OverlayType.swift in Sources */, @@ -3319,31 +3395,32 @@ E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */, E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */, E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, - E1B5861329E32EEF00E45D6E /* Set.swift in Sources */, + E1B5861329E32EEF00E45D6E /* Sequence.swift in Sources */, C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */, E1575E70293E77B5001665B1 /* TextPair.swift in Sources */, - E1575E79293E77B5001665B1 /* DeviceProfileBuilder.swift in Sources */, E18E021C2887492B0022598C /* BlurView.swift in Sources */, + E187F7682B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */, E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, 535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */, + E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E1575E9A293E7B1E001665B1 /* Array.swift in Sources */, - E13317022ADE4B8D009BF865 /* PaddingMultiplierModifier.swift in Sources */, E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */, E187A60329AB28F0008387E6 /* RotateContentView.swift in Sources */, + E1D37F4F2B9CEDC400343D2B /* DeviceProfile.swift in Sources */, E1575E94293E7B1E001665B1 /* VerticalAlignment.swift in Sources */, E1575EA3293E7B1E001665B1 /* UIDevice.swift in Sources */, E193D547271941C500900D82 /* UserListView.swift in Sources */, + E1DE2B4D2B9838B200F6715F /* PaletteOverlayRenderingModifier.swift in Sources */, E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */, E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */, E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, - E1549669296CA2EF00C4EF88 /* PlaybackManager.swift in Sources */, E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, 53ABFDE4267974EF00886593 /* MediaViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, @@ -3354,9 +3431,9 @@ E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */, C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */, E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */, - E1575E83293E784A001665B1 /* MediaItemViewModel.swift in Sources */, - 4E8B34EB2AB91B6E0018F305 /* FilterDrawerSelection.swift in Sources */, + 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */, E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, + E14EDECD2B8FB709000F00A4 /* ItemYear.swift in Sources */, E154965F296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, E154967E296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift in Sources */, E1DABAFE2A27B982008AC34A /* RatingsCard.swift in Sources */, @@ -3377,7 +3454,7 @@ E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */, 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */, - 4E8B34EA2AB91B6E0018F305 /* FilterDrawerSelection.swift in Sources */, + 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */, E1A1528828FD229500600579 /* ChevronButton.swift in Sources */, E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */, 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */, @@ -3400,15 +3477,12 @@ E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */, E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */, E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, - E148128828C154BF003B8787 /* ItemFilter.swift in Sources */, + E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E11895AF2893840F0042947B /* NavBarOffsetView.swift in Sources */, - E1401D45293A952300E8B599 /* MediaItemViewModel.swift in Sources */, - E168BD11289A4162001A6922 /* HomeContentView.swift in Sources */, - E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */, E18E0208288749200022598C /* BlurView.swift in Sources */, E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */, E1E1643F28BB075C00323B0A /* SelectorView.swift in Sources */, @@ -3421,13 +3495,13 @@ E16AA60828A364A6009A983C /* PosterButton.swift in Sources */, E1E1644128BB301900323B0A /* Array.swift in Sources */, E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */, - E129429828F4785200796AC6 /* EnumPicker.swift in Sources */, + E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */, E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, + E1E2F8422B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */, E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, - 4F1282B12A7F3E8F005BCA29 /* RandomItemButton.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */, E18E01FA288747580022598C /* AboutAppView.swift in Sources */, @@ -3442,16 +3516,16 @@ E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, E133328829538D8D00EE76AB /* Files.swift in Sources */, - E1D3043228D175CE00587289 /* StaticLibraryViewModel.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */, - E16DEAC228EFCF590058F196 /* EnvironmentValue.swift in Sources */, + E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */, + E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */, E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */, - E1FBDB6629D0F336003DD5E2 /* KeyCommandAction.swift in Sources */, E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, + E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, - E1B5861229E32EEF00E45D6E /* Set.swift in Sources */, + E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */, @@ -3462,13 +3536,14 @@ E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, E113132B28BDB4B500930F75 /* NavBarDrawerView.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, - E19169CE272514760085832A /* HTTPScheme.swift in Sources */, E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */, - 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */, + E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */, + E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */, E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */, E12A9EF829499E0100731C3A /* JellyfinClient.swift in Sources */, E1722DB129491C3900CC0239 /* ImageBlurHashes.swift in Sources */, + E14EDEC82B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, E1EBCB42278BD174009FE6E9 /* TruncatedText.swift in Sources */, 62133890265F83A900A81A2A /* MediaView.swift in Sources */, 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */, @@ -3478,9 +3553,9 @@ E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */, C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, - E18E0204288749200022598C /* Divider.swift in Sources */, + E18E0204288749200022598C /* RowDivider.swift in Sources */, E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, - E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */, + E1047E2327E5880000CB0D4A /* TypeSystemNameView.swift in Sources */, E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */, E18ACA922A15A32F00BB4F35 /* (null) in Sources */, E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, @@ -3490,11 +3565,10 @@ E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */, E1E6C45029B104840064123F /* Button.swift in Sources */, E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */, + E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, E11245B728D97ED200D8A977 /* TopBarView.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */, - E1DA656928E78B5900592A73 /* SpecialFeaturesViewModel.swift in Sources */, - E12CC1B928D11A1D00678D5D /* BasicLibraryView.swift in Sources */, - E1B5784128F8AFCB00D42911 /* Wrapped View.swift in Sources */, + E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */, E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */, C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */, E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, @@ -3502,20 +3576,21 @@ 6264E88C273850380081A12A /* Strings.swift in Sources */, C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */, E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, + E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, - E113133428BE988200930F75 /* FilterDrawerHStack.swift in Sources */, + E1DD55372B6EE533007501C0 /* Task.swift in Sources */, + E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, + E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */, E1DC9819296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, - 4EAA35BB2AB9699B00D840DD /* FilterDrawerButtonSelectorView.swift in Sources */, E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, - E1DA656C28E78C1700592A73 /* MenuPosterHStackModel.swift in Sources */, E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */, E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */, E1E306CD28EF6E8000537998 /* TimerProxy.swift in Sources */, @@ -3523,17 +3598,15 @@ E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */, E18E01F0288747230022598C /* AttributeHStack.swift in Sources */, 6334175B287DDFB9000603CE /* QuickConnectSettingsView.swift in Sources */, - E17FB54F28C1197700311DFE /* SelectorType.swift in Sources */, - E13F05F128BC9016003499D2 /* LibraryItemRow.swift in Sources */, + E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */, - E1D3043C28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift in Sources */, E1401CB129386C9200E8B599 /* UIColor.swift in Sources */, + E1E2F8452B757E3400B75998 /* AfterLastDisappearModifier.swift in Sources */, E18E01AB288746AF0022598C /* PillHStack.swift in Sources */, E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */, E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */, E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, - E1D3043A28D189C500587289 /* CastAndCrewLibraryView.swift in Sources */, E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, @@ -3542,13 +3615,11 @@ E12F038C28F8B0B100976CC3 /* EdgeInsets.swift in Sources */, 6267B3D626710B8900A7371D /* Collection.swift in Sources */, E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, - E168BD15289A4162001A6922 /* HomeErrorView.swift in Sources */, E17AC9732955007A003D2BC2 /* DownloadTaskButton.swift in Sources */, E1A1528228FD126C00600579 /* VerticalAlignment.swift in Sources */, - E13317012ADE4B8D009BF865 /* PaddingMultiplierModifier.swift in Sources */, E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */, E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */, - E148128B28C15526003B8787 /* SortBy.swift in Sources */, + E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */, @@ -3566,23 +3637,26 @@ E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, E43918662AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */, + E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */, E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */, E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */, E113133228BDC72000930F75 /* FilterView.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, E170D105294D21FA0017224C /* MediaSourceInfoView.swift in Sources */, + E1D37F4B2B9CEA5C00343D2B /* ImageSource.swift in Sources */, E11895AC289383EE0042947B /* NavBarOffsetModifier.swift in Sources */, E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */, E157563029355B7900976E1F /* UpdateView.swift in Sources */, E1D8424F2932F7C400D1041A /* OverviewView.swift in Sources */, E113133628BE98AA00930F75 /* FilterDrawerButton.swift in Sources */, + E1DE84142B9531C1008CCE21 /* OrderedSectionSelectorView.swift in Sources */, E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */, E18E01DE288747230022598C /* iPadOSSeriesItemView.swift in Sources */, + E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */, E1EF4C412911B783008CC695 /* StreamType.swift in Sources */, 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, E1921B7628E63306003A5238 /* GestureView.swift in Sources */, E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, - E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */, E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, @@ -3604,15 +3678,15 @@ E1BDF2F729524ECD00CC0294 /* PlaybackSpeedActionButton.swift in Sources */, E113132F28BDB66A00930F75 /* NavBarDrawerModifier.swift in Sources */, E1E750692A33E9B400B2C1EE /* MediaSourcesCard.swift in Sources */, + E1DE2B4C2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift in Sources */, E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */, C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */, E129429328F2845000796AC6 /* SliderType.swift in Sources */, - E14A08CD28E68729004FC984 /* MenuPosterHStack.swift in Sources */, E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */, E1E6C44C29AED2BE0064123F /* HorizontalAlignment.swift in Sources */, E1A1528D28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E18E01EE288747230022598C /* AboutView.swift in Sources */, - 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, + 62E632E0267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */, E1549662296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */, E15756362936856700976E1F /* VideoPlayerType.swift in Sources */, @@ -3620,6 +3694,7 @@ E11CEB8B28998552003E74C7 /* iOSViewExtensions.swift in Sources */, E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */, E1A1529028FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, + E11042752B8013DF00821020 /* Stateful.swift in Sources */, E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E12376AE2A33D680001F5B44 /* AboutViewCard.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */, @@ -3636,22 +3711,22 @@ E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */, E18E01E9288747230022598C /* SeriesItemView.swift in Sources */, E15756342936851D00976E1F /* NativeVideoPlayerSettingsView.swift in Sources */, - E18E023A288749540022598C /* UIScrollView.swift in Sources */, E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */, E1BDF2F329524C3B00CC0294 /* ChaptersActionButton.swift in Sources */, E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, E1CFE28028FA606800B7D34C /* ChapterTrack.swift in Sources */, - 8BF1BD842AE93DFB00C3B271 /* LatestInLibraryViewModel.swift in Sources */, E1401CA22938122C00E8B599 /* AppIcons.swift in Sources */, E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */, E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */, + E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */, E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */, E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */, C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, + E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */, E1D8429329340B8300D1041A /* Utilities.swift in Sources */, @@ -3660,15 +3735,13 @@ E187A60229AB28F0008387E6 /* RotateContentView.swift in Sources */, 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, E1721FAE28FB801C00762992 /* SmallPlaybackButtons.swift in Sources */, + E1A7B1662B9ADAD300152546 /* ItemTypeLibraryViewModel.swift in Sources */, E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */, - 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */, - E1D3044228D1976600587289 /* CastAndCrewItemRow.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, - E1549668296CA2EF00C4EF88 /* PlaybackManager.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, - 535870AD2669D8DD00D05A09 /* ItemFilters.swift in Sources */, + 535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */, E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */, E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E18E01F1288747230022598C /* PlayButton.swift in Sources */, @@ -3676,31 +3749,34 @@ E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */, 62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */, + E11BDF7A2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, E170D0E4294CC8AB0017224C /* VideoPlayer+KeyCommands.swift in Sources */, - E1267D3E271A1F46003C492E /* PreferenceUIHostingController.swift in Sources */, E18E01E6288747230022598C /* CollectionItemView.swift in Sources */, + E15D4F0A2B1BD88900442DB8 /* Edge.swift in Sources */, 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */, E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, + E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, + E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */, + E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */, E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */, - E12CC1BF28D1260600678D5D /* ItemTypeLibraryViewModel.swift in Sources */, E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */, E1E6C44029AECC6D0064123F /* ActionButtons.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, + E1E2F83F2B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, + E15D4F072B1B12C300442DB8 /* Backport.swift in Sources */, E1549660296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */, E18E01EA288747230022598C /* MovieItemView.swift in Sources */, - E12CC1B528D1124400678D5D /* BasicLibraryCoordinator.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, - E148128528C15472003B8787 /* APISortOrder.swift in Sources */, + E148128528C15472003B8787 /* SortOrder.swift in Sources */, E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */, - E13F05F328BC9016003499D2 /* LibraryView.swift in Sources */, E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */, E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */, 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */, @@ -3708,6 +3784,7 @@ E11245B128D919CD00D8A977 /* Overlay.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* SearchView.swift in Sources */, + E1D37F522B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */, E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */, E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */, 625CB5752678C33500530A6E /* MediaViewModel.swift in Sources */, @@ -4051,6 +4128,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; + OTHER_CFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4089,6 +4167,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; + OTHER_CFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4132,6 +4211,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = PreferencesView; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { isa = XCRemoteSwiftPackageReference; @@ -4165,6 +4251,22 @@ minimumVersion = 1.0.0; }; }; + E104DC8B2B9D8979008F506D /* XCRemoteSwiftPackageReference "CollectionHStack" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/CollectionHStack"; + requirement = { + branch = main; + kind = branch; + }; + }; + E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/CollectionVGrid"; + requirement = { + branch = main; + kind = branch; + }; + }; E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Pulse"; @@ -4242,7 +4344,7 @@ repositoryURL = "https://github.com/kean/Nuke"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 11.1.0; + minimumVersion = 12.0.0; }; }; E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */ = { @@ -4315,6 +4417,26 @@ package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; productName = Algorithms; }; + E104DC8C2B9D8979008F506D /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + package = E104DC8B2B9D8979008F506D /* XCRemoteSwiftPackageReference "CollectionHStack" */; + productName = CollectionHStack; + }; + E104DC8F2B9D8995008F506D /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + package = E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */; + productName = CollectionVGrid; + }; + E104DC912B9D89A2008F506D /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + package = E104DC8B2B9D8979008F506D /* XCRemoteSwiftPackageReference "CollectionHStack" */; + productName = CollectionHStack; + }; + E104DC932B9D89A2008F506D /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + package = E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */; + productName = CollectionVGrid; + }; E107060F2942F57D00646DAF /* Pulse */ = { isa = XCSwiftPackageProductDependency; package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; @@ -4330,6 +4452,18 @@ package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; productName = PulseUI; }; + E113A2A62B5A178D009CAAAA /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; + E113A2A92B5A179A009CAAAA /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; + }; + E114DB322B1944FA00B75FB3 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; + }; E12186DD2718F1C50010884C /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; @@ -4400,6 +4534,10 @@ package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; productName = PulseUI; }; + E1523F812B132C350062821A /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; E1575E3B293C6B15001665B1 /* Files */ = { isa = XCSwiftPackageProductDependency; package = E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */; @@ -4415,6 +4553,10 @@ package = E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */; productName = Files; }; + E15D4F042B1B0C3C00442DB8 /* PreferencesView */ = { + isa = XCSwiftPackageProductDependency; + productName = PreferencesView; + }; E18443CA2A037773002DDDC8 /* UDPBroadcast */ = { isa = XCSwiftPackageProductDependency; package = E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */; @@ -4455,6 +4597,11 @@ package = E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */; productName = BlurHashKit; }; + E1A7B1642B9A9F7800152546 /* PreferencesView */ = { + isa = XCSwiftPackageProductDependency; + package = E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */; + productName = PreferencesView; + }; E1B5F7A629577BCE004B26CF /* Pulse */ = { isa = XCSwiftPackageProductDependency; package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e4c74b81a..5087afbf0 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,24 @@ "version" : "1.2.0" } }, + { + "identity" : "collectionhstack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LePips/CollectionHStack", + "state" : { + "branch" : "main", + "revision" : "ff54c0ea655840a12e7faaabb31aa66f50cc4767" + } + }, + { + "identity" : "collectionvgrid", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LePips/CollectionVGrid", + "state" : { + "branch" : "main", + "revision" : "2be0988304df1ab59a3340e41c07f94eee480e66" + } + }, { "identity" : "collectionview", "kind" : "remoteSourceControl", @@ -36,6 +54,15 @@ "version" : "7.3.1" } }, + { + "identity" : "differencekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ra1028/DifferenceKit", + "state" : { + "revision" : "073b9671ce2b9b5b96398611427a1f929927e428", + "version" : "1.3.0" + } + }, { "identity" : "factory", "kind" : "remoteSourceControl", @@ -77,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke", "state" : { - "revision" : "33f7e93be5d4ec027d42af77a8ec4680d1862ad2", - "version" : "11.6.4" + "revision" : "8ecbfc886da39bccb01c34abef5f2ff4073ad633", + "version" : "12.4.0" } }, { @@ -105,7 +132,7 @@ "location" : "https://github.com/rundfunk47/stinsen", "state" : { "branch" : "master", - "revision" : "d6ad23f4c68212fed8ac64c739bef224628776e3" + "revision" : "6dda57096e16020342b36ebea86dc4bdf6783426" } }, { @@ -122,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", - "version" : "1.1.0" + "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", + "version" : "1.0.5" } }, { @@ -158,8 +185,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MarioIannotta/SwizzleSwift", "state" : { - "revision" : "337dd5f158182620b2bb53c6847f8874a0117b2f", - "version" : "1.0.0" + "branch" : "master", + "revision" : "e2d31c646182bf94a496b173c6ee5ad191230e9a" } }, { diff --git a/Swiftfin/App/AppDelegate.swift b/Swiftfin/App/AppDelegate.swift index f4f21943a..285954fc4 100644 --- a/Swiftfin/App/AppDelegate.swift +++ b/Swiftfin/App/AppDelegate.swift @@ -17,8 +17,6 @@ import UIKit class AppDelegate: NSObject, UIApplicationDelegate { - static var orientationLock: UIInterfaceOrientationMask = .all - func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -33,42 +31,4 @@ class AppDelegate: NSObject, UIApplicationDelegate { return true } - - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - AppDelegate.orientationLock - } - - static func enterPlaybackOrientation() { - AppDelegate.changeOrientation(.landscape) - } - - static func leavePlaybackOrientation() { - if UIDevice.isIPad { - AppDelegate.changeOrientation(.allButUpsideDown) - } else { - // On iPhone, users likely want to return to portrait mode after leaving playback. - // However, we don't want to lock them into portrait mode, so switch back after a delay. - AppDelegate.changeOrientation(.portrait) - - // 0.25 seconds is about the time to switch from landscape to portrait. - // Changing orientation again too early will cause the top time/battery bar to remain - // at the side instead of moving up top, like it should. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - AppDelegate.changeOrientation(.allButUpsideDown) - } - } - } - - private static func changeOrientation(_ orientation: UIInterfaceOrientationMask) { - guard UIDevice.isPhone || UIDevice.isIPad else { return } - - orientationLock = orientation - - if #available(iOS 16, *) { - let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene - windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation)) - } else { - UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") - } - } } diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift deleted file mode 100644 index 403c96dd2..000000000 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift +++ /dev/null @@ -1,187 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import SwiftUI -import UIKit - -class PreferenceUIHostingController: UIHostingController { - - init(@ViewBuilder wrappedView: @escaping () -> V) { - let box = Box() - super.init(rootView: AnyView( - wrappedView() - .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { - box.value?._prefersHomeIndicatorAutoHidden = $0 - }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { - box.value?._orientations = $0 - }.onPreferenceChange(ViewPreferenceKey.self) { - box.value?._viewPreference = $0 - }.onPreferenceChange(KeyCommandsPreferenceKey.self) { - box.value?._keyCommands = $0 - }.onPreferenceChange(AddingKeyCommandPreferenceKey.self) { - guard let newAction = $0 else { return } - box.value?._keyCommands.append(newAction) - } - )) - box.value = self - } - - @objc - dynamic required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - super.modalPresentationStyle = .fullScreen - } - - private class Box { - weak var value: PreferenceUIHostingController? - init() {} - } - - // MARK: Prefers Home Indicator Auto Hidden - - var _prefersHomeIndicatorAutoHidden = false { - didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } - } - - override var prefersHomeIndicatorAutoHidden: Bool { - _prefersHomeIndicatorAutoHidden - } - - // MARK: Lock orientation - - var _orientations: UIInterfaceOrientationMask = .allButUpsideDown { - didSet { - print("didset orientations: \(_orientations)") - if #available(iOS 16.0, *) { -// setNeedsUpdateOfSupportedInterfaceOrientations() - } else { - // Fallback on earlier versions - } -// if _orientations == .landscape { -// let value = UIInterfaceOrientation.landscapeRight.rawValue -// UIDevice.current.setValue(value, forKey: "orientation") -// UIViewController.attemptRotationToDeviceOrientation() -// } - } - } - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - _orientations - } - - var _viewPreference: UIUserInterfaceStyle = .unspecified { - didSet { - overrideUserInterfaceStyle = _viewPreference - } - } - - var _keyCommands: [KeyCommandAction] = [] - - override var keyCommands: [UIKeyCommand]? { - let castedCommands: [UIKeyCommand] = _keyCommands.map { .init( - title: $0.title, - action: #selector(keyCommandHit), - input: $0.input, - modifierFlags: $0.modifierFlags - ) } - - castedCommands.forEach { $0.wantsPriorityOverSystemBehavior = true } - - return castedCommands - } - - @objc - private func keyCommandHit(keyCommand: UIKeyCommand) { - guard let action = _keyCommands.first(where: { $0.input == keyCommand.input }) else { return } - - action.action() - } -} - -// MARK: Preference Keys - -// TODO: look at namespacing? - -struct AddingKeyCommandPreferenceKey: PreferenceKey { - - static var defaultValue: KeyCommandAction? - - static func reduce(value: inout KeyCommandAction?, nextValue: () -> KeyCommandAction?) { - value = nextValue() - } -} - -struct KeyCommandsPreferenceKey: PreferenceKey { - - static var defaultValue: [KeyCommandAction] = [] - - static func reduce(value: inout [KeyCommandAction], nextValue: () -> [KeyCommandAction]) { - value = nextValue() - } -} - -struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey { - - static var defaultValue: Bool = false - - static func reduce(value: inout Bool, nextValue: () -> Bool) { - value = nextValue() || value - } -} - -struct SupportedOrientationsPreferenceKey: PreferenceKey { - - static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown - - static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) { - // use the most restrictive set from the stack - value.formIntersection(nextValue()) - } -} - -struct ViewPreferenceKey: PreferenceKey { - - static var defaultValue: UIUserInterfaceStyle = .unspecified - - static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { - value = nextValue() - } -} - -// MARK: Preference Key View Extension - -extension View { - - func keyCommands(_ commands: [KeyCommandAction]) -> some View { - preference(key: KeyCommandsPreferenceKey.self, value: commands) - } - - func addingKeyCommand( - title: String, - input: String, - modifierFlags: UIKeyModifierFlags = [], - action: @escaping () -> Void - ) -> some View { - preference( - key: AddingKeyCommandPreferenceKey.self, - value: .init(title: title, input: input, modifierFlags: modifierFlags, action: action) - ) - } - - func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View { - preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value) - } - - func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { - preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) - } - - func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { - preference(key: ViewPreferenceKey.self, value: viewPreference) - } -} diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift deleted file mode 100644 index f3e787fb5..000000000 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import SwiftUI -import SwizzleSwift -import UIKit - -// MARK: - wrapper view - -/// Wrapper view that will apply swizzling to make iOS query the child view for preference settings. -/// Used in combination with PreferenceUIHostingController. -/// -/// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d -struct PreferenceUIHostingControllerView: UIViewControllerRepresentable { - init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { - _ = UIViewController.preferenceSwizzling - self.wrappedView = wrappedView - } - - var wrappedView: () -> Wrapped - - func makeUIViewController(context: Context) -> PreferenceUIHostingController { - PreferenceUIHostingController { wrappedView() } - } - - func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} -} - -// MARK: - swizzling uiviewcontroller extensions - -extension UIViewController { - static var preferenceSwizzling: Void = { - Swizzle(UIViewController.self) { - #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) - #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) - } - }() -} - -extension UIViewController { - @objc - func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { - if self is PreferenceUIHostingController { - // dont continue searching - return nil - } else { - return search() - } - } - - @objc - func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { - if self is PreferenceUIHostingController { - // dont continue searching - return nil - } else { - return search() - } - } - - private func search() -> PreferenceUIHostingController? { - if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { - return result - } - - for child in children { - if let result = child.search() { - return result - } - } - - return nil - } -} diff --git a/Swiftfin/App/SwiftfinApp.swift b/Swiftfin/App/SwiftfinApp.swift index 68b009444..99d63476d 100644 --- a/Swiftfin/App/SwiftfinApp.swift +++ b/Swiftfin/App/SwiftfinApp.swift @@ -9,6 +9,7 @@ import CoreStore import Defaults import Logging +import PreferencesView import Pulse import PulseLogHandler import SwiftUI @@ -25,7 +26,6 @@ struct SwiftfinApp: App { Task { for await newValue in Defaults.updates(.accentColor) { UIApplication.shared.setAccentColor(newValue.uiColor) - UIApplication.shared.setNavigationBackButtonAccentColor(newValue.uiColor) } } @@ -56,10 +56,10 @@ struct SwiftfinApp: App { var body: some Scene { WindowGroup { - PreferenceUIHostingControllerView { + PreferencesView { MainCoordinator() .view() - .supportedOrientations(.portrait) + .supportedOrientations(UIDevice.isPad ? .allButUpsideDown : .portrait) } .ignoresSafeArea() .onOpenURL { url in diff --git a/Swiftfin/AppURLHandler/AppURLHandler.swift b/Swiftfin/AppURLHandler/AppURLHandler.swift index 443323dc6..044a6a837 100644 --- a/Swiftfin/AppURLHandler/AppURLHandler.swift +++ b/Swiftfin/AppURLHandler/AppURLHandler.swift @@ -55,7 +55,7 @@ extension AppURLHandler { func processLaunchedURLIfNeeded() { guard let launchURL = launchURL, - !launchURL.absoluteString.isEmpty else { return } + launchURL.absoluteString.isNotEmpty else { return } if processDeepLink(url: launchURL) { self.launchURL = nil } diff --git a/Swiftfin/Components/DotHStack.swift b/Swiftfin/Components/DotHStack.swift index fac353ebe..6c496c19a 100644 --- a/Swiftfin/Components/DotHStack.swift +++ b/Swiftfin/Components/DotHStack.swift @@ -8,16 +8,17 @@ import SwiftUI -struct DotHStack: View { +struct DotHStack: View { @ViewBuilder - var content: () -> any View + var content: () -> Content var body: some View { SeparatorHStack(content) .separator { Circle() .frame(width: 2, height: 2) + .padding(.horizontal, 5) } } } diff --git a/Swiftfin/Components/ErrorView.swift b/Swiftfin/Components/ErrorView.swift new file mode 100644 index 000000000..2fc0a6299 --- /dev/null +++ b/Swiftfin/Components/ErrorView.swift @@ -0,0 +1,47 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: should use environment refresh instead? +struct ErrorView<_Error: Error>: View { + + private let error: _Error + private var onRetry: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(Color.red) + + Text(error.localizedDescription) + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) + + PrimaryButton(title: L10n.retry) + .onSelect(onRetry) + .frame(maxWidth: 300) + .frame(height: 50) + } + } +} + +extension ErrorView { + + init(error: _Error) { + self.init( + error: error, + onRetry: {} + ) + } + + func onRetry(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onRetry, with: action) + } +} diff --git a/Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.swift b/Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.swift deleted file mode 100644 index fa34aa5d9..000000000 --- a/Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct FilterDrawerHStack: View { - - @ObservedObject - private var viewModel: FilterViewModel - private var filterDrawerButtonSelection: [FilterDrawerButtonSelection] - private var onSelect: (FilterCoordinator.Parameters) -> Void - - var body: some View { - HStack { - if viewModel.currentFilters.hasFilters { - Menu { - Button(role: .destructive) { - viewModel.currentFilters = .init() - } label: { - L10n.reset.text - } - } label: { - FilterDrawerButton(systemName: "line.3.horizontal.decrease.circle.fill", activated: true) - } - } - ForEach(filterDrawerButtonSelection, id: \.self) { button in - FilterDrawerButton(title: button.displayTitle, activated: button.isItemsFilterActive( - activeFilters: viewModel.currentFilters - )) - .onSelect { - onSelect(.init( - title: button.displayTitle, - viewModel: viewModel, - filter: button.itemFilter, - selectorType: button.selectorType - )) - } - } - } - } -} - -extension FilterDrawerHStack { - - init(viewModel: FilterViewModel, filterDrawerButtonSelection: [FilterDrawerButtonSelection]) { - self.init( - viewModel: viewModel, - filterDrawerButtonSelection: filterDrawerButtonSelection, - onSelect: { _ in } - ) - } - - func onSelect(_ action: @escaping (FilterCoordinator.Parameters) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Components/LandscapePosterProgressBar.swift b/Swiftfin/Components/LandscapePosterProgressBar.swift index 1f249dcab..2fb6c5c2b 100644 --- a/Swiftfin/Components/LandscapePosterProgressBar.swift +++ b/Swiftfin/Components/LandscapePosterProgressBar.swift @@ -20,38 +20,38 @@ struct LandscapePosterProgressBar: View { // Scale padding depending on view width @State private var paddingScale: CGFloat = 1.0 + @State + private var width: CGFloat = 0 var body: some View { - GeometryReader { reader in - ZStack(alignment: .bottom) { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black.opacity(0.7), location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 40) - - VStack(alignment: .leading, spacing: 3 * paddingScale) { - - Spacer() - - Text(title) - .font(.subheadline) - .foregroundColor(.white) - - ProgressBar(progress: progress) - .foregroundColor(accentColor) - .frame(height: 3) - } - .padding(.horizontal, 5 * paddingScale) - .padding(.bottom, 7 * paddingScale) - .onAppear { - paddingScale = reader.size.width / 300 - } + ZStack(alignment: .bottom) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.7), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 40) + + VStack(alignment: .leading, spacing: 3 * paddingScale) { + + Spacer() + + Text(title) + .font(.subheadline) + .foregroundColor(.white) + + ProgressBar(progress: progress) + .foregroundColor(accentColor) + .frame(height: 3) } + .padding(.horizontal, 5 * paddingScale) + .padding(.bottom, 7 * paddingScale) + } + .onSizeChanged { newSize in + width = newSize.width } } } diff --git a/Swiftfin/Components/LibraryItemRow.swift b/Swiftfin/Components/LibraryItemRow.swift deleted file mode 100644 index 511335b9b..000000000 --- a/Swiftfin/Components/LibraryItemRow.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct LibraryItemRow: View { - - @EnvironmentObject - private var router: LibraryCoordinator.Router - - private let item: BaseItemDto - private var onSelect: () -> Void - - private let posterWidth: CGFloat = 60 - - var body: some View { - Button { - onSelect() - } label: { - HStack(alignment: .bottom) { - ImageView(item.portraitPosterImageSource(maxWidth: posterWidth)) - .posterStyle(.portrait) - .frame(width: posterWidth) - .posterShadow() - - VStack(alignment: .leading) { - Text(item.displayTitle) - .foregroundColor(.primary) - .fontWeight(.semibold) - .lineLimit(2) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - - DotHStack { - if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLocator { - Text(seasonEpisodeLocator) - } else if let premiereYear = item.premiereDateYear { - Text(premiereYear) - } - - if let runtime = item.getItemRuntime() { - Text(runtime) - } - - if let officialRating = item.officialRating { - Text(officialRating) - } - } - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - } - .padding(.vertical) - - Spacer() - } - } - } -} - -extension LibraryItemRow { - - init(item: BaseItemDto) { - self.init( - item: item, - onSelect: {} - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Components/LibraryViewTypeToggle.swift b/Swiftfin/Components/LibraryViewTypeToggle.swift deleted file mode 100644 index 323699a11..000000000 --- a/Swiftfin/Components/LibraryViewTypeToggle.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct LibraryViewTypeToggle: View { - - @Binding - var libraryViewType: LibraryViewType - - var body: some View { - Button { - switch libraryViewType { - case .grid: - libraryViewType = .list - case .list: - libraryViewType = .grid - } - } label: { - switch libraryViewType { - case .grid: - Label(L10n.list, systemImage: "list.dash") - case .list: - Label(L10n.grid, systemImage: "square.grid.2x2") - } - } - } -} diff --git a/Swiftfin/Components/MenuPosterHStack.swift b/Swiftfin/Components/MenuPosterHStack.swift deleted file mode 100644 index 011b4a9b1..000000000 --- a/Swiftfin/Components/MenuPosterHStack.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct MenuPosterHStack: View { - - @ObservedObject - private var manager: Model - - private let type: PosterType - private var itemScale: CGFloat - private let singleImage: Bool - private var content: (Model.Item) -> any View - private var imageOverlay: (Model.Item) -> any View - private var contextMenu: (Model.Item) -> any View - private var onSelect: (Model.Item) -> Void - - @ViewBuilder - private var selectorMenu: some View { - Menu { - ForEach(manager.menuSections.keys.sorted(by: { manager.menuSectionSort($0, $1) }), id: \.displayTitle) { section in - Button { - manager.select(section: section) - } label: { - if section == manager.menuSelection { - Label(section.displayTitle, systemImage: "checkmark") - } else { - Text(section.displayTitle) - } - } - } - } label: { - HStack(spacing: 5) { - Group { - Text(manager.menuSelection?.displayTitle ?? L10n.unknown) - .fixedSize() - Image(systemName: "chevron.down") - } - .font(.title3.weight(.semibold)) - } - } - .padding(.bottom) - .fixedSize() - } - - private var items: [Model.Item] { - guard let selection = manager.menuSelection, - let items = manager.menuSections[selection] else { return [] } - return items - } - - var body: some View { - PosterHStack( - type: type, - items: items, - singleImage: singleImage - ) - .header { - selectorMenu - } - .scaleItems(itemScale) - .content(content) - .imageOverlay(imageOverlay) - .contextMenu(contextMenu) - .onSelect { item in - onSelect(item) - } - } -} - -extension MenuPosterHStack { - - init( - type: PosterType, - manager: Model, - singleImage: Bool = false - ) { - self.init( - manager: manager, - type: type, - itemScale: 1, - singleImage: singleImage, - content: { _ in EmptyView() }, - imageOverlay: { _ in EmptyView() }, - contextMenu: { _ in EmptyView() }, - onSelect: { _ in } - ) - } -} - -extension MenuPosterHStack { - - func scaleItems(_ scale: CGFloat) -> Self { - copy(modifying: \.itemScale, with: scale) - } - - func content(@ViewBuilder _ content: @escaping (Model.Item) -> any View) -> Self { - copy(modifying: \.content, with: content) - } - - func imageOverlay(@ViewBuilder _ content: @escaping (Model.Item) -> any View) -> Self { - copy(modifying: \.imageOverlay, with: content) - } - - func contextMenu(@ViewBuilder _ content: @escaping (Model.Item) -> any View) -> Self { - copy(modifying: \.contextMenu, with: content) - } - - func onSelect(_ action: @escaping (Model.Item) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift b/Swiftfin/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift similarity index 83% rename from Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift rename to Swiftfin/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift index 3621ffa94..9de398212 100644 --- a/Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift +++ b/Swiftfin/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift @@ -9,11 +9,11 @@ import Defaults import SwiftUI -extension FilterDrawerHStack { +extension NavigationBarFilterDrawer { struct FilterDrawerButton: View { - @Default(.accentColor) + @Environment(\.accentColor) private var accentColor private let systemName: String? @@ -21,18 +21,6 @@ extension FilterDrawerHStack { private let activated: Bool private var onSelect: () -> Void - private init( - systemName: String?, - title: String, - activated: Bool, - onSelect: @escaping () -> Void - ) { - self.systemName = systemName - self.title = title - self.activated = activated - self.onSelect = onSelect - } - var body: some View { Button { onSelect() @@ -67,7 +55,7 @@ extension FilterDrawerHStack { } } -extension FilterDrawerHStack.FilterDrawerButton { +extension NavigationBarFilterDrawer.FilterDrawerButton { init(title: String, activated: Bool) { self.init( diff --git a/Swiftfin/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift b/Swiftfin/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift new file mode 100644 index 000000000..ba3462ca2 --- /dev/null +++ b/Swiftfin/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift @@ -0,0 +1,64 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct NavigationBarFilterDrawer: View { + + @ObservedObject + private var viewModel: FilterViewModel + + private var filterTypes: [ItemFilterType] + private var onSelect: (FilterCoordinator.Parameters) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + if viewModel.currentFilters.hasFilters { + Menu { + Button(L10n.reset, role: .destructive) { + viewModel.currentFilters = .default + } + } label: { + FilterDrawerButton(systemName: "line.3.horizontal.decrease.circle.fill", activated: true) + } + } + + ForEach(filterTypes, id: \.self) { type in + FilterDrawerButton( + title: type.displayTitle, + activated: viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] != ItemFilterCollection + .default[keyPath: type.collectionAnyKeyPath] + ) + .onSelect { + onSelect(.init(type: type, viewModel: viewModel)) + } + } + } + .padding(.horizontal) + .padding(.vertical, 1) + } + } +} + +extension NavigationBarFilterDrawer { + + init(viewModel: FilterViewModel, types: [ItemFilterType]) { + self.init( + viewModel: viewModel, + filterTypes: types, + onSelect: { _ in } + ) + } + + func onSelect(_ action: @escaping (FilterCoordinator.Parameters) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Components/OrderedSectionSelectorView.swift b/Swiftfin/Components/OrderedSectionSelectorView.swift new file mode 100644 index 000000000..1db42cec8 --- /dev/null +++ b/Swiftfin/Components/OrderedSectionSelectorView.swift @@ -0,0 +1,124 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct OrderedSectionSelectorView: View { + + @Environment(\.editMode) + private var editMode + + @Binding + private var selection: [Element] + + @State + private var updateSelection: [Element] + + private var disabledSelection: [Element] { + sources.subtracting(updateSelection) + } + + private var label: (Element) -> any View + private let sources: [Element] + + private func move(from source: IndexSet, to destination: Int) { + updateSelection.move(fromOffsets: source, toOffset: destination) + } + + private func select(element: Element) { + if updateSelection.contains(element) { + updateSelection.removeAll(where: { $0 == element }) + } else { + updateSelection.append(element) + } + } + + var body: some View { + List { + Section(L10n.enabled) { + + if updateSelection.isEmpty { + L10n.none.text + .foregroundStyle(.secondary) + } + + ForEach(updateSelection, id: \.self) { element in + Button { + if !(editMode?.wrappedValue.isEditing ?? true) { + select(element: element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !(editMode?.wrappedValue.isEditing ?? false) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + } + .foregroundColor(.primary) + } + } + .onMove(perform: move) + } + + Section(L10n.disabled) { + + if disabledSelection.isEmpty { + L10n.none.text + .foregroundStyle(.secondary) + } + + ForEach(disabledSelection, id: \.self) { element in + Button { + if !(editMode?.wrappedValue.isEditing ?? true) { + select(element: element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !(editMode?.wrappedValue.isEditing ?? false) { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + } + .foregroundColor(.primary) + } + } + } + } + .animation(.linear(duration: 0.2), value: updateSelection) + .toolbar { + EditButton() + } + .onChange(of: updateSelection) { newValue in + selection = newValue + } + } +} + +extension OrderedSectionSelectorView { + + init(selection: Binding<[Element]>, sources: [Element]) { + self._selection = selection + self._updateSelection = State(initialValue: selection.wrappedValue) + self.sources = sources + self.label = { Text($0.displayTitle).foregroundColor(.primary) } + } + + func label(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { + copy(modifying: \.label, with: content) + } +} diff --git a/Swiftfin/Components/PagingLibraryView.swift b/Swiftfin/Components/PagingLibraryView.swift deleted file mode 100644 index d3a70c22d..000000000 --- a/Swiftfin/Components/PagingLibraryView.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import Defaults -import JellyfinAPI -import SwiftUI - -struct PagingLibraryView: View { - - @Default(.Customization.Library.gridPosterType) - private var libraryGridPosterType - @Default(.Customization.Library.viewType) - private var libraryViewType - - @ObservedObject - var viewModel: PagingLibraryViewModel - - private var onSelect: (BaseItemDto) -> Void - - private let portraitPosterScale = 1.25 - - private var gridLayout: NSCollectionLayoutSection.GridLayoutMode { - if libraryGridPosterType == .landscape && UIDevice.isPhone { - return .fixedNumberOfColumns(2) - } else { - return .adaptive(withMinItemSize: libraryGridPosterType.width * portraitPosterScale + 10) - } - } - - @ViewBuilder - private var libraryListView: some View { - CollectionView(items: viewModel.items.elements) { _, item, _ in - LibraryItemRow(item: item) - .onSelect { - onSelect(item) - } - .padding() - } - .layout { _, layoutEnvironment in - .list(using: .init(appearance: .plain), layoutEnvironment: layoutEnvironment) - } - .willReachEdge(insets: .init(top: 0, leading: 0, bottom: 200, trailing: 0)) { edge in - if !viewModel.isLoading && edge == .bottom { - viewModel.requestNextPage() - } - } - .onEdgeReached { edge in - if viewModel.hasNextPage, !viewModel.isLoading, edge == .bottom { - viewModel.requestNextPage() - } - } - .configure { configuration in - configuration.showsVerticalScrollIndicator = false - } - } - - @ViewBuilder - private var libraryGridView: some View { - CollectionView(items: viewModel.items.elements) { _, item, _ in - PosterButton(item: item, type: libraryGridPosterType) - .scaleItem(libraryGridPosterType == .landscape && UIDevice.isPhone ? 0.85 : portraitPosterScale) - .onSelect { - onSelect(item) - } - } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: gridLayout, - sectionInsets: .init(top: 0, leading: 10, bottom: 0, trailing: 10) - ) - } - .willReachEdge(insets: .init(top: 0, leading: 0, bottom: 200, trailing: 0)) { edge in - if !viewModel.isLoading && edge == .bottom { - viewModel.requestNextPage() - } - } - .onEdgeReached { edge in - if viewModel.hasNextPage, !viewModel.isLoading, edge == .bottom { - viewModel.requestNextPage() - } - } - .configure { configuration in - configuration.showsVerticalScrollIndicator = false - } - } - - var body: some View { - switch libraryViewType { - case .grid: - libraryGridView - case .list: - libraryListView - } - } -} - -extension PagingLibraryView { - init(viewModel: PagingLibraryViewModel) { - self.init( - viewModel: viewModel, - onSelect: { _ in } - ) - } - - func onSelect(_ action: @escaping (BaseItemDto) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Components/PillHStack.swift b/Swiftfin/Components/PillHStack.swift index 75d99597e..c9bbfc53a 100644 --- a/Swiftfin/Components/PillHStack.swift +++ b/Swiftfin/Components/PillHStack.swift @@ -20,10 +20,7 @@ struct PillHStack: View { .font(.title2) .fontWeight(.semibold) .accessibility(addTraits: [.isHeader]) - .padding(.leading) - .if(UIDevice.isIPad) { view in - view.padding(.leading) - } + .edgePadding(.leading) ScrollView(.horizontal, showsIndicators: false) { HStack { @@ -43,10 +40,7 @@ struct PillHStack: View { } } } - .padding(.horizontal) - .if(UIDevice.isIPad) { view in - view.padding(.horizontal) - } + .edgePadding(.horizontal) } } } diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift index 35c1aa963..e63feec41 100644 --- a/Swiftfin/Components/PosterButton.swift +++ b/Swiftfin/Components/PosterButton.swift @@ -10,64 +10,56 @@ import Defaults import JellyfinAPI import SwiftUI -// TODO: builder methods shouldn't take the item - struct PosterButton: View { private var item: Item private var type: PosterType - private var itemScale: CGFloat - private var content: (Item) -> any View - private var imageOverlay: (Item) -> any View - private var contextMenu: (Item) -> any View + private var content: () -> any View + private var imageOverlay: () -> any View + private var contextMenu: () -> any View private var onSelect: () -> Void private var singleImage: Bool - private var itemWidth: CGFloat { - type.width * itemScale - } - @ViewBuilder - private func poster(from item: any Poster) -> some View { + private func poster(from item: Item) -> some View { switch type { case .portrait: - ImageView(item.portraitPosterImageSource(maxWidth: itemWidth)) + ImageView(item.portraitPosterImageSource(maxWidth: 200)) .failure { - InitialFailureView(item.displayTitle.initials) + TypeSystemNameView(item: item) } case .landscape: - ImageView(item.landscapePosterImageSources(maxWidth: itemWidth, single: singleImage)) + ImageView(item.landscapePosterImageSources(maxWidth: 500, single: singleImage)) .failure { - InitialFailureView(item.displayTitle.initials) + TypeSystemNameView(item: item) } } } var body: some View { VStack(alignment: .leading) { - Button { onSelect() } label: { - poster(from: item) - .overlay { - imageOverlay(item) - .eraseToAnyView() - .posterStyle(type) - } + ZStack { + Color.clear + + poster(from: item) + + imageOverlay() + .eraseToAnyView() + } + .posterStyle(type) } .contextMenu(menuItems: { - contextMenu(item) + contextMenu() .eraseToAnyView() }) - .posterStyle(type) - .frame(width: itemWidth) .posterShadow() - content(item) + content() .eraseToAnyView() } - .frame(width: itemWidth) } } @@ -81,28 +73,23 @@ extension PosterButton { self.init( item: item, type: type, - itemScale: 1, - content: { DefaultContentView(item: $0) }, - imageOverlay: { DefaultOverlay(item: $0) }, - contextMenu: { _ in EmptyView() }, + content: { TitleSubtitleContentView(item: item) }, + imageOverlay: { DefaultOverlay(item: item) }, + contextMenu: { EmptyView() }, onSelect: {}, singleImage: singleImage ) } - func scaleItem(_ scale: CGFloat) -> Self { - copy(modifying: \.itemScale, with: scale) - } - - func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { copy(modifying: \.content, with: content) } - func imageOverlay(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + func imageOverlay(@ViewBuilder _ content: @escaping () -> any View) -> Self { copy(modifying: \.imageOverlay, with: content) } - func contextMenu(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + func contextMenu(@ViewBuilder _ content: @escaping () -> any View) -> Self { copy(modifying: \.contextMenu, with: content) } @@ -111,43 +98,49 @@ extension PosterButton { } } +// TODO: Shared default content? + extension PosterButton { // MARK: Default Content - struct DefaultContentView: View { + struct TitleContentView: View { let item: Item - @ViewBuilder - private var title: some View { - if item.showTitle { - Text(item.displayTitle) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .lineLimit(2) - } else { - EmptyView() - } + var body: some View { + Text(item.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) } + } - @ViewBuilder - private var subtitle: some View { - if let subtitle = item.subtitle { - Text(subtitle) - .font(.caption.weight(.medium)) - .foregroundColor(.secondary) - .lineLimit(2) - } else { - EmptyView() - } + struct SubtitleContentView: View { + + let item: Item + + var body: some View { + Text(item.subtitle ?? "") + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) } + } + + struct TitleSubtitleContentView: View { + + let item: Item var body: some View { VStack(alignment: .leading) { - title + if item.showTitle { + TitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) + } - subtitle + SubtitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) } } } diff --git a/Swiftfin/Components/PosterHStack.swift b/Swiftfin/Components/PosterHStack.swift index b29b4225a..ae3469223 100644 --- a/Swiftfin/Components/PosterHStack.swift +++ b/Swiftfin/Components/PosterHStack.swift @@ -6,24 +6,69 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import CollectionHStack +import OrderedCollections import SwiftUI -// TODO: Remove `Header` and `TrailingContent` and create `HeaderPosterHStack` - struct PosterHStack: View { private var header: () -> any View private var title: String? private var type: PosterType - private var items: [Item] + private var items: Binding> private var singleImage: Bool - private var itemScale: CGFloat private var content: (Item) -> any View private var imageOverlay: (Item) -> any View private var contextMenu: (Item) -> any View private var trailingContent: () -> any View private var onSelect: (Item) -> Void + @ViewBuilder + private var padHStack: some View { + CollectionHStack( + items, + minWidth: type == .portrait ? 140 : 220 + ) { item in + PosterButton( + item: item, + type: type, + singleImage: singleImage + ) + .content { content(item).eraseToAnyView() } + .imageOverlay { imageOverlay(item).eraseToAnyView() } + .contextMenu { contextMenu(item).eraseToAnyView() } + .onSelect { onSelect(item) } + } + .clipsToBounds(false) + .dataPrefix(20) + .horizontalInset(EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .scrollBehavior(.continuousLeadingEdge) + } + + @ViewBuilder + private var phoneHStack: some View { + CollectionHStack( + items, + columns: type == .portrait ? 3 : 2 + ) { item in + PosterButton( + item: item, + type: type, + singleImage: singleImage + ) + .content { content(item).eraseToAnyView() } + .imageOverlay { imageOverlay(item).eraseToAnyView() } + .contextMenu { contextMenu(item).eraseToAnyView() } + .onSelect { onSelect(item) } + } + .clipsToBounds(false) + .dataPrefix(20) + .horizontalInset(EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .scrollBehavior(.continuousLeadingEdge) + } + var body: some View { VStack(alignment: .leading) { @@ -36,30 +81,12 @@ struct PosterHStack: View { trailingContent() .eraseToAnyView() } - .padding(.horizontal) - .if(UIDevice.isIPad) { view in - view.padding(.horizontal) - } + .edgePadding(.horizontal) - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 15) { - ForEach(items, id: \.self) { item in - PosterButton( - item: item, - type: type, - singleImage: singleImage - ) - .scaleItem(itemScale) - .content { content($0).eraseToAnyView() } - .imageOverlay { imageOverlay($0).eraseToAnyView() } - .contextMenu { contextMenu($0).eraseToAnyView() } - .onSelect { onSelect(item) } - } - } - .padding(.horizontal) - .if(UIDevice.isIPad) { view in - view.padding(.horizontal) - } + if UIDevice.isPhone { + phoneHStack + } else { + padHStack } } } @@ -68,9 +95,9 @@ struct PosterHStack: View { extension PosterHStack { init( - title: String, + title: String? = nil, type: PosterType, - items: [Item], + items: Binding>, singleImage: Bool = false ) { self.init( @@ -79,8 +106,7 @@ extension PosterHStack { type: type, items: items, singleImage: singleImage, - itemScale: 1, - content: { PosterButton.DefaultContentView(item: $0) }, + content: { PosterButton.TitleSubtitleContentView(item: $0) }, imageOverlay: { PosterButton.DefaultOverlay(item: $0) }, contextMenu: { _ in EmptyView() }, trailingContent: { EmptyView() }, @@ -88,23 +114,17 @@ extension PosterHStack { ) } - init( + init>( + title: String? = nil, type: PosterType, - items: [Item], + items: S, singleImage: Bool = false ) { self.init( - header: { DefaultHeader(title: nil) }, - title: nil, + title: title, type: type, - items: items, - singleImage: singleImage, - itemScale: 1, - content: { PosterButton.DefaultContentView(item: $0) }, - imageOverlay: { PosterButton.DefaultOverlay(item: $0) }, - contextMenu: { _ in EmptyView() }, - trailingContent: { EmptyView() }, - onSelect: { _ in } + items: .constant(OrderedSet(items)), + singleImage: singleImage ) } @@ -112,10 +132,6 @@ extension PosterHStack { copy(modifying: \.header, with: header) } - func scaleItems(_ scale: CGFloat) -> Self { - copy(modifying: \.itemScale, with: scale) - } - func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { copy(modifying: \.content, with: content) } diff --git a/Swiftfin/Components/RandomItemButton.swift b/Swiftfin/Components/RandomItemButton.swift deleted file mode 100644 index 2290ff9ee..000000000 --- a/Swiftfin/Components/RandomItemButton.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct RandomItemButton: View { - - @ObservedObject - private var viewModel: PagingLibraryViewModel - private var onSelect: (BaseItemDtoQueryResult) -> Void - - var body: some View { - Button { - Task { - let response = try await viewModel.getRandomItemFromLibrary() - onSelect(response) - } - } label: { - Label(L10n.random, systemImage: "dice.fill") - } - } -} - -extension RandomItemButton { - init(viewModel: PagingLibraryViewModel) { - self.init( - viewModel: viewModel, - onSelect: { _ in } - ) - } - - func onSelect(_ action: @escaping (BaseItemDtoQueryResult) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Components/RefreshableScrollView.swift b/Swiftfin/Components/RefreshableScrollView.swift deleted file mode 100644 index f9fa5b1c1..000000000 --- a/Swiftfin/Components/RefreshableScrollView.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Introspect -import SwiftUI - -struct RefreshableScrollView: View { - - let content: () -> Content - let onRefresh: () -> Void - - private let refreshHelper = RefreshHelper() - - var body: some View { - ScrollView(showsIndicators: false) { - content() - } - .introspectScrollView { scrollView in - let control = UIRefreshControl() - - refreshHelper.refreshControl = control - refreshHelper.refreshAction = onRefresh - - control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged) - scrollView.refreshControl = control - } - } -} diff --git a/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerModifier.swift b/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerModifier.swift index b8fbd455f..382e514d3 100644 --- a/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerModifier.swift +++ b/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerModifier.swift @@ -8,18 +8,17 @@ import SwiftUI -struct NavBarDrawerModifier: ViewModifier { +struct NavBarDrawerModifier: ViewModifier { - let drawer: () -> any View + private let drawer: () -> Drawer - init(@ViewBuilder drawer: @escaping () -> any View) { + init(@ViewBuilder drawer: @escaping () -> Drawer) { self.drawer = drawer } func body(content: Content) -> some View { NavBarDrawerView { drawer() - .eraseToAnyView() .ignoresSafeArea() } content: { content diff --git a/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerView.swift b/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerView.swift index bd6647986..3bfedec4d 100644 --- a/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerView.swift +++ b/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerView.swift @@ -32,6 +32,8 @@ class UINavBarDrawerHostingController: UIViewController { private let buttons: () -> any View private let content: () -> any View + + // TODO: see if we can get the height instead from the view passed in private let drawerHeight: CGFloat = 36 private lazy var navBarBlurView: UIVisualEffectView = { diff --git a/Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetView.swift b/Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetView.swift index 1b2c2ef77..3b91d9185 100644 --- a/Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetView.swift +++ b/Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetView.swift @@ -8,20 +8,20 @@ import SwiftUI -struct NavBarOffsetView: UIViewControllerRepresentable { +struct NavBarOffsetView: UIViewControllerRepresentable { @Binding private var scrollViewOffset: CGFloat private let start: CGFloat private let end: CGFloat - private let content: () -> any View + private let content: () -> Content init( scrollViewOffset: Binding, start: CGFloat, end: CGFloat, - @ViewBuilder content: @escaping () -> any View + @ViewBuilder content: @escaping () -> Content ) { self._scrollViewOffset = scrollViewOffset self.start = start @@ -29,29 +29,16 @@ struct NavBarOffsetView: UIViewControllerRepresentable { self.content = content } - init( - start: CGFloat, - end: CGFloat, - @ViewBuilder body: @escaping () -> any View - ) { - self._scrollViewOffset = Binding(get: { 0 }, set: { _ in }) - self.start = start - self.end = end - self.content = body - } - - func makeUIViewController(context: Context) -> UINavBarOffsetHostingController { - let a = UINavBarOffsetHostingController(rootView: content().eraseToAnyView()) - a.additionalSafeAreaInsets = .zero - return a + func makeUIViewController(context: Context) -> UINavBarOffsetHostingController { + UINavBarOffsetHostingController(rootView: content()) } - func updateUIViewController(_ uiViewController: UINavBarOffsetHostingController, context: Context) { + func updateUIViewController(_ uiViewController: UINavBarOffsetHostingController, context: Context) { uiViewController.scrollViewDidScroll(scrollViewOffset, start: start, end: end) } } -class UINavBarOffsetHostingController: UIHostingController { +class UINavBarOffsetHostingController: UIHostingController { private var lastScrollViewOffset: CGFloat = 0 diff --git a/Swiftfin/Extensions/View/iOSViewExtensions.swift b/Swiftfin/Extensions/View/iOSViewExtensions.swift index 282047b63..2a649f344 100644 --- a/Swiftfin/Extensions/View/iOSViewExtensions.swift +++ b/Swiftfin/Extensions/View/iOSViewExtensions.swift @@ -15,49 +15,53 @@ extension View { modifier(DetectOrientation(orientation: orientation)) } - func navBarOffset(_ scrollViewOffset: Binding, start: CGFloat, end: CGFloat) -> some View { + func navigationBarOffset(_ scrollViewOffset: Binding, start: CGFloat, end: CGFloat) -> some View { modifier(NavBarOffsetModifier(scrollViewOffset: scrollViewOffset, start: start, end: end)) } - func navBarDrawer(@ViewBuilder _ drawer: @escaping () -> Drawer) -> some View { + func navigationBarDrawer(@ViewBuilder _ drawer: @escaping () -> Drawer) -> some View { modifier(NavBarDrawerModifier(drawer: drawer)) } + @ViewBuilder + func navigationBarFilterDrawer( + viewModel: FilterViewModel, + types: [ItemFilterType], + onSelect: @escaping (FilterCoordinator.Parameters) -> Void + ) -> some View { + if types.isEmpty { + self + } else { + navigationBarDrawer { + NavigationBarFilterDrawer( + viewModel: viewModel, + types: types + ) + .onSelect(onSelect) + } + } + } + func onAppDidEnterBackground(_ action: @escaping () -> Void) -> some View { - modifier( - OnReceiveNotificationModifier( - notification: UIApplication.didEnterBackgroundNotification, - onReceive: action - ) - ) + onNotification(UIApplication.didEnterBackgroundNotification, perform: action) } func onAppWillResignActive(_ action: @escaping () -> Void) -> some View { - modifier( - OnReceiveNotificationModifier( - notification: UIApplication.willResignActiveNotification, - onReceive: action - ) - ) + onNotification(UIApplication.willResignActiveNotification, perform: action) } func onAppWillTerminate(_ action: @escaping () -> Void) -> some View { - modifier( - OnReceiveNotificationModifier( - notification: UIApplication.willTerminateNotification, - onReceive: action - ) - ) + onNotification(UIApplication.willTerminateNotification, perform: action) } - func navigationCloseButton(accentColor: Color = Defaults[.accentColor], _ action: @escaping () -> Void) -> some View { + func navigationBarCloseButton(_ action: @escaping () -> Void) -> some View { toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { + ToolbarItemGroup(placement: .topBarLeading) { Button { action() } label: { Image(systemName: "xmark.circle.fill") - .accentSymbolRendering(accentColor: accentColor) + .paletteOverlayRendering() } } } diff --git a/Swiftfin/Objects/RefreshHelper.swift b/Swiftfin/Objects/RefreshHelper.swift deleted file mode 100644 index ebec2dab0..000000000 --- a/Swiftfin/Objects/RefreshHelper.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import UIKit - -// A more general derivative of -// https://stackoverflow.com/questions/65812080/introspect-library-uirefreshcontrol-with-swiftui-not-working -final class RefreshHelper { - var refreshControl: UIRefreshControl? - var refreshAction: (() -> Void)? - private var lastAutomaticRefresh = Date() - - @objc - func didRefresh() { - guard let refreshControl = refreshControl else { return } - refreshAction?() - refreshControl.endRefreshing() - } -} - -// MARK: - automatic refreshing - -extension RefreshHelper { - private static let timeUntilStale = TimeInterval(60) - - func refreshStaleData() { - guard isStale else { return } - lastAutomaticRefresh = .now - refreshAction?() - } - - private var isStale: Bool { - lastAutomaticRefresh.addingTimeInterval(Self.timeUntilStale) < .now - } -} diff --git a/Swiftfin/Views/AppIconSelectorView.swift b/Swiftfin/Views/AppIconSelectorView.swift index 8ad927ff3..99972a9fd 100644 --- a/Swiftfin/Views/AppIconSelectorView.swift +++ b/Swiftfin/Views/AppIconSelectorView.swift @@ -15,7 +15,6 @@ struct AppIconSelectorView: View { var viewModel: SettingsViewModel var body: some View { - Form { Section { @@ -87,7 +86,7 @@ extension AppIconSelectorView { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) - .accentSymbolRendering() + .paletteOverlayRendering() } } } diff --git a/Swiftfin/Views/BasicAppSettingsView.swift b/Swiftfin/Views/BasicAppSettingsView.swift index 1e00f548e..125f8a6af 100644 --- a/Swiftfin/Views/BasicAppSettingsView.swift +++ b/Swiftfin/Views/BasicAppSettingsView.swift @@ -39,7 +39,7 @@ struct BasicAppSettingsView: View { } Section { - EnumPicker(title: L10n.appearance, selection: $appAppearance) + CaseIterablePicker(title: L10n.appearance, selection: $appAppearance) ChevronButton(title: L10n.appIcon) .onSelect { @@ -86,9 +86,9 @@ struct BasicAppSettingsView: View { viewModel.removeAllServers() } } - .navigationBarTitle(L10n.settings) + .navigationTitle(L10n.settings) .navigationBarTitleDisplayMode(.inline) - .navigationCloseButton { + .navigationBarCloseButton { router.dismissCoordinator() } } diff --git a/Swiftfin/Views/BasicLibraryView.swift b/Swiftfin/Views/BasicLibraryView.swift deleted file mode 100644 index 0b1f56e3b..000000000 --- a/Swiftfin/Views/BasicLibraryView.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import Defaults -import JellyfinAPI -import SwiftUI - -struct BasicLibraryView: View { - - @Default(.Customization.Library.viewType) - private var libraryViewType - - @EnvironmentObject - private var router: BasicLibraryCoordinator.Router - - @ObservedObject - var viewModel: PagingLibraryViewModel - - @ViewBuilder - private var loadingView: some View { - ProgressView() - } - - @ViewBuilder - private var noResultsView: some View { - L10n.noResults.text - } - - @ViewBuilder - private var libraryItemsView: some View { - PagingLibraryView(viewModel: viewModel) - .onSelect { item in - router.route(to: \.item, item) - } - .ignoresSafeArea() - } - - var body: some View { - Group { - if viewModel.isLoading && viewModel.items.isEmpty { - loadingView - } else if viewModel.items.isEmpty { - noResultsView - } else { - libraryItemsView - } - } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - - if viewModel.isLoading && !viewModel.items.isEmpty { - ProgressView() - } - Menu { - LibraryViewTypeToggle(libraryViewType: $libraryViewType) - RandomItemButton(viewModel: viewModel) - .onSelect { response in - if let item = response.items?.first { - router.route(to: \.item, item) - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - } -} diff --git a/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewItemRow.swift b/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewItemRow.swift deleted file mode 100644 index 353f42919..000000000 --- a/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewItemRow.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension CastAndCrewLibraryView { - - struct CastAndCrewItemRow: View { - - @EnvironmentObject - private var router: CastAndCrewLibraryCoordinator.Router - - private let person: BaseItemPerson - private var onSelect: () -> Void - - var body: some View { - Button { - onSelect() - } label: { - HStack(alignment: .bottom) { - ImageView(person.portraitPosterImageSource(maxWidth: 60)) - .posterStyle(.portrait) - .frame(width: 60) - - VStack(alignment: .leading) { - Text(person.displayTitle) - .foregroundColor(.primary) - .fontWeight(.semibold) - .lineLimit(2) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - - if let subtitle = person.subtitle { - Text(subtitle) - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - } - } - .padding(.vertical) - - Spacer() - } - } - } - } -} - -extension CastAndCrewLibraryView.CastAndCrewItemRow { - init(person: BaseItemPerson) { - self.person = person - self.onSelect = {} - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewLibraryView.swift b/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewLibraryView.swift deleted file mode 100644 index 9ac604400..000000000 --- a/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewLibraryView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import Defaults -import JellyfinAPI -import SwiftUI - -struct CastAndCrewLibraryView: View { - - @Default(.Customization.Library.viewType) - private var libraryViewType - - @EnvironmentObject - private var router: CastAndCrewLibraryCoordinator.Router - - let people: [BaseItemPerson] - - @ViewBuilder - private var noResultsView: some View { - L10n.noResults.text - } - - @ViewBuilder - private var libraryListView: some View { - CollectionView(items: people) { _, person, _ in - CastAndCrewItemRow(person: person) - .onSelect { - router.route(to: \.library, .init(parent: person, type: .person, filters: .init())) - } - .padding() - } - .layout { _, layoutEnvironment in - .list(using: .init(appearance: .plain), layoutEnvironment: layoutEnvironment) - } - .configure { configuration in - configuration.showsVerticalScrollIndicator = false - } - } - - @ViewBuilder - private var libraryGridView: some View { - CollectionView(items: people) { _, person, _ in - PosterButton(item: person, type: .portrait) - .onSelect { - router.route(to: \.library, .init(parent: person, type: .person, filters: .init())) - } - } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .adaptive(withMinItemSize: 150 + (UIDevice.isIPad ? 10 : 0)), - sectionInsets: .init(top: 0, leading: 10, bottom: 0, trailing: 10) - ) - } - .configure { configuration in - configuration.showsVerticalScrollIndicator = false - } - } - - var body: some View { - Group { - if people.isEmpty { - noResultsView - } else { - switch libraryViewType { - case .grid: - libraryGridView - case .list: - libraryListView - } - } - } - .navigationTitle(L10n.castAndCrew) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - LibraryViewTypeToggle(libraryViewType: $libraryViewType) - } - } - } -} diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift index 20a2b61a6..ec550341f 100644 --- a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift +++ b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift @@ -164,7 +164,7 @@ extension DownloadTaskView.ContentView { Text(productionYear) } - if let runtime = downloadTask.item.getItemRuntime() { + if let runtime = downloadTask.item.runTimeLabel { Text(runtime) } } diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift index 39a9b9489..efafdedb8 100644 --- a/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift +++ b/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift @@ -21,7 +21,7 @@ struct DownloadTaskView: View { ScrollView(showsIndicators: false) { ContentView(downloadTask: downloadTask) } - .navigationCloseButton { + .navigationBarCloseButton { router.dismissCoordinator() } } diff --git a/Swiftfin/Views/FilterView.swift b/Swiftfin/Views/FilterView.swift index 61c2a0401..fa510c732 100644 --- a/Swiftfin/Views/FilterView.swift +++ b/Swiftfin/Views/FilterView.swift @@ -9,47 +9,90 @@ import JellyfinAPI import SwiftUI +// Note: Keep all of the ItemFilterCollection/ItemFilter/AnyItemFilter KeyPath wackiness in this file + struct FilterView: View { + @Binding + private var selection: [AnyItemFilter] + @EnvironmentObject private var router: FilterCoordinator.Router @ObservedObject private var viewModel: FilterViewModel - private let title: String - private let filter: WritableKeyPath - private let selectedFiltersBinding: Binding<[ItemFilters.Filter]> - private let selectorType: SelectorType - - init( - title: String, - viewModel: FilterViewModel, - filter: WritableKeyPath, - selectorType: SelectorType - ) { - self.title = title - self.viewModel = viewModel - self.filter = filter - self.selectorType = selectorType - - self.selectedFiltersBinding = Binding(get: { - viewModel.currentFilters[keyPath: filter] - }, set: { newValue, _ in - viewModel.currentFilters[keyPath: filter] = newValue - }) - } + private let type: ItemFilterType var body: some View { SelectorView( - selection: selectedFiltersBinding, - allItems: viewModel.allFilters[keyPath: filter], - type: selectorType + selection: $selection, + sources: viewModel.allFilters[keyPath: type.collectionAnyKeyPath], + type: type.selectorType ) - .navigationTitle(title) + .navigationTitle(type.displayTitle) .navigationBarTitleDisplayMode(.inline) - .navigationCloseButton { + .navigationBarCloseButton { router.dismissCoordinator() } + .topBarTrailing { + Button { + switch type { + case .genres: + viewModel.currentFilters.genres = ItemFilterCollection.default.genres + case .sortBy: + viewModel.currentFilters.sortBy = ItemFilterCollection.default.sortBy + case .sortOrder: + viewModel.currentFilters.sortOrder = ItemFilterCollection.default.sortOrder + case .tags: + viewModel.currentFilters.tags = ItemFilterCollection.default.tags + case .traits: + viewModel.currentFilters.traits = ItemFilterCollection.default.traits + case .years: + viewModel.currentFilters.years = ItemFilterCollection.default.years + } + } label: { + L10n.reset.text + } + .environment( + \.isEnabled, + viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] != ItemFilterCollection + .default[keyPath: type.collectionAnyKeyPath] + ) + } + } +} + +extension FilterView { + + init( + viewModel: FilterViewModel, + type: ItemFilterType + ) { + + let selectionBinding = Binding { + viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] + } set: { newValue in + switch type { + case .genres: + viewModel.currentFilters.genres = newValue.map(ItemGenre.init) + case .sortBy: + viewModel.currentFilters.sortBy = newValue.map(ItemSortBy.init) + case .sortOrder: + viewModel.currentFilters.sortOrder = newValue.map(ItemSortOrder.init) + case .tags: + viewModel.currentFilters.tags = newValue.map(ItemTag.init) + case .traits: + viewModel.currentFilters.traits = newValue.map(ItemTrait.init) + case .years: + viewModel.currentFilters.years = newValue.map(ItemYear.init) + } + } + + self.init( + selection: selectionBinding, + viewModel: viewModel, + type: type + ) } } diff --git a/Swiftfin/Views/FontPickerView.swift b/Swiftfin/Views/FontPickerView.swift index 32f3299a0..d5eb9c648 100644 --- a/Swiftfin/Views/FontPickerView.swift +++ b/Swiftfin/Views/FontPickerView.swift @@ -26,7 +26,7 @@ struct FontPickerView: View { var body: some View { SelectorView( selection: $updateSelection, - allItems: UIFont.familyNames + sources: UIFont.familyNames ) .label { fontFamily in Text(fontFamily) @@ -36,5 +36,6 @@ struct FontPickerView: View { .onChange(of: updateSelection) { newValue in selection = newValue } + .navigationTitle("Font") } } diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift index f636f613b..837c5e1af 100644 --- a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift @@ -6,7 +6,9 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import CollectionHStack import JellyfinAPI +import OrderedCollections import SwiftUI extension HomeView { @@ -19,34 +21,47 @@ extension HomeView { @ObservedObject var viewModel: HomeViewModel - var body: some View { - PosterHStack( - type: .landscape, - items: viewModel.resumeItems - ) - .scaleItems(1.5) - .contextMenu { item in - Button { - viewModel.markItemPlayed(item) - } label: { - Label(L10n.played, systemImage: "checkmark.circle") - } - - Button(role: .destructive) { - viewModel.markItemUnplayed(item) - } label: { - Label(L10n.unplayed, systemImage: "minus.circle") - } - } - .imageOverlay { item in - LandscapePosterProgressBar( - title: item.progressLabel ?? L10n.continue, - progress: (item.userData?.playedPercentage ?? 0) / 100 - ) + // TODO: see how this looks across multiple screen sizes + // alongside PosterHStack + landscape + // TODO: need better handling for iPadOS + portrait orientation + private var columnCount: CGFloat { + if UIDevice.isPhone { + 1.5 + } else { + 3.5 } - .onSelect { item in - router.route(to: \.item, item) + } + + var body: some View { + CollectionHStack( + $viewModel.resumeItems, + columns: columnCount + ) { item in + PosterButton(item: item, type: .landscape) + .contextMenu { + Button { + viewModel.markItemPlayed(item) + } label: { + Label(L10n.played, systemImage: "checkmark.circle") + } + + Button(role: .destructive) { + viewModel.markItemUnplayed(item) + } label: { + Label(L10n.unplayed, systemImage: "minus.circle") + } + } + .imageOverlay { + LandscapePosterProgressBar( + title: item.progressLabel ?? L10n.continue, + progress: (item.userData?.playedPercentage ?? 0) / 100 + ) + } + .onSelect { + router.route(to: \.item, item) + } } + .scrollBehavior(.continuousLeadingEdge) } } } diff --git a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift index d58bcfd97..02c82c6a7 100644 --- a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift +++ b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift @@ -6,8 +6,10 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import CollectionHStack import Defaults import JellyfinAPI +import OrderedCollections import SwiftUI extension HomeView { @@ -23,30 +25,22 @@ extension HomeView { @ObservedObject var viewModel: LatestInLibraryViewModel - private var items: [BaseItemDto] { - viewModel.items.prefix(20).asArray - } - var body: some View { - PosterHStack( - title: L10n.latestWithString(viewModel.parent.displayTitle), - type: latestInLibraryPosterType, - items: items - ) - .trailing { - SeeAllButton() - .onSelect { - router.route( - to: \.basicLibrary, - .init( - title: L10n.latestWithString(viewModel.parent.displayTitle), - viewModel: viewModel - ) - ) - } - } - .onSelect { item in - router.route(to: \.item, item) + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), + type: latestInLibraryPosterType, + items: $viewModel.elements + ) + .trailing { + SeeAllButton() + .onSelect { + router.route(to: \.library, viewModel) + } + } + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin/Views/HomeView/Components/NextUpView.swift b/Swiftfin/Views/HomeView/Components/NextUpView.swift index fdbb90763..a1451a2c7 100644 --- a/Swiftfin/Views/HomeView/Components/NextUpView.swift +++ b/Swiftfin/Views/HomeView/Components/NextUpView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import CollectionHStack import Defaults import JellyfinAPI import SwiftUI @@ -23,31 +24,29 @@ extension HomeView { @ObservedObject var viewModel: NextUpLibraryViewModel - private var items: [BaseItemDto] { - viewModel.items.prefix(20).asArray - } - var body: some View { - PosterHStack( - title: L10n.nextUp, - type: nextUpPosterType, - items: items - ) - .trailing { - SeeAllButton() - .onSelect { - router.route(to: \.basicLibrary, .init(title: L10n.nextUp, viewModel: viewModel)) + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.nextUp, + type: nextUpPosterType, + items: $viewModel.elements + ) + .trailing { + SeeAllButton() + .onSelect { + router.route(to: \.library, viewModel) + } + } + .contextMenu { item in + Button { + viewModel.markPlayed(item: item) + } label: { + Label(L10n.played, systemImage: "checkmark.circle") } - } - .contextMenu { item in - Button { - viewModel.markPlayed(item: item) - } label: { - Label(L10n.played, systemImage: "checkmark.circle") } - } - .onSelect { item in - router.route(to: \.item, item) + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift index 2988863ea..3a2d96f2d 100644 --- a/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift +++ b/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift @@ -21,26 +21,27 @@ extension HomeView { private var router: HomeCoordinator.Router @ObservedObject - var viewModel: ItemTypeLibraryViewModel - - private var items: [BaseItemDto] { - viewModel.items.prefix(20).asArray - } + var viewModel: RecentlyAddedLibraryViewModel var body: some View { - PosterHStack( - title: L10n.recentlyAdded, - type: recentlyAddedPosterType, - items: items - ) - .trailing { - SeeAllButton() - .onSelect { - router.route(to: \.basicLibrary, .init(title: L10n.recentlyAdded, viewModel: viewModel)) - } - } - .onSelect { item in - router.route(to: \.item, item) + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.recentlyAdded, + type: recentlyAddedPosterType, + items: $viewModel.elements + ) + .trailing { + SeeAllButton() + .onSelect { + // Give a new view model becaues we don't want to + // keep paginated items on the home view model + let viewModel = RecentlyAddedLibraryViewModel() + router.route(to: \.library, viewModel) + } + } + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin/Views/HomeView/HomeContentView.swift b/Swiftfin/Views/HomeView/HomeContentView.swift deleted file mode 100644 index c3caf905a..000000000 --- a/Swiftfin/Views/HomeView/HomeContentView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import Defaults -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct ContentView: View { - - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType - @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType - - @ObservedObject - var viewModel: HomeViewModel - - var body: some View { - RefreshableScrollView { - VStack(alignment: .leading, spacing: 20) { - if !viewModel.resumeItems.isEmpty { - ContinueWatchingView(viewModel: viewModel) - } - - if viewModel.hasNextUp { - NextUpView(viewModel: .init()) - } - - if viewModel.hasRecentlyAdded { - RecentlyAddedView( - viewModel: .init( - itemTypes: [.movie, .series], - filters: .init(sortOrder: [APISortOrder.descending.filter], sortBy: [SortBy.dateAdded.filter]) - ) - ) - } - - ForEach(viewModel.libraries, id: \.self) { library in - LatestInLibraryView(viewModel: .init(parent: library)) - } - } - .padding(.bottom, 50) - } onRefresh: { - viewModel.refresh() - } - } - } -} diff --git a/Swiftfin/Views/HomeView/HomeErrorView.swift b/Swiftfin/Views/HomeView/HomeErrorView.swift deleted file mode 100644 index 1d5078f1d..000000000 --- a/Swiftfin/Views/HomeView/HomeErrorView.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension HomeView { - - struct ErrorView: View { - - @ObservedObject - var viewModel: HomeViewModel - - let errorMessage: ErrorMessage - - var body: some View { - VStack(spacing: 5) { - if viewModel.isLoading { - ProgressView() - .frame(width: 100, height: 100) - .scaleEffect(2) - } else { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 72)) - .foregroundColor(Color.red) - .frame(width: 100, height: 100) - } - - if let code = errorMessage.code { - Text("\(code)") - } - - Text(errorMessage.message) - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - PrimaryButton(title: L10n.retry) - .onSelect { - viewModel.refresh() - } - .frame(maxWidth: 300) - .frame(height: 50) - } - .offset(y: -50) - } - } -} diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index b6fc0421e..88a9a6434 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -6,33 +6,70 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Defaults import Foundation import SwiftUI +// TODO: seems to redraw view when popped to sometimes? +// - similar to MediaView TODO bug? struct HomeView: View { + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + @EnvironmentObject private var router: HomeCoordinator.Router - @ObservedObject - var viewModel: HomeViewModel + @StateObject + private var viewModel = HomeViewModel() + + private var contentView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + + ContinueWatchingView(viewModel: viewModel) + + NextUpView(viewModel: viewModel.nextUpViewModel) + + RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) + + ForEach(viewModel.libraries) { viewModel in + LatestInLibraryView(viewModel: viewModel) + } + } + .edgePadding(.vertical) + } + } + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } var body: some View { - Group { - if let errorMessage = viewModel.errorMessage { - ErrorView( - viewModel: viewModel, - errorMessage: .init(message: errorMessage, code: -1) - ) - } else if viewModel.isLoading { - ProgressView() - } else { - ContentView(viewModel: viewModel) + WrappedView { + Group { + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + ProgressView() + } } + .transition(.opacity.animation(.linear(duration: 0.1))) + } + .onFirstAppear { + viewModel.send(.refresh) } .navigationTitle(L10n.home) .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { Button { router.route(to: \.settings) } label: { diff --git a/Swiftfin/Views/ItemOverviewView.swift b/Swiftfin/Views/ItemOverviewView.swift index 762bd6f80..5401da53b 100644 --- a/Swiftfin/Views/ItemOverviewView.swift +++ b/Swiftfin/Views/ItemOverviewView.swift @@ -9,6 +9,8 @@ import JellyfinAPI import SwiftUI +// TODO: fix with shorter text + struct ItemOverviewView: View { @EnvironmentObject @@ -18,12 +20,25 @@ struct ItemOverviewView: View { var body: some View { ScrollView(showsIndicators: false) { - ItemView.OverviewView(item: item) - .padding() + VStack(alignment: .leading, spacing: 10) { + + if let firstTagline = item.taglines?.first { + Text(firstTagline) + .font(.title3) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + } + + if let itemOverview = item.overview { + Text(itemOverview) + .font(.body) + } + } + .edgePadding() } - .navigationTitle(L10n.overview) + .navigationTitle(item.displayTitle) .navigationBarTitleDisplayMode(.inline) - .navigationCloseButton { + .navigationBarCloseButton { router.dismissCoordinator() } } diff --git a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift index 277a6b408..872741b73 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift @@ -30,7 +30,7 @@ extension ItemView { .fontWeight(.bold) .accessibility(addTraits: [.isHeader]) .padding(.horizontal) - .if(UIDevice.isIPad) { view in + .if(UIDevice.isPad) { view in view.padding(.horizontal) } @@ -55,7 +55,7 @@ extension ItemView { RatingsCard(item: viewModel.item) } .padding(.horizontal) - .if(UIDevice.isIPad) { view in + .if(UIDevice.isPad) { view in view.padding(.horizontal) } } diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift index 9bb9aef6b..ba783219d 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift @@ -31,10 +31,14 @@ extension ItemView.AboutView { .font(.title2) .fontWeight(.semibold) .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) if let subtitle { Text(subtitle) .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } Spacer() diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift index 334ab68cb..258e89653 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift @@ -19,15 +19,13 @@ extension ItemView.AboutView { let item: BaseItemDto var body: some View { - Card(title: item.displayTitle) + Card(title: item.displayTitle, subtitle: item.alternateTitle) .content { if let overview = item.overview { TruncatedText(overview) - .seeMoreAction { - router.route(to: \.itemOverview, item) - } .lineLimit(4) .font(.footnote) + .allowsHitTesting(false) } else { L10n.noOverviewAvailable.text .font(.footnote) diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift index 7abb2050d..e31e102f3 100644 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift @@ -29,7 +29,7 @@ extension ItemView { self.viewModel = viewModel self.equalSpacing = equalSpacing - self.downloadManager = Container.downloadManager.callAsFunction() + self.downloadManager = Container.downloadManager() } var body: some View { diff --git a/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift b/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift index 28bd3ed08..9440fe1f0 100644 --- a/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift +++ b/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift @@ -22,10 +22,7 @@ extension ItemView { PosterHStack( title: L10n.castAndCrew, type: .portrait, - items: people - .filter(\.isDisplayed) - .prefix(20) - .asArray + items: people.filter(\.isDisplayed) ) .trailing { SeeAllButton() @@ -34,7 +31,8 @@ extension ItemView { } } .onSelect { person in - router.route(to: \.library, .init(parent: person, type: .person, filters: .init())) + let viewModel = ItemLibraryViewModel(parent: person) + router.route(to: \.library, viewModel) } } } diff --git a/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift index 50cf128c1..612c6f9fc 100644 --- a/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift +++ b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift @@ -45,7 +45,7 @@ struct DownloadTaskButton: View { extension DownloadTaskButton { init(item: BaseItemDto) { - let downloadManager = Container.downloadManager.callAsFunction() + let downloadManager = Container.downloadManager() self.downloadTask = downloadManager.task(for: item) ?? .init(item: item) self.onSelect = { _ in } diff --git a/Swiftfin/Views/ItemView/Components/GenresHStack.swift b/Swiftfin/Views/ItemView/Components/GenresHStack.swift index af6873d39..1e3720053 100644 --- a/Swiftfin/Views/ItemView/Components/GenresHStack.swift +++ b/Swiftfin/Views/ItemView/Components/GenresHStack.swift @@ -16,14 +16,15 @@ extension ItemView { @EnvironmentObject private var router: ItemCoordinator.Router - let genres: [NameGuidPair] + let genres: [ItemGenre] var body: some View { PillHStack( title: L10n.genres, items: genres ).onSelect { genre in - router.route(to: \.library, .init(filters: .init(genres: [genre.filter]))) + let viewModel = ItemLibraryViewModel(title: genre.displayTitle, filters: .init(genres: [genre])) + router.route(to: \.library, viewModel) } } } diff --git a/Swiftfin/Views/ItemView/Components/OverviewView.swift b/Swiftfin/Views/ItemView/Components/OverviewView.swift index d5313a676..c7980a299 100644 --- a/Swiftfin/Views/ItemView/Components/OverviewView.swift +++ b/Swiftfin/Views/ItemView/Components/OverviewView.swift @@ -17,8 +17,8 @@ extension ItemView { private var router: ItemCoordinator.Router let item: BaseItemDto - private var overviewLineLimit: Int - private var taglineLineLimit: Int + private var overviewLineLimit: Int? + private var taglineLineLimit: Int? var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -28,16 +28,21 @@ extension ItemView { .font(.body) .fontWeight(.semibold) .multilineTextAlignment(.leading) - .lineLimit(taglineLineLimit) + .ifLet(taglineLineLimit) { view, lineLimit in + view.lineLimit(lineLimit) + } } if let itemOverview = item.overview { TruncatedText(itemOverview) - .seeMoreAction { + .onSeeMore { router.route(to: \.itemOverview, item) } + .seeMoreType(.view) .font(.footnote) - .lineLimit(overviewLineLimit) + .ifLet(overviewLineLimit) { view, lineLimit in + view.lineLimit(lineLimit) + } } } } @@ -49,8 +54,8 @@ extension ItemView.OverviewView { init(item: BaseItemDto) { self.init( item: item, - overviewLineLimit: 1000, - taglineLineLimit: 1000 + overviewLineLimit: nil, + taglineLineLimit: nil ) } diff --git a/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift b/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift index 2a40208e8..8a210aae1 100644 --- a/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift +++ b/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import CollectionHStack import Defaults import JellyfinAPI import SwiftUI @@ -18,22 +19,66 @@ struct SeriesEpisodeSelector: View { @ObservedObject var viewModel: SeriesItemViewModel - var body: some View { - MenuPosterHStack( - type: .landscape, - manager: viewModel, - singleImage: true - ) - .scaleItems(1.2) - .imageOverlay { type in - EpisodeOverlay(episode: type) - } - .content { type in - EpisodeContent(episode: type) + @ViewBuilder + private var selectorMenu: some View { + Menu { + ForEach(viewModel.menuSections.keys.sorted(by: { viewModel.menuSectionSort($0, $1) }), id: \.displayTitle) { section in + Button { + viewModel.select(section: section) + } label: { + if section == viewModel.menuSelection { + Label(section.displayTitle, systemImage: "checkmark") + } else { + Text(section.displayTitle) + } + } + } + } label: { + HStack(spacing: 5) { + Group { + Text(viewModel.menuSelection?.displayTitle ?? L10n.unknown) + .fixedSize() + Image(systemName: "chevron.down") + } + .font(.title3.weight(.semibold)) + } } - .onSelect { item in - guard let mediaSource = item.mediaSources?.first else { return } - mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) + .padding(.bottom) + .fixedSize() + } + + var body: some View { + VStack(alignment: .leading) { + selectorMenu + .edgePadding(.horizontal) + + if viewModel.currentItems.isEmpty { + EmptyView() + } else { + CollectionHStack( + $viewModel.currentItems, + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { item in + PosterButton( + item: item, + type: .landscape, + singleImage: true + ) + .content { + EpisodeContent(episode: item) + } + .imageOverlay { + EpisodeOverlay(episode: item) + } + .onSelect { + guard let mediaSource = item.mediaSources?.first else { return } + mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) + } + } + .scrollBehavior(.continuousLeadingEdge) + .horizontalInset(16) + .itemSpacing(8) + } } } } @@ -57,7 +102,7 @@ extension SeriesEpisodeSelector { Image(systemName: "checkmark.circle.fill") .resizable() .frame(width: 30, height: 30, alignment: .bottomTrailing) - .accentSymbolRendering(accentColor: .white) + .paletteOverlayRendering(color: .white) .padding() } } @@ -93,6 +138,7 @@ extension SeriesEpisodeSelector { .multilineTextAlignment(.leading) } + // TODO: why the static overview height? @ViewBuilder private var content: some View { Group { @@ -114,7 +160,7 @@ extension SeriesEpisodeSelector { } .font(.caption.weight(.light)) .foregroundColor(.secondary) - .lineLimit(4) + .lineLimit(3) .multilineTextAlignment(.leading) } diff --git a/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift b/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift index f5baab6cc..797766025 100644 --- a/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift +++ b/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift @@ -8,6 +8,7 @@ import Defaults import JellyfinAPI +import OrderedCollections import SwiftUI extension ItemView { @@ -20,19 +21,23 @@ extension ItemView { @EnvironmentObject private var router: ItemCoordinator.Router - let items: [BaseItemDto] + @StateObject + private var viewModel: PagingLibraryViewModel + + init(items: [BaseItemDto]) { + self._viewModel = StateObject(wrappedValue: PagingLibraryViewModel(items, parent: BaseItemDto(name: L10n.recommended))) + } var body: some View { PosterHStack( title: L10n.recommended, type: similarPosterType, - items: items + items: $viewModel.elements ) .trailing { SeeAllButton() .onSelect { - let viewModel = StaticLibraryViewModel(items: items) - router.route(to: \.basicLibrary, .init(title: L10n.recommended, viewModel: viewModel)) + router.route(to: \.library, viewModel) } } .onSelect { item in diff --git a/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift b/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift index 65867a9a9..3ff752ad7 100644 --- a/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift +++ b/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift @@ -7,6 +7,7 @@ // import JellyfinAPI +import OrderedCollections import SwiftUI extension ItemView { @@ -22,7 +23,7 @@ extension ItemView { PosterHStack( title: L10n.specialFeatures, type: .landscape, - items: items + items: .constant(OrderedSet(items)) ) .onSelect { item in guard let mediaSource = item.mediaSources?.first else { return } diff --git a/Swiftfin/Views/ItemView/Components/StudiosHStack.swift b/Swiftfin/Views/ItemView/Components/StudiosHStack.swift index a5357dd75..dadee2d12 100644 --- a/Swiftfin/Views/ItemView/Components/StudiosHStack.swift +++ b/Swiftfin/Views/ItemView/Components/StudiosHStack.swift @@ -23,7 +23,8 @@ extension ItemView { title: L10n.studios, items: studios ).onSelect { studio in - router.route(to: \.library, .init(parent: studio, type: .studio, filters: .init())) + let viewModel = ItemLibraryViewModel(parent: studio) + router.route(to: \.library, viewModel) } } } diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index 4d965560f..33f2884f9 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -19,31 +19,29 @@ struct ItemView: View { Group { switch item.type { case .movie: - if UIDevice.isIPad { + if UIDevice.isPad { iPadOSMovieItemView(viewModel: .init(item: item)) } else { MovieItemView(viewModel: .init(item: item)) } case .series: - if UIDevice.isIPad { + if UIDevice.isPad { iPadOSSeriesItemView(viewModel: .init(item: item)) } else { SeriesItemView(viewModel: .init(item: item)) } case .episode: - if UIDevice.isIPad { + if UIDevice.isPad { iPadOSEpisodeItemView(viewModel: .init(item: item)) } else { EpisodeItemView(viewModel: .init(item: item)) } case .boxSet: - if UIDevice.isIPad { + if UIDevice.isPad { iPadOSCollectionItemView(viewModel: .init(item: item)) } else { CollectionItemView(viewModel: .init(item: item)) } - case .person: - LibraryView(viewModel: .init(parent: item, type: .person)) default: Text(L10n.notImplementedYetWithType(item.type ?? "--")) } diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift index 5d1813278..285996097 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift @@ -24,29 +24,31 @@ extension CollectionItemView { // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { ItemView.GenresHStack(genres: genres) - Divider() + RowDivider() } // MARK: Studios - if let studios = viewModel.item.studios, !studios.isEmpty { + if let studios = viewModel.item.studios, studios.isNotEmpty { ItemView.StudiosHStack(studios: studios) - Divider() + RowDivider() } // MARK: Items - PosterHStack( - title: L10n.items, - type: .portrait, - items: viewModel.collectionItems - ) - .onSelect { item in - router.route(to: \.item, item) + if viewModel.collectionItems.isNotEmpty { + PosterHStack( + title: L10n.items, + type: .portrait, + items: viewModel.collectionItems + ) + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift index 78ba3d539..26b8283f5 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift @@ -41,37 +41,43 @@ extension EpisodeItemView { // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { ItemView.GenresHStack(genres: genres) - Divider() + RowDivider() } // MARK: Studios - if let studios = viewModel.item.studios, !studios.isEmpty { + if let studios = viewModel.item.studios, studios.isNotEmpty { ItemView.StudiosHStack(studios: studios) - Divider() + RowDivider() } // MARK: Cast and Crew if let castAndCrew = viewModel.item.people, - !castAndCrew.isEmpty + castAndCrew.isNotEmpty { ItemView.CastAndCrewHStack(people: castAndCrew) - Divider() + RowDivider() } // MARK: Series + // TODO: have different way to get to series item + // - about view poster? if let seriesItem = viewModel.seriesItem { - PosterHStack(title: L10n.series, type: .portrait, items: [seriesItem]) - .onSelect { item in - router.route(to: \.item, item) - } + PosterHStack( + title: L10n.series, + type: .portrait, + items: [seriesItem] + ) + .onSelect { item in + router.route(to: \.item, item) + } } ItemView.AboutView(viewModel: viewModel) @@ -116,7 +122,7 @@ extension EpisodeItemView.ContentView { Text(productionYear) } - if let runtime = viewModel.item.getItemRuntime() { + if let runtime = viewModel.item.runTimeLabel { Text(runtime) } } diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift index 143289c5e..6092f52a3 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift @@ -25,13 +25,13 @@ struct EpisodeItemView: View { ContentView(viewModel: viewModel) } .scrollViewOffset($scrollViewOffset) - .navBarOffset( + .navigationBarOffset( $scrollViewOffset, start: 0, end: 30 ) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { if viewModel.isLoading { ProgressView() } diff --git a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift index 877337e4c..6e28758ec 100644 --- a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift @@ -22,44 +22,44 @@ extension MovieItemView { // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { ItemView.GenresHStack(genres: genres) - Divider() + RowDivider() } // MARK: Studios - if let studios = viewModel.item.studios, !studios.isEmpty { + if let studios = viewModel.item.studios, studios.isNotEmpty { ItemView.StudiosHStack(studios: studios) - Divider() + RowDivider() } // MARK: Cast and Crew if let castAndCrew = viewModel.item.people, - !castAndCrew.isEmpty + castAndCrew.isNotEmpty { ItemView.CastAndCrewHStack(people: castAndCrew) - Divider() + RowDivider() } // MARK: Special Features - if !viewModel.specialFeatures.isEmpty { + if viewModel.specialFeatures.isNotEmpty { ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) - Divider() + RowDivider() } // MARK: Similar - if !viewModel.similarItems.isEmpty { + if viewModel.similarItems.isNotEmpty { ItemView.SimilarItemsHStack(items: viewModel.similarItems) - Divider() + RowDivider() } ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift index a42688b65..063110c69 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift @@ -96,7 +96,7 @@ extension ItemView { } .edgesIgnoringSafeArea(.top) .scrollViewOffset($scrollViewOffset) - .navBarOffset( + .navigationBarOffset( $scrollViewOffset, start: UIScreen.main.bounds.height * 0.66, end: UIScreen.main.bounds.height * 0.66 + 50 @@ -109,7 +109,7 @@ extension ItemView { headerView } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { if viewModel.isLoading { ProgressView() } @@ -137,19 +137,20 @@ extension ItemView.CinematicScrollView { VStack(alignment: .center, spacing: 10) { if !cinematicItemViewTypeUsePrimaryImage { ImageView(viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width)) - .resizingMode(.aspectFit) - .placeholder { - EmptyView() - } - .failure { - Text(viewModel.item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) - .multilineTextAlignment(.center) - .foregroundColor(.white) - } - .frame(height: 100) - .frame(maxWidth: .infinity) +// .resizingMode(.aspectFit) + .placeholder { + EmptyView() + } + .failure { + MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 100) + .font(.largeTitle.weight(.semibold)) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundColor(.white) + } + .aspectRatio(contentMode: .fit) + .frame(height: 100) + .frame(maxWidth: .infinity) } else { Spacer() .frame(height: 50) @@ -164,7 +165,7 @@ extension ItemView.CinematicScrollView { Text(premiereYear) } - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { Text(runtime) } } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift index adbb336ba..6452631f4 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift @@ -36,7 +36,8 @@ extension ItemView { @ViewBuilder private var headerView: some View { - ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)) + ImageView(viewModel.item.imageSource(.backdrop, maxHeight: UIScreen.main.bounds.height * 0.35)) + .aspectRatio(contentMode: .fill) .frame(height: UIScreen.main.bounds.height * 0.35) .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) .onAppear { @@ -58,7 +59,7 @@ extension ItemView { VStack { Spacer() - OverlayView(viewModel: viewModel, scrollViewOffset: $scrollViewOffset) + OverlayView(viewModel: viewModel) .padding(.horizontal) .padding(.bottom) .background { @@ -93,7 +94,7 @@ extension ItemView { } .edgesIgnoringSafeArea(.top) .scrollViewOffset($scrollViewOffset) - .navBarOffset( + .navigationBarOffset( $scrollViewOffset, start: UIScreen.main.bounds.height * 0.42 - 50, end: UIScreen.main.bounds.height * 0.42 @@ -106,7 +107,7 @@ extension ItemView { headerView } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { if viewModel.isLoading { ProgressView() } @@ -126,25 +127,21 @@ extension ItemView.CompactLogoScrollView { @ObservedObject var viewModel: ItemViewModel - @Binding - var scrollViewOffset: CGFloat - var body: some View { VStack(alignment: .center, spacing: 10) { - ImageView(viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width, maxHeight: 100)) - .resizingMode(.aspectFit) + ImageView(viewModel.item.imageURL(.logo, maxHeight: 70)) .placeholder { EmptyView() } .failure { - Text(viewModel.item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) + MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 70) + .font(.largeTitle.weight(.semibold)) + .lineLimit(2) .multilineTextAlignment(.center) .foregroundColor(.white) } - .frame(maxWidth: .infinity) - .frame(height: 100) + .aspectRatio(contentMode: .fit) + .frame(height: 70, alignment: .bottom) DotHStack { if let firstGenre = viewModel.item.genres?.first { @@ -155,7 +152,7 @@ extension ItemView.CompactLogoScrollView { Text(premiereYear) } - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { Text(runtime) } } @@ -174,6 +171,7 @@ extension ItemView.CompactLogoScrollView { .frame(maxWidth: 300) .foregroundColor(.white) } + .frame(maxWidth: .infinity, alignment: .bottom) } } } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift index e0d6dcd2c..afe7aba42 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift @@ -84,7 +84,7 @@ extension ItemView { } .edgesIgnoringSafeArea(.top) .scrollViewOffset($scrollViewOffset) - .navBarOffset( + .navigationBarOffset( $scrollViewOffset, start: UIScreen.main.bounds.height * 0.28, end: UIScreen.main.bounds.height * 0.28 + 50 @@ -97,7 +97,7 @@ extension ItemView { headerView } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { if viewModel.isLoading { ProgressView() } @@ -154,7 +154,7 @@ extension ItemView.CompactPosterScrollView { } } - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { Text(runtime) } } @@ -173,7 +173,7 @@ extension ItemView.CompactPosterScrollView { // MARK: Portrait Image ImageView(viewModel.item.imageSource(.primary, maxWidth: 130)) - .posterStyle(.portrait) + .aspectRatio(2 / 3, contentMode: .fit) .frame(width: 130) .accessibilityIgnoresInvertColors() diff --git a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift index 47e693cdb..5947064a2 100644 --- a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift @@ -26,36 +26,36 @@ extension SeriesItemView { // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { ItemView.GenresHStack(genres: genres) - Divider() + RowDivider() } // MARK: Studios - if let studios = viewModel.item.studios, !studios.isEmpty { + if let studios = viewModel.item.studios, studios.isNotEmpty { ItemView.StudiosHStack(studios: studios) - Divider() + RowDivider() } // MARK: Cast and Crew if let castAndCrew = viewModel.item.people, - !castAndCrew.isEmpty + castAndCrew.isNotEmpty { ItemView.CastAndCrewHStack(people: castAndCrew) - Divider() + RowDivider() } // MARK: Similar - if !viewModel.similarItems.isEmpty { + if viewModel.similarItems.isNotEmpty { ItemView.SimilarItemsHStack(items: viewModel.similarItems) - Divider() + RowDivider() } ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift index 848903641..91bba8efe 100644 --- a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift @@ -24,29 +24,31 @@ extension iPadOSCollectionItemView { // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { ItemView.GenresHStack(genres: genres) - Divider() + RowDivider() } // MARK: Studios - if let studios = viewModel.item.studios, !studios.isEmpty { + if let studios = viewModel.item.studios, studios.isNotEmpty { ItemView.StudiosHStack(studios: studios) - Divider() + RowDivider() } // MARK: Items - PosterHStack( - title: L10n.items, - type: .portrait, - items: viewModel.collectionItems - ) - .onSelect { item in - router.route(to: \.item, item) + if viewModel.collectionItems.isNotEmpty { + PosterHStack( + title: L10n.items, + type: .portrait, + items: viewModel.collectionItems + ) + .onSelect { item in + router.route(to: \.item, item) + } } ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift index 8cf6ddac5..613bcfc0b 100644 --- a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift @@ -24,37 +24,41 @@ extension iPadOSEpisodeItemView { // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { ItemView.GenresHStack(genres: genres) - Divider() + RowDivider() } // MARK: Studios - if let studios = viewModel.item.studios, !studios.isEmpty { + if let studios = viewModel.item.studios, studios.isNotEmpty { ItemView.StudiosHStack(studios: studios) - Divider() + RowDivider() } // MARK: Cast and Crew if let castAndCrew = viewModel.item.people, - !castAndCrew.isEmpty + castAndCrew.isNotEmpty { ItemView.CastAndCrewHStack(people: castAndCrew) - Divider() + RowDivider() } // MARK: Series if let seriesItem = viewModel.seriesItem { - PosterHStack(title: L10n.series, type: .portrait, items: [seriesItem]) - .onSelect { item in - router.route(to: \.item, item) - } + PosterHStack( + title: L10n.series, + type: .portrait, + items: [seriesItem] + ) + .onSelect { item in + router.route(to: \.item, item) + } } ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift index bc9781ac9..51af5e231 100644 --- a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift @@ -24,44 +24,44 @@ extension iPadOSMovieItemView { // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { ItemView.GenresHStack(genres: genres) - Divider() + RowDivider() } // MARK: Studios - if let studios = viewModel.item.studios, !studios.isEmpty { + if let studios = viewModel.item.studios, studios.isNotEmpty { ItemView.StudiosHStack(studios: studios) - Divider() + RowDivider() } // MARK: Cast and Crew if let castAndCrew = viewModel.item.people, - !castAndCrew.isEmpty + castAndCrew.isNotEmpty { ItemView.CastAndCrewHStack(people: castAndCrew) - Divider() + RowDivider() } // MARK: Special Features - if !viewModel.specialFeatures.isEmpty { + if viewModel.specialFeatures.isNotEmpty { ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) - Divider() + RowDivider() } // MARK: Similar - if !viewModel.similarItems.isEmpty { + if viewModel.similarItems.isNotEmpty { ItemView.SimilarItemsHStack(items: viewModel.similarItems) - Divider() + RowDivider() } ItemView.AboutView(viewModel: viewModel) @@ -69,5 +69,3 @@ extension iPadOSMovieItemView { } } } - -// diff --git a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift index 5022b02ff..46013c52e 100644 --- a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift @@ -80,7 +80,7 @@ extension ItemView { .edgesIgnoringSafeArea(.top) .edgesIgnoringSafeArea(.horizontal) .scrollViewOffset($scrollViewOffset) - .navBarOffset( + .navigationBarOffset( $scrollViewOffset, start: UIScreen.main.bounds.height * 0.65, end: UIScreen.main.bounds.height * 0.65 + 50 @@ -93,7 +93,7 @@ extension ItemView { headerView } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { if viewModel.isLoading { ProgressView() } @@ -121,9 +121,8 @@ extension ItemView.iPadOSCinematicScrollView { ImageView(viewModel.item.imageSource( .logo, maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 150 + maxHeight: 130 )) - .resizingMode(.bottomLeft) .placeholder { EmptyView() } @@ -135,6 +134,8 @@ extension ItemView.iPadOSCinematicScrollView { .multilineTextAlignment(.leading) .foregroundColor(.white) } + .aspectRatio(contentMode: .fit) + .frame(maxWidth: UIScreen.main.bounds.width * 0.4, maxHeight: 130, alignment: .bottomLeading) ItemView.OverviewView(item: viewModel.item) .overviewLineLimit(3) @@ -153,7 +154,7 @@ extension ItemView.iPadOSCinematicScrollView { Text(premiereYear) } - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { Text(runtime) } } diff --git a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift index 9314902ec..68fcf6729 100644 --- a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift @@ -28,36 +28,36 @@ extension iPadOSSeriesItemView { // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { ItemView.GenresHStack(genres: genres) - Divider() + RowDivider() } // MARK: Studios - if let studios = viewModel.item.studios, !studios.isEmpty { + if let studios = viewModel.item.studios, studios.isNotEmpty { ItemView.StudiosHStack(studios: studios) - Divider() + RowDivider() } // MARK: Cast and Crew if let castAndCrew = viewModel.item.people, - !castAndCrew.isEmpty + castAndCrew.isNotEmpty { ItemView.CastAndCrewHStack(people: castAndCrew) - Divider() + RowDivider() } // MARK: Similar - if !viewModel.similarItems.isEmpty { + if viewModel.similarItems.isNotEmpty { ItemView.SimilarItemsHStack(items: viewModel.similarItems) - Divider() + RowDivider() } ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView.swift deleted file mode 100644 index e46dc3dd5..000000000 --- a/Swiftfin/Views/LibraryView.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionView -import Defaults -import JellyfinAPI -import SwiftUI - -struct LibraryView: View { - - @Default(.Customization.Library.viewType) - private var libraryViewType - - @Default(.Customization.Filters.libraryFilterDrawerButtons) - private var filterDrawerButtonSelection - - @EnvironmentObject - private var router: LibraryCoordinator.Router - - @ObservedObject - var viewModel: LibraryViewModel - - @ViewBuilder - private var loadingView: some View { - ProgressView() - } - - @ViewBuilder - private var noResultsView: some View { - L10n.noResults.text - } - - private func baseItemOnSelect(_ item: BaseItemDto) { - if let baseParent = viewModel.parent as? BaseItemDto { - if baseParent.collectionType == "folders" { - router.route(to: \.library, .init(parent: item, type: .folders, filters: .init())) - } else if item.type == .folder { - router.route(to: \.library, .init(parent: item, type: .library, filters: .init())) - } else { - router.route(to: \.item, item) - } - } else { - router.route(to: \.item, item) - } - } - - @ViewBuilder - private var libraryItemsView: some View { - PagingLibraryView(viewModel: viewModel) - .onSelect { item in - baseItemOnSelect(item) - } - .ignoresSafeArea() - } - - var body: some View { - Group { - if viewModel.isLoading && viewModel.items.isEmpty { - loadingView - } else if viewModel.items.isEmpty { - noResultsView - } else { - libraryItemsView - } - } - .navigationTitle(viewModel.parent?.displayTitle ?? "") - .navigationBarTitleDisplayMode(.inline) - .if(!filterDrawerButtonSelection.isEmpty) { view in - view.navBarDrawer { - ScrollView(.horizontal, showsIndicators: false) { - FilterDrawerHStack(viewModel: viewModel.filterViewModel, filterDrawerButtonSelection: filterDrawerButtonSelection) - .onSelect { filterCoordinatorParameters in - router.route(to: \.filter, filterCoordinatorParameters) - } - .padding(.horizontal) - .padding(.vertical, 1) - } - } - } - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - if viewModel.isLoading && !viewModel.items.isEmpty { - ProgressView() - } - Menu { - LibraryViewTypeToggle(libraryViewType: $libraryViewType) - RandomItemButton(viewModel: viewModel) - .onSelect { response in - if let item = response.items?.first { - router.route(to: \.item, item) - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - } -} diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift index 43ee1de09..1d637a22c 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -105,7 +105,7 @@ struct LiveTVChannelItemWideElement: View { titleText: currentProgramText.title, color: Color("TextHighlightColor") ) - if !nextProgramsText.isEmpty { + if nextProgramsText.isNotEmpty { let nextItem = nextProgramsText[0] programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray) } diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 3135a32f1..663e3d7bc 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -56,7 +56,7 @@ struct LiveTVChannelsView: View { if viewModel.isLoading { ProgressView() - } else if !viewModel.channelPrograms.isEmpty { + } else if viewModel.channelPrograms.isNotEmpty { CollectionView(items: viewModel.channelPrograms) { _, program, _ in channelCell(for: program) diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index fa4522996..066e7245a 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -19,9 +19,9 @@ struct LiveTVProgramsView: View { var body: some View { ScrollView { LazyVStack(alignment: .leading) { - if !viewModel.recommendedItems.isEmpty { + if viewModel.recommendedItems.isNotEmpty { let items = viewModel.recommendedItems - PosterHStack(title: L10n.onNow, type: .portrait, items: items) +// PosterHStack(title: L10n.onNow, type: .portrait, items: items) // .onSelect { item in // if let chanId = item.channelId, // let chan = viewModel.findChannel(id: chanId) @@ -32,9 +32,9 @@ struct LiveTVProgramsView: View { // } // } } - if !viewModel.seriesItems.isEmpty { + if viewModel.seriesItems.isNotEmpty { let items = viewModel.seriesItems - PosterHStack(title: L10n.tvShows, type: .portrait, items: items) +// PosterHStack(title: L10n.tvShows, type: .portrait, items: items) // .onSelect { item in // if let chanId = item.channelId, // let chan = viewModel.findChannel(id: chanId) @@ -45,9 +45,9 @@ struct LiveTVProgramsView: View { // } // } } - if !viewModel.movieItems.isEmpty { + if viewModel.movieItems.isNotEmpty { let items = viewModel.movieItems - PosterHStack(title: L10n.movies, type: .portrait, items: items) +// PosterHStack(title: L10n.movies, type: .portrait, items: items) // .onSelect { item in // if let chanId = item.channelId, // let chan = viewModel.findChannel(id: chanId) @@ -58,9 +58,9 @@ struct LiveTVProgramsView: View { // } // } } - if !viewModel.sportsItems.isEmpty { + if viewModel.sportsItems.isNotEmpty { let items = viewModel.sportsItems - PosterHStack(title: L10n.sports, type: .portrait, items: items) +// PosterHStack(title: L10n.sports, type: .portrait, items: items) // .onSelect { item in // if let chanId = item.channelId, // let chan = viewModel.findChannel(id: chanId) @@ -71,9 +71,9 @@ struct LiveTVProgramsView: View { // } // } } - if !viewModel.kidsItems.isEmpty { + if viewModel.kidsItems.isNotEmpty { let items = viewModel.kidsItems - PosterHStack(title: L10n.kids, type: .portrait, items: items) +// PosterHStack(title: L10n.kids, type: .portrait, items: items) // .onSelect { item in // if let chanId = item.channelId, // let chan = viewModel.findChannel(id: chanId) @@ -84,9 +84,9 @@ struct LiveTVProgramsView: View { // } // } } - if !viewModel.newsItems.isEmpty { + if viewModel.newsItems.isNotEmpty { let items = viewModel.newsItems - PosterHStack(title: L10n.news, type: .portrait, items: items) +// PosterHStack(title: L10n.news, type: .portrait, items: items) // .onSelect { item in // if let chanId = item.channelId, // let chan = viewModel.findChannel(id: chanId) diff --git a/Swiftfin/Views/MediaSourceInfoView.swift b/Swiftfin/Views/MediaSourceInfoView.swift index 49628b531..6b3ba8344 100644 --- a/Swiftfin/Views/MediaSourceInfoView.swift +++ b/Swiftfin/Views/MediaSourceInfoView.swift @@ -20,7 +20,7 @@ struct MediaSourceInfoView: View { var body: some View { Form { if let videoStreams = source.videoStreams, - !videoStreams.isEmpty + videoStreams.isNotEmpty { Section(L10n.video) { ForEach(videoStreams, id: \.self) { stream in @@ -33,7 +33,7 @@ struct MediaSourceInfoView: View { } if let audioStreams = source.audioStreams, - !audioStreams.isEmpty + audioStreams.isNotEmpty { Section(L10n.audio) { ForEach(audioStreams, id: \.self) { stream in @@ -46,7 +46,7 @@ struct MediaSourceInfoView: View { } if let subtitleStreams = source.subtitleStreams, - !subtitleStreams.isEmpty + subtitleStreams.isNotEmpty { Section(L10n.subtitle) { ForEach(subtitleStreams, id: \.self) { stream in @@ -60,7 +60,7 @@ struct MediaSourceInfoView: View { } .navigationTitle(source.displayTitle) .navigationBarTitleDisplayMode(.inline) - .navigationCloseButton { + .navigationBarCloseButton { router.dismissCoordinator() } } diff --git a/Swiftfin/Views/MediaStreamInfoView.swift b/Swiftfin/Views/MediaStreamInfoView.swift index 3b21f4cc3..4bf560058 100644 --- a/Swiftfin/Views/MediaStreamInfoView.swift +++ b/Swiftfin/Views/MediaStreamInfoView.swift @@ -21,7 +21,7 @@ struct MediaStreamInfoView: View { } } - if !mediaStream.colorProperties.isEmpty { + if mediaStream.colorProperties.isNotEmpty { Section(L10n.color) { ForEach(mediaStream.colorProperties) { property in TextPairView(property) @@ -29,7 +29,7 @@ struct MediaStreamInfoView: View { } } - if !mediaStream.deliveryProperties.isEmpty { + if mediaStream.deliveryProperties.isNotEmpty { Section(L10n.delivery) { ForEach(mediaStream.deliveryProperties) { property in TextPairView(property) diff --git a/Swiftfin/Views/MediaView.swift b/Swiftfin/Views/MediaView.swift index 1f9707811..597442d92 100644 --- a/Swiftfin/Views/MediaView.swift +++ b/Swiftfin/Views/MediaView.swift @@ -6,126 +6,170 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionView +import CollectionVGrid import Defaults +import Factory import JellyfinAPI import Stinsen import SwiftUI +// TODO: seems to redraw view when popped to sometimes? +// - similar to HomeView TODO bug? +// TODO: list view struct MediaView: View { @EnvironmentObject private var router: MediaCoordinator.Router - @ObservedObject - var viewModel: MediaViewModel + @StateObject + private var viewModel = MediaViewModel() - private var gridLayout: NSCollectionLayoutSection.GridLayoutMode { - if UIDevice.isPhone { - return .fixedNumberOfColumns(2) - } else { - return .adaptive(withMinItemSize: PosterType.landscape.width) - } + private var padLayout: CollectionVGridLayout { + .minWidth(200) } - var body: some View { - CollectionView(items: viewModel.libraryItems) { _, viewModel, _ in - LibraryCard(viewModel: viewModel) + private var phoneLayout: CollectionVGridLayout { + .columns(2) + } + + private var contentView: some View { + CollectionVGrid( + $viewModel.mediaItems, + layout: UIDevice.isPhone ? phoneLayout : padLayout + ) { mediaType in + MediaItem(viewModel: viewModel, type: mediaType) .onSelect { - switch viewModel.item.collectionType { - case "downloads": + switch mediaType { + case .downloads: router.route(to: \.downloads) - case "favorites": - router.route(to: \.library, .init(parent: viewModel.item, type: .library, filters: .favorites)) - case "folders": - router.route(to: \.library, .init(parent: viewModel.item, type: .folders, filters: .init())) - case "liveTV": + case .favorites: + let viewModel = ItemLibraryViewModel( + title: L10n.favorites, + filters: .favorites + ) + router.route(to: \.library, viewModel) + case .liveTV: router.route(to: \.liveTV) - default: - router.route(to: \.library, .init(parent: viewModel.item, type: .library, filters: .init())) + case let .userView(item): + let viewModel = ItemLibraryViewModel( + parent: item, + filters: .default + ) + router.route(to: \.library, viewModel) } } } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: gridLayout, - sectionInsets: .init(top: 0, leading: 10, bottom: 0, trailing: 10) - ) - } - .configure { configuration in - configuration.showsVerticalScrollIndicator = false + } + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + var body: some View { + WrappedView { + Group { + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + ProgressView() + } + } + .transition(.opacity.animation(.linear(duration: 0.1))) } .ignoresSafeArea() .navigationTitle(L10n.allMedia) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - if viewModel.isLoading { - ProgressView() - } + .topBarTrailing { + if viewModel.isLoading { + ProgressView() } } + .onFirstAppear { + viewModel.send(.refresh) + } } } extension MediaView { - struct LibraryCard: View { + // TODO: custom view for folders and tv (allow customization?) + struct MediaItem: View { + + @Default(.Customization.Library.randomImage) + private var useRandomImage @ObservedObject - var viewModel: MediaItemViewModel + var viewModel: MediaViewModel + + @State + private var imageSources: [ImageSource] = [] private var onSelect: () -> Void + private let mediaType: MediaViewModel.MediaType + + init(viewModel: MediaViewModel, type: MediaViewModel.MediaType) { + self.viewModel = viewModel + self.onSelect = {} + self.mediaType = type + } + + private func setImageSources() { + Task { @MainActor in + if useRandomImage { + self.imageSources = try await viewModel.randomItemImageSources(for: mediaType) + return + } - private var itemWidth: CGFloat { - PosterType.landscape.width * (UIDevice.isPhone ? 0.85 : 1) + if case let MediaViewModel.MediaType.userView(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } + } } var body: some View { Button { onSelect() } label: { - Group { - if let imageSources = viewModel.imageSources { - ImageView(imageSources) - } else { - ImageView(nil) - } - } - .overlay { - if Defaults[.Customization.Library.randomImage] || - viewModel.item.collectionType == "favorites" || - viewModel.item.collectionType == "downloads" + ZStack { + Color.clear + + ImageView(imageSources) + .id(imageSources.hashValue) + + if useRandomImage || + mediaType == .favorites || + mediaType == .downloads { ZStack { Color.black .opacity(0.5) - Text(viewModel.item.displayTitle) + Text(mediaType.displayTitle) .foregroundColor(.white) .font(.title2) .fontWeight(.semibold) - .lineLimit(2) + .lineLimit(1) .multilineTextAlignment(.center) .frame(alignment: .center) } } } .posterStyle(.landscape) - .frame(width: itemWidth) + } + .onFirstAppear(perform: setImageSources) + .onChange(of: useRandomImage) { _ in + setImageSources() } } } } -extension MediaView.LibraryCard { - - init(viewModel: MediaItemViewModel) { - self.init( - viewModel: viewModel, - onSelect: {} - ) - } +extension MediaView.MediaItem { func onSelect(_ action: @escaping () -> Void) -> Self { copy(modifying: \.onSelect, with: action) diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift new file mode 100644 index 000000000..e1f94860d --- /dev/null +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift @@ -0,0 +1,135 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension PagingLibraryView { + + struct LibraryRow: View { + + @State + private var contentWidth: CGFloat = 0 + + private let item: Element + private var onSelect: () -> Void + private let posterType: PosterType + + @ViewBuilder + private func itemAccessoryView(item: BaseItemDto) -> some View { + DotHStack { + if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel { + Text(seasonEpisodeLocator) + } else if let premiereYear = item.premiereDateYear { + Text(premiereYear) + } + + if let runtime = item.runTimeLabel { + Text(runtime) + } + + if let officialRating = item.officialRating { + Text(officialRating) + } + } + } + + @ViewBuilder + private func personAccessoryView(person: BaseItemPerson) -> some View { + if let subtitle = person.subtitle { + Text(subtitle) + } + } + + @ViewBuilder + private var accessoryView: some View { + switch item { + case let element as BaseItemDto: + itemAccessoryView(item: element) + case let element as BaseItemPerson: + personAccessoryView(person: element) + default: + AssertionFailureView("Used an unexpected type within a `PagingLibaryView`?") + } + } + + // MARK: body + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Button { + onSelect() + } label: { + HStack(alignment: .center, spacing: EdgeInsets.defaultEdgePadding) { + ZStack { + Color.clear + + switch posterType { + case .portrait: + ImageView(item.portraitPosterImageSource(maxWidth: 60)) + .failure { + TypeSystemNameView(item: item) + } + case .landscape: + ImageView(item.landscapePosterImageSources(maxWidth: 110, single: false)) + .failure { + TypeSystemNameView(item: item) + } + } + } + .posterStyle(posterType) + .frame(width: posterType == .landscape ? 110 : 60) + .posterShadow() + .padding(.vertical, 8) + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(item.displayTitle) + .font(posterType == .landscape ? .subheadline : .callout) + .fontWeight(.regular) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + accessoryView + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + } + + Spacer() + } + .frame(maxWidth: .infinity) + .onSizeChanged { newSize in + contentWidth = newSize.width + } + } + } + + Color.secondarySystemFill + .frame(width: contentWidth, height: 1) + } + .edgePadding(.horizontal) + } + } +} + +extension PagingLibraryView.LibraryRow { + + init(item: Element, posterType: PosterType) { + self.init( + item: item, + onSelect: {}, + posterType: posterType + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift new file mode 100644 index 000000000..d01b0054a --- /dev/null +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift @@ -0,0 +1,93 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension PagingLibraryView { + + struct LibraryViewTypeToggle: View { + + @Binding + private var listColumnCount: Int + @Binding + private var posterType: PosterType + @Binding + private var viewType: LibraryViewType + + init( + posterType: Binding, + viewType: Binding, + listColumnCount: Binding + ) { + self._listColumnCount = listColumnCount + self._posterType = posterType + self._viewType = viewType + } + + var body: some View { + Menu { + + Section("Poster") { + Button { + posterType = .landscape + } label: { + if posterType == .landscape { + Label("Landscape", systemImage: "checkmark") + } else { + Label("Landscape", systemImage: "rectangle") + } + } + + Button { + posterType = .portrait + } label: { + if posterType == .portrait { + Label("Portrait", systemImage: "checkmark") + } else { + Label("Portrait", systemImage: "rectangle.portrait") + } + } + } + + Section("Layout") { + Button { + viewType = .grid + } label: { + if viewType == .grid { + Label("Grid", systemImage: "checkmark") + } else { + Label("Grid", systemImage: "square.grid.2x2") + } + } + + Button { + viewType = .list + } label: { + if viewType == .list { + Label("List", systemImage: "checkmark") + } else { + Label("List", systemImage: "square.fill.text.grid.1x2") + } + } + } + + if viewType == .list, UIDevice.isPad { + Stepper("Columns: \(listColumnCount)", value: $listColumnCount, in: 1 ... 3) + } + } label: { + switch viewType { + case .grid: + Label("Layout", systemImage: "square.grid.2x2") + case .list: + Label("Layout", systemImage: "square.fill.text.grid.1x2") + } + } + } + } +} diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift new file mode 100644 index 000000000..2741ab370 --- /dev/null +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -0,0 +1,302 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionVGrid +import Defaults +import JellyfinAPI +import SwiftUI + +// Note: Currently, it is a conscious decision to not have grid posters have subtitle content. +// This is due to episodes, which have their `S_E_` subtitles, and these can be alongside +// other items that don't have a subtitle which requires the entire library to implement +// subtitle content but that doesn't look appealing. Until a solution arrives grid posters +// will not have subtitle content. + +struct PagingLibraryView: View { + + @Default(.Customization.Library.enabledDrawerFilters) + private var enabledDrawerFilters + @Default(.Customization.Library.listColumnCount) + private var listColumnCount + @Default(.Customization.Library.posterType) + private var posterType + @Default(.Customization.Library.viewType) + private var viewType + + @EnvironmentObject + private var router: LibraryCoordinator.Router + + @State + private var layout: CollectionVGridLayout + + @StateObject + private var collectionVGridProxy: CollectionVGridProxy = .init() + @StateObject + private var viewModel: PagingLibraryViewModel + + // MARK: init + + init(viewModel: PagingLibraryViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + + let initialPosterType = Defaults[.Customization.Library.posterType] + let initialViewType = Defaults[.Customization.Library.viewType] + let initialListColumnCount = Defaults[.Customization.Library.listColumnCount] + + if UIDevice.isPhone { + layout = Self.phoneLayout( + posterType: initialPosterType, + viewType: initialViewType + ) + } else { + layout = Self.padLayout( + posterType: initialPosterType, + viewType: initialViewType, + listColumnCount: initialListColumnCount + ) + } + } + + // MARK: onSelect + + private func onSelect(_ element: Element) { + switch element { + case let element as BaseItemDto: + select(item: element) + case let element as BaseItemPerson: + select(person: element) + default: + assertionFailure("Used an unexpected type within a `PagingLibaryView`?") + } + } + + private func select(item: BaseItemDto) { + switch item.type { + case .collectionFolder, .folder: + let viewModel = ItemLibraryViewModel(parent: item, filters: .default) + router.route(to: \.library, viewModel) + default: + router.route(to: \.item, item) + } + } + + private func select(person: BaseItemPerson) { + let viewModel = ItemLibraryViewModel(parent: person) + router.route(to: \.library, viewModel) + } + + // MARK: layout + + private static func padLayout( + posterType: PosterType, + viewType: LibraryViewType, + listColumnCount: Int + ) -> CollectionVGridLayout { + switch (posterType, viewType) { + case (.landscape, .grid): + .minWidth(200) + case (.portrait, .grid): + .minWidth(150) + case (_, .list): + .columns(listColumnCount, insets: .zero, itemSpacing: 0, lineSpacing: 0) + } + } + + private static func phoneLayout( + posterType: PosterType, + viewType: LibraryViewType + ) -> CollectionVGridLayout { + switch (posterType, viewType) { + case (.landscape, .grid): + .columns(2) + case (.portrait, .grid): + .columns(3) + case (_, .list): + .columns(1, insets: .zero, itemSpacing: 0, lineSpacing: 0) + } + } + + // MARK: item view + + private func landscapeGridItemView(item: Element) -> some View { + PosterButton(item: item, type: .landscape) + .content { + if item.showTitle { + PosterButton.TitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) + } + } + .onSelect { + onSelect(item) + } + } + + private func portraitGridItemView(item: Element) -> some View { + PosterButton(item: item, type: .portrait) + .content { + if item.showTitle { + PosterButton.TitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) + } + } + .onSelect { + onSelect(item) + } + } + + private func listItemView(item: Element) -> some View { + LibraryRow(item: item, posterType: posterType) + .onSelect { + onSelect(item) + } + } + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + private var contentView: some View { + CollectionVGrid( + $viewModel.elements, + layout: $layout + ) { item in + switch (posterType, viewType) { + case (.landscape, .grid): + landscapeGridItemView(item: item) + case (.portrait, .grid): + portraitGridItemView(item: item) + case (_, .list): + listItemView(item: item) + } + } + .onReachedBottomEdge(offset: 300) { + viewModel.send(.getNextPage) + } + .proxy(collectionVGridProxy) + } + + // MARK: body + + var body: some View { + WrappedView { + Group { + switch viewModel.state { + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + ProgressView() + case .gettingNextPage, .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + contentView + } + } + } + .transition(.opacity.animation(.linear(duration: 0.2))) + } + .ignoresSafeArea() + .navigationTitle(viewModel.parent?.displayTitle ?? "") + .navigationBarTitleDisplayMode(.inline) + .ifLet(viewModel.filterViewModel) { view, filterViewModel in + view.navigationBarFilterDrawer( + viewModel: filterViewModel, + types: enabledDrawerFilters + ) { + router.route(to: \.filter, $0) + } + } + .onChange(of: posterType) { newValue in + if UIDevice.isPhone { + if viewType == .list { + collectionVGridProxy.layout() + } else { + layout = Self.phoneLayout( + posterType: newValue, + viewType: viewType + ) + } + } else { + if viewType == .list { + collectionVGridProxy.layout() + } else { + layout = Self.padLayout( + posterType: newValue, + viewType: viewType, + listColumnCount: listColumnCount + ) + } + } + } + .onChange(of: viewType) { newValue in + if UIDevice.isPhone { + layout = Self.phoneLayout( + posterType: posterType, + viewType: newValue + ) + } else { + layout = Self.padLayout( + posterType: posterType, + viewType: newValue, + listColumnCount: listColumnCount + ) + } + } + .onChange(of: listColumnCount) { newValue in + if UIDevice.isPad { + layout = Self.padLayout( + posterType: posterType, + viewType: viewType, + listColumnCount: newValue + ) + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .gotRandomItem(item): + switch item { + case let item as BaseItemDto: + router.route(to: \.item, item) + case let item as BaseItemPerson: + let viewModel = ItemLibraryViewModel(parent: item, filters: .default) + router.route(to: \.library, viewModel) + default: + assertionFailure("Used an unexpected type within a `PagingLibaryView`?") + } + } + } + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } + .topBarTrailing { + + if viewModel.state == .gettingNextPage { + ProgressView() + } + + Menu { + + LibraryViewTypeToggle(posterType: $posterType, viewType: $viewType, listColumnCount: $listColumnCount) + + Button(L10n.random, systemImage: "dice.fill") { + viewModel.send(.getRandomItem) + } + .disabled(viewModel.elements.isEmpty) + } label: { + Image(systemName: "ellipsis.circle") + } + } + } +} diff --git a/Swiftfin/Views/QuickConnectView.swift b/Swiftfin/Views/QuickConnectView.swift index 71a764f2a..580416835 100644 --- a/Swiftfin/Views/QuickConnectView.swift +++ b/Swiftfin/Views/QuickConnectView.swift @@ -47,7 +47,7 @@ struct QuickConnectView: View { .onDisappear { viewModel.stopQuickConnectAuthCheck() } - .navigationCloseButton { + .navigationBarCloseButton { router.dismissCoordinator() } } diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift index cebd1d5f7..fee30f1ca 100644 --- a/Swiftfin/Views/SearchView.swift +++ b/Swiftfin/Views/SearchView.swift @@ -11,67 +11,73 @@ import Defaults import JellyfinAPI import SwiftUI +// TODO: have a `SearchLibraryViewModel` that allows paging on searched items? +// TODO: implement search view result type between `PosterHStack` +// and `ListHStack` (3 row list columns)? (iOS only) struct SearchView: View { + @Default(.Customization.Search.enabledDrawerFilters) + private var enabledDrawerFilters @Default(.Customization.searchPosterType) private var searchPosterType - @Default(.Customization.Filters.searchFilterDrawerButtons) - private var filterDrawerButtonSelection - @EnvironmentObject private var router: SearchCoordinator.Router - @ObservedObject - var viewModel: SearchViewModel - @State - private var searchText = "" + private var searchQuery = "" + + @StateObject + private var viewModel = SearchViewModel() + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.search(query: searchQuery)) + } + } - @ViewBuilder private var suggestionsView: some View { VStack(spacing: 20) { - ForEach(viewModel.suggestions, id: \.id) { item in - Button { - searchText = item.displayTitle - } label: { - Text(item.displayTitle) - .font(.body) + ForEach(viewModel.suggestions) { item in + Button(item.displayTitle) { + searchQuery = item.displayTitle } } } } - @ViewBuilder private var resultsView: some View { ScrollView(showsIndicators: false) { VStack(spacing: 20) { - if !viewModel.movies.isEmpty { + if viewModel.movies.isNotEmpty { itemsSection(title: L10n.movies, keyPath: \.movies, posterType: searchPosterType) } - if !viewModel.collections.isEmpty { - itemsSection(title: L10n.collections, keyPath: \.collections, posterType: searchPosterType) + if viewModel.series.isNotEmpty { + itemsSection(title: L10n.tvShows, keyPath: \.series, posterType: searchPosterType) } - if !viewModel.series.isEmpty { - itemsSection(title: L10n.tvShows, keyPath: \.series, posterType: searchPosterType) + if viewModel.collections.isNotEmpty { + itemsSection(title: L10n.collections, keyPath: \.collections, posterType: searchPosterType) } - if !viewModel.episodes.isEmpty { + if viewModel.episodes.isNotEmpty { itemsSection(title: L10n.episodes, keyPath: \.episodes, posterType: searchPosterType) } - if !viewModel.people.isEmpty { + if viewModel.people.isNotEmpty { itemsSection(title: L10n.people, keyPath: \.people, posterType: .portrait) } } + .edgePadding(.vertical) } } - private func baseItemOnSelect(_ item: BaseItemDto) { + private func select(_ item: BaseItemDto) { if item.type == .person { - router.route(to: \.library, .init(parent: item, type: .person, filters: .init())) + let viewModel = ItemLibraryViewModel(parent: item) + router.route(to: \.library, viewModel) } else { router.route(to: \.item, item) } @@ -88,38 +94,54 @@ struct SearchView: View { type: posterType, items: viewModel[keyPath: keyPath] ) - .onSelect { item in - baseItemOnSelect(item) + .trailing { + SeeAllButton() + .onSelect { + router.route(to: \.library, .init(viewModel[keyPath: keyPath])) + } } + .onSelect(select) } var body: some View { - Group { - if searchText.isEmpty { - suggestionsView - } else if !viewModel.isLoading && viewModel.noResults { - L10n.noResults.text - } else { - resultsView + WrappedView { + Group { + switch viewModel.state { + case let .error(error): + errorView(with: error) + case .initial: + suggestionsView + case .content: + if viewModel.hasNoResults { + L10n.noResults.text + } else { + resultsView + } + case .searching: + ProgressView() + } } + .transition(.opacity.animation(.linear(duration: 0.1))) } - .onChange(of: searchText) { newText in - viewModel.search(with: newText) - } + .ignoresSafeArea(.keyboard, edges: .bottom) .navigationTitle(L10n.search) .navigationBarTitleDisplayMode(.inline) - .if(!filterDrawerButtonSelection.isEmpty) { view in - view.navBarDrawer { - ScrollView(.horizontal, showsIndicators: false) { - FilterDrawerHStack(viewModel: viewModel.filterViewModel, filterDrawerButtonSelection: filterDrawerButtonSelection) - .onSelect { filterCoordinatorParameters in - router.route(to: \.filter, filterCoordinatorParameters) - } - .padding(.horizontal) - .padding(.vertical, 1) - } - } + .navigationBarFilterDrawer( + viewModel: viewModel.filterViewModel, + types: enabledDrawerFilters + ) { + router.route(to: \.filter, $0) + } + .onFirstAppear { + viewModel.send(.getSuggestions) } - .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.search) + .onChange(of: searchQuery) { newValue in + viewModel.send(.search(query: newValue)) + } + .searchable( + text: $searchQuery, + placement: .navigationBarDrawer(displayMode: .always), + prompt: L10n.search + ) } } diff --git a/Swiftfin/Views/ServerListView.swift b/Swiftfin/Views/ServerListView.swift index a03efb659..4e5e7d0ca 100644 --- a/Swiftfin/Views/ServerListView.swift +++ b/Swiftfin/Views/ServerListView.swift @@ -92,7 +92,7 @@ struct ServerListView: View { @ViewBuilder private var trailingToolbarContent: some View { - if !viewModel.servers.isEmpty { + if viewModel.servers.isNotEmpty { Button { router.route(to: \.connectToServer) } label: { @@ -115,12 +115,12 @@ struct ServerListView: View { innerBody .navigationTitle(L10n.servers) .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { trailingToolbarContent } } .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { + ToolbarItemGroup(placement: .topBarLeading) { leadingToolbarContent } } diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift index 76320966b..f7ac75af5 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift @@ -12,7 +12,7 @@ import SwiftUI struct CustomizeViewsSettings: View { @Default(.Customization.itemViewType) - var itemViewType + private var itemViewType @Default(.Customization.CinematicItemViewType.usePrimaryImage) private var cinematicItemViewTypeUsePrimaryImage @@ -20,32 +20,34 @@ struct CustomizeViewsSettings: View { private var hapticFeedback @Default(.Customization.shouldShowMissingSeasons) - var shouldShowMissingSeasons + private var shouldShowMissingSeasons @Default(.Customization.shouldShowMissingEpisodes) - var shouldShowMissingEpisodes + private var shouldShowMissingEpisodes - @Default(.Customization.Filters.libraryFilterDrawerButtons) - var libraryFilterDrawerButtons - @Default(.Customization.Filters.searchFilterDrawerButtons) - var searchFilterDrawerButtons + @Default(.Customization.Library.enabledDrawerFilters) + private var libraryEnabledDrawerFilters + @Default(.Customization.Search.enabledDrawerFilters) + private var searchEnabledDrawerFilters @Default(.Customization.showPosterLabels) - var showPosterLabels + private var showPosterLabels @Default(.Customization.nextUpPosterType) - var nextUpPosterType + private var nextUpPosterType @Default(.Customization.recentlyAddedPosterType) - var recentlyAddedPosterType + private var recentlyAddedPosterType @Default(.Customization.latestInLibraryPosterType) - var latestInLibraryPosterType + private var latestInLibraryPosterType @Default(.Customization.similarPosterType) - var similarPosterType + private var similarPosterType @Default(.Customization.searchPosterType) - var searchPosterType - @Default(.Customization.Library.gridPosterType) - var libraryGridPosterType + private var searchPosterType + @Default(.Customization.Library.viewType) + private var libraryViewType + @Default(.Customization.Library.listColumnCount) + private var listColumnCount @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) - var useSeriesLandscapeBackdrop + private var useSeriesLandscapeBackdrop @Default(.Customization.Library.showFavorites) private var showFavorites @@ -60,7 +62,7 @@ struct CustomizeViewsSettings: View { if UIDevice.isPhone { Section { - EnumPicker(title: L10n.items, selection: $itemViewType) + CaseIterablePicker(title: L10n.items, selection: $itemViewType) } if itemViewType == .cinematic { @@ -87,12 +89,12 @@ struct CustomizeViewsSettings: View { ChevronButton(title: L10n.library) .onSelect { - router.route(to: \.filterDrawerButtonSelector, $libraryFilterDrawerButtons) + router.route(to: \.itemFilterDrawerSelector, $libraryEnabledDrawerFilters) } ChevronButton(title: L10n.search) .onSelect { - router.route(to: \.filterDrawerButtonSelector, $searchFilterDrawerButtons) + router.route(to: \.itemFilterDrawerSelector, $searchEnabledDrawerFilters) } } header: { @@ -115,17 +117,28 @@ struct CustomizeViewsSettings: View { Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) - EnumPicker(title: L10n.next, selection: $nextUpPosterType) + CaseIterablePicker(title: L10n.next, selection: $nextUpPosterType) - EnumPicker(title: L10n.recentlyAdded, selection: $recentlyAddedPosterType) + CaseIterablePicker(title: L10n.recentlyAdded, selection: $recentlyAddedPosterType) - EnumPicker(title: L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) + CaseIterablePicker(title: L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) - EnumPicker(title: L10n.recommended, selection: $similarPosterType) + CaseIterablePicker(title: L10n.recommended, selection: $similarPosterType) - EnumPicker(title: L10n.search, selection: $searchPosterType) + CaseIterablePicker(title: L10n.search, selection: $searchPosterType) + + // TODO: figure out how we can do the same Menu as the library menu picker? + CaseIterablePicker(title: L10n.library, selection: $libraryViewType) + + if libraryViewType == .list, UIDevice.isPad { + BasicStepper( + title: "Columns", + value: $listColumnCount, + range: 1 ... 4, + step: 1 + ) + } - EnumPicker(title: L10n.library, selection: $libraryGridPosterType) } header: { L10n.posters.text } diff --git a/Swiftfin/Views/SettingsView/DebugSettingsView.swift b/Swiftfin/Views/SettingsView/DebugSettingsView.swift index 092886eac..4abdf4acf 100644 --- a/Swiftfin/Views/SettingsView/DebugSettingsView.swift +++ b/Swiftfin/Views/SettingsView/DebugSettingsView.swift @@ -9,6 +9,8 @@ import Defaults import SwiftUI +// NOTE: All settings *MUST* be surrounded by DEBUG compiler conditional as usage site + #if DEBUG struct DebugSettingsView: View { diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index 1a186b6e1..036c24e43 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -15,8 +15,6 @@ struct ExperimentalSettingsView: View { private var forceDirectPlay @Default(.Experimental.syncSubtitleStateWithAdjacent) private var syncSubtitleStateWithAdjacent - @Default(.Experimental.liveTVAlphaEnabled) - private var liveTVAlphaEnabled @Default(.Experimental.liveTVForceDirectPlay) private var liveTVForceDirectPlay @@ -32,8 +30,6 @@ struct ExperimentalSettingsView: View { Section { - Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) - Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) } header: { diff --git a/Swiftfin/Views/SettingsView/FilterDrawerSettingsView/Components/FilterDrawerButtonSelectorView.swift b/Swiftfin/Views/SettingsView/FilterDrawerSettingsView/Components/FilterDrawerButtonSelectorView.swift deleted file mode 100644 index a617d4ec2..000000000 --- a/Swiftfin/Views/SettingsView/FilterDrawerSettingsView/Components/FilterDrawerButtonSelectorView.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// TODO: Look at moving across sections -// TODO: Look at general implementation in SelectorView -struct FilterDrawerButtonSelectorView: View { - - @Binding - var selectedButtonsBinding: [FilterDrawerButtonSelection] - - @Environment(\.editMode) - private var editMode - - @State - private var _selectedButtons: [FilterDrawerButtonSelection] - - private var disabledButtons: [FilterDrawerButtonSelection] { - FilterDrawerButtonSelection.allCases.filter { !_selectedButtons.contains($0) } - } - - var body: some View { - List { - Section { - ForEach(_selectedButtons) { item in - Button { - if !(editMode?.wrappedValue.isEditing ?? true) { - select(item: item) - } - } label: { - HStack { - Text(item.displayTitle) - - Spacer() - - if !(editMode?.wrappedValue.isEditing ?? false) { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - } - .foregroundColor(.primary) - } - } - .onMove(perform: move) - - if _selectedButtons.isEmpty { - Text(L10n.none) - .foregroundColor(.secondary) - } - } header: { - Text(L10n.enabled) - } - - Section { - ForEach(disabledButtons) { item in - Button { - if !(editMode?.wrappedValue.isEditing ?? true) { - select(item: item) - } - } label: { - HStack { - Text(item.displayTitle) - - Spacer() - - if !(editMode?.wrappedValue.isEditing ?? false) { - Image(systemName: "plus.circle.fill") - .foregroundColor(.green) - } - } - .foregroundColor(.primary) - } - } - - if disabledButtons.isEmpty { - Text(L10n.none) - .foregroundColor(.secondary) - } - } header: { - Text(L10n.disabled) - } - } - .animation(.linear(duration: 0.2), value: _selectedButtons) - .toolbar { - EditButton() - } - .onChange(of: _selectedButtons) { newValue in - selectedButtonsBinding = newValue - } - } - - func move(from source: IndexSet, to destination: Int) { - _selectedButtons.move(fromOffsets: source, toOffset: destination) - } - - private func select(item: FilterDrawerButtonSelection) { - if _selectedButtons.contains(item) { - _selectedButtons.removeAll(where: { $0.id == item.id }) - } else { - _selectedButtons.append(item) - } - } -} - -extension FilterDrawerButtonSelectorView { - - init(selectedButtonsBinding: Binding<[FilterDrawerButtonSelection]>) { - self.init( - selectedButtonsBinding: selectedButtonsBinding, - _selectedButtons: selectedButtonsBinding.wrappedValue - ) -// self._selectedButtonsBinding = selectedButtonsBinding -// self._selectedButtons = selectedButtonsBinding.wrappedValue - } -} diff --git a/Swiftfin/Views/SettingsView/GestureSettingsView.swift b/Swiftfin/Views/SettingsView/GestureSettingsView.swift index b3cd894eb..f793ae668 100644 --- a/Swiftfin/Views/SettingsView/GestureSettingsView.swift +++ b/Swiftfin/Views/SettingsView/GestureSettingsView.swift @@ -38,24 +38,25 @@ struct GestureSettingsView: View { Section { - EnumPicker(title: "Horizontal Pan", selection: $horizontalPanGesture) + CaseIterablePicker(title: "Horizontal Pan", selection: $horizontalPanGesture) .disabled(horizontalSwipeGesture != .none && horizontalPanGesture == .none) - EnumPicker(title: "Horizontal Swipe", selection: $horizontalSwipeGesture) + CaseIterablePicker(title: "Horizontal Swipe", selection: $horizontalSwipeGesture) .disabled(horizontalPanGesture != .none && horizontalSwipeGesture == .none) - EnumPicker(title: "Long Press", selection: $longPressGesture) + CaseIterablePicker(title: "Long Press", selection: $longPressGesture) - EnumPicker(title: "Multi Tap", selection: $multiTapGesture) + CaseIterablePicker(title: "Multi Tap", selection: $multiTapGesture) - EnumPicker(title: "Double Touch", selection: $doubleTouchGesture) + CaseIterablePicker(title: "Double Touch", selection: $doubleTouchGesture) - EnumPicker(title: "Pinch", selection: $pinchGesture) + CaseIterablePicker(title: "Pinch", selection: $pinchGesture) - EnumPicker(title: "Left Vertical Pan", selection: $verticalPanGestureLeft) + CaseIterablePicker(title: "Left Vertical Pan", selection: $verticalPanGestureLeft) - EnumPicker(title: "Right Vertical Pan", selection: $verticalPanGestureRight) + CaseIterablePicker(title: "Right Vertical Pan", selection: $verticalPanGestureRight) } } + .navigationTitle("Gestures") } } diff --git a/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift b/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift index 374a4b126..0f64516c2 100644 --- a/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift +++ b/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift @@ -26,7 +26,7 @@ struct NativeVideoPlayerSettingsView: View { step: 1 ) .valueFormatter { - $0.secondFormat + $0.secondLabel } } footer: { Text("Resume content seconds before the recorded resume time") diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index 890dab737..d94cd023f 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView.swift @@ -62,7 +62,7 @@ struct SettingsView: View { } Section { - EnumPicker( + CaseIterablePicker( title: L10n.videoPlayerType, selection: $videoPlayerType ) @@ -81,7 +81,7 @@ struct SettingsView: View { } Section { - EnumPicker(title: L10n.appearance, selection: $appAppearance) + CaseIterablePicker(title: L10n.appearance, selection: $appAppearance) ChevronButton(title: L10n.appIcon) .onSelect { @@ -126,9 +126,9 @@ struct SettingsView: View { #endif } - .navigationBarTitle(L10n.settings) + .navigationTitle(L10n.settings) .navigationBarTitleDisplayMode(.inline) - .navigationCloseButton { + .navigationBarCloseButton { router.dismissCoordinator() } } diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift index 90f4a751e..fc92ba9ef 100644 --- a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift @@ -9,116 +9,22 @@ import Defaults import SwiftUI -// TODO: Look at moving across sections -// TODO: Look at general implementation in SelectorView struct ActionButtonSelectorView: View { @Binding - var selectedButtonsBinding: [VideoPlayerActionButton] - - @Environment(\.editMode) - private var editMode - - @State - private var _selectedButtons: [VideoPlayerActionButton] - - private var disabledButtons: [VideoPlayerActionButton] { - VideoPlayerActionButton.allCases.filter { !_selectedButtons.contains($0) } - } + var selection: [VideoPlayerActionButton] var body: some View { - List { - Section { - ForEach(_selectedButtons) { item in - Button { - if !(editMode?.wrappedValue.isEditing ?? true) { - select(item: item) - } - } label: { - HStack { - Image(systemName: item.settingsSystemImage) - Text(item.displayTitle) - - Spacer() - - if !(editMode?.wrappedValue.isEditing ?? false) { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - } - .foregroundColor(.primary) - } - } - .onMove(perform: move) - - if _selectedButtons.isEmpty { - Text("None") - .foregroundColor(.secondary) - } - } header: { - Text("Enabled") - } - - Section { - ForEach(disabledButtons) { item in - Button { - if !(editMode?.wrappedValue.isEditing ?? true) { - select(item: item) - } - } label: { - HStack { - Image(systemName: item.settingsSystemImage) - Text(item.displayTitle) - - Spacer() - - if !(editMode?.wrappedValue.isEditing ?? false) { - Image(systemName: "plus.circle.fill") - .foregroundColor(.green) - } - } - .foregroundColor(.primary) - } - } + OrderedSectionSelectorView( + selection: $selection, + sources: VideoPlayerActionButton.allCases + ) + .label { button in + HStack { + Image(systemName: button.settingsSystemImage) - if disabledButtons.isEmpty { - Text("None") - .foregroundColor(.secondary) - } - } header: { - Text("Disabled") + Text(button.displayTitle) } } - .animation(.linear(duration: 0.2), value: _selectedButtons) - .toolbar { - EditButton() - } - .onChange(of: _selectedButtons) { newValue in - selectedButtonsBinding = newValue - } - } - - func move(from source: IndexSet, to destination: Int) { - _selectedButtons.move(fromOffsets: source, toOffset: destination) - } - - private func select(item: VideoPlayerActionButton) { - if _selectedButtons.contains(item) { - _selectedButtons.removeAll(where: { $0.id == item.id }) - } else { - _selectedButtons.append(item) - } - } -} - -extension ActionButtonSelectorView { - - init(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) { - self.init( - selectedButtonsBinding: selectedButtonsBinding, - _selectedButtons: selectedButtonsBinding.wrappedValue - ) -// self._selectedButtonsBinding = selectedButtonsBinding -// self._selectedButtons = selectedButtonsBinding.wrappedValue } } diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift index 611e8aee6..132c995a9 100644 --- a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift @@ -70,9 +70,9 @@ struct VideoPlayerSettingsView: View { router.route(to: \.gestureSettings) } - EnumPicker(title: L10n.jumpBackwardLength, selection: $jumpBackwardLength) + CaseIterablePicker(title: L10n.jumpBackwardLength, selection: $jumpBackwardLength) - EnumPicker(title: L10n.jumpForwardLength, selection: $jumpForwardLength) + CaseIterablePicker(title: L10n.jumpForwardLength, selection: $jumpForwardLength) Section { @@ -83,7 +83,7 @@ struct VideoPlayerSettingsView: View { step: 1 ) .valueFormatter { - $0.secondFormat + $0.secondLabel } } footer: { Text(L10n.resumeOffsetDescription) @@ -91,7 +91,7 @@ struct VideoPlayerSettingsView: View { Section(L10n.buttons) { - EnumPicker(title: L10n.playbackButtons, selection: $playbackButtonType) + CaseIterablePicker(title: L10n.playbackButtons, selection: $playbackButtonType) Toggle(isOn: $showJumpButtons) { HStack { @@ -119,7 +119,7 @@ struct VideoPlayerSettingsView: View { Text(L10n.sliderColor) } - EnumPicker(title: L10n.sliderType, selection: $sliderType) + CaseIterablePicker(title: L10n.sliderType, selection: $sliderType) } Section { @@ -150,9 +150,9 @@ struct VideoPlayerSettingsView: View { Toggle(L10n.scrubCurrentTime, isOn: $showCurrentTimeWhileScrubbing) - EnumPicker(title: L10n.timestampType, selection: $timestampType) + CaseIterablePicker(title: L10n.timestampType, selection: $timestampType) - EnumPicker(title: L10n.trailingValue, selection: $trailingTimestampType) + CaseIterablePicker(title: L10n.trailingValue, selection: $trailingTimestampType) } Section(L10n.transition) { diff --git a/Swiftfin/Views/UserListView.swift b/Swiftfin/Views/UserListView.swift index f2881ace0..659bc5f4a 100644 --- a/Swiftfin/Views/UserListView.swift +++ b/Swiftfin/Views/UserListView.swift @@ -68,8 +68,8 @@ struct UserListView: View { } .navigationTitle(viewModel.server.name) .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - if !viewModel.users.isEmpty { + ToolbarItemGroup(placement: .topBarTrailing) { + if viewModel.users.isNotEmpty { Button { router.route(to: \.userSignIn, viewModel.server) } label: { diff --git a/Swiftfin/Views/VideoPlayer/Components/PlaybackSettingsView.swift b/Swiftfin/Views/VideoPlayer/Components/PlaybackSettingsView.swift index ad2987b10..4bfb82db8 100644 --- a/Swiftfin/Views/VideoPlayer/Components/PlaybackSettingsView.swift +++ b/Swiftfin/Views/VideoPlayer/Components/PlaybackSettingsView.swift @@ -51,7 +51,7 @@ struct PlaybackSettingsView: View { step: 100 ) .valueFormatter { - $0.millisecondFormat + $0.millisecondLabel } BasicStepper( @@ -61,10 +61,10 @@ struct PlaybackSettingsView: View { step: 100 ) .valueFormatter { - $0.millisecondFormat + $0.millisecondLabel } - if !viewModel.videoStreams.isEmpty { + if viewModel.videoStreams.isNotEmpty { Section(L10n.video) { ForEach(viewModel.videoStreams, id: \.displayTitle) { mediaStream in ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) @@ -75,7 +75,7 @@ struct PlaybackSettingsView: View { } } - if !viewModel.audioStreams.isEmpty { + if viewModel.audioStreams.isNotEmpty { Section(L10n.audio) { ForEach(viewModel.audioStreams, id: \.displayTitle) { mediaStream in ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) @@ -86,7 +86,7 @@ struct PlaybackSettingsView: View { } } - if !viewModel.subtitleStreams.isEmpty { + if viewModel.subtitleStreams.isNotEmpty { Section(L10n.subtitle) { ForEach(viewModel.subtitleStreams, id: \.displayTitle) { mediaStream in ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) @@ -99,7 +99,7 @@ struct PlaybackSettingsView: View { } .navigationTitle(L10n.playback) .navigationBarTitleDisplayMode(.inline) - .navigationCloseButton { + .navigationBarCloseButton { splitContentViewProxy.hide() } } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift b/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift index a2593911c..4c4123859 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import CollectionHStack import Defaults import JellyfinAPI import SwiftUI @@ -41,7 +42,7 @@ extension VideoPlayer.Overlay { var body: some View { VStack { - Spacer() + Spacer(minLength: 0) .allowsHitTesting(false) HStack { @@ -65,72 +66,102 @@ extension VideoPlayer.Overlay { .foregroundColor(accentColor) } } - .padding(.leading, safeAreaInsets.leading) - .padding(.trailing, safeAreaInsets.trailing) - .if(UIDevice.isIPad) { view in + .padding(.horizontal, safeAreaInsets.leading) + .if(UIDevice.isPad) { view in view.padding(.horizontal) } - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 15) { - ForEach(viewModel.chapters, id: \.self) { chapter in - PosterButton( - item: chapter, - type: .landscape - ) - .imageOverlay { info in - if info.secondsRange.contains(currentProgressHandler.seconds) { - RoundedRectangle(cornerRadius: 6) - .stroke(accentColor, lineWidth: 8) - } - } - .content { info in - VStack(alignment: .leading, spacing: 5) { - Text(info.chapterInfo.displayTitle) - .font(.subheadline) - .fontWeight(.semibold) - .lineLimit(1) - .foregroundColor(.white) - - Text(info.chapterInfo.timestampLabel) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(Color(UIColor.systemBlue)) - .padding(.vertical, 2) - .padding(.horizontal, 4) - .background { - Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) - } - } - } - .onSelect { - let seconds = chapter.chapterInfo.startTimeSeconds - videoPlayerProxy.setTime(.seconds(seconds)) +// ScrollViewReader { proxy in + CollectionHStack( + viewModel.chapters, + minWidth: 200 + ) { chapter in + PosterButton( + item: chapter, + type: .landscape + ) + .content { + VStack(alignment: .leading, spacing: 5) { + Text(chapter.chapterInfo.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundColor(.white) - if videoPlayerManager.state != .playing { - videoPlayerProxy.play() - } + Text(chapter.chapterInfo.timestampLabel) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.systemBlue)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) } - } - } - .padding(.leading, safeAreaInsets.leading) - .padding(.trailing, safeAreaInsets.trailing) - .padding(.bottom) - .if(UIDevice.isIPad) { view in - view.padding(.horizontal) - } - } - .onChange(of: currentOverlayType) { newValue in - guard newValue == .chapters else { return } - if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { - scrollViewProxy?.scrollTo(currentChapter.hashValue, anchor: .center) } } - .onAppear { - scrollViewProxy = proxy - } } + .scrollBehavior(.continuousLeadingEdge) + .horizontalInset(safeAreaInsets.leading) + +// ScrollView(.horizontal, showsIndicators: false) { +// HStack(alignment: .top, spacing: 15) { +// ForEach(viewModel.chapters, id: \.self) { chapter in +// PosterButton( +// item: chapter, +// type: .landscape +// ) +// .imageOverlay { +// if chapter.secondsRange.contains(currentProgressHandler.seconds) { +// RoundedRectangle(cornerRadius: 6) +// .stroke(accentColor, lineWidth: 8) +// } +// } +// .content { +// VStack(alignment: .leading, spacing: 5) { +// Text(chapter.chapterInfo.displayTitle) +// .font(.subheadline) +// .fontWeight(.semibold) +// .lineLimit(1) +// .foregroundColor(.white) +// +// Text(chapter.chapterInfo.timestampLabel) +// .font(.subheadline) +// .fontWeight(.semibold) +// .foregroundColor(Color(UIColor.systemBlue)) +// .padding(.vertical, 2) +// .padding(.horizontal, 4) +// .background { +// Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) +// } +// } +// } +// .onSelect { +// let seconds = chapter.chapterInfo.startTimeSeconds +// videoPlayerProxy.setTime(.seconds(seconds)) +// +// if videoPlayerManager.state != .playing { +// videoPlayerProxy.play() +// } +// } +// } +// } +// .padding(.leading, safeAreaInsets.leading) +// .padding(.trailing, safeAreaInsets.trailing) +// .padding(.bottom) +// .if(UIDevice.isPad) { view in +// view.padding(.horizontal) +// } +// } +// .onChange(of: currentOverlayType) { newValue in +// guard newValue == .chapters else { return } +// if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { +// scrollViewProxy?.scrollTo(currentChapter.hashValue, anchor: .center) +// } +// } +// .onAppear { +// scrollViewProxy = proxy +// } +// } } .background { LinearGradient( diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift index 20bba2c04..77eb23539 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift @@ -28,17 +28,7 @@ extension VideoPlayer.Overlay.ActionButtons { var body: some View { Button { overlayTimer.start(5) - if aspectFilled { - aspectFilled = false - UIView.animate(withDuration: 0.2) { - videoPlayerProxy.aspectFill(0) - } - } else { - aspectFilled = true - UIView.animate(withDuration: 0.2) { - videoPlayerProxy.aspectFill(1) - } - } + aspectFilled.toggle() } label: { content(aspectFilled).eraseToAnyView() } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift index 3d04d0cdb..6804a5c00 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift @@ -80,7 +80,7 @@ extension VideoPlayer.Overlay { @ViewBuilder private var chaptersButton: some View { - if !viewModel.chapters.isEmpty { + if viewModel.chapters.isNotEmpty { ActionButtons.Chapters { Image(systemName: "list.dash") .frame(width: 45, height: 45) @@ -160,7 +160,7 @@ extension VideoPlayer.Overlay { } } - if !menuActionButtons.isEmpty { + if menuActionButtons.isNotEmpty { OverlayMenu() } } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift index e8c823ab1..fe379345d 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift @@ -58,7 +58,7 @@ extension VideoPlayer.Overlay { CapsuleSlider(progress: $currentProgressHandler.scrubbedProgress) .isEditing(_isScrubbing.wrappedValue) .trackMask { - if chapterSlider && !viewModel.chapters.isEmpty { + if chapterSlider && viewModel.chapters.isNotEmpty { ChapterTrack() .clipShape(Capsule()) } else { @@ -91,7 +91,7 @@ extension VideoPlayer.Overlay { ThumbSlider(progress: $currentProgressHandler.scrubbedProgress) .isEditing(_isScrubbing.wrappedValue) .trackMask { - if chapterSlider && !viewModel.chapters.isEmpty { + if chapterSlider && viewModel.chapters.isNotEmpty { ChapterTrack() .clipShape(Capsule()) } else { diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift index 794923990..fe6e1da1e 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift @@ -84,7 +84,7 @@ extension VideoPlayer.Overlay { @ViewBuilder private var chaptersButton: some View { - if !viewModel.chapters.isEmpty { + if viewModel.chapters.isNotEmpty { ActionButtons.Chapters { HStack { Image(systemName: "list.dash") diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift index ea2966af4..ff3ca6530 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift @@ -29,7 +29,6 @@ extension VideoPlayer.Overlay { HStack(alignment: .center) { Button { videoPlayerProxy.stop() - AppDelegate.leavePlaybackOrientation() router.dismissCoordinator() } label: { Image(systemName: "xmark") diff --git a/Swiftfin/Views/VideoPlayer/Overlays/MainOverlay.swift b/Swiftfin/Views/VideoPlayer/Overlays/MainOverlay.swift index f9e8eff70..e43fbc301 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/MainOverlay.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/MainOverlay.swift @@ -42,7 +42,7 @@ extension VideoPlayer { view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) } - .if(UIDevice.isIPad) { view in + .if(UIDevice.isPad) { view in view.padding(.top) .padding2(.horizontal) } @@ -67,7 +67,7 @@ extension VideoPlayer { view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) } - .if(UIDevice.isIPad) { view in + .if(UIDevice.isPad) { view in view.padding2(.bottom) .padding2(.horizontal) } diff --git a/Swiftfin/Views/VideoPlayer/VideoPlayer+KeyCommands.swift b/Swiftfin/Views/VideoPlayer/VideoPlayer+KeyCommands.swift index 71a15b9f6..2112ecaf2 100644 --- a/Swiftfin/Views/VideoPlayer/VideoPlayer+KeyCommands.swift +++ b/Swiftfin/Views/VideoPlayer/VideoPlayer+KeyCommands.swift @@ -6,17 +6,22 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import PreferencesView import SwiftUI extension View { func videoPlayerKeyCommands( + isAspectFilled: Binding, gestureStateHandler: VideoPlayer.GestureStateHandler, videoPlayerManager: VideoPlayerManager, updateViewProxy: UpdateViewProxy ) -> some View { - self - .addingKeyCommand( + keyCommands { + + // MARK: play/pause + + KeyCommandAction( title: L10n.playAndPause, input: " " ) { @@ -28,7 +33,10 @@ extension View { updateViewProxy.present(systemName: "play.fill", title: "Play") } } - .addingKeyCommand( + + // MARK: jump forward + + KeyCommandAction( title: L10n.jumpForward, input: UIKeyCommand.inputRightArrow ) { @@ -57,41 +65,85 @@ extension View { DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) } - -// jumpAction(unitPoint: .init(x: 1, y: 0), amount: gestureStateHandler.jumpForwardKeyPressAmount) } - .addingKeyCommand( - title: L10n.jumpBackward, - input: UIKeyCommand.inputLeftArrow - ) { - if gestureStateHandler.jumpBackwardKeyPressActive { - gestureStateHandler.jumpBackwardKeyPressAmount += 1 - gestureStateHandler.jumpBackwardKeyPressWorkItem?.cancel() - - let task = DispatchWorkItem { - gestureStateHandler.jumpBackwardKeyPressActive = false - gestureStateHandler.jumpBackwardKeyPressAmount = 0 - } - gestureStateHandler.jumpBackwardKeyPressWorkItem = task + // MARK: aspect fill - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) - } else { - gestureStateHandler.jumpBackwardKeyPressActive = true - gestureStateHandler.jumpBackwardKeyPressAmount += 1 - - let task = DispatchWorkItem { - gestureStateHandler.jumpBackwardKeyPressActive = false - gestureStateHandler.jumpBackwardKeyPressAmount = 0 - } - - gestureStateHandler.jumpBackwardKeyPressWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + KeyCommandAction( + title: "Aspect Fill", + input: "f", + modifierFlags: .command + ) { + DispatchQueue.main.async { + isAspectFilled.wrappedValue.toggle() } - -// jumpAction(unitPoint: .init(x: 0, y: 0), amount: gestureStateHandler.jumpBackwardKeyPressAmount) } + } + +// .addingKeyCommand( +// title: L10n.jumpForward, +// input: UIKeyCommand.inputRightArrow +// ) { +// if gestureStateHandler.jumpForwardKeyPressActive { +// gestureStateHandler.jumpForwardKeyPressAmount += 1 +// gestureStateHandler.jumpForwardKeyPressWorkItem?.cancel() +// +// let task = DispatchWorkItem { +// gestureStateHandler.jumpForwardKeyPressActive = false +// gestureStateHandler.jumpForwardKeyPressAmount = 0 +// } +// +// gestureStateHandler.jumpForwardKeyPressWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } else { +// gestureStateHandler.jumpForwardKeyPressActive = true +// gestureStateHandler.jumpForwardKeyPressAmount += 1 +// +// let task = DispatchWorkItem { +// gestureStateHandler.jumpForwardKeyPressActive = false +// gestureStateHandler.jumpForwardKeyPressAmount = 0 +// } +// +// gestureStateHandler.jumpForwardKeyPressWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// +// jumpAction(unitPoint: .init(x: 1, y: 0), amount: gestureStateHandler.jumpForwardKeyPressAmount) +// } +// .addingKeyCommand( +// title: L10n.jumpBackward, +// input: UIKeyCommand.inputLeftArrow +// ) { +// if gestureStateHandler.jumpBackwardKeyPressActive { +// gestureStateHandler.jumpBackwardKeyPressAmount += 1 +// gestureStateHandler.jumpBackwardKeyPressWorkItem?.cancel() +// +// let task = DispatchWorkItem { +// gestureStateHandler.jumpBackwardKeyPressActive = false +// gestureStateHandler.jumpBackwardKeyPressAmount = 0 +// } +// +// gestureStateHandler.jumpBackwardKeyPressWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } else { +// gestureStateHandler.jumpBackwardKeyPressActive = true +// gestureStateHandler.jumpBackwardKeyPressAmount += 1 +// +// let task = DispatchWorkItem { +// gestureStateHandler.jumpBackwardKeyPressActive = false +// gestureStateHandler.jumpBackwardKeyPressAmount = 0 +// } +// +// gestureStateHandler.jumpBackwardKeyPressWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// + //// jumpAction(unitPoint: .init(x: 0, y: 0), amount: gestureStateHandler.jumpBackwardKeyPressAmount) +// } // self.keyCommands([ // .init( diff --git a/Swiftfin/Views/VideoPlayer/VideoPlayer.swift b/Swiftfin/Views/VideoPlayer/VideoPlayer.swift index fec4e8ffc..b7d7602e4 100644 --- a/Swiftfin/Views/VideoPlayer/VideoPlayer.swift +++ b/Swiftfin/Views/VideoPlayer/VideoPlayer.swift @@ -133,7 +133,6 @@ struct VideoPlayer: View { { videoPlayerManager.selectNextViewModel() } else { - AppDelegate.leavePlaybackOrientation() router.dismissCoordinator() } } @@ -191,6 +190,7 @@ struct VideoPlayer: View { .padding(.top) } .videoPlayerKeyCommands( + isAspectFilled: $isAspectFilled, gestureStateHandler: gestureStateHandler, videoPlayerManager: videoPlayerManager, updateViewProxy: updateViewProxy @@ -212,6 +212,11 @@ struct VideoPlayer: View { .onChange(of: audioOffset) { newValue in videoPlayerManager.proxy.setAudioDelay(.ticks(newValue)) } + .onChange(of: isAspectFilled) { newValue in + UIView.animate(withDuration: 0.2) { + videoPlayerManager.proxy.aspectFill(newValue ? 1 : 0) + } + } .onChange(of: isGestureLocked) { newValue in if newValue { updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked") @@ -374,7 +379,6 @@ extension VideoPlayer { case .none: return case .aspectFill: () -// aspectFillAction(state: state, unitPoint: unitPoint, scale: <#T##CGFloat#>) case .gestureLock: guard !isPresentingOverlay else { return } isGestureLocked.toggle() @@ -391,14 +395,8 @@ extension VideoPlayer { guard state == .began || state == .changed else { return } if scale > 1, !isAspectFilled { isAspectFilled = true - UIView.animate(withDuration: 0.2) { - videoPlayerManager.proxy.aspectFill(1) - } } else if scale < 1, isAspectFilled { isAspectFilled = false - UIView.animate(withDuration: 0.2) { - videoPlayerManager.proxy.aspectFill(0) - } } } @@ -421,7 +419,7 @@ extension VideoPlayer { toNearest: 100 ) - updateViewProxy.present(systemName: "speaker.wave.2.fill", title: newOffset.millisecondFormat) + updateViewProxy.present(systemName: "speaker.wave.2.fill", title: newOffset.millisecondLabel) audioOffset = clamp(newOffset, min: -30000, max: 30000) } @@ -545,7 +543,7 @@ extension VideoPlayer { ) let clampedOffset = clamp(newOffset, min: -30000, max: 30000) - updateViewProxy.present(systemName: "captions.bubble.fill", title: clampedOffset.millisecondFormat) + updateViewProxy.present(systemName: "captions.bubble.fill", title: clampedOffset.millisecondLabel) subtitleOffset = clampedOffset }