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 = "