- Swiftfin is a modern video client for the Jellyfin media server. Redesigned in Swift to maximize direct play with the power of VLC and look native on all classes of Apple devices.
+ Swiftfin is a modern video client for the Jellyfin media server. Made using Swift to maximize direct play with the power of VLC and look native on all classes of Apple devices.
## ⚡️ Download
**✨New! Available on the App Store**
-
+Learn more on our [announcement post](https://jellyfin.org/posts/2022/12/29/swiftfin/).
-Read about the details on our [announcement post](https://jellyfin.org/posts/2022/12/29/swiftfin/).
+
+
+
+
+A [TestFlight](./TESTFLIGHT.md) instance is also available.
+
+## ⚙️ Development
+
+Thank you for your interest in Swiftfin! Please check out the [Contribution Guidelines](https://github.com/jellyfin/Swiftfin/blob/main/contributing.md) to get started.
## 📚 Translations
**Don't see Swiftfin in your language?**
-Check out our [Weblate instance](https://translate.jellyfin.org/projects/swiftfin/) to help translate Swiftfin and other projects.
+Check out our [Weblate instance](https://translate.jellyfin.org/projects/swiftfin/) to help translate Swiftfin and other Jellyfin projects.
-
-## ⚙️ Development
-
-Thank you for your interest in Swiftfin! Please check out the [Contribution Guidelines](https://github.com/jellyfin/Swiftfin/blob/main/contributing.md) to get started.
diff --git a/Resources/AppIcons/Dark/AppIcon-dark-blue.svg b/Resources/AppIcons/Dark/AppIcon-dark-blue.svg
new file mode 100644
index 00000000..0359f76d
--- /dev/null
+++ b/Resources/AppIcons/Dark/AppIcon-dark-blue.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Dark/AppIcon-dark-green.svg b/Resources/AppIcons/Dark/AppIcon-dark-green.svg
new file mode 100644
index 00000000..fd482fa9
--- /dev/null
+++ b/Resources/AppIcons/Dark/AppIcon-dark-green.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Dark/AppIcon-dark-jellyfin.svg b/Resources/AppIcons/Dark/AppIcon-dark-jellyfin.svg
new file mode 100644
index 00000000..df67de89
--- /dev/null
+++ b/Resources/AppIcons/Dark/AppIcon-dark-jellyfin.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Dark/AppIcon-dark-orange.svg b/Resources/AppIcons/Dark/AppIcon-dark-orange.svg
new file mode 100644
index 00000000..62a768a5
--- /dev/null
+++ b/Resources/AppIcons/Dark/AppIcon-dark-orange.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Dark/AppIcon-dark-red.svg b/Resources/AppIcons/Dark/AppIcon-dark-red.svg
new file mode 100644
index 00000000..6c848265
--- /dev/null
+++ b/Resources/AppIcons/Dark/AppIcon-dark-red.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Dark/AppIcon-dark-yellow.svg b/Resources/AppIcons/Dark/AppIcon-dark-yellow.svg
new file mode 100644
index 00000000..9787edce
--- /dev/null
+++ b/Resources/AppIcons/Dark/AppIcon-dark-yellow.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.svg b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.svg
new file mode 100644
index 00000000..5183ef54
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.svg b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.svg
new file mode 100644
index 00000000..d0438c88
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.svg b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.svg
new file mode 100644
index 00000000..f551bcb8
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.svg b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.svg
new file mode 100644
index 00000000..855ae608
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.svg b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.svg
new file mode 100644
index 00000000..6d6f5085
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.svg b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.svg
new file mode 100644
index 00000000..9935c5cd
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.svg b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.svg
new file mode 100644
index 00000000..7fe53b01
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-green.svg b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-green.svg
new file mode 100644
index 00000000..1b434671
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-green.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.svg b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.svg
new file mode 100644
index 00000000..03df4bb1
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.svg b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.svg
new file mode 100644
index 00000000..46a6c39b
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-red.svg b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-red.svg
new file mode 100644
index 00000000..9f251a13
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-red.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.svg b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.svg
new file mode 100644
index 00000000..72209c33
--- /dev/null
+++ b/Resources/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Light/AppIcon-light-blue.svg b/Resources/AppIcons/Light/AppIcon-light-blue.svg
new file mode 100644
index 00000000..bc41f6e1
--- /dev/null
+++ b/Resources/AppIcons/Light/AppIcon-light-blue.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Light/AppIcon-light-green.svg b/Resources/AppIcons/Light/AppIcon-light-green.svg
new file mode 100644
index 00000000..b48a3ce1
--- /dev/null
+++ b/Resources/AppIcons/Light/AppIcon-light-green.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Light/AppIcon-light-jellyfin.svg b/Resources/AppIcons/Light/AppIcon-light-jellyfin.svg
new file mode 100644
index 00000000..d23e85d5
--- /dev/null
+++ b/Resources/AppIcons/Light/AppIcon-light-jellyfin.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Light/AppIcon-light-orange.svg b/Resources/AppIcons/Light/AppIcon-light-orange.svg
new file mode 100644
index 00000000..bb1a78ef
--- /dev/null
+++ b/Resources/AppIcons/Light/AppIcon-light-orange.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Light/AppIcon-light-red.svg b/Resources/AppIcons/Light/AppIcon-light-red.svg
new file mode 100644
index 00000000..9101ca42
--- /dev/null
+++ b/Resources/AppIcons/Light/AppIcon-light-red.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Light/AppIcon-light-yellow.svg b/Resources/AppIcons/Light/AppIcon-light-yellow.svg
new file mode 100644
index 00000000..08b69104
--- /dev/null
+++ b/Resources/AppIcons/Light/AppIcon-light-yellow.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcons/Primary/AppIcon-primary-primary.svg b/Resources/AppIcons/Primary/AppIcon-primary-primary.svg
new file mode 100644
index 00000000..b9734257
--- /dev/null
+++ b/Resources/AppIcons/Primary/AppIcon-primary-primary.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Resources/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg b/Resources/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg
new file mode 100755
index 00000000..072b425a
--- /dev/null
+++ b/Resources/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg
@@ -0,0 +1,46 @@
+
diff --git a/Resources/primary-wide.svg b/Resources/primary-wide.svg
new file mode 100644
index 00000000..e094a8db
--- /dev/null
+++ b/Resources/primary-wide.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/Shared/AppIcons/AppIcons.swift b/Shared/AppIcons/AppIcons.swift
new file mode 100644
index 00000000..743fb5b4
--- /dev/null
+++ b/Shared/AppIcons/AppIcons.swift
@@ -0,0 +1,40 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import UIKit
+
+protocol AppIcon: CaseIterable, Identifiable, Displayable, RawRepresentable {
+ var iconName: String { get }
+ var iconPreview: UIImage { get }
+ static var tag: String { get }
+
+ static func createCase(iconName: String) -> Self?
+}
+
+extension AppIcon where ID == String, RawValue == String {
+
+ var iconName: String {
+ "AppIcon-\(Self.tag)-\(rawValue)"
+ }
+
+ var iconPreview: UIImage {
+ UIImage(named: iconName) ?? UIImage()
+ }
+
+ var id: String {
+ iconName
+ }
+
+ static func createCase(iconName: String) -> Self? {
+ let split = iconName.split(separator: "-")
+ guard split.count == 3, split[1] == Self.tag else { return nil }
+
+ return Self(rawValue: String(split[2]))
+ }
+}
diff --git a/Shared/AppIcons/DarkAppIcon.swift b/Shared/AppIcons/DarkAppIcon.swift
new file mode 100644
index 00000000..36930f3b
--- /dev/null
+++ b/Shared/AppIcons/DarkAppIcon.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+enum DarkAppIcon: String, AppIcon {
+
+ case blue
+ case green
+ case orange
+ case red
+ case yellow
+ case jellyfin
+
+ var displayTitle: String {
+ switch self {
+ case .blue:
+ return L10n.blue
+ case .green:
+ return L10n.green
+ case .orange:
+ return L10n.orange
+ case .red:
+ return L10n.red
+ case .yellow:
+ return L10n.yellow
+ case .jellyfin:
+ return "Jellyfin"
+ }
+ }
+
+ static let tag: String = "dark"
+}
diff --git a/Shared/AppIcons/InvertedDarkAppIcon.swift b/Shared/AppIcons/InvertedDarkAppIcon.swift
new file mode 100644
index 00000000..8284d9be
--- /dev/null
+++ b/Shared/AppIcons/InvertedDarkAppIcon.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+enum InvertedDarkAppIcon: String, AppIcon {
+
+ case blue
+ case green
+ case orange
+ case red
+ case yellow
+ case jellyfin
+
+ var displayTitle: String {
+ switch self {
+ case .blue:
+ return L10n.blue
+ case .green:
+ return L10n.green
+ case .orange:
+ return L10n.orange
+ case .red:
+ return L10n.red
+ case .yellow:
+ return L10n.yellow
+ case .jellyfin:
+ return "Jellyfin"
+ }
+ }
+
+ static let tag: String = "invertedDark"
+}
diff --git a/Shared/AppIcons/InvertedLightAppIcon.swift b/Shared/AppIcons/InvertedLightAppIcon.swift
new file mode 100644
index 00000000..d8b2c21f
--- /dev/null
+++ b/Shared/AppIcons/InvertedLightAppIcon.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+enum InvertedLightAppIcon: String, AppIcon {
+
+ case blue
+ case green
+ case orange
+ case red
+ case yellow
+ case jellyfin
+
+ var displayTitle: String {
+ switch self {
+ case .blue:
+ return L10n.blue
+ case .green:
+ return L10n.green
+ case .orange:
+ return L10n.orange
+ case .red:
+ return L10n.red
+ case .yellow:
+ return L10n.yellow
+ case .jellyfin:
+ return "Jellyfin"
+ }
+ }
+
+ static let tag: String = "invertedLight"
+}
diff --git a/Shared/AppIcons/LightAppIcon.swift b/Shared/AppIcons/LightAppIcon.swift
new file mode 100644
index 00000000..a2c5f5ca
--- /dev/null
+++ b/Shared/AppIcons/LightAppIcon.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+enum LightAppIcon: String, AppIcon {
+
+ case blue
+ case green
+ case orange
+ case red
+ case yellow
+ case jellyfin
+
+ var displayTitle: String {
+ switch self {
+ case .blue:
+ return L10n.blue
+ case .green:
+ return L10n.green
+ case .orange:
+ return L10n.orange
+ case .red:
+ return L10n.red
+ case .yellow:
+ return L10n.yellow
+ case .jellyfin:
+ return "Jellyfin"
+ }
+ }
+
+ static let tag: String = "light"
+}
diff --git a/Shared/AppIcons/PrimaryAppIcon.swift b/Shared/AppIcons/PrimaryAppIcon.swift
new file mode 100644
index 00000000..abf23e55
--- /dev/null
+++ b/Shared/AppIcons/PrimaryAppIcon.swift
@@ -0,0 +1,23 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+enum PrimaryAppIcon: String, AppIcon {
+
+ case primary
+
+ var displayTitle: String {
+ switch self {
+ case .primary:
+ return L10n.primary
+ }
+ }
+
+ static let tag: String = "primary"
+}
diff --git a/Shared/Coordinators/BasicAppSettingsCoordinator.swift b/Shared/Coordinators/BasicAppSettingsCoordinator.swift
index d08c0222..4603cb92 100644
--- a/Shared/Coordinators/BasicAppSettingsCoordinator.swift
+++ b/Shared/Coordinators/BasicAppSettingsCoordinator.swift
@@ -3,10 +3,10 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
-import Foundation
+import PulseUI
import Stinsen
import SwiftUI
@@ -16,16 +16,46 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
+
+ #if os(iOS)
@Route(.push)
var about = makeAbout
+ @Route(.push)
+ var appIconSelector = makeAppIconSelector
+ @Route(.push)
+ var log = makeLog
+ #endif
+ #if os(tvOS)
+ @Route(.modal)
+ var log = makeLog
+ #endif
+
+ private let viewModel: SettingsViewModel
+
+ init() {
+ viewModel = .init()
+ }
+
+ #if os(iOS)
@ViewBuilder
func makeAbout() -> some View {
- AboutAppView()
+ AboutAppView(viewModel: viewModel)
+ }
+
+ @ViewBuilder
+ func makeAppIconSelector() -> some View {
+ AppIconSelectorView(viewModel: viewModel)
+ }
+ #endif
+
+ @ViewBuilder
+ func makeLog() -> some View {
+ ConsoleView()
}
@ViewBuilder
func makeStart() -> some View {
- BasicAppSettingsView(viewModel: BasicAppSettingsViewModel())
+ BasicAppSettingsView(viewModel: viewModel)
}
}
diff --git a/Shared/Coordinators/BasicLibraryCoordinator.swift b/Shared/Coordinators/BasicLibraryCoordinator.swift
index d42a73c3..83894cf1 100644
--- a/Shared/Coordinators/BasicLibraryCoordinator.swift
+++ b/Shared/Coordinators/BasicLibraryCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -24,10 +24,20 @@ final class BasicLibraryCoordinator: NavigationCoordinatable {
@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
@@ -38,7 +48,7 @@ final class BasicLibraryCoordinator: NavigationCoordinatable {
@ViewBuilder
func makeStart() -> some View {
BasicLibraryView(viewModel: parameters.viewModel)
- #if !os(tvOS)
+ #if os(iOS)
.if(parameters.title != nil) { view in
view.navigationTitle(parameters.title ?? .emptyDash)
}
diff --git a/Shared/Coordinators/BasicNavigationCoordinator.swift b/Shared/Coordinators/BasicNavigationCoordinator.swift
new file mode 100644
index 00000000..fd48bce8
--- /dev/null
+++ b/Shared/Coordinators/BasicNavigationCoordinator.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Stinsen
+import SwiftUI
+
+/// Basic coordinator to wrap a view for the purpose of being wrapped in a NavigationViewCoordinator
+final class BasicNavigationViewCoordinator: NavigationCoordinatable {
+
+ let stack = NavigationStack(initial: \BasicNavigationViewCoordinator.start)
+
+ @Root
+ var start = makeStart
+
+ private let content: () -> any View
+
+ init(@ViewBuilder _ content: @escaping () -> any View) {
+ self.content = content
+ }
+
+ @ViewBuilder
+ private func makeStart() -> some View {
+ content().eraseToAnyView()
+ }
+}
diff --git a/Shared/Coordinators/CastAndCrewLibraryCoordinator.swift b/Shared/Coordinators/CastAndCrewLibraryCoordinator.swift
index ab7ab203..9a211c9a 100644
--- a/Shared/Coordinators/CastAndCrewLibraryCoordinator.swift
+++ b/Shared/Coordinators/CastAndCrewLibraryCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Coordinators/ConnectToServerCoodinator.swift b/Shared/Coordinators/ConnectToServerCoodinator.swift
index 071cb689..e2d37349 100644
--- a/Shared/Coordinators/ConnectToServerCoodinator.swift
+++ b/Shared/Coordinators/ConnectToServerCoodinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Coordinators/DownloadListCoordinator.swift b/Shared/Coordinators/DownloadListCoordinator.swift
new file mode 100644
index 00000000..ea37e49c
--- /dev/null
+++ b/Shared/Coordinators/DownloadListCoordinator.swift
@@ -0,0 +1,32 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+#if os(iOS)
+import Foundation
+import Stinsen
+import SwiftUI
+
+final class DownloadListCoordinator: NavigationCoordinatable {
+
+ let stack = NavigationStack(initial: \DownloadListCoordinator.start)
+
+ @Root
+ var start = makeStart
+ @Route(.modal)
+ var downloadTask = makeDownloadTask
+
+ func makeDownloadTask(downloadTask: DownloadTask) -> NavigationViewCoordinator {
+ NavigationViewCoordinator(DownloadTaskCoordinator(downloadTask: downloadTask))
+ }
+
+ @ViewBuilder
+ private func makeStart() -> DownloadListView {
+ DownloadListView(viewModel: .init())
+ }
+}
+#endif
diff --git a/Shared/Coordinators/DownloadTaskCoordinator.swift b/Shared/Coordinators/DownloadTaskCoordinator.swift
new file mode 100644
index 00000000..46ec417b
--- /dev/null
+++ b/Shared/Coordinators/DownloadTaskCoordinator.swift
@@ -0,0 +1,32 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+#if os(iOS)
+import Foundation
+import Stinsen
+import SwiftUI
+
+final class DownloadTaskCoordinator: NavigationCoordinatable {
+
+ let stack = NavigationStack(initial: \DownloadTaskCoordinator.start)
+
+ @Root
+ var start = makeStart
+
+ let downloadTask: DownloadTask
+
+ init(downloadTask: DownloadTask) {
+ self.downloadTask = downloadTask
+ }
+
+ @ViewBuilder
+ private func makeStart() -> DownloadTaskView {
+ DownloadTaskView(downloadTask: downloadTask)
+ }
+}
+#endif
diff --git a/Shared/Coordinators/FilterCoordinator.swift b/Shared/Coordinators/FilterCoordinator.swift
index 48565be9..19b2a2d3 100644
--- a/Shared/Coordinators/FilterCoordinator.swift
+++ b/Shared/Coordinators/FilterCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Coordinators/HomeCoordinator.swift b/Shared/Coordinators/HomeCoordinator.swift
index 855e4934..359aa611 100644
--- a/Shared/Coordinators/HomeCoordinator.swift
+++ b/Shared/Coordinators/HomeCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift
index 3dec8324..607f4db1 100644
--- a/Shared/Coordinators/ItemCoordinator.swift
+++ b/Shared/Coordinators/ItemCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -27,8 +27,18 @@ final class ItemCoordinator: NavigationCoordinatable {
var castAndCrew = makeCastAndCrew
@Route(.modal)
var itemOverview = makeItemOverview
+
+ #if os(iOS)
+ @Route(.modal)
+ var mediaSourceInfo = makeMediaSourceInfo
+ @Route(.modal)
+ var downloadTask = makeDownloadTask
+ #endif
+
+ #if os(tvOS)
@Route(.fullScreen)
var videoPlayer = makeVideoPlayer
+ #endif
let itemDto: BaseItemDto
@@ -56,10 +66,22 @@ final class ItemCoordinator: NavigationCoordinatable {
NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto))
}
- func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator {
- NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
+ #if os(iOS)
+ func makeMediaSourceInfo(mediaSourceInfo: MediaSourceInfo) -> NavigationViewCoordinator {
+ NavigationViewCoordinator(MediaSourceInfoCoordinator(mediaSourceInfo: mediaSourceInfo))
}
+ func makeDownloadTask(downloadTask: DownloadTask) -> NavigationViewCoordinator {
+ NavigationViewCoordinator(DownloadTaskCoordinator(downloadTask: downloadTask))
+ }
+ #endif
+
+ #if os(tvOS)
+ func makeVideoPlayer(manager: VideoPlayerManager) -> NavigationViewCoordinator {
+ NavigationViewCoordinator(VideoPlayerCoordinator(manager: manager))
+ }
+ #endif
+
@ViewBuilder
func makeStart() -> some View {
ItemView(item: itemDto)
diff --git a/Shared/Coordinators/ItemOverviewCoordinator.swift b/Shared/Coordinators/ItemOverviewCoordinator.swift
index ed840184..c73c5df9 100644
--- a/Shared/Coordinators/ItemOverviewCoordinator.swift
+++ b/Shared/Coordinators/ItemOverviewCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
diff --git a/Shared/Coordinators/LibraryCoordinator.swift b/Shared/Coordinators/LibraryCoordinator.swift
index 3897ea20..9d5d26fa 100644
--- a/Shared/Coordinators/LibraryCoordinator.swift
+++ b/Shared/Coordinators/LibraryCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/LiveTVChannelsCoordinator.swift
index 9e5f17c5..62712ed0 100644
--- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift
+++ b/Shared/Coordinators/LiveTVChannelsCoordinator.swift
@@ -3,46 +3,47 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import Defaults
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class LiveTVChannelsCoordinator: NavigationCoordinatable {
+
let stack = NavigationStack(initial: \LiveTVChannelsCoordinator.start)
@Root
var start = makeStart
- @Route(.modal)
- var modalItem = makeModalItem
+
+ #if os(tvOS)
@Route(.fullScreen)
var videoPlayer = makeVideoPlayer
+ #endif
- func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator {
- NavigationViewCoordinator(ItemCoordinator(item: item))
- }
-
- func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator {
- NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
+ #if os(tvOS)
+ func makeVideoPlayer(manager: VideoPlayerManager) -> NavigationViewCoordinator {
+ BasicNavigationViewCoordinator {
+ Group {
+ if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
+ VideoPlayer(manager: manager)
+ .overlay {
+ VideoPlayer.Overlay()
+ }
+ } else {
+ NativeVideoPlayer(manager: manager)
+ }
+ }
+ }
+ .inNavigationViewCoordinator()
}
+ #endif
@ViewBuilder
func makeStart() -> some View {
LiveTVChannelsView()
}
}
-
-final class EmptyViewCoordinator: NavigationCoordinatable {
- let stack = NavigationStack(initial: \EmptyViewCoordinator.start)
-
- @Root
- var start = makeStart
-
- @ViewBuilder
- func makeStart() -> some View {
- Text("Empty")
- }
-}
diff --git a/Shared/Coordinators/LiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator.swift
index c1e8f813..76ba0df2 100644
--- a/Shared/Coordinators/LiveTVCoordinator.swift
+++ b/Shared/Coordinators/LiveTVCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -12,19 +12,14 @@ import Stinsen
import SwiftUI
final class LiveTVCoordinator: NavigationCoordinatable {
+
let stack = NavigationStack(initial: \LiveTVCoordinator.start)
@Root
var start = makeStart
- @Route(.fullScreen)
- var videoPlayer = makeVideoPlayer
@ViewBuilder
func makeStart() -> some View {
LiveTVChannelsView()
}
-
- func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator {
- NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
- }
}
diff --git a/Shared/Coordinators/LiveTVProgramsCoordinator.swift b/Shared/Coordinators/LiveTVProgramsCoordinator.swift
index f44885f0..2460e534 100644
--- a/Shared/Coordinators/LiveTVProgramsCoordinator.swift
+++ b/Shared/Coordinators/LiveTVProgramsCoordinator.swift
@@ -3,9 +3,11 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import Algorithms
+import Defaults
import Foundation
import JellyfinAPI
import Stinsen
@@ -17,15 +19,47 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
+
+ #if os(tvOS)
@Route(.fullScreen)
var videoPlayer = makeVideoPlayer
+ #endif
- func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator {
- NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
+ #if os(tvOS)
+ func makeVideoPlayer(manager: VideoPlayerManager) -> NavigationViewCoordinator {
+ BasicNavigationViewCoordinator {
+ Group {
+ if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
+ VideoPlayer(manager: manager)
+ .overlay {
+ VideoPlayer.Overlay()
+ }
+ } else {
+ NativeVideoPlayer(manager: manager)
+ }
+ }
+ }
+ .inNavigationViewCoordinator()
}
+ #endif
- @ViewBuilder
+// @ViewBuilder
func makeStart() -> some View {
- LiveTVProgramsView()
+ let viewModel = LiveTVProgramsViewModel()
+
+ let channels = (1 ..< 20).map { _ in BaseItemDto.randomItem() }
+
+ channels.forEach { channel in
+ 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/LiveTVTabCoordinator.swift b/Shared/Coordinators/LiveTVTabCoordinator.swift
index 3d85893c..1c462f34 100644
--- a/Shared/Coordinators/LiveTVTabCoordinator.swift
+++ b/Shared/Coordinators/LiveTVTabCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -11,6 +11,7 @@ import Stinsen
import SwiftUI
final class LiveTVTabCoordinator: TabCoordinatable {
+
var child = TabChild(startingItems: [
\LiveTVTabCoordinator.programs,
\LiveTVTabCoordinator.channels,
diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift
index f6bc1059..feaa0d5b 100644
--- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift
+++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift
@@ -3,13 +3,14 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
import Defaults
import Factory
import Foundation
+import JellyfinAPI
import Nuke
import Stinsen
import SwiftUI
@@ -26,14 +27,17 @@ final class MainCoordinator: NavigationCoordinatable {
var mainTab = makeMainTab
@Root
var serverList = makeServerList
+ @Route(.fullScreen)
+ var videoPlayer = makeVideoPlayer
private var cancellables = Set()
init() {
- if SessionManager.main.currentLogin != nil {
- self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
+
+ if Container.userSession.callAsFunction().authenticated {
+ stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else {
- self.stack = NavigationStack(initial: \MainCoordinator.serverList)
+ stack = NavigationStack(initial: \MainCoordinator.serverList)
}
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
@@ -42,25 +46,11 @@ final class MainCoordinator: NavigationCoordinatable {
WidgetCenter.shared.reloadAllTimelines()
UIScrollView.appearance().keyboardDismissMode = .onDrag
- // Back bar button item setup
- let config = UIImage.SymbolConfiguration(paletteColors: [.white, .jellyfinPurple])
- let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill", withConfiguration: config)
- let barAppearance = UINavigationBar.appearance()
- barAppearance.backIndicatorImage = backButtonBackgroundImage
- barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage
- barAppearance.tintColor = UIColor(Color.jellyfinPurple)
-
// Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
Notifications[.processDeepLink].subscribe(self, selector: #selector(processDeepLink(_:)))
Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeServerCurrentURI(_:)))
-
- Defaults.publisher(.appAppearance)
- .sink { _ in
- JellyfinPlayerApp.setupAppearance()
- }
- .store(in: &cancellables)
}
@objc
@@ -91,12 +81,12 @@ final class MainCoordinator: NavigationCoordinatable {
@objc
func didChangeServerCurrentURI(_ notification: Notification) {
- guard let newCurrentServerState = notification.object as? SwiftfinStore.State.Server
- else { fatalError("Need to have new current login state server") }
- guard SessionManager.main.currentLogin != nil else { return }
- if newCurrentServerState.id == SessionManager.main.currentLogin.server.id {
- SessionManager.main.signInUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user)
- }
+// guard let newCurrentServerState = notification.object as? SwiftfinStore.State.Server
+// else { fatalError("Need to have new current login state server") }
+// guard SessionManager.main.currentLogin != nil else { return }
+// if newCurrentServerState.id == SessionManager.main.currentLogin.server.id {
+// SessionManager.main.signInUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user)
+// }
}
func makeMainTab() -> MainTabCoordinator {
@@ -106,4 +96,8 @@ final class MainCoordinator: NavigationCoordinatable {
func makeServerList() -> NavigationViewCoordinator {
NavigationViewCoordinator(ServerListCoordinator())
}
+
+ func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator {
+ VideoPlayerCoordinator(manager: manager)
+ }
}
diff --git a/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift
index 4f6a89a3..2c9c5c11 100644
--- a/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift
+++ b/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -11,6 +11,7 @@ import Stinsen
import SwiftUI
final class MainTabCoordinator: TabCoordinatable {
+
var child = TabChild(startingItems: [
\MainTabCoordinator.home,
\MainTabCoordinator.search,
diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift
index 12a40d38..fea50445 100644
--- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift
+++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Factory
@@ -17,7 +17,7 @@ final class MainCoordinator: NavigationCoordinatable {
@Injected(LogManager.service)
private var logger
- var stack = Stinsen.NavigationStack(initial: \MainCoordinator.mainTab)
+ var stack: Stinsen.NavigationStack
@Root
var mainTab = makeMainTab
@@ -25,12 +25,15 @@ final class MainCoordinator: NavigationCoordinatable {
var serverList = makeServerList
@Root
var liveTV = makeLiveTV
+// @Route(.fullScreen)
+// var videoPlayer = makeVideoPlayer
init() {
- if SessionManager.main.currentLogin != nil {
- self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
+
+ if Container.userSession.callAsFunction().authenticated {
+ stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else {
- self.stack = NavigationStack(initial: \MainCoordinator.serverList)
+ stack = NavigationStack(initial: \MainCoordinator.serverList)
}
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
@@ -66,4 +69,8 @@ final class MainCoordinator: NavigationCoordinatable {
func makeLiveTV() -> LiveTVTabCoordinator {
LiveTVTabCoordinator()
}
+
+// func makeVideoPlayer(parameters: VideoPlayerCoordinator.Parameters) -> NavigationViewCoordinator {
+// NavigationViewCoordinator(VideoPlayerCoordinator(parameters: parameters))
+// }
}
diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift
index a4e10749..a5d9b26d 100644
--- a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift
+++ b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Coordinators/MediaCoordinator.swift b/Shared/Coordinators/MediaCoordinator.swift
index 958e68ad..4b89a7a3 100644
--- a/Shared/Coordinators/MediaCoordinator.swift
+++ b/Shared/Coordinators/MediaCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -24,6 +24,8 @@ final class MediaCoordinator: NavigationCoordinatable {
var library = makeLibrary
@Route(.push)
var liveTV = makeLiveTV
+ @Route(.push)
+ var downloads = makeDownloads
#endif
#if os(tvOS)
@@ -39,6 +41,10 @@ final class MediaCoordinator: NavigationCoordinatable {
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()
}
+
+ func makeDownloads() -> DownloadListCoordinator {
+ DownloadListCoordinator()
+ }
#endif
@ViewBuilder
diff --git a/Shared/Coordinators/MediaSourceInfoCoordinator.swift b/Shared/Coordinators/MediaSourceInfoCoordinator.swift
new file mode 100644
index 00000000..d1dbd79c
--- /dev/null
+++ b/Shared/Coordinators/MediaSourceInfoCoordinator.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import JellyfinAPI
+import Stinsen
+import SwiftUI
+
+final class MediaSourceInfoCoordinator: NavigationCoordinatable {
+
+ let stack = NavigationStack(initial: \MediaSourceInfoCoordinator.start)
+
+ @Root
+ var start = makeStart
+ @Route(.push)
+ var mediaStreamInfo = makeMediaStreamInfo
+
+ private let mediaSourceInfo: MediaSourceInfo
+
+ init(mediaSourceInfo: MediaSourceInfo) {
+ self.mediaSourceInfo = mediaSourceInfo
+ }
+
+ @ViewBuilder
+ func makeMediaStreamInfo(mediaStream: MediaStream) -> some View {
+ MediaStreamInfoView(mediaStream: mediaStream)
+ }
+
+ @ViewBuilder
+ func makeStart() -> some View {
+ ItemView.MediaSourceInfoView(mediaSource: mediaSourceInfo)
+ }
+}
diff --git a/Shared/Coordinators/PlaybackSettingsCoordinator.swift b/Shared/Coordinators/PlaybackSettingsCoordinator.swift
new file mode 100644
index 00000000..c4190b70
--- /dev/null
+++ b/Shared/Coordinators/PlaybackSettingsCoordinator.swift
@@ -0,0 +1,46 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import JellyfinAPI
+import Stinsen
+import SwiftUI
+
+final class PlaybackSettingsCoordinator: NavigationCoordinatable {
+
+ let stack = NavigationStack(initial: \PlaybackSettingsCoordinator.start)
+
+ @Root
+ var start = makeStart
+ @Route(.push)
+ var videoPlayerSettings = makeVideoPlayerSettings
+
+ #if os(iOS)
+ @Route(.push)
+ var mediaStreamInfo = makeMediaStreamInfo
+ #endif
+
+ func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
+ VideoPlayerSettingsCoordinator()
+ }
+
+ #if os(iOS)
+ @ViewBuilder
+ func makeMediaStreamInfo(mediaStream: MediaStream) -> some View {
+ MediaStreamInfoView(mediaStream: mediaStream)
+ }
+ #endif
+
+ @ViewBuilder
+ func makeStart() -> some View {
+ #if os(iOS)
+ PlaybackSettingsView()
+ #else
+ EmptyView()
+ #endif
+ }
+}
diff --git a/Shared/Coordinators/QuickConnectCoordinator.swift b/Shared/Coordinators/QuickConnectCoordinator.swift
index db79c2cf..6cadc4b8 100644
--- a/Shared/Coordinators/QuickConnectCoordinator.swift
+++ b/Shared/Coordinators/QuickConnectCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Coordinators/SearchCoordinator.swift b/Shared/Coordinators/SearchCoordinator.swift
index 140b18e2..fe073465 100644
--- a/Shared/Coordinators/SearchCoordinator.swift
+++ b/Shared/Coordinators/SearchCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -17,11 +17,16 @@ final class SearchCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
+ #if os(tvOS)
@Route(.modal)
var item = makeItem
+ @Route(.modal)
+ var library = makeLibrary
+ #else
+ @Route(.push)
+ var item = makeItem
@Route(.push)
var library = makeLibrary
- #if !os(tvOS)
@Route(.modal)
var filter = makeFilter
#endif
diff --git a/Shared/Coordinators/ServerDetailCoordinator.swift b/Shared/Coordinators/ServerDetailCoordinator.swift
index 60f59b61..207377f4 100644
--- a/Shared/Coordinators/ServerDetailCoordinator.swift
+++ b/Shared/Coordinators/ServerDetailCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Coordinators/ServerListCoordinator.swift b/Shared/Coordinators/ServerListCoordinator.swift
index fea08265..1740dd9d 100644
--- a/Shared/Coordinators/ServerListCoordinator.swift
+++ b/Shared/Coordinators/ServerListCoordinator.swift
@@ -3,10 +3,11 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
+import PulseUI
import Stinsen
import SwiftUI
diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift
index e9daaa53..32846cdb 100644
--- a/Shared/Coordinators/SettingsCoordinator.swift
+++ b/Shared/Coordinators/SettingsCoordinator.swift
@@ -3,49 +3,84 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
-import Foundation
+import PulseUI
import Stinsen
import SwiftUI
final class SettingsCoordinator: NavigationCoordinatable {
+
let stack = NavigationStack(initial: \SettingsCoordinator.start)
@Root
var start = makeStart
+
+ #if os(iOS)
@Route(.push)
- var serverDetail = makeServerDetail
+ var about = makeAbout
@Route(.push)
- var overlaySettings = makeOverlaySettings
+ var appIconSelector = makeAppIconSelector
@Route(.push)
- var experimentalSettings = makeExperimentalSettings
+ var log = makeLog
+ @Route(.push)
+ var nativePlayerSettings = makeNativePlayerSettings
+ @Route(.push)
+ var quickConnect = makeQuickConnectSettings
+
@Route(.push)
var customizeViewsSettings = makeCustomizeViewsSettings
@Route(.push)
- var about = makeAbout
-
- #if !os(tvOS)
+ var experimentalSettings = makeExperimentalSettings
@Route(.push)
- var quickConnect = makeQuickConnectSettings
+ var indicatorSettings = makeIndicatorSettings
@Route(.push)
- var fontPicker = makeFontPicker
+ var serverDetail = makeServerDetail
+ @Route(.push)
+ var videoPlayerSettings = makeVideoPlayerSettings
#endif
+ #if os(tvOS)
+ @Route(.modal)
+ var customizeViewsSettings = makeCustomizeViewsSettings
+ @Route(.modal)
+ var experimentalSettings = makeExperimentalSettings
+ @Route(.modal)
+ var indicatorSettings = makeIndicatorSettings
+ @Route(.modal)
+ var log = makeLog
+ @Route(.modal)
+ var serverDetail = makeServerDetail
+ @Route(.modal)
+ var videoPlayerSettings = makeVideoPlayerSettings
+ #endif
+
+ private let viewModel: SettingsViewModel
+
+ init() {
+ viewModel = .init()
+ }
+
+ #if os(iOS)
@ViewBuilder
- func makeServerDetail() -> some View {
- ServerDetailView(viewModel: .init(server: SessionManager.main.currentLogin.server))
+ func makeAbout() -> some View {
+ AboutAppView(viewModel: viewModel)
}
@ViewBuilder
- func makeOverlaySettings() -> some View {
- OverlaySettingsView()
+ func makeAppIconSelector() -> some View {
+ AppIconSelectorView(viewModel: viewModel)
}
@ViewBuilder
- func makeExperimentalSettings() -> some View {
- ExperimentalSettingsView()
+ func makeNativePlayerSettings() -> some View {
+ NativeVideoPlayerSettingsView()
+ }
+
+ @ViewBuilder
+ func makeQuickConnectSettings() -> some View {
+ QuickConnectSettingsView(viewModel: .init())
}
@ViewBuilder
@@ -54,27 +89,70 @@ final class SettingsCoordinator: NavigationCoordinatable {
}
@ViewBuilder
- func makeAbout() -> some View {
- AboutAppView()
- }
-
- #if !os(tvOS)
- @ViewBuilder
- func makeQuickConnectSettings() -> some View {
- let viewModel = QuickConnectSettingsViewModel()
- QuickConnectSettingsView(viewModel: viewModel)
+ func makeExperimentalSettings() -> some View {
+ ExperimentalSettingsView()
}
@ViewBuilder
- func makeFontPicker() -> some View {
- FontPickerView()
- .navigationTitle(L10n.subtitleFont)
+ func makeIndicatorSettings() -> some View {
+ IndicatorSettingsView()
+ }
+
+ @ViewBuilder
+ func makeServerDetail(server: ServerState) -> some View {
+ ServerDetailView(viewModel: .init(server: server))
+ }
+
+ func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
+ VideoPlayerSettingsCoordinator()
+ }
+ #endif
+
+ #if os(tvOS)
+ func makeCustomizeViewsSettings() -> NavigationViewCoordinator {
+ NavigationViewCoordinator(
+ BasicNavigationViewCoordinator {
+ CustomizeViewsSettings()
+ }
+ )
+ }
+
+ func makeExperimentalSettings() -> NavigationViewCoordinator {
+ NavigationViewCoordinator(
+ BasicNavigationViewCoordinator {
+ ExperimentalSettingsView()
+ }
+ )
+ }
+
+ func makeIndicatorSettings() -> NavigationViewCoordinator {
+ NavigationViewCoordinator(
+ BasicNavigationViewCoordinator {
+ IndicatorSettingsView()
+ }
+ )
+ }
+
+ func makeServerDetail(server: ServerState) -> NavigationViewCoordinator {
+ NavigationViewCoordinator(
+ BasicNavigationViewCoordinator {
+ ServerDetailView(viewModel: .init(server: server))
+ }
+ )
+ }
+
+ func makeVideoPlayerSettings() -> NavigationViewCoordinator {
+ NavigationViewCoordinator(VideoPlayerSettingsCoordinator())
}
#endif
+ @ViewBuilder
+ func makeLog() -> some View {
+ ConsoleView()
+ }
+
@ViewBuilder
func makeStart() -> some View {
- let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user)
SettingsView(viewModel: viewModel)
}
}
diff --git a/Shared/Coordinators/UserListCoordinator.swift b/Shared/Coordinators/UserListCoordinator.swift
index 99b40f2d..2422bec6 100644
--- a/Shared/Coordinators/UserListCoordinator.swift
+++ b/Shared/Coordinators/UserListCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Coordinators/UserSignInCoordinator.swift b/Shared/Coordinators/UserSignInCoordinator.swift
index 1d6e0c87..b06d8622 100644
--- a/Shared/Coordinators/UserSignInCoordinator.swift
+++ b/Shared/Coordinators/UserSignInCoordinator.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -16,7 +16,7 @@ final class UserSignInCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
- #if !os(tvOS)
+ #if os(iOS)
@Route(.modal)
var quickConnect = makeQuickConnect
#endif
@@ -27,7 +27,7 @@ final class UserSignInCoordinator: NavigationCoordinatable {
self.viewModel = viewModel
}
- #if !os(tvOS)
+ #if os(iOS)
func makeQuickConnect() -> NavigationViewCoordinator {
NavigationViewCoordinator(QuickConnectCoordinator(viewModel: viewModel))
}
diff --git a/Shared/Coordinators/VideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator.swift
new file mode 100644
index 00000000..e60e762b
--- /dev/null
+++ b/Shared/Coordinators/VideoPlayerCoordinator.swift
@@ -0,0 +1,69 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Foundation
+import JellyfinAPI
+import Stinsen
+import SwiftUI
+
+final class VideoPlayerCoordinator: NavigationCoordinatable {
+
+ let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
+
+ @Root
+ var start = makeStart
+
+ let videoPlayerManager: VideoPlayerManager
+
+ init(manager: VideoPlayerManager) {
+ self.videoPlayerManager = manager
+ }
+
+ @ViewBuilder
+ func makeStart() -> some View {
+ #if os(iOS)
+
+ PreferenceUIHostingControllerView {
+ Group {
+ if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
+ VideoPlayer(manager: self.videoPlayerManager)
+ .overlay {
+ VideoPlayer.Overlay()
+ }
+ } else {
+ NativeVideoPlayer(manager: self.videoPlayerManager)
+ }
+ }
+ .overrideViewPreference(.dark)
+ }
+ .ignoresSafeArea()
+ .hideSystemOverlays()
+// .onAppear {
+// AppDelegate.changeOrientation(.landscape)
+// }
+
+ #else
+
+ PreferenceUIHostingControllerView {
+ Group {
+ if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
+ VideoPlayer(manager: self.videoPlayerManager)
+ .overlay {
+ VideoPlayer.Overlay()
+ }
+ } else {
+ NativeVideoPlayer(manager: self.videoPlayerManager)
+ }
+ }
+ }
+ .ignoresSafeArea()
+
+ #endif
+ }
+}
diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift
deleted file mode 100644
index 85e641c6..00000000
--- a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift
+++ /dev/null
@@ -1,40 +0,0 @@
-//
-// Swiftfin is subject to the terms of the Mozilla Public
-// License, v2.0. If a copy of the MPL was not distributed with this
-// file, you can obtain one at https://mozilla.org/MPL/2.0/.
-//
-// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Defaults
-import Foundation
-import JellyfinAPI
-import Stinsen
-import SwiftUI
-
-final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable {
-
- let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start)
-
- @Root
- var start = makeStart
-
- let viewModel: VideoPlayerViewModel
-
- init(viewModel: VideoPlayerViewModel) {
- self.viewModel = viewModel
- }
-
- @ViewBuilder
- func makeStart() -> some View {
- if Defaults[.Experimental.liveTVNativePlayer] {
- LiveTVNativePlayerView(viewModel: viewModel)
- .navigationBarHidden(true)
- .ignoresSafeArea()
- } else {
- LiveTVPlayerView(viewModel: viewModel)
- .navigationBarHidden(true)
- .ignoresSafeArea()
- }
- }
-}
diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift
deleted file mode 100644
index cb9d2725..00000000
--- a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift
+++ /dev/null
@@ -1,48 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Defaults
-import Foundation
-import JellyfinAPI
-import Stinsen
-import SwiftUI
-
-final class VideoPlayerCoordinator: NavigationCoordinatable {
-
- let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
-
- @Root
- var start = makeStart
-
- let viewModel: VideoPlayerViewModel
-
- init(viewModel: VideoPlayerViewModel) {
- self.viewModel = viewModel
- }
-
- @ViewBuilder
- func makeStart() -> some View {
- PreferenceUIHostingControllerView {
- if Defaults[.Experimental.nativePlayer] {
- NativePlayerView(viewModel: self.viewModel)
- .navigationBarHidden(true)
- .statusBar(hidden: true)
- .ignoresSafeArea()
- .prefersHomeIndicatorAutoHidden(true)
- .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape)
- } else {
- VLCPlayerView(viewModel: self.viewModel)
- .navigationBarHidden(true)
- .statusBar(hidden: true)
- .ignoresSafeArea()
- .prefersHomeIndicatorAutoHidden(true)
- .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape)
- }
- }.ignoresSafeArea()
- }
-}
diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift
deleted file mode 100644
index 593cdd28..00000000
--- a/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift
+++ /dev/null
@@ -1,40 +0,0 @@
-//
-// Swiftfin is subject to the terms of the Mozilla Public
-// License, v2.0. If a copy of the MPL was not distributed with this
-// file, you can obtain one at https://mozilla.org/MPL/2.0/.
-//
-// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Defaults
-import Foundation
-import JellyfinAPI
-import Stinsen
-import SwiftUI
-
-final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable {
-
- let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start)
-
- @Root
- var start = makeStart
-
- let viewModel: VideoPlayerViewModel
-
- init(viewModel: VideoPlayerViewModel) {
- self.viewModel = viewModel
- }
-
- @ViewBuilder
- func makeStart() -> some View {
- if Defaults[.Experimental.liveTVNativePlayer] {
- LiveTVNativeVideoPlayerView(viewModel: viewModel)
- .navigationBarHidden(true)
- .ignoresSafeArea()
- } else {
- LiveTVVideoPlayerView(viewModel: viewModel)
- .navigationBarHidden(true)
- .ignoresSafeArea()
- }
- }
-}
diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift
deleted file mode 100644
index b7d1c82c..00000000
--- a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift
+++ /dev/null
@@ -1,40 +0,0 @@
-//
-// Swiftfin is subject to the terms of the Mozilla Public
-// License, v2.0. If a copy of the MPL was not distributed with this
-// file, you can obtain one at https://mozilla.org/MPL/2.0/.
-//
-// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Defaults
-import Foundation
-import JellyfinAPI
-import Stinsen
-import SwiftUI
-
-final class VideoPlayerCoordinator: NavigationCoordinatable {
-
- let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
-
- @Root
- var start = makeStart
-
- let viewModel: VideoPlayerViewModel
-
- init(viewModel: VideoPlayerViewModel) {
- self.viewModel = viewModel
- }
-
- @ViewBuilder
- func makeStart() -> some View {
- if Defaults[.Experimental.nativePlayer] {
- NativePlayerView(viewModel: viewModel)
- .navigationBarHidden(true)
- .ignoresSafeArea()
- } else {
- VLCPlayerView(viewModel: viewModel)
- .navigationBarHidden(true)
- .ignoresSafeArea()
- }
- }
-}
diff --git a/Shared/Coordinators/VideoPlayerSettingsCoordinator.swift b/Shared/Coordinators/VideoPlayerSettingsCoordinator.swift
new file mode 100644
index 00000000..2f7db1b6
--- /dev/null
+++ b/Shared/Coordinators/VideoPlayerSettingsCoordinator.swift
@@ -0,0 +1,59 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Stinsen
+import SwiftUI
+
+final class VideoPlayerSettingsCoordinator: NavigationCoordinatable {
+
+ let stack = NavigationStack(initial: \VideoPlayerSettingsCoordinator.start)
+
+ @Root
+ var start = makeStart
+ @Route(.push)
+ var fontPicker = makeFontPicker
+
+ #if os(iOS)
+ @Route(.push)
+ var gestureSettings = makeGestureSettings
+ @Route(.push)
+ var actionButtonSelector = makeActionButtonSelector
+ #endif
+
+ #if os(tvOS)
+
+ #endif
+
+ func makeFontPicker(selection: Binding) -> some View {
+ FontPickerView(selection: selection)
+ .navigationTitle(L10n.subtitleFont)
+ }
+
+ #if os(iOS)
+
+ @ViewBuilder
+ func makeGestureSettings() -> some View {
+ GestureSettingsView()
+ .navigationTitle("Gestures")
+ }
+
+ func makeActionButtonSelector(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) -> some View {
+ ActionButtonSelectorView(selectedButtonsBinding: 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 5c818ca5..fdd49c0f 100644
--- a/Shared/Errors/ErrorMessage.swift
+++ b/Shared/Errors/ErrorMessage.swift
@@ -3,29 +3,23 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
-struct ErrorMessage: Identifiable {
+struct ErrorMessage: Hashable, Identifiable {
- let code: Int
- let title: String
+ let code: Int?
let message: String
- // Chosen value such that if an error has this code, don't show the code to the UI
- // This was chosen because of its unlikelyhood to ever be used
- static let noShowErrorCode = -69420
-
- var id: String {
- "\(code)\(title)\(message)"
+ var id: Int {
+ hashValue
}
- init(code: Int, title: String, message: String) {
+ init(message: String, code: Int? = nil) {
self.code = code
- self.title = title
self.message = message
}
}
diff --git a/Shared/Errors/NetworkError.swift b/Shared/Errors/NetworkError.swift
index 6794de30..a310e264 100644
--- a/Shared/Errors/NetworkError.swift
+++ b/Shared/Errors/NetworkError.swift
@@ -3,112 +3,112 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
-enum NetworkError: Error {
-
- /// For the case that the ErrorResponse object has a code of -1
- case URLError(response: ErrorResponse, displayMessage: String?)
-
- /// For the case that the ErrorRespones object has a code of -2
- case HTTPURLError(response: ErrorResponse, displayMessage: String?)
-
- /// For the case that the ErrorResponse object has a positive code
- case JellyfinError(response: ErrorResponse, displayMessage: String?)
-
- var errorMessage: ErrorMessage {
- switch self {
- case let .URLError(response, displayMessage):
- return NetworkError.parseURLError(from: response, displayMessage: displayMessage)
- case let .HTTPURLError(response, displayMessage):
- return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage)
- case let .JellyfinError(response, displayMessage):
- return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage)
- }
- }
-
- private static func parseURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
- let errorMessage: ErrorMessage
-
- switch response {
- case let .error(_, _, _, err):
-
- // Code references:
- // https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes
- switch err._code {
- case -1001:
- errorMessage = ErrorMessage(
- code: err._code,
- title: L10n.error,
- message: L10n.networkTimedOut
- )
- case -1003:
- errorMessage = ErrorMessage(
- code: err._code,
- title: L10n.error,
- message: L10n.unableToFindHost
- )
- case -1004:
- errorMessage = ErrorMessage(
- code: err._code,
- title: L10n.error,
- message: L10n.cannotConnectToHost
- )
- default:
- errorMessage = ErrorMessage(
- code: err._code,
- title: L10n.error,
- message: L10n.unknownError
- )
- }
- }
-
- return errorMessage
- }
-
- private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
- let errorMessage: ErrorMessage
-
- // Not implemented as has not run into one of these errors as time of writing
- switch response {
- case .error:
- errorMessage = ErrorMessage(
- code: 0,
- title: L10n.error,
- message: "An HTTP URL error has occurred"
- )
- }
-
- return errorMessage
- }
-
- private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
- let errorMessage: ErrorMessage
-
- switch response {
- case let .error(code, _, _, _):
-
- // Generic HTTP status codes
- switch code {
- case 401:
- errorMessage = ErrorMessage(
- code: code,
- title: L10n.unauthorized,
- message: L10n.unauthorizedUser
- )
- default:
- errorMessage = ErrorMessage(
- code: code,
- title: L10n.error,
- message: displayMessage ?? L10n.unknownError
- )
- }
- }
-
- return errorMessage
- }
-}
+// enum NetworkError: Error {
+//
+// /// For the case that the ErrorResponse object has a code of -1
+// case URLError(response: ErrorResponse, displayMessage: String?)
+//
+// /// For the case that the ErrorRespones object has a code of -2
+// case HTTPURLError(response: ErrorResponse, displayMessage: String?)
+//
+// /// For the case that the ErrorResponse object has a positive code
+// case JellyfinError(response: ErrorResponse, displayMessage: String?)
+//
+// var errorMessage: ErrorMessage {
+// switch self {
+// case let .URLError(response, displayMessage):
+// return NetworkError.parseURLError(from: response, displayMessage: displayMessage)
+// case let .HTTPURLError(response, displayMessage):
+// return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage)
+// case let .JellyfinError(response, displayMessage):
+// return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage)
+// }
+// }
+//
+// private static func parseURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
+// let errorMessage: ErrorMessage
+//
+// switch response {
+// case let .error(_, _, _, err):
+//
+// // Code references:
+// // https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes
+// switch err._code {
+// case -1001:
+// errorMessage = ErrorMessage(
+// code: err._code,
+// title: L10n.error,
+// message: L10n.networkTimedOut
+// )
+// case -1003:
+// errorMessage = ErrorMessage(
+// code: err._code,
+// title: L10n.error,
+// message: L10n.unableToFindHost
+// )
+// case -1004:
+// errorMessage = ErrorMessage(
+// code: err._code,
+// title: L10n.error,
+// message: L10n.cannotConnectToHost
+// )
+// default:
+// errorMessage = ErrorMessage(
+// code: err._code,
+// title: L10n.error,
+// message: L10n.unknownError
+// )
+// }
+// }
+//
+// return errorMessage
+// }
+//
+// private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
+// let errorMessage: ErrorMessage
+//
+// // Not implemented as has not run into one of these errors as time of writing
+// switch response {
+// case .error:
+// errorMessage = ErrorMessage(
+// code: 0,
+// title: L10n.error,
+// message: "An HTTP URL error has occurred"
+// )
+// }
+//
+// return errorMessage
+// }
+//
+// private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
+// let errorMessage: ErrorMessage
+//
+// switch response {
+// case let .error(code, _, _, _):
+//
+// // Generic HTTP status codes
+// switch code {
+// case 401:
+// errorMessage = ErrorMessage(
+// code: code,
+// title: L10n.unauthorized,
+// message: L10n.unauthorizedUser
+// )
+// default:
+// errorMessage = ErrorMessage(
+// code: code,
+// title: L10n.error,
+// message: displayMessage ?? L10n.unknownError
+// )
+// }
+// }
+//
+// return errorMessage
+// }
+// }
diff --git a/Shared/Extensions/Array.swift b/Shared/Extensions/Array.swift
new file mode 100644
index 00000000..0e04b66f
--- /dev/null
+++ b/Shared/Extensions/Array.swift
@@ -0,0 +1,48 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+extension Array {
+
+ func appending(_ element: Element) -> [Element] {
+ self + [element]
+ }
+
+ func appending(_ element: Element, if condition: Bool) -> [Element] {
+ if condition {
+ return self + [element]
+ } else {
+ return self
+ }
+ }
+
+ func appending(_ contents: [Element]) -> [Element] {
+ self + contents
+ }
+
+ func prepending(_ element: Element) -> [Element] {
+ [element] + self
+ }
+
+ func prepending(_ element: Element, if condition: Bool) -> [Element] {
+ if condition {
+ return [element] + self
+ } else {
+ return self
+ }
+ }
+
+ // There are instances where `removeFirst()` is called on an empty
+ // collection even with a count check and causes a crash
+ @discardableResult
+ mutating func removeFirstSafe() -> Element? {
+ guard count > 0 else { return nil }
+ return removeFirst()
+ }
+}
diff --git a/Shared/Extensions/ArrayExtensions.swift b/Shared/Extensions/ArrayExtensions.swift
deleted file mode 100644
index eacee289..00000000
--- a/Shared/Extensions/ArrayExtensions.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-//
-// Swiftfin is subject to the terms of the Mozilla Public
-// License, v2.0. If a copy of the MPL was not distributed with this
-// file, you can obtain one at https://mozilla.org/MPL/2.0/.
-//
-// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-
-extension Array {
- func appending(_ element: Element) -> [Element] {
- self + [element]
- }
-
- func appending(_ element: Element, if condition: Bool) -> [Element] {
- if condition {
- return self + [element]
- } else {
- return self
- }
- }
-
- func appending(_ contents: [Element]) -> [Element] {
- self + contents
- }
-}
-
-extension ArraySlice {
- var asArray: [Element] {
- Array(self)
- }
-}
diff --git a/Shared/Extensions/BundleExtensions.swift b/Shared/Extensions/BundleExtensions.swift
deleted file mode 100644
index 16380fb6..00000000
--- a/Shared/Extensions/BundleExtensions.swift
+++ /dev/null
@@ -1,20 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-
-extension Bundle {
- var iconFileName: String? {
- guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any],
- let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any],
- let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String],
- let iconFileName = iconFiles.last
- else { return nil }
- return iconFileName
- }
-}
diff --git a/Shared/Extensions/Button.swift b/Shared/Extensions/Button.swift
new file mode 100644
index 00000000..2ea13d0f
--- /dev/null
+++ b/Shared/Extensions/Button.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+extension Button where Label: View {
+
+ /// Creates a Button with an empty action and a custom label.
+ init(role: ButtonRole? = nil, @ViewBuilder label: @escaping () -> Label) {
+ self.init {} label: {
+ label()
+ }
+ }
+}
+
+extension Button where Label == Text {
+
+ /// Creates a Button with an empty action and a plain text label.
+ init(_ title: String, role: ButtonRole? = nil) {
+ self.init(role: role) {
+ Text(title)
+ }
+ }
+}
diff --git a/Shared/Extensions/CGPoint.swift b/Shared/Extensions/CGPoint.swift
new file mode 100644
index 00000000..e37304b6
--- /dev/null
+++ b/Shared/Extensions/CGPoint.swift
@@ -0,0 +1,19 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import UIKit
+
+extension CGPoint {
+
+ func isNear(_ other: CGPoint, padding: CGFloat) -> Bool {
+ let xRange = (x - padding) ... (x + padding)
+ let yRange = (y - padding) ... (y + padding)
+
+ return xRange.contains(other.x) && yRange.contains(other.y)
+ }
+}
diff --git a/Shared/Extensions/CGSize.swift b/Shared/Extensions/CGSize.swift
new file mode 100644
index 00000000..6700aa58
--- /dev/null
+++ b/Shared/Extensions/CGSize.swift
@@ -0,0 +1,16 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import UIKit
+
+extension CGSize {
+
+ static func Square(length: CGFloat) -> CGSize {
+ CGSize(width: length, height: length)
+ }
+}
diff --git a/Shared/Extensions/CGSizeExtensions.swift b/Shared/Extensions/CGSizeExtensions.swift
deleted file mode 100644
index 952d0567..00000000
--- a/Shared/Extensions/CGSizeExtensions.swift
+++ /dev/null
@@ -1,31 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import UIKit
-
-extension CGSize {
-
- static func Circle(radius: CGFloat) -> CGSize {
- CGSize(width: radius, height: radius)
- }
-
- // From https://gist.github.com/jkosoy/c835fea2c03e76720c77
- static func aspectFill(aspectRatio: CGSize, minimumSize: CGSize) -> CGSize {
- var minimumSize = minimumSize
- let mW = minimumSize.width / aspectRatio.width
- let mH = minimumSize.height / aspectRatio.height
-
- if mH > mW {
- minimumSize.width = minimumSize.height / aspectRatio.height * aspectRatio.width
- } else if mW > mH {
- minimumSize.height = minimumSize.width / aspectRatio.width * aspectRatio.height
- }
-
- return minimumSize
- }
-}
diff --git a/Shared/Extensions/Collection.swift b/Shared/Extensions/Collection.swift
new file mode 100644
index 00000000..f778113f
--- /dev/null
+++ b/Shared/Extensions/Collection.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+extension Collection {
+
+ var asArray: [Element] {
+ Array(self)
+ }
+
+ func sorted(using keyPath: KeyPath) -> [Element] {
+ sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
+ }
+
+ subscript(safe index: Index) -> Element? {
+ indices.contains(index) ? self[index] : nil
+ }
+}
diff --git a/Shared/Extensions/CollectionExtensions.swift b/Shared/Extensions/CollectionExtensions.swift
deleted file mode 100644
index d6d681dd..00000000
--- a/Shared/Extensions/CollectionExtensions.swift
+++ /dev/null
@@ -1,23 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-
-public extension Collection {
-
- /// SwifterSwift: Safe protects the array from out of bounds by use of optional.
- ///
- /// let arr = [1, 2, 3, 4, 5]
- /// arr[safe: 1] -> 2
- /// arr[safe: 10] -> nil
- ///
- /// - Parameter index: index of element to access element.
- subscript(safe index: Index) -> Element? {
- indices.contains(index) ? self[index] : nil
- }
-}
diff --git a/Shared/Extensions/ColorExtensions.swift b/Shared/Extensions/Color.swift
similarity index 71%
rename from Shared/Extensions/ColorExtensions.swift
rename to Shared/Extensions/Color.swift
index 031a7554..e89e9268 100644
--- a/Shared/Extensions/ColorExtensions.swift
+++ b/Shared/Extensions/Color.swift
@@ -3,15 +3,24 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import SwiftUI
-public extension Color {
+extension Color {
- internal static let jellyfinPurple = Color(uiColor: .jellyfinPurple)
+ static let jellyfinPurple = Color(uiColor: .jellyfinPurple)
+ var uiColor: UIColor {
+ UIColor(self)
+ }
+
+ var overlayColor: Color {
+ Color(uiColor: uiColor.overlayColor)
+ }
+
+ // TODO: Correct and add colors
#if os(tvOS) // tvOS doesn't have these
static let systemFill = Color(UIColor.white)
static let secondarySystemFill = Color(UIColor.gray)
@@ -24,7 +33,3 @@ public extension Color {
static let tertiarySystemFill = Color(UIColor.tertiarySystemFill)
#endif
}
-
-extension UIColor {
- static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1)
-}
diff --git a/Shared/Extensions/CoreStore.swift b/Shared/Extensions/CoreStore.swift
new file mode 100644
index 00000000..2f1434c7
--- /dev/null
+++ b/Shared/Extensions/CoreStore.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import CoreStore
+import Foundation
+import Logging
+
+extension CoreStore.LogLevel {
+
+ var asSwiftLog: Logger.Level {
+ switch self {
+ case .trace:
+ return .trace
+ case .notice:
+ return .debug
+ case .warning:
+ return .warning
+ case .fatal:
+ return .critical
+ }
+ }
+}
diff --git a/Shared/Extensions/Defaults+Workaround.swift b/Shared/Extensions/Defaults+Workaround.swift
deleted file mode 100755
index 11bc3b9f..00000000
--- a/Shared/Extensions/Defaults+Workaround.swift
+++ /dev/null
@@ -1,46 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Defaults
-import Foundation
-
-public extension Defaults.Serializable where Self: Codable {
- static var bridge: Defaults.TopLevelCodableBridge { Defaults.TopLevelCodableBridge() }
-}
-
-public extension Defaults.Serializable where Self: Codable & NSSecureCoding {
- static var bridge: Defaults.CodableNSSecureCodingBridge { Defaults.CodableNSSecureCodingBridge() }
-}
-
-public extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding {
- static var bridge: Defaults.NSSecureCodingBridge { Defaults.NSSecureCodingBridge() }
-}
-
-public extension Defaults.Serializable where Self: Codable & RawRepresentable {
- static var bridge: Defaults.RawRepresentableCodableBridge { Defaults.RawRepresentableCodableBridge() }
-}
-
-public extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable {
- static var bridge: Defaults.RawRepresentableBridge { Defaults.RawRepresentableBridge() }
-}
-
-public extension Defaults.Serializable where Self: RawRepresentable {
- static var bridge: Defaults.RawRepresentableBridge { Defaults.RawRepresentableBridge() }
-}
-
-public extension Defaults.Serializable where Self: NSSecureCoding {
- static var bridge: Defaults.NSSecureCodingBridge { Defaults.NSSecureCodingBridge() }
-}
-
-public extension Defaults.CollectionSerializable where Element: Defaults.Serializable {
- static var bridge: Defaults.CollectionBridge { Defaults.CollectionBridge() }
-}
-
-public extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable {
- static var bridge: Defaults.SetAlgebraBridge { Defaults.SetAlgebraBridge() }
-}
diff --git a/Shared/Extensions/DoubleExtensions.swift b/Shared/Extensions/DoubleExtensions.swift
deleted file mode 100644
index 391c539f..00000000
--- a/Shared/Extensions/DoubleExtensions.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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-
-extension Double {
-
- func subtract(_ other: Double, floor: Double) -> Double {
- var v = self - other
-
- if v < floor {
- v += abs(floor - v)
- }
-
- return v
- }
-}
diff --git a/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift b/Shared/Extensions/EdgeInsets.swift
similarity index 51%
rename from Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift
rename to Shared/Extensions/EdgeInsets.swift
index 7e091939..7520d98b 100644
--- a/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift
+++ b/Shared/Extensions/EdgeInsets.swift
@@ -3,18 +3,14 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import SwiftUI
-import UIKit
-@main
-struct JellyfinPlayer_tvOSApp: App {
+extension UIEdgeInsets {
- var body: some Scene {
- WindowGroup {
- MainCoordinator().view()
- }
+ var asEdgeInsets: EdgeInsets {
+ EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right)
}
}
diff --git a/Shared/Extensions/EnvironmentValue.swift b/Shared/Extensions/EnvironmentValue.swift
new file mode 100644
index 00000000..8757e509
--- /dev/null
+++ b/Shared/Extensions/EnvironmentValue.swift
@@ -0,0 +1,87 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+// TODO: Look at name spacing
+
+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/Equatable.swift b/Shared/Extensions/Equatable.swift
new file mode 100644
index 00000000..e48f7e01
--- /dev/null
+++ b/Shared/Extensions/Equatable.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+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
+ return copy
+ }
+}
diff --git a/Shared/Extensions/Files.swift b/Shared/Extensions/Files.swift
new file mode 100644
index 00000000..b6e008e9
--- /dev/null
+++ b/Shared/Extensions/Files.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+#if os(iOS)
+extension FileManager {
+
+ var availableStorage: Int {
+ let availableStorage: Int64
+
+ let fileURL = URL(fileURLWithPath: NSHomeDirectory() as String)
+
+ do {
+ let values = try fileURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
+
+ if let capacity = values.volumeAvailableCapacityForImportantUsage {
+ availableStorage = capacity
+ } else {
+ availableStorage = -1
+ }
+ } catch {
+ availableStorage = -1
+ }
+
+ return Int(availableStorage)
+ }
+}
+#endif
diff --git a/Shared/Extensions/JellyfinAPIExtensions/RequestBuilderExtensions.swift b/Shared/Extensions/Float.swift
similarity index 57%
rename from Shared/Extensions/JellyfinAPIExtensions/RequestBuilderExtensions.swift
rename to Shared/Extensions/Float.swift
index 6c76bfa5..b68747a1 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/RequestBuilderExtensions.swift
+++ b/Shared/Extensions/Float.swift
@@ -3,14 +3,14 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
-import JellyfinAPI
-extension RequestBuilder where T == URL {
- var url: URL {
- URL(string: URLString)!
+extension Float {
+
+ var rateLabel: String {
+ String(format: "%.2f", self).appending("x")
}
}
diff --git a/Shared/Extensions/FontExtensions.swift b/Shared/Extensions/Font.swift
similarity index 92%
rename from Shared/Extensions/FontExtensions.swift
rename to Shared/Extensions/Font.swift
index 03947512..6d906e0d 100644
--- a/Shared/Extensions/FontExtensions.swift
+++ b/Shared/Extensions/Font.swift
@@ -3,15 +3,16 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension Font {
- func toUIFont() -> UIFont {
+
+ var uiFont: UIFont {
switch self {
- #if !os(tvOS)
+ #if os(iOS)
case .largeTitle:
return UIFont.preferredFont(forTextStyle: .largeTitle)
#endif
diff --git a/Shared/Extensions/HorizontalAlignment.swift b/Shared/Extensions/HorizontalAlignment.swift
new file mode 100644
index 00000000..56b10ae7
--- /dev/null
+++ b/Shared/Extensions/HorizontalAlignment.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+extension HorizontalAlignment {
+
+ struct VideoPlayerTitleAlignment: AlignmentID {
+ static func defaultValue(in context: ViewDimensions) -> CGFloat {
+ context[HorizontalAlignment.leading]
+ }
+ }
+
+ static let VideoPlayerTitleAlignmentGuide = HorizontalAlignment(VideoPlayerTitleAlignment.self)
+}
diff --git a/Shared/Extensions/Int.swift b/Shared/Extensions/Int.swift
new file mode 100644
index 00000000..e9c4d897
--- /dev/null
+++ b/Shared/Extensions/Int.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+extension FixedWidthInteger {
+
+ var timeLabel: String {
+ let hours = self / 3600
+ let minutes = (self % 3600) / 60
+ 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)
+ .appending(":")
+ let secondsText = String(seconds).leftPad(toWidth: 2, withString: "0")
+
+ return hourText
+ .appending(minutesText)
+ .appending(secondsText)
+ }
+}
+
+extension Int {
+
+ /// Format if the current value represents milliseconds
+ var millisecondFormat: String {
+ let isNegative = self < 0
+ let value = abs(self)
+ let seconds = "\(value / 1000)"
+ let milliseconds = "\(value % 1000)".first ?? "0"
+
+ return seconds
+ .appending(".")
+ .appending(milliseconds)
+ .appending("s")
+ .prepending("-", if: isNegative)
+ }
+
+ // Format if the current value represents seconds
+ var secondFormat: String {
+ let isNegative = self < 0
+ let value = abs(self)
+ let seconds = "\(value)"
+
+ return seconds
+ .appending("s")
+ .prepending("-", if: isNegative)
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/APISortOrderExtensions.swift b/Shared/Extensions/JellyfinAPI/APISortOrder.swift
similarity index 66%
rename from Shared/Extensions/JellyfinAPIExtensions/APISortOrderExtensions.swift
rename to Shared/Extensions/JellyfinAPI/APISortOrder.swift
index 211c2610..c8532ae8 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/APISortOrderExtensions.swift
+++ b/Shared/Extensions/JellyfinAPI/APISortOrder.swift
@@ -3,15 +3,17 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
-extension APISortOrder {
+typealias APISortOrder = JellyfinAPI.SortOrder
+
+extension APISortOrder: Displayable {
// TODO: Localize
- var localized: String {
+ var displayTitle: String {
switch self {
case .ascending:
return "Ascending"
@@ -19,8 +21,11 @@ extension APISortOrder {
return "Descending"
}
}
+}
+
+extension APISortOrder {
var filter: ItemFilters.Filter {
- .init(displayName: localized, filterName: rawValue)
+ .init(displayTitle: displayTitle, filterName: rawValue)
}
}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Images.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto+Images.swift
similarity index 85%
rename from Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Images.swift
rename to Shared/Extensions/JellyfinAPI/BaseItemDto+Images.swift
index b11814ee..11d144a3 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Images.swift
+++ b/Shared/Extensions/JellyfinAPI/BaseItemDto+Images.swift
@@ -3,9 +3,10 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import Factory
import Foundation
import JellyfinAPI
import UIKit
@@ -52,15 +53,15 @@ extension BaseItemDto {
// MARK: Series Images
func seriesImageURL(_ type: ImageType, maxWidth: Int? = nil, maxHeight: Int? = nil) -> URL {
- _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesId ?? "")
+ _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesID ?? "")
}
func seriesImageURL(_ type: ImageType, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> URL {
- _imageURL(type, maxWidth: Int(maxWidth), maxHeight: Int(maxHeight), itemID: seriesId ?? "")
+ _imageURL(type, maxWidth: Int(maxWidth), maxHeight: Int(maxHeight), itemID: seriesID ?? "")
}
func seriesImageSource(_ type: ImageType, maxWidth: Int? = nil, maxHeight: Int? = nil) -> ImageSource {
- let url = _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesId ?? "")
+ let url = _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesID ?? "")
return ImageSource(url: url, blurHash: nil)
}
@@ -80,16 +81,25 @@ 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!)
let tag = imageTags?[type.rawValue]
- return ImageAPI.getItemImageWithRequestBuilder(
- itemId: itemID,
- imageType: type,
+
+ let client = Container.userSession.callAsFunction().client
+ let parameters = Paths.GetItemImageParameters(
maxWidth: scaleWidth,
maxHeight: scaleHeight,
tag: tag
- ).url
+ )
+
+ let request = Paths.getItemImage(
+ itemID: itemID,
+ imageType: type.rawValue,
+ parameters: parameters
+ )
+
+ return client.fullURL(with: request)
}
fileprivate func _imageSource(_ type: ImageType, maxWidth: Int?, maxHeight: Int?) -> ImageSource {
diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto+Poster.swift
similarity index 86%
rename from Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift
rename to Shared/Extensions/JellyfinAPI/BaseItemDto+Poster.swift
index a67c7678..36466136 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift
+++ b/Shared/Extensions/JellyfinAPI/BaseItemDto+Poster.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
@@ -18,9 +18,9 @@ extension BaseItemDto: Poster {
var title: String {
switch type {
case .episode:
- return seriesName ?? displayName
+ return seriesName ?? displayTitle
default:
- return displayName
+ return displayTitle
}
}
@@ -28,6 +28,8 @@ extension BaseItemDto: Poster {
switch type {
case .episode:
return seasonEpisodeLocator
+ case .video:
+ return extraType?.displayTitle
default:
return nil
}
@@ -63,6 +65,8 @@ extension BaseItemDto: Poster {
imageSource(.primary, maxWidth: maxWidth),
]
}
+ case .video:
+ return [imageSource(.primary, maxWidth: maxWidth)]
default:
return [
imageSource(.thumb, maxWidth: maxWidth),
diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto+VideoPlayerViewModel.swift
new file mode 100644
index 00000000..7ad9bd5e
--- /dev/null
+++ b/Shared/Extensions/JellyfinAPI/BaseItemDto+VideoPlayerViewModel.swift
@@ -0,0 +1,48 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Combine
+import Defaults
+import Factory
+import Foundation
+import JellyfinAPI
+import SwiftUI
+
+extension BaseItemDto {
+
+ func videoPlayerViewModel(with mediaSource: MediaSourceInfo) async throws -> VideoPlayerViewModel {
+
+ let builder = DeviceProfileBuilder()
+ // TODO: fix bitrate settings
+ let tempOverkillBitrate = 360_000_000
+ builder.setMaxBitrate(bitrate: tempOverkillBitrate)
+ let profile = builder.buildProfile()
+
+ let userSession = Container.userSession.callAsFunction()
+
+ let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
+ let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(
+ userID: userSession.user.id,
+ maxStreamingBitrate: tempOverkillBitrate
+ )
+
+ let request = Paths.getPostedPlaybackInfo(
+ itemID: self.id!,
+ parameters: playbackInfoParameters,
+ playbackInfo
+ )
+
+ let response = try await userSession.client.send(request)
+
+ guard let matchingMediaSource = response.value.mediaSources?
+ .first(where: { $0.eTag == mediaSource.eTag && $0.id == mediaSource.id })
+ else { throw JellyfinAPIError("Matching media source not in playback info") }
+
+ return try matchingMediaSource.videoPlayerViewModel(with: self, playSessionID: response.value.playSessionID!)
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto.swift
similarity index 56%
rename from Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift
rename to Shared/Extensions/JellyfinAPI/BaseItemDto.swift
index c5f3390c..dafaf0f7 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift
+++ b/Shared/Extensions/JellyfinAPI/BaseItemDto.swift
@@ -3,20 +3,22 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import Algorithms
+import Factory
import Foundation
import JellyfinAPI
import UIKit
extension BaseItemDto: Displayable {
- var displayName: String {
+
+ var displayTitle: String {
name ?? .emptyDash
}
}
-extension BaseItemDto: Identifiable {}
extension BaseItemDto: LibraryParent {}
extension BaseItemDto {
@@ -26,6 +28,11 @@ extension BaseItemDto {
return L10n.episodeNumber(episodeNo)
}
+ 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))
@@ -33,8 +40,14 @@ extension BaseItemDto {
return nil
}
+ var startTimeSeconds: Int {
+ let playbackPositionTicks = userData?.playbackPositionTicks ?? 0
+ return Int(playbackPositionTicks / 10_000_000)
+ }
+
// MARK: Calculations
+ // TODO: make computed var or function that takes allowed units
func getItemRuntime() -> String? {
let timeHMSFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
@@ -49,7 +62,7 @@ extension BaseItemDto {
return text
}
- var progress: String? {
+ var progressLabel: String? {
guard let playbackPositionTicks = userData?.playbackPositionTicks,
let totalTicks = runTimeTicks,
playbackPositionTicks != 0,
@@ -92,54 +105,6 @@ extension BaseItemDto {
return 0
}
- // MARK: ItemDetail
-
- struct ItemDetail {
- let title: String
- let content: String
- }
-
- func createInformationItems() -> [ItemDetail] {
- var informationItems: [ItemDetail] = []
-
- if let productionYear = productionYear {
- informationItems.append(ItemDetail(title: L10n.released, content: "\(productionYear)"))
- }
-
- if let rating = officialRating {
- informationItems.append(ItemDetail(title: L10n.rated, content: "\(rating)"))
- }
-
- if let runtime = getItemRuntime() {
- informationItems.append(ItemDetail(title: L10n.runtime, content: runtime))
- }
-
- return informationItems
- }
-
- func createMediaItems() -> [ItemDetail] {
- var mediaItems: [ItemDetail] = []
-
- if let mediaStreams = mediaStreams {
- let audioStreams = mediaStreams.filter { $0.type == .audio }
- let subtitleStreams = mediaStreams.filter { $0.type == .subtitle }
-
- if !audioStreams.isEmpty {
- let audioList = audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
- .joined(separator: "\n")
- mediaItems.append(ItemDetail(title: L10n.audio, content: audioList))
- }
-
- if !subtitleStreams.isEmpty {
- let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
- .joined(separator: "\n")
- mediaItems.append(ItemDetail(title: L10n.subtitles, content: subtitleList))
- }
- }
-
- return mediaItems
- }
-
var subtitleStreams: [MediaStream] {
mediaStreams?.filter { $0.type == .subtitle } ?? []
}
@@ -148,13 +113,17 @@ extension BaseItemDto {
mediaStreams?.filter { $0.type == .audio } ?? []
}
+ var videoStreams: [MediaStream] {
+ mediaStreams?.filter { $0.type == .video } ?? []
+ }
+
// MARK: Missing and Unaired
- var missing: Bool {
+ var isMissing: Bool {
locationType == .virtual
}
- var unaired: Bool {
+ var isUnaired: Bool {
if let premierDate = premiereDate {
return premierDate > Date()
} else {
@@ -184,32 +153,86 @@ extension BaseItemDto {
// MARK: Chapter Images
- func getChapterImage(maxWidth: Int) -> [URL] {
- guard let chapters = chapters, !chapters.isEmpty else { return [] }
+ var fullChapterInfo: [ChapterInfo.FullInfo] {
+ guard let chapters else { return [] }
- var chapterImageURLs: [URL] = []
+ let ranges: [Range] = []
+ .appending(chapters.map(\.startTimeSeconds))
+ .appending(runTimeSeconds + 1)
+ .adjacentPairs()
+ .map { $0 ..< $1 }
- for chapterIndex in 0 ..< chapters.count {
- let urlString = ImageAPI.getItemImageWithRequestBuilder(
- itemId: id ?? "",
- imageType: .chapter,
- maxWidth: maxWidth,
- imageIndex: chapterIndex
- ).URLString
- chapterImageURLs.append(URL(string: urlString)!)
+ return chapters
+ .enumerated()
+ .map { index, chapterInfo in
+
+ let client = Container.userSession.callAsFunction().client
+ let parameters = Paths.GetItemImageParameters(
+ maxWidth: 500,
+ quality: 90,
+ imageIndex: index
+ )
+ let request = Paths.getItemImage(
+ itemID: id ?? "",
+ imageType: ImageType.chapter.rawValue,
+ parameters: parameters
+ )
+
+ let imageURL = client.fullURL(with: request)
+
+ let range = ranges.first(where: { $0.first == chapterInfo.startTimeSeconds }) ?? startTimeSeconds ..< startTimeSeconds + 1
+
+ return ChapterInfo.FullInfo(
+ chapterInfo: chapterInfo,
+ imageSource: .init(url: imageURL),
+ secondsRange: range
+ )
+ }
+ }
+
+ // TODO: series-season-episode hierarchy for episodes
+ // TODO: user hierarchy for downloads
+ var downloadFolder: URL? {
+ guard let type, let id else { return nil }
+
+ let root = URL.downloads
+// .appendingPathComponent(userSession.user.id)
+
+ switch type {
+ case .movie, .episode:
+ return root
+ .appendingPathComponent(id)
+// case .episode:
+// guard let seasonID = seasonID,
+// let seriesID = seriesID
+// else {
+// return nil
+// }
+// return root
+// .appendingPathComponent(seriesID)
+// .appendingPathComponent(seasonID)
+// .appendingPathComponent(id)
+ default:
+ return nil
}
-
- return chapterImageURLs
}
// TODO: Don't use spoof objects as a placeholder or no results
static var placeHolder: BaseItemDto {
.init(
- name: "Placeholder",
id: "1",
- overview: String(repeating: "a", count: 100),
- indexNumber: 20
+ name: "Placeholder",
+ overview: String(repeating: "a", count: 100)
+// indexNumber: 20
+ )
+ }
+
+ static func randomItem() -> BaseItemDto {
+ .init(
+ id: UUID().uuidString,
+ name: "Lorem Ipsum",
+ overview: "Lorem ipsum dolor sit amet"
)
}
@@ -217,36 +240,3 @@ extension BaseItemDto {
.init(name: L10n.noResults)
}
}
-
-extension BaseItemDtoImageBlurHashes {
- subscript(imageType: ImageType) -> [String: String]? {
- switch imageType {
- case .primary:
- return primary
- case .art:
- return art
- case .backdrop:
- return backdrop
- case .banner:
- return banner
- case .logo:
- return logo
- case .thumb:
- return thumb
- case .disc:
- return disc
- case .box:
- return box
- case .screenshot:
- return screenshot
- case .menu:
- return menu
- case .chapter:
- return chapter
- case .boxRear:
- return boxRear
- case .profile:
- return profile
- }
- }
-}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson+Poster.swift
similarity index 67%
rename from Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift
rename to Shared/Extensions/JellyfinAPI/BaseItemPerson+Poster.swift
index 2c0d82b2..03a650b7 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift
+++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson+Poster.swift
@@ -3,9 +3,10 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import Factory
import Foundation
import JellyfinAPI
import UIKit
@@ -22,12 +23,19 @@ extension BaseItemPerson: Poster {
func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource {
let scaleWidth = UIScreen.main.scale(maxWidth)
- let url = ImageAPI.getItemImageWithRequestBuilder(
- itemId: id ?? "",
- imageType: .primary,
+ let client = Container.userSession.callAsFunction().client
+ let imageRequestParameters = Paths.GetItemImageParameters(
maxWidth: scaleWidth,
tag: primaryImageTag
- ).url
+ )
+
+ let imageRequest = Paths.getItemImage(
+ itemID: id ?? "",
+ imageType: ImageType.primary.rawValue,
+ parameters: imageRequestParameters
+ )
+
+ let url = client.fullURL(with: imageRequest)
var blurHash: String?
diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson.swift
similarity index 95%
rename from Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift
rename to Shared/Extensions/JellyfinAPI/BaseItemPerson.swift
index a65bea77..91ac1931 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift
+++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -11,7 +11,7 @@ import JellyfinAPI
import UIKit
extension BaseItemPerson: Displayable {
- var displayName: String {
+ var displayTitle: String {
self.name ?? .emptyDash
}
}
diff --git a/Shared/Extensions/JellyfinAPI/ChapterInfo.swift b/Shared/Extensions/JellyfinAPI/ChapterInfo.swift
new file mode 100644
index 00000000..a1e8efba
--- /dev/null
+++ b/Shared/Extensions/JellyfinAPI/ChapterInfo.swift
@@ -0,0 +1,65 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import JellyfinAPI
+
+extension ChapterInfo: Displayable {
+
+ var displayTitle: String {
+ name ?? .emptyDash
+ }
+}
+
+extension ChapterInfo {
+
+ var timestampLabel: String {
+ let seconds = (startPositionTicks ?? 0) / 10_000_000
+ return seconds.timeLabel
+ }
+
+ var startTimeSeconds: Int {
+ let playbackPositionTicks = startPositionTicks ?? 0
+ return Int(playbackPositionTicks / 10_000_000)
+ }
+}
+
+extension ChapterInfo {
+
+ struct FullInfo: Poster, Hashable {
+
+ let chapterInfo: ChapterInfo
+ let imageSource: ImageSource
+ let secondsRange: Range
+
+ var displayTitle: String {
+ chapterInfo.displayTitle
+ }
+
+ var subtitle: String?
+ var showTitle: Bool = true
+
+ init(
+ chapterInfo: ChapterInfo,
+ imageSource: ImageSource,
+ secondsRange: Range
+ ) {
+ self.chapterInfo = chapterInfo
+ self.imageSource = imageSource
+ self.secondsRange = secondsRange
+ }
+
+ func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource {
+ .init()
+ }
+
+ func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] {
+ [imageSource]
+ }
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPI/ImageBlurHashes.swift b/Shared/Extensions/JellyfinAPI/ImageBlurHashes.swift
new file mode 100644
index 00000000..98d3188b
--- /dev/null
+++ b/Shared/Extensions/JellyfinAPI/ImageBlurHashes.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import JellyfinAPI
+
+extension BaseItemDto.ImageBlurHashes {
+
+ subscript(imageType: ImageType) -> [String: String]? {
+ switch imageType {
+ case .primary:
+ return primary
+ case .art:
+ return art
+ case .backdrop:
+ return backdrop
+ case .banner:
+ return banner
+ case .logo:
+ return logo
+ case .thumb:
+ return thumb
+ case .disc:
+ return disc
+ case .box:
+ return box
+ case .screenshot:
+ return screenshot
+ case .menu:
+ return menu
+ case .chapter:
+ return chapter
+ case .boxRear:
+ return boxRear
+ case .profile:
+ return profile
+ }
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPI/ItemFields.swift b/Shared/Extensions/JellyfinAPI/ItemFields.swift
new file mode 100644
index 00000000..1d19fece
--- /dev/null
+++ b/Shared/Extensions/JellyfinAPI/ItemFields.swift
@@ -0,0 +1,21 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import JellyfinAPI
+
+extension ItemFields {
+
+ static let minimumCases: [ItemFields] = [
+ .chapters,
+ .mediaSources,
+ .overview,
+ .parentID,
+ .taglines,
+ ]
+}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/ItemFilterExtensions.swift b/Shared/Extensions/JellyfinAPI/ItemFilter.swift
similarity index 79%
rename from Shared/Extensions/JellyfinAPIExtensions/ItemFilterExtensions.swift
rename to Shared/Extensions/JellyfinAPI/ItemFilter.swift
index a36ec855..7a96254b 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/ItemFilterExtensions.swift
+++ b/Shared/Extensions/JellyfinAPI/ItemFilter.swift
@@ -3,19 +3,15 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
-extension ItemFilter {
- static var supportedCases: [ItemFilter] {
- [.isUnplayed, .isPlayed, .isFavorite, .likes]
- }
-
+extension ItemFilter: Displayable {
// TODO: Localize
- var localized: String {
+ var displayTitle: String {
switch self {
case .isUnplayed:
return "Unplayed"
@@ -29,8 +25,15 @@ extension ItemFilter {
return ""
}
}
+}
+
+extension ItemFilter {
+
+ static var supportedCases: [ItemFilter] {
+ [.isUnplayed, .isPlayed, .isFavorite, .likes]
+ }
var filter: ItemFilters.Filter {
- .init(displayName: localized, filterName: rawValue)
+ .init(displayTitle: displayTitle, filterName: rawValue)
}
}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift b/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift
similarity index 88%
rename from Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift
rename to Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift
index 6142ccbd..d7c1fdbc 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift
+++ b/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Extensions/JellyfinAPI/JellyfinClient.swift b/Shared/Extensions/JellyfinAPI/JellyfinClient.swift
new file mode 100644
index 00000000..779e0e91
--- /dev/null
+++ b/Shared/Extensions/JellyfinAPI/JellyfinClient.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import Get
+import JellyfinAPI
+
+extension JellyfinClient {
+
+ func fullURL(with request: Request) -> URL {
+ let fullPath = configuration.url.appendingPathComponent(request.url)
+
+ var components = URLComponents(string: fullPath.absoluteString)!
+ components.queryItems = request.query?.map { URLQueryItem(name: $0.0, value: $0.1) } ?? []
+
+ return components.url ?? fullPath
+ }
+
+ func fullURL(with path: String) -> URL {
+ URL(string: configuration.url.absoluteString + path)!
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPI/MediaSourceInfo+ItemVideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/MediaSourceInfo+ItemVideoPlayerViewModel.swift
new file mode 100644
index 00000000..1426dfab
--- /dev/null
+++ b/Shared/Extensions/JellyfinAPI/MediaSourceInfo+ItemVideoPlayerViewModel.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Factory
+import Foundation
+import JellyfinAPI
+import UIKit
+
+extension MediaSourceInfo {
+
+ func videoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
+
+ let userSession = Container.userSession.callAsFunction()
+ let playbackURL: URL
+ let streamType: StreamType
+
+ if let transcodingURL, !Defaults[.Experimental.forceDirectPlay] {
+ guard let fullTranscodeURL = URL(string: "".appending(transcodingURL))
+ else { throw JellyfinAPIError("Unable to construct transcoded url") }
+ playbackURL = fullTranscodeURL
+ streamType = .transcode
+ } else {
+
+ let videoStreamParameters = Paths.GetVideoStreamParameters(
+ isStatic: true,
+ tag: item.etag,
+ playSessionID: playSessionID,
+ mediaSourceID: id
+ )
+
+ let videoStreamRequest = Paths.getVideoStream(
+ itemID: item.id!,
+ parameters: videoStreamParameters
+ )
+
+ playbackURL = userSession.client.fullURL(with: videoStreamRequest)
+ streamType = .direct
+ }
+
+ let videoStreams = mediaStreams?.filter { $0.type == .video } ?? []
+ let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? []
+ let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? []
+
+ return .init(
+ playbackURL: playbackURL,
+ item: item,
+ mediaSource: self,
+ playSessionID: playSessionID,
+ videoStreams: videoStreams,
+ audioStreams: audioStreams,
+ subtitleStreams: subtitleStreams,
+ selectedAudioStreamIndex: defaultAudioStreamIndex ?? -1,
+ selectedSubtitleStreamIndex: defaultSubtitleStreamIndex ?? -1,
+ chapters: item.fullChapterInfo,
+ streamType: streamType
+ )
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPI/MediaSourceInfo.swift b/Shared/Extensions/JellyfinAPI/MediaSourceInfo.swift
new file mode 100644
index 00000000..38317b66
--- /dev/null
+++ b/Shared/Extensions/JellyfinAPI/MediaSourceInfo.swift
@@ -0,0 +1,32 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import JellyfinAPI
+
+extension MediaSourceInfo: Displayable {
+
+ var displayTitle: String {
+ name ?? .emptyDash
+ }
+}
+
+extension MediaSourceInfo {
+
+ var audioStreams: [MediaStream]? {
+ mediaStreams?.filter { $0.type == .audio }
+ }
+
+ var subtitleStreams: [MediaStream]? {
+ mediaStreams?.filter { $0.type == .subtitle }
+ }
+
+ var videoStreams: [MediaStream]? {
+ mediaStreams?.filter { $0.type == .video }
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPI/MediaStream.swift b/Shared/Extensions/JellyfinAPI/MediaStream.swift
new file mode 100644
index 00000000..b1a3ec10
--- /dev/null
+++ b/Shared/Extensions/JellyfinAPI/MediaStream.swift
@@ -0,0 +1,275 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Factory
+import Foundation
+import JellyfinAPI
+import VLCUI
+
+extension MediaStream {
+
+ // TODO: Localize
+ static var none: MediaStream = .init(displayTitle: "None", index: -1)
+
+ var asPlaybackChild: VLCVideoPlayer.PlaybackChild? {
+ guard let deliveryURL else { return nil }
+ let client = Container.userSession.callAsFunction().client
+ let deliveryPath = deliveryURL.removingFirst(if: client.configuration.url.absoluteString.last == "/")
+
+ let fullURL = client.fullURL(with: deliveryPath)
+
+ return .init(
+ url: fullURL,
+ type: .subtitle,
+ enforce: false
+ )
+ }
+
+ var is4kVideo: Bool {
+ (width ?? 0) > 3800 && type == .video
+ }
+
+ var is51AudioChannelLayout: Bool {
+ channelLayout == "5.1"
+ }
+
+ var is71AudioChannelLayout: Bool {
+ channelLayout == "7.1"
+ }
+
+ var isHDVideo: Bool {
+ (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))
+ }
+
+ if let value = codec {
+ properties.append(.init(displayTitle: "Codec", subtitle: value))
+ }
+
+ if let value = codecTag {
+ properties.append(.init(displayTitle: "Codec Tag", subtitle: value))
+ }
+
+ if let value = language {
+ properties.append(.init(displayTitle: "Language", subtitle: value))
+ }
+
+ if let value = timeBase {
+ properties.append(.init(displayTitle: "Time Base", subtitle: value))
+ }
+
+ if let value = codecTimeBase {
+ properties.append(.init(displayTitle: "Codec Time Base", subtitle: value))
+ }
+
+ if let value = videoRange {
+ properties.append(.init(displayTitle: "Video Range", subtitle: value))
+ }
+
+ if let value = isInterlaced {
+ properties.append(.init(displayTitle: "Interlaced", subtitle: value.description))
+ }
+
+ if let value = isAVC {
+ properties.append(.init(displayTitle: "AVC", subtitle: value.description))
+ }
+
+ if let value = channelLayout {
+ properties.append(.init(displayTitle: "Channel Layout", subtitle: value))
+ }
+
+ if let value = bitRate {
+ properties.append(.init(displayTitle: "Bitrate", subtitle: value.description))
+ }
+
+ if let value = bitDepth {
+ properties.append(.init(displayTitle: "Bit Depth", subtitle: value.description))
+ }
+
+ if let value = refFrames {
+ properties.append(.init(displayTitle: "Reference Frames", subtitle: value.description))
+ }
+
+ if let value = packetLength {
+ properties.append(.init(displayTitle: "Packet Length", subtitle: value.description))
+ }
+
+ if let value = channels {
+ properties.append(.init(displayTitle: "Channels", subtitle: value.description))
+ }
+
+ if let value = sampleRate {
+ properties.append(.init(displayTitle: "Sample Rate", subtitle: value.description))
+ }
+
+ if let value = isDefault {
+ properties.append(.init(displayTitle: "Default", subtitle: value.description))
+ }
+
+ if let value = isForced {
+ properties.append(.init(displayTitle: "Forced", subtitle: value.description))
+ }
+
+ if let value = averageFrameRate {
+ properties.append(.init(displayTitle: "Average Frame Rate", subtitle: value.description))
+ }
+
+ if let value = realFrameRate {
+ properties.append(.init(displayTitle: "Real Frame Rate", subtitle: value.description))
+ }
+
+ if let value = profile {
+ properties.append(.init(displayTitle: "Profile", subtitle: value))
+ }
+
+ if let value = aspectRatio {
+ properties.append(.init(displayTitle: "Aspect Ratio", subtitle: value))
+ }
+
+ if let value = index {
+ properties.append(.init(displayTitle: "Index", subtitle: value.description))
+ }
+
+ if let value = score {
+ properties.append(.init(displayTitle: "Score", subtitle: value.description))
+ }
+
+ if let value = pixelFormat {
+ properties.append(.init(displayTitle: "Pixel Format", subtitle: value))
+ }
+
+ if let value = level {
+ properties.append(.init(displayTitle: "Level", subtitle: value.description))
+ }
+
+ if let value = isAnamorphic {
+ properties.append(.init(displayTitle: "Anamorphic", subtitle: value.description))
+ }
+
+ return properties
+ }
+
+ var colorProperties: [TextPair] {
+ var properties: [TextPair] = []
+
+ if let value = colorRange {
+ properties.append(.init(displayTitle: "Range", subtitle: value))
+ }
+
+ if let value = colorSpace {
+ properties.append(.init(displayTitle: "Space", subtitle: value))
+ }
+
+ if let value = colorTransfer {
+ properties.append(.init(displayTitle: "Transfer", subtitle: value))
+ }
+
+ if let value = colorPrimaries {
+ properties.append(.init(displayTitle: "Primaries", subtitle: value))
+ }
+
+ return properties
+ }
+
+ var deliveryProperties: [TextPair] {
+ var properties: [TextPair] = []
+
+ if let value = isExternal {
+ properties.append(.init(displayTitle: "External", subtitle: value.description))
+ }
+
+ if let value = deliveryMethod {
+ properties.append(.init(displayTitle: "Delivery Method", subtitle: value.rawValue))
+ }
+
+ if let value = deliveryURL {
+ properties.append(.init(displayTitle: "URL", subtitle: value))
+ }
+
+ if let value = deliveryURL {
+ properties.append(.init(displayTitle: "External URL", subtitle: value.description))
+ }
+
+ if let value = isTextSubtitleStream {
+ properties.append(.init(displayTitle: "Text Subtitle", subtitle: value.description))
+ }
+
+ if let value = path {
+ properties.append(.init(displayTitle: "Path", subtitle: value))
+ }
+
+ return properties
+ }
+}
+
+extension [MediaStream] {
+
+ func adjustExternalSubtitleIndexes(audioStreamCount: Int) -> [MediaStream] {
+ guard allSatisfy({ $0.type == .subtitle }) else { return self }
+ let embeddedSubtitleCount = filter { !($0.isExternal ?? false) }.count
+
+ var mediaStreams = self
+
+ for (i, mediaStream) in mediaStreams.enumerated() {
+ guard mediaStream.isExternal ?? false else { continue }
+ var _mediaStream = mediaStream
+ _mediaStream.index = 1 + embeddedSubtitleCount + audioStreamCount
+
+ mediaStreams[i] = _mediaStream
+ }
+
+ return mediaStreams
+ }
+
+ func adjustAudioForExternalSubtitles(externalMediaStreamCount: Int) -> [MediaStream] {
+ guard allSatisfy({ $0.type == .audio }) else { return self }
+
+ var mediaStreams = self
+
+ for (i, mediaStream) in mediaStreams.enumerated() {
+ var copy = mediaStream
+ copy.index = (copy.index ?? 0) - externalMediaStreamCount
+ mediaStreams[i] = copy
+ }
+
+ return mediaStreams
+ }
+
+ var has4KVideo: Bool {
+ first(where: { $0.is4kVideo }) != nil
+ }
+
+ var has51AudioChannelLayout: Bool {
+ first(where: { $0.is51AudioChannelLayout }) != nil
+ }
+
+ var has71AudioChannelLayout: Bool {
+ first(where: { $0.is71AudioChannelLayout }) != nil
+ }
+
+ var hasHDVideo: Bool {
+ first(where: { $0.isHDVideo }) != nil
+ }
+
+ var hasSubtitles: Bool {
+ first(where: { $0.type == .subtitle }) != nil
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift b/Shared/Extensions/JellyfinAPI/NameGuidPair.swift
similarity index 67%
rename from Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift
rename to Shared/Extensions/JellyfinAPI/NameGuidPair.swift
index 0bcd865f..bff13d1e 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift
+++ b/Shared/Extensions/JellyfinAPI/NameGuidPair.swift
@@ -3,22 +3,24 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
-extension NameGuidPair {
- var filter: ItemFilters.Filter {
- .init(displayName: displayName, id: id, filterName: displayName)
- }
-}
-
extension NameGuidPair: Displayable {
- var displayName: String {
- self.name ?? .emptyDash
+
+ var displayTitle: String {
+ name ?? .emptyDash
}
}
extension NameGuidPair: LibraryParent {}
+
+extension NameGuidPair {
+
+ var filter: ItemFilters.Filter {
+ .init(displayTitle: displayTitle, id: id, filterName: displayTitle)
+ }
+}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/UserDtoExtensions.swift b/Shared/Extensions/JellyfinAPI/UserDto.swift
similarity index 50%
rename from Shared/Extensions/JellyfinAPIExtensions/UserDtoExtensions.swift
rename to Shared/Extensions/JellyfinAPI/UserDto.swift
index f8bb1395..860a662d 100644
--- a/Shared/Extensions/JellyfinAPIExtensions/UserDtoExtensions.swift
+++ b/Shared/Extensions/JellyfinAPI/UserDto.swift
@@ -3,23 +3,28 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import Factory
import Foundation
+import Get
import JellyfinAPI
import UIKit
extension UserDto {
- func profileImageSource(maxWidth: CGFloat, maxHeight: CGFloat) -> ImageSource {
+
+ func profileImageSource(client: JellyfinClient, maxWidth: CGFloat, maxHeight: CGFloat) -> ImageSource {
let scaleWidth = UIScreen.main.scale(maxWidth)
let scaleHeight = UIScreen.main.scale(maxHeight)
- let profileImageURL = ImageAPI.getUserImageWithRequestBuilder(
- userId: id ?? "",
- imageType: .primary,
- maxWidth: scaleWidth,
- maxHeight: scaleHeight
- ).url
+
+ let request = Paths.getUserImage(
+ userID: id ?? "",
+ imageType: "Primary",
+ parameters: .init(maxWidth: scaleWidth, maxHeight: scaleHeight)
+ )
+
+ let profileImageURL = client.fullURL(with: request)
return ImageSource(url: profileImageURL, blurHash: nil)
}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift
deleted file mode 100644
index 53d35ebb..00000000
--- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift
+++ /dev/null
@@ -1,351 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Combine
-import Defaults
-import JellyfinAPI
-import UIKit
-
-extension BaseItemDto {
- func createVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> {
-
- LogManager.service().debug("Creating video player view model for item: \(id ?? "")")
-
- let builder = DeviceProfileBuilder()
- // TODO: fix bitrate settings
- let tempOverkillBitrate = 360_000_000
- builder.setMaxBitrate(bitrate: tempOverkillBitrate)
- let profile = builder.buildProfile()
- let segmentContainer = Defaults[.Experimental.usefmp4Hls] ? "mp4" : "ts"
-
- let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(
- userId: SessionManager.main.currentLogin.user.id,
- maxStreamingBitrate: tempOverkillBitrate,
- startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
- deviceProfile: profile,
- autoOpenLiveStream: true
- )
-
- return MediaInfoAPI.getPostedPlaybackInfo(
- itemId: self.id!,
- userId: SessionManager.main.currentLogin.user.id,
- maxStreamingBitrate: tempOverkillBitrate,
- startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
- autoOpenLiveStream: true,
- getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest
- )
- .map { response -> [VideoPlayerViewModel] in
- let mediaSources = response.mediaSources!
-
- var viewModels: [VideoPlayerViewModel] = []
-
- for currentMediaSource in mediaSources {
- let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first
- let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? []
- let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? []
-
- let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! })
-
- let defaultSubtitleStream = subtitleStreams
- .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 })
-
- // MARK: Build Streams
-
- let directStreamURL: URL
- let transcodedStreamURL: URLComponents?
- var hlsStreamURL: URL
- let mediaSourceID: String
- let streamType: ServerStreamType
-
- if mediaSources.count > 1 {
- mediaSourceID = currentMediaSource.id!
- } else {
- mediaSourceID = self.id!
- }
-
- let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(
- itemId: self.id!,
- _static: true,
- tag: self.etag,
- playSessionId: response.playSessionId,
- minSegments: 6,
- mediaSourceId: mediaSourceID
- )
- directStreamURL = URL(string: directStreamBuilder.URLString)!
-
- if let transcodeURL = currentMediaSource.transcodingUrl {
- streamType = .transcode
- transcodedStreamURL = URLComponents(
- string: SessionManager.main.currentLogin.server.currentURI
- .appending(transcodeURL)
- )!
- } else {
- streamType = .direct
- transcodedStreamURL = nil
- }
-
- let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(
- itemId: id ?? "",
- mediaSourceId: id ?? "",
- _static: true,
- tag: currentMediaSource.eTag,
- deviceProfileId: nil,
- playSessionId: response.playSessionId,
- segmentContainer: segmentContainer,
- segmentLength: nil,
- minSegments: 2,
- deviceId: UIDevice.vendorUUIDString,
- audioCodec: audioStreams
- .compactMap(\.codec)
- .joined(separator: ","),
- breakOnNonKeyFrames: true,
- requireAvc: true,
- transcodingMaxAudioChannels: 6,
- videoCodec: videoStream?.codec,
- videoStreamIndex: videoStream?.index,
- enableAdaptiveBitrateStreaming: true
- )
-
- var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)!
- hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken)
-
- hlsStreamURL = hlsStreamComponents.url!
-
- // MARK: VidoPlayerViewModel Creation
-
- var subtitle: String?
-
- // MARK: Attach media content to self
-
- var modifiedSelfItem = self
- modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
-
- // TODO: other forms of media subtitle
- if self.type == .episode {
- if let seriesName = self.seriesName, let episodeLocator = self.episodeLocator {
- subtitle = "\(seriesName) - \(episodeLocator)"
- }
- }
-
- let subtitlesEnabled = defaultSubtitleStream != nil
-
- let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && type == .episode
- let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
-
- let overlayType = Defaults[.overlayType]
-
- let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && type == .episode
- let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && type == .episode
-
- var fileName: String?
- if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
- fileName = String(lastInPath)
- }
-
- let videoPlayerViewModel = VideoPlayerViewModel(
- item: modifiedSelfItem,
- title: modifiedSelfItem.name ?? "",
- subtitle: subtitle,
- directStreamURL: directStreamURL,
- transcodedStreamURL: transcodedStreamURL?.url,
- hlsStreamURL: hlsStreamURL,
- streamType: streamType,
- response: response,
- videoStream: videoStream!,
- audioStreams: audioStreams,
- subtitleStreams: subtitleStreams,
- chapters: modifiedSelfItem.chapters ?? [],
- selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
- selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
- subtitlesEnabled: subtitlesEnabled,
- autoplayEnabled: autoplayEnabled,
- overlayType: overlayType,
- shouldShowPlayPreviousItem: shouldShowPlayPreviousItem,
- shouldShowPlayNextItem: shouldShowPlayNextItem,
- shouldShowAutoPlay: shouldShowAutoPlay,
- container: currentMediaSource.container ?? "",
- filename: fileName,
- versionName: currentMediaSource.name
- )
-
- viewModels.append(videoPlayerViewModel)
- }
-
- return viewModels
- }
- .eraseToAnyPublisher()
- }
-
- func createLiveTVVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> {
-
- LogManager.service().debug("Creating liveTV video player view model for item: \(id ?? "")")
-
- let builder = DeviceProfileBuilder()
- // TODO: fix bitrate settings
- let tempOverkillBitrate = 360_000_000
- builder.setMaxBitrate(bitrate: tempOverkillBitrate)
- let profile = builder.buildProfile()
-
- let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(
- userId: SessionManager.main.currentLogin.user.id,
- maxStreamingBitrate: tempOverkillBitrate,
- startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
- deviceProfile: profile,
- autoOpenLiveStream: true
- )
-
- return MediaInfoAPI.getPostedPlaybackInfo(
- itemId: self.id!,
- userId: SessionManager.main.currentLogin.user.id,
- maxStreamingBitrate: tempOverkillBitrate,
- startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
- autoOpenLiveStream: true,
- getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest
- )
- .map { response -> [VideoPlayerViewModel] in
- let mediaSources = response.mediaSources!
-
- var viewModels: [VideoPlayerViewModel] = []
-
- for currentMediaSource in mediaSources {
- let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first
- let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? []
- let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? []
-
- let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! })
-
- let defaultSubtitleStream = subtitleStreams
- .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 })
-
- // MARK: Build Streams
-
- let directStreamURL: URL
- let transcodedStreamURL: URLComponents?
- var hlsStreamURL: URL
- let mediaSourceID: String
- let streamType: ServerStreamType
-
- if mediaSources.count > 1 {
- mediaSourceID = currentMediaSource.id!
- } else {
- mediaSourceID = self.id!
- }
-
- let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(
- itemId: self.id!,
- _static: true,
- tag: self.etag,
- playSessionId: response.playSessionId,
- minSegments: 6,
- mediaSourceId: mediaSourceID
- )
- directStreamURL = URL(string: directStreamBuilder.URLString)!
-
- if let transcodeURL = currentMediaSource.transcodingUrl, !Defaults[.Experimental.liveTVForceDirectPlay] {
- streamType = .transcode
- transcodedStreamURL = URLComponents(
- string: SessionManager.main.currentLogin.server.currentURI
- .appending(transcodeURL)
- )!
- } else {
- streamType = .direct
- transcodedStreamURL = nil
- }
-
- let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(
- itemId: id ?? "",
- mediaSourceId: id ?? "",
- _static: true,
- tag: currentMediaSource.eTag,
- deviceProfileId: nil,
- playSessionId: response.playSessionId,
- segmentContainer: "ts",
- segmentLength: nil,
- minSegments: 2,
- deviceId: UIDevice.vendorUUIDString,
- audioCodec: audioStreams
- .compactMap(\.codec)
- .joined(separator: ","),
- breakOnNonKeyFrames: true,
- requireAvc: true,
- transcodingMaxAudioChannels: 6,
- videoCodec: videoStream?.codec,
- videoStreamIndex: videoStream?.index,
- enableAdaptiveBitrateStreaming: true
- )
-
- var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)!
- hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken)
-
- hlsStreamURL = hlsStreamComponents.url!
-
- // MARK: VidoPlayerViewModel Creation
-
- var subtitle: String?
-
- // MARK: Attach media content to self
-
- var modifiedSelfItem = self
- modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
-
- // TODO: other forms of media subtitle
- if self.type == .episode {
- if let seriesName = self.seriesName, let episodeLocator = self.episodeLocator {
- subtitle = "\(seriesName) - \(episodeLocator)"
- }
- }
-
- let subtitlesEnabled = defaultSubtitleStream != nil
-
- let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && type == .episode
- let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
-
- let overlayType = Defaults[.overlayType]
-
- let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && type == .episode
- let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && type == .episode
-
- var fileName: String?
- if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
- fileName = String(lastInPath)
- }
-
- let videoPlayerViewModel = VideoPlayerViewModel(
- item: modifiedSelfItem,
- title: modifiedSelfItem.name ?? "",
- subtitle: subtitle,
- directStreamURL: directStreamURL,
- transcodedStreamURL: transcodedStreamURL?.url,
- hlsStreamURL: hlsStreamURL,
- streamType: streamType,
- response: response,
- videoStream: videoStream!,
- audioStreams: audioStreams,
- subtitleStreams: subtitleStreams,
- chapters: modifiedSelfItem.chapters ?? [],
- selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
- selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
- subtitlesEnabled: subtitlesEnabled,
- autoplayEnabled: autoplayEnabled,
- overlayType: overlayType,
- shouldShowPlayPreviousItem: shouldShowPlayPreviousItem,
- shouldShowPlayNextItem: shouldShowPlayNextItem,
- shouldShowAutoPlay: shouldShowAutoPlay,
- container: currentMediaSource.container ?? "",
- filename: fileName,
- versionName: currentMediaSource.name
- )
-
- viewModels.append(videoPlayerViewModel)
- }
-
- return viewModels
- }
- .eraseToAnyPublisher()
- }
-}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift
deleted file mode 100644
index 2e1625e0..00000000
--- a/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift
+++ /dev/null
@@ -1,44 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-import JellyfinAPI
-
-extension ChapterInfo {
-
- var timestampLabel: String {
- let seconds = (startPositionTicks ?? 0) / 10_000_000
- return seconds.toReadableString()
- }
-}
-
-extension Int64 {
-
- func toReadableString() -> String {
-
- let s = Int(self) % 60
- let mn = (Int(self) / 60) % 60
- let hr = (Int(self) / 3600)
-
- var final = ""
-
- if hr != 0 {
- final += "\(hr):"
- }
-
- if mn != 0 {
- final += String(format: "%0.2d:", mn)
- } else {
- final += "00:"
- }
-
- final += String(format: "%0.2d", s)
-
- return final
- }
-}
diff --git a/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift b/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift
deleted file mode 100644
index c1a0e6a2..00000000
--- a/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift
+++ /dev/null
@@ -1,21 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-import JellyfinAPI
-
-extension MediaStream {
- func externalURL(base: String) -> URL? {
- var base = base
- while base.last == Character("/") {
- base.removeLast()
- }
- guard let deliveryURL = deliveryUrl else { return nil }
- return URL(string: base + deliveryURL)
- }
-}
diff --git a/Shared/Extensions/NavigationCoordinatable.swift b/Shared/Extensions/NavigationCoordinatable.swift
new file mode 100644
index 00000000..d5ebb83f
--- /dev/null
+++ b/Shared/Extensions/NavigationCoordinatable.swift
@@ -0,0 +1,16 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Stinsen
+
+extension NavigationCoordinatable {
+
+ func inNavigationViewCoordinator() -> NavigationViewCoordinator {
+ NavigationViewCoordinator(self)
+ }
+}
diff --git a/Shared/Extensions/PersistentLogHandler.swift b/Shared/Extensions/PersistentLogHandler.swift
new file mode 100644
index 00000000..181324ea
--- /dev/null
+++ b/Shared/Extensions/PersistentLogHandler.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import Logging
+import PulseLogHandler
+
+extension PersistentLogHandler {
+
+ func withLogLevel(_ level: Logger.Level) -> Self {
+ var copy = self
+ copy.logLevel = level
+ return copy
+ }
+}
diff --git a/Shared/Extensions/Set.swift b/Shared/Extensions/Set.swift
new file mode 100644
index 00000000..696b6d91
--- /dev/null
+++ b/Shared/Extensions/Set.swift
@@ -0,0 +1,16 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+extension Set {
+
+ func sorted(using keyPath: KeyPath) -> [Element] {
+ sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
+ }
+}
diff --git a/Shared/Extensions/StringExtensions.swift b/Shared/Extensions/String.swift
similarity index 62%
rename from Shared/Extensions/StringExtensions.swift
rename to Shared/Extensions/String.swift
index 745356a3..d5b9a539 100644
--- a/Shared/Extensions/StringExtensions.swift
+++ b/Shared/Extensions/String.swift
@@ -3,13 +3,58 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
import SwiftUI
+extension String: Displayable {
+
+ var displayTitle: String {
+ self
+ }
+}
+
+extension String: Identifiable {
+
+ public var id: String {
+ self
+ }
+}
+
extension String {
+
+ func appending(_ element: String) -> String {
+ self + element
+ }
+
+ func appending(_ element: String.Element) -> String {
+ self + String(element)
+ }
+
+ func prepending(_ element: String) -> String {
+ element + self
+ }
+
+ func removingFirst(if condition: Bool) -> String {
+ if condition {
+ var copy = self
+ copy.removeFirst()
+ return copy
+ } else {
+ return self
+ }
+ }
+
+ func prepending(_ element: String, if condition: Bool) -> String {
+ if condition {
+ return element + self
+ } else {
+ return self
+ }
+ }
+
func removeRegexMatches(pattern: String, replaceWith: String = "") -> String {
do {
let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive)
@@ -56,12 +101,18 @@ extension String {
}
var filter: ItemFilters.Filter {
- .init(displayName: self, id: self, filterName: self)
+ .init(displayTitle: self, id: self, filterName: self)
}
static var emptyDash = "--"
+
+ var shortFileName: String {
+ (split(separator: "/").last?.description ?? self)
+ .replacingOccurrences(of: ".swift", with: "")
+ }
}
-public extension CharacterSet {
+extension CharacterSet {
+
static var objectReplacement: CharacterSet = .init(charactersIn: "\u{fffc}")
}
diff --git a/Shared/Extensions/UIApplication.swift b/Shared/Extensions/UIApplication.swift
new file mode 100644
index 00000000..74d5b3cc
--- /dev/null
+++ b/Shared/Extensions/UIApplication.swift
@@ -0,0 +1,49 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import UIKit
+
+extension UIApplication {
+
+ static var appVersion: String? {
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ }
+
+ static var bundleVersion: String? {
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
+ }
+
+ var keyWindow: UIWindow? {
+ connectedScenes
+ .compactMap {
+ $0 as? UIWindowScene
+ }
+ .flatMap(\.windows)
+ .first {
+ $0.isKeyWindow
+ }
+ }
+
+ func setAccentColor(_ newColor: UIColor) {
+ keyWindow?.tintColor = newColor
+ }
+
+ 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/UIApplicationExtensions.swift b/Shared/Extensions/UIApplicationExtensions.swift
deleted file mode 100644
index 94f90c73..00000000
--- a/Shared/Extensions/UIApplicationExtensions.swift
+++ /dev/null
@@ -1,19 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import UIKit
-
-extension UIApplication {
- static var appVersion: String? {
- Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
- }
-
- static var bundleVersion: String? {
- Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
- }
-}
diff --git a/Shared/Extensions/UIColor.swift b/Shared/Extensions/UIColor.swift
new file mode 100644
index 00000000..55ed3c3a
--- /dev/null
+++ b/Shared/Extensions/UIColor.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import UIKit
+
+extension UIColor {
+
+ static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1)
+
+ var overlayColor: UIColor {
+ var red: CGFloat = 0
+ var green: CGFloat = 0
+ var blue: CGFloat = 0
+ var alpha: CGFloat = 0
+
+ getRed(&red, green: &green, blue: &blue, alpha: &alpha)
+
+ let brightness = ((red * 299) + (green * 587) + (blue * 114)) / 1000
+
+ return brightness < 0.5 ? .white : .black
+ }
+}
diff --git a/Shared/Extensions/UIDevice.swift b/Shared/Extensions/UIDevice.swift
new file mode 100644
index 00000000..01694040
--- /dev/null
+++ b/Shared/Extensions/UIDevice.swift
@@ -0,0 +1,59 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import UIKit
+
+extension UIDevice {
+
+ static var vendorUUIDString: String {
+ current.identifierForVendor!.uuidString
+ }
+
+ static var isIPad: Bool {
+ current.userInterfaceIdiom == .pad
+ }
+
+ static var isPhone: Bool {
+ current.userInterfaceIdiom == .phone
+ }
+
+ static var hasNotch: Bool {
+ (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0) > 0 &&
+ isPhone
+ }
+
+ static var platform: String {
+ #if os(tvOS)
+ "tvOS"
+ #else
+ if UIDevice.isIPad {
+ return "iPadOS"
+ } else {
+ return "iOS"
+ }
+ #endif
+ }
+
+ #if os(iOS)
+ static var isPortrait: Bool {
+ current.orientation.isPortrait
+ }
+
+ static var isLandscape: Bool {
+ isIPad || current.orientation.isLandscape
+ }
+
+ static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) {
+ UINotificationFeedbackGenerator().notificationOccurred(type)
+ }
+
+ static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) {
+ UIImpactFeedbackGenerator(style: type).impactOccurred()
+ }
+ #endif
+}
diff --git a/Shared/Extensions/UIDeviceExtensions.swift b/Shared/Extensions/UIDeviceExtensions.swift
deleted file mode 100644
index e78aafcd..00000000
--- a/Shared/Extensions/UIDeviceExtensions.swift
+++ /dev/null
@@ -1,43 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import UIKit
-
-extension UIDevice {
- static var vendorUUIDString: String {
- current.identifierForVendor!.uuidString
- }
-
- static var isIPad: Bool {
- UIDevice.current.userInterfaceIdiom == .pad
- }
-
- static var isPhone: Bool {
- UIDevice.current.userInterfaceIdiom == .phone
- }
-
- #if os(iOS)
- static var isPortrait: Bool {
- UIDevice.current.orientation.isPortrait
- }
-
- static var isLandscape: Bool {
- isIPad || UIDevice.current.orientation.isLandscape
- }
-
- static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) {
- let generator = UINotificationFeedbackGenerator()
- generator.notificationOccurred(type)
- }
-
- static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) {
- let generator = UIImpactFeedbackGenerator(style: type)
- generator.impactOccurred()
- }
- #endif
-}
diff --git a/Shared/Extensions/UIGestureRecognizer.swift b/Shared/Extensions/UIGestureRecognizer.swift
new file mode 100644
index 00000000..76983cdc
--- /dev/null
+++ b/Shared/Extensions/UIGestureRecognizer.swift
@@ -0,0 +1,17 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+extension UIGestureRecognizer {
+
+ func unitPoint(in view: UIView) -> UnitPoint {
+ let location = location(in: view)
+ return .init(x: location.x / view.frame.width, y: location.y / view.frame.height)
+ }
+}
diff --git a/Shared/Extensions/UIScreenExtensions.swift b/Shared/Extensions/UIScreen.swift
similarity index 91%
rename from Shared/Extensions/UIScreenExtensions.swift
rename to Shared/Extensions/UIScreen.swift
index 7c9218f0..edde689d 100644
--- a/Shared/Extensions/UIScreenExtensions.swift
+++ b/Shared/Extensions/UIScreen.swift
@@ -3,12 +3,13 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import UIKit
extension UIScreen {
+
func scale(_ x: Int) -> Int {
Int(nativeScale) * x
}
diff --git a/Shared/Extensions/UIScrollViewExtensions.swift b/Shared/Extensions/UIScrollView.swift
similarity index 87%
rename from Shared/Extensions/UIScrollViewExtensions.swift
rename to Shared/Extensions/UIScrollView.swift
index 59651d6f..e0baf561 100644
--- a/Shared/Extensions/UIScrollViewExtensions.swift
+++ b/Shared/Extensions/UIScrollView.swift
@@ -3,12 +3,13 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 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/URL.swift b/Shared/Extensions/URL.swift
new file mode 100644
index 00000000..1239347d
--- /dev/null
+++ b/Shared/Extensions/URL.swift
@@ -0,0 +1,70 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+extension URL: Identifiable {
+
+ public var id: String {
+ absoluteString
+ }
+}
+
+extension URL {
+
+ static var documents: URL {
+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ }
+
+ static var downloads: URL {
+ documents.appendingPathComponent("Downloads")
+ }
+
+ static var tmp: URL {
+ URL(string: NSTemporaryDirectory())!
+ }
+
+ static let swiftfinGithub: URL = URL(string: "https://github.com/jellyfin/Swiftfin")!
+
+ static let swiftfinGithubIssues: URL = URL(string: "https://github.com/jellyfin/Swiftfin/issues")!
+
+ func isDirectoryAndReachable() throws -> Bool {
+ guard try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true else {
+ return false
+ }
+ return try checkResourceIsReachable()
+ }
+
+ func directoryTotalAllocatedSize(includingSubfolders: Bool = false) throws -> Int? {
+ guard try isDirectoryAndReachable() else { return nil }
+
+ if includingSubfolders {
+ guard let urls = FileManager.default.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL]
+ else { return nil }
+ return try urls.lazy.reduce(0) {
+ try ($1.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0) + $0
+ }
+ }
+
+ return try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil).lazy.reduce(0) {
+ try (
+ $1.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
+ .totalFileAllocatedSize ?? 0
+ ) + $0
+ }
+ }
+
+ var sizeOnDisk: Int {
+ do {
+ guard let size = try directoryTotalAllocatedSize(includingSubfolders: true) else { return -1 }
+ return size
+ } catch {
+ return -1
+ }
+ }
+}
diff --git a/Shared/Extensions/URLComponents.swift b/Shared/Extensions/URLComponents.swift
new file mode 100644
index 00000000..1eb0e7a5
--- /dev/null
+++ b/Shared/Extensions/URLComponents.swift
@@ -0,0 +1,23 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+extension URLComponents {
+
+ func addingQueryItem(key: String, value: String?) -> Self {
+ var copy = self
+
+ if copy.queryItems == nil {
+ copy.queryItems = []
+ }
+
+ copy.queryItems?.append(.init(name: key, value: value))
+ return copy
+ }
+}
diff --git a/Shared/Extensions/URLComponentsExtensions.swift b/Shared/Extensions/URLComponentsExtensions.swift
deleted file mode 100644
index 2345c94a..00000000
--- a/Shared/Extensions/URLComponentsExtensions.swift
+++ /dev/null
@@ -1,21 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-
-extension URLComponents {
-
- mutating func addQueryItem(name: String, value: String?) {
- if let _ = self.queryItems {
- self.queryItems?.append(URLQueryItem(name: name, value: value))
- } else {
- self.queryItems = []
- self.queryItems?.append(URLQueryItem(name: name, value: value))
- }
- }
-}
diff --git a/Shared/Extensions/URLExtensions.swift b/Shared/Extensions/URLExtensions.swift
deleted file mode 100644
index 5462e041..00000000
--- a/Shared/Extensions/URLExtensions.swift
+++ /dev/null
@@ -1,29 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-
-public extension URL {
- /// Dictionary of the URL's query parameters
- var queryParameters: [String: String]? {
- guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false),
- let queryItems = components.queryItems else { return nil }
-
- var items: [String: String] = [:]
-
- for queryItem in queryItems {
- items[queryItem.name] = queryItem.value
- }
-
- return items
- }
-
- static var documents: URL {
- FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
- }
-}
diff --git a/Shared/Extensions/URLResponse.swift b/Shared/Extensions/URLResponse.swift
new file mode 100644
index 00000000..4e2a9a89
--- /dev/null
+++ b/Shared/Extensions/URLResponse.swift
@@ -0,0 +1,17 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+extension URLResponse {
+
+ var mimeSubtype: String? {
+ guard let subtype = mimeType?.split(separator: "/")[safe: 1] else { return nil }
+ return String(subtype)
+ }
+}
diff --git a/Shared/Extensions/VLCPlayer+subtitles.swift b/Shared/Extensions/VLCPlayer+subtitles.swift
deleted file mode 100644
index 6485386a..00000000
--- a/Shared/Extensions/VLCPlayer+subtitles.swift
+++ /dev/null
@@ -1,36 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import UIKit
-#if os(tvOS)
-import TVVLCKit
-#else
-import MobileVLCKit
-#endif
-
-extension VLCMediaPlayer {
- /// Applies font size to the player
- ///
- /// This is pretty hacky until VLCKit 4 has a public API to support this
- func setSubtitleSize(_ size: SubtitleSize) {
- perform(
- Selector(("setTextRendererFontSize:")),
- with: size.textRendererFontSize
- )
- }
-
- /// Applies font to the player
- ///
- /// This is pretty hacky until VLCKit 4 has a public API to support this
- func setSubtitleFont(fontName: String) {
- perform(
- Selector(("setTextRendererFont:")),
- with: fontName
- )
- }
-}
diff --git a/Shared/Extensions/VerticalAlignment.swift b/Shared/Extensions/VerticalAlignment.swift
new file mode 100644
index 00000000..96b40d41
--- /dev/null
+++ b/Shared/Extensions/VerticalAlignment.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+extension VerticalAlignment {
+
+ private struct SliderCenterAlignment: AlignmentID {
+ static func defaultValue(in context: ViewDimensions) -> CGFloat {
+ context[VerticalAlignment.center]
+ }
+ }
+
+ static let sliderCenterAlignmentGuide = VerticalAlignment(
+ SliderCenterAlignment.self
+ )
+}
diff --git a/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift
new file mode 100644
index 00000000..44d69b4b
--- /dev/null
+++ b/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift
@@ -0,0 +1,46 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+struct AttributeViewModifier: ViewModifier {
+
+ enum Style {
+ case fill
+ case outline
+ }
+
+ let style: Style
+
+ func body(content: Content) -> some View {
+ if style == .fill {
+ content
+ .font(.caption.weight(.semibold))
+ .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
+ .hidden()
+ .background {
+ Color(UIColor.lightGray)
+ .cornerRadius(2)
+ .inverseMask(
+ content
+ .font(.caption.weight(.semibold))
+ .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
+ )
+ }
+ } else {
+ content
+ .font(.caption.weight(.semibold))
+ .foregroundColor(Color(UIColor.lightGray))
+ .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
+ .overlay(
+ RoundedRectangle(cornerRadius: 2)
+ .stroke(Color(UIColor.lightGray), lineWidth: 1)
+ )
+ }
+ }
+}
diff --git a/Shared/Extensions/ViewExtensions/BackgroundParallaxHeaderModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift
similarity index 83%
rename from Shared/Extensions/ViewExtensions/BackgroundParallaxHeaderModifier.swift
rename to Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift
index e2d1afce..ee8edd2a 100644
--- a/Shared/Extensions/ViewExtensions/BackgroundParallaxHeaderModifier.swift
+++ b/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import SwiftUI
@@ -34,6 +34,10 @@ struct BackgroundParallaxHeaderModifier: ViewModifier {
header()
.offset(y: scrollViewOffset > 0 ? -scrollViewOffset * multiplier : 0)
.scaleEffect(scrollViewOffset < 0 ? (height - scrollViewOffset) / height : 1, anchor: .top)
+ .mask(alignment: .top) {
+ Color.black
+ .frame(height: max(0, height - scrollViewOffset))
+ }
.ignoresSafeArea()
}
}
diff --git a/Shared/Extensions/ViewExtensions/Modifiers/BlurViewModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/BlurViewModifier.swift
new file mode 100644
index 00000000..62baca00
--- /dev/null
+++ b/Shared/Extensions/ViewExtensions/Modifiers/BlurViewModifier.swift
@@ -0,0 +1,21 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+struct BlurViewModifier: ViewModifier {
+
+ let style: UIBlurEffect.Style
+
+ func body(content: Content) -> some View {
+ content
+ .overlay {
+ BlurView(style: style)
+ }
+ }
+}
diff --git a/Shared/Extensions/ViewExtensions/BottomEdgeGradientModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/BottomEdgeGradientModifier.swift
similarity index 94%
rename from Shared/Extensions/ViewExtensions/BottomEdgeGradientModifier.swift
rename to Shared/Extensions/ViewExtensions/Modifiers/BottomEdgeGradientModifier.swift
index 0f6934da..5acb4a94 100644
--- a/Shared/Extensions/ViewExtensions/BottomEdgeGradientModifier.swift
+++ b/Shared/Extensions/ViewExtensions/Modifiers/BottomEdgeGradientModifier.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import SwiftUI
diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift
new file mode 100644
index 00000000..d0da752d
--- /dev/null
+++ b/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+struct OnReceiveNotificationModifier: ViewModifier {
+
+ let notification: NSNotification.Name
+ let onReceive: () -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .onReceive(NotificationCenter.default.publisher(for: notification)) { _ in
+ onReceive()
+ }
+ }
+}
diff --git a/Shared/Extensions/ViewExtensions/ScrollViewOffsetModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift
similarity index 87%
rename from Shared/Extensions/ViewExtensions/ScrollViewOffsetModifier.swift
rename to Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift
index 6ae59916..24d2dc7b 100644
--- a/Shared/Extensions/ViewExtensions/ScrollViewOffsetModifier.swift
+++ b/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Introspect
@@ -33,7 +33,7 @@ struct ScrollViewOffsetModifier: ViewModifier {
var parent: ScrollViewOffsetModifier?
func scrollViewDidScroll(_ scrollView: UIScrollView) {
- parent?.scrollViewOffset = scrollView.contentOffset.y
+ parent?._scrollViewOffset.wrappedValue = scrollView.contentOffset.y
}
}
}
diff --git a/Shared/Extensions/ViewExtensions/Modifiers/VisibilityModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/VisibilityModifier.swift
new file mode 100644
index 00000000..452c7b92
--- /dev/null
+++ b/Shared/Extensions/ViewExtensions/Modifiers/VisibilityModifier.swift
@@ -0,0 +1,25 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+public struct VisibilityModifier: ViewModifier {
+
+ @usableFromInline
+ let isVisible: Bool
+
+ @usableFromInline
+ init(isVisible: Bool) {
+ self.isVisible = isVisible
+ }
+
+ @inlinable
+ public func body(content: Content) -> some View {
+ content.opacity(isVisible ? 1 : 0)
+ }
+}
diff --git a/Shared/Extensions/ViewExtensions/PreferenceKeys.swift b/Shared/Extensions/ViewExtensions/PreferenceKeys.swift
new file mode 100644
index 00000000..64d6e845
--- /dev/null
+++ b/Shared/Extensions/ViewExtensions/PreferenceKeys.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+struct FramePreferenceKey: PreferenceKey {
+ static var defaultValue: CGRect = .zero
+ static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
+}
+
+struct LocationPreferenceKey: PreferenceKey {
+ static var defaultValue: CGPoint = .zero
+ static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
+}
+
+struct SizePreferenceKey: PreferenceKey {
+ static var defaultValue: CGSize = .zero
+ static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
+}
diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift
index 56c2886d..53ba418d 100644
--- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift
+++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift
@@ -3,19 +3,23 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import Defaults
import Foundation
import SwiftUI
+// TODO: organize
+
extension View {
+
@inlinable
func eraseToAnyView() -> AnyView {
AnyView(self)
}
- public func inverseMask(_ mask: M) -> some View {
+ func inverseMask(_ mask: M) -> some View {
// exchange foreground and background
let inversed = mask
.foregroundColor(.black) // hide foreground
@@ -49,6 +53,7 @@ extension View {
// TODO: Simplify plethora of calls
// TODO: Centralize math
// TODO: Move poster stuff to own file
+ // TODO: Figure out proper handling of corner radius for tvOS buttons
func posterStyle(type: PosterType, width: CGFloat) -> some View {
Group {
switch type {
@@ -72,31 +77,32 @@ extension View {
}
private func portraitPoster(width: CGFloat) -> some View {
- self.frame(width: width, height: width * 1.5)
+ frame(width: width, height: width * 1.5)
.cornerRadius((width * 1.5) / 40)
}
private func landscapePoster(width: CGFloat) -> some View {
- self.frame(width: width, height: width / 1.77)
+ frame(width: width, height: width / 1.77)
+ #if !os(tvOS)
.cornerRadius(width / 30)
+ #endif
}
private func portraitPoster(height: CGFloat) -> some View {
- self.portraitPoster(width: height / 1.5)
+ portraitPoster(width: height / 1.5)
}
private func landscapePoster(height: CGFloat) -> some View {
- self.landscapePoster(width: height * 1.77)
+ landscapePoster(width: height * 1.77)
}
@inlinable
func padding2(_ edges: Edge.Set = .all) -> some View {
- self.padding(edges)
- .padding(edges)
+ padding(edges).padding(edges)
}
func scrollViewOffset(_ scrollViewOffset: Binding) -> some View {
- self.modifier(ScrollViewOffsetModifier(scrollViewOffset: scrollViewOffset))
+ modifier(ScrollViewOffsetModifier(scrollViewOffset: scrollViewOffset))
}
func backgroundParallaxHeader(
@@ -105,14 +111,116 @@ extension View {
multiplier: CGFloat = 1,
@ViewBuilder header: @escaping () -> Header
) -> some View {
- self.modifier(BackgroundParallaxHeaderModifier(scrollViewOffset, height: height, multiplier: multiplier, header: header))
+ modifier(BackgroundParallaxHeaderModifier(scrollViewOffset, height: height, multiplier: multiplier, header: header))
}
func bottomEdgeGradient(bottomColor: Color) -> some View {
- self.modifier(BottomEdgeGradientModifier(bottomColor: bottomColor))
+ modifier(BottomEdgeGradientModifier(bottomColor: bottomColor))
}
func posterShadow() -> some View {
- self.shadow(radius: 4, y: 2)
+ shadow(radius: 4, y: 2)
+ }
+
+ func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
+ clipShape(RoundedCorner(radius: radius, corners: corners))
+ }
+
+ func onFrameChanged(_ onChange: @escaping (CGRect) -> Void) -> some View {
+ background {
+ GeometryReader { reader in
+ Color.clear
+ .preference(key: FramePreferenceKey.self, value: reader.frame(in: .global))
+ }
+ }
+ .onPreferenceChange(FramePreferenceKey.self, perform: onChange)
+ }
+
+ func onLocationChanged(_ onChange: @escaping (CGPoint) -> Void) -> some View {
+ background {
+ GeometryReader { reader in
+ Color.clear
+ .preference(
+ key: LocationPreferenceKey.self,
+ value: CGPoint(x: reader.frame(in: .global).midX, y: reader.frame(in: .global).midY)
+ )
+ }
+ }
+ .onPreferenceChange(LocationPreferenceKey.self, perform: onChange)
+ }
+
+ func onSizeChanged(_ onChange: @escaping (CGSize) -> Void) -> some View {
+ background {
+ GeometryReader { reader in
+ Color.clear
+ .preference(key: SizePreferenceKey.self, value: reader.size)
+ }
+ }
+ .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
+ }
+
+ 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
+ }
+ }
+
+ @inlinable
+ func visible(_ isVisible: Bool) -> some View {
+ opacity(isVisible ? 1 : 0)
+// modifier(VisibilityModifier(isVisible: isVisible))
+ }
+
+ func blurred(style: UIBlurEffect.Style = .regular) -> some View {
+ modifier(BlurViewModifier(style: style))
+ }
+
+ func accentSymbolRendering(accentColor: Color = Defaults[.accentColor]) -> some View {
+ symbolRenderingMode(.palette)
+ .foregroundStyle(accentColor.overlayColor, accentColor)
+ }
+
+ @ViewBuilder
+ func navigationBarHidden() -> some View {
+ if #available(iOS 16, tvOS 16, *) {
+ toolbar(.hidden, for: .navigationBar)
+ } else {
+ navigationBarHidden(true)
+ }
+ }
+
+ func asAttributeStyle(_ style: AttributeViewModifier.Style) -> some View {
+ modifier(AttributeViewModifier(style: style))
+ }
+
+ func blurFullScreenCover(
+ isPresented: Binding,
+ onDismiss: (() -> Void)? = nil,
+ @ViewBuilder content: @escaping () -> any View
+ ) -> some View {
+ fullScreenCover(isPresented: isPresented, onDismiss: onDismiss) {
+ ZStack {
+ BlurView()
+
+ content()
+ .eraseToAnyView()
+ }
+ .ignoresSafeArea()
+ }
+ }
+
+ func inBasicNavigationCoordinatable() -> BasicNavigationViewCoordinator {
+ BasicNavigationViewCoordinator {
+ self
+ }
}
}
diff --git a/Shared/Objects/AppAppearance.swift b/Shared/Objects/AppAppearance.swift
index 4b59cafb..444d16ce 100644
--- a/Shared/Objects/AppAppearance.swift
+++ b/Shared/Objects/AppAppearance.swift
@@ -3,18 +3,19 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
-enum AppAppearance: String, CaseIterable, Defaults.Serializable {
+enum AppAppearance: String, CaseIterable, Defaults.Serializable, Displayable {
+
case system
case dark
case light
- var localizedName: String {
+ var displayTitle: String {
switch self {
case .system:
return L10n.system
diff --git a/Shared/Objects/DeviceProfileBuilder.swift b/Shared/Objects/DeviceProfileBuilder.swift
index f8902c33..05013381 100644
--- a/Shared/Objects/DeviceProfileBuilder.swift
+++ b/Shared/Objects/DeviceProfileBuilder.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
// lol can someone buy me a coffee this took forever :|
@@ -43,8 +43,8 @@ class DeviceProfileBuilder {
self.bitrate = bitrate
}
- public func buildProfile() -> ClientCapabilitiesDeviceProfile {
- let segmentContainer = Defaults[.Experimental.usefmp4Hls] ? "mp4" : "ts"
+ public func buildProfile() -> DeviceProfile {
+ let segmentContainer = "mp4"
let maxStreamingBitrate = bitrate
let maxStaticBitrate = bitrate
let musicStreamingTranscodingBitrate = bitrate
@@ -52,23 +52,23 @@ class DeviceProfileBuilder {
// Build direct play profiles
var directPlayProfiles: [DirectPlayProfile] = []
directPlayProfiles =
- [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav", videoCodec: "h264,mpeg4,vp9", type: .video)]
+ [DirectPlayProfile(audioCodec: "aac,mp3,wav", container: "mov,mp4,mkv,webm", type: .video, videoCodec: "h264,mpeg4,vp9")]
// Device supports Dolby Digital (AC3, EAC3)
if supportsFeature(minimumSupported: .A8X) {
if supportsFeature(minimumSupported: .A9) {
directPlayProfiles = [DirectPlayProfile(
- container: "mov,mp4,mkv,webm",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
- videoCodec: "hevc,h264,hev1,mpeg4,vp9",
- type: .video
+ container: "mov,mp4,mkv,webm",
+ type: .video,
+ videoCodec: "hevc,h264,hev1,mpeg4,vp9"
)] // HEVC/H.264 with Dolby Digital
} else {
directPlayProfiles = [DirectPlayProfile(
- container: "mov,mp4,mkv,webm",
audioCodec: "ac3,eac3,aac,mp3,wav,opus",
- videoCodec: "h264,mpeg4,vp9",
- type: .video
+ container: "mov,mp4,mkv,webm",
+ type: .video,
+ videoCodec: "h264,mpeg4,vp9"
)] // H.264 with Dolby Digital
}
}
@@ -76,52 +76,52 @@ class DeviceProfileBuilder {
// Device supports Dolby Vision?
if supportsFeature(minimumSupported: .A10X) {
directPlayProfiles = [DirectPlayProfile(
- container: "mov,mp4,mkv,webm",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
- videoCodec: "dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9",
- type: .video
+ container: "mov,mp4,mkv,webm",
+ type: .video,
+ videoCodec: "dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9"
)] // H.264/HEVC with Dolby Digital - No Atmos - Vision
}
// Device supports Dolby Atmos?
if supportsFeature(minimumSupported: .A12) {
directPlayProfiles = [DirectPlayProfile(
- container: "mov,mp4,mkv,webm",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,truehd,dts,dca,opus",
- videoCodec: "h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9",
- type: .video
+ container: "mov,mp4,mkv,webm",
+ type: .video,
+ videoCodec: "h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9"
)] // H.264/HEVC with Dolby Digital & Atmos - Vision
}
// Build transcoding profiles
var transcodingProfiles: [TranscodingProfile] = []
- transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,mpeg4", audioCodec: "aac,mp3,wav")]
+ transcodingProfiles = [TranscodingProfile(audioCodec: "aac,mp3,wav", container: "ts", type: .video, videoCodec: "h264,mpeg4")]
// Device supports Dolby Digital (AC3, EAC3)
if supportsFeature(minimumSupported: .A8X) {
if supportsFeature(minimumSupported: .A9) {
transcodingProfiles = [TranscodingProfile(
- container: segmentContainer,
- type: .video,
- videoCodec: "h264,hevc,mpeg4",
audioCodec: "aac,mp3,wav,eac3,ac3,flac,opus",
- _protocol: "hls",
+ isBreakOnNonKeyFrames: true,
+ container: segmentContainer,
context: .streaming,
maxAudioChannels: "6",
minSegments: 2,
- breakOnNonKeyFrames: true
+ protocol: "hls",
+ type: .video,
+ videoCodec: "h264,hevc,mpeg4"
)]
} else {
transcodingProfiles = [TranscodingProfile(
- container: segmentContainer,
- type: .video,
- videoCodec: "h264,mpeg4",
audioCodec: "aac,mp3,wav,eac3,ac3,opus",
- _protocol: "hls",
+ isBreakOnNonKeyFrames: true,
+ container: segmentContainer,
context: .streaming,
maxAudioChannels: "6",
minSegments: 2,
- breakOnNonKeyFrames: true
+ protocol: "hls",
+ type: .video,
+ videoCodec: "h264,mpeg4"
)]
}
}
@@ -129,42 +129,77 @@ class DeviceProfileBuilder {
// Device supports FLAC?
if supportsFeature(minimumSupported: .A10X) {
transcodingProfiles = [TranscodingProfile(
- container: segmentContainer,
- type: .video,
- videoCodec: "hevc,h264,mpeg4",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
- _protocol: "hls",
+ isBreakOnNonKeyFrames: true,
+ container: segmentContainer,
context: .streaming,
maxAudioChannels: "6",
minSegments: 2,
- breakOnNonKeyFrames: true
+ protocol: "hls",
+ type: .video,
+ videoCodec: "hevc,h264,mpeg4"
)]
}
var codecProfiles: [CodecProfile] = []
let h264CodecConditions: [ProfileCondition] = [
- ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false),
+ ProfileCondition(
+ condition: .notEquals,
+ isRequired: false,
+ property: .isAnamorphic,
+ value: "true"
+ ),
ProfileCondition(
condition: .equalsAny,
+ isRequired: false,
property: .videoProfile,
- value: "high|main|baseline|constrained baseline",
- isRequired: false
+ value: "high|main|baseline|constrained baseline"
+ ),
+ ProfileCondition(
+ condition: .lessThanEqual,
+ isRequired: false,
+ property: .videoLevel,
+ value: "80"
+ ),
+ ProfileCondition(
+ condition: .notEquals,
+ isRequired: false,
+ property: .isInterlaced,
+ value: "true"
),
- ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "80", isRequired: false),
- ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false),
]
let hevcCodecConditions: [ProfileCondition] = [
- ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false),
- ProfileCondition(condition: .equalsAny, property: .videoProfile, value: "high|main|main 10", isRequired: false),
- ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "175", isRequired: false),
- ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false),
+ 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(type: .video, applyConditions: h264CodecConditions, codec: "h264"))
+ codecProfiles.append(CodecProfile(applyConditions: h264CodecConditions, codec: "h264", type: .video))
if supportsFeature(minimumSupported: .A9) {
- codecProfiles.append(CodecProfile(type: .video, applyConditions: hevcCodecConditions, codec: "hevc"))
+ codecProfiles.append(CodecProfile(applyConditions: hevcCodecConditions, codec: "hevc", type: .video))
}
var subtitleProfiles: [SubtitleProfile] = []
@@ -184,21 +219,19 @@ class DeviceProfileBuilder {
subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external))
- let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", type: .video, mimeType: "video/mp4")]
+ let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", mimeType: "video/mp4", type: .video)]
- let profile = ClientCapabilitiesDeviceProfile(
- maxStreamingBitrate: maxStreamingBitrate,
- maxStaticBitrate: maxStaticBitrate,
- musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate,
- directPlayProfiles: directPlayProfiles,
- transcodingProfiles: transcodingProfiles,
- containerProfiles: [],
+ return .init(
codecProfiles: codecProfiles,
+ containerProfiles: [],
+ directPlayProfiles: directPlayProfiles,
+ maxStaticBitrate: maxStaticBitrate,
+ maxStreamingBitrate: maxStreamingBitrate,
+ musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate,
responseProfiles: responseProfiles,
- subtitleProfiles: subtitleProfiles
+ subtitleProfiles: subtitleProfiles,
+ transcodingProfiles: transcodingProfiles
)
-
- return profile
}
private func supportsFeature(minimumSupported: CPUModel) -> Bool {
@@ -243,8 +276,8 @@ class DeviceProfileBuilder {
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let identifier = machineMirror.children.reduce("") { identifier, element in
- guard let value = element.value as? Int8, value != 0 else { return identifier }
- return identifier + String(UnicodeScalar(UInt8(value)))
+ guard let subtitle = element.value as? Int8, subtitle != 0 else { return identifier }
+ return identifier + String(UnicodeScalar(UInt8(subtitle)))
}
#endif
diff --git a/Shared/Objects/Displayable.swift b/Shared/Objects/Displayable.swift
index af7b7d7c..75a67fb0 100644
--- a/Shared/Objects/Displayable.swift
+++ b/Shared/Objects/Displayable.swift
@@ -3,11 +3,11 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
protocol Displayable {
- var displayName: String { get }
+ var displayTitle: String { get }
}
diff --git a/Shared/Objects/EnumPicker.swift b/Shared/Objects/EnumPicker.swift
new file mode 100644
index 00000000..b5f56e12
--- /dev/null
+++ b/Shared/Objects/EnumPicker.swift
@@ -0,0 +1,32 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+// TODO: Allow optional binding
+
+struct EnumPicker: View {
+
+ @Binding
+ var selection: EnumType
+
+ let title: String
+
+ init(title: String, selection: Binding) {
+ self.title = title
+ self._selection = selection
+ }
+
+ var body: some View {
+ Picker(title, selection: $selection) {
+ ForEach(EnumType.allCases.asArray, id: \.hashValue) {
+ Text($0.displayTitle).tag($0)
+ }
+ }
+ }
+}
diff --git a/Shared/Objects/GestureAction.swift b/Shared/Objects/GestureAction.swift
new file mode 100644
index 00000000..69be8aa6
--- /dev/null
+++ b/Shared/Objects/GestureAction.swift
@@ -0,0 +1,128 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Foundation
+
+// TODO: look at optional values for defaults to remove .none
+
+protocol GestureAction: CaseIterable, Codable, Defaults.Serializable, Displayable {}
+
+enum LongPressAction: String, GestureAction {
+
+ case none
+ case gestureLock
+
+ var displayTitle: String {
+ switch self {
+ case .none:
+ return L10n.none
+ case .gestureLock:
+ return "Gesture Lock"
+ }
+ }
+}
+
+enum MultiTapAction: String, GestureAction {
+
+ case none
+ case jump
+
+ var displayTitle: String {
+ switch self {
+ case .none:
+ return L10n.none
+ case .jump:
+ return "Jump"
+ }
+ }
+}
+
+enum DoubleTouchAction: String, GestureAction {
+
+ case none
+ case aspectFill
+ case gestureLock
+ case pausePlay
+
+ var displayTitle: String {
+ switch self {
+ case .none:
+ return L10n.none
+ case .aspectFill:
+ return "Aspect Fill"
+ case .gestureLock:
+ return "Gesture Lock"
+ case .pausePlay:
+ return "Pause/Play"
+ }
+ }
+}
+
+enum PanAction: String, GestureAction {
+
+ case none
+ case audioffset
+ case brightness
+ case playbackSpeed
+ case scrub
+ case slowScrub
+ case subtitleOffset
+ case volume
+
+ var displayTitle: String {
+ switch self {
+ case .none:
+ return L10n.none
+ case .audioffset:
+ return "Audio Offset"
+ case .brightness:
+ return "Brightness"
+ case .playbackSpeed:
+ return "Playback Speed"
+ case .scrub:
+ return "Scrub"
+ case .slowScrub:
+ return "Slow Scrub"
+ case .subtitleOffset:
+ return "Subtitle Offset"
+ case .volume:
+ return "Volume"
+ }
+ }
+}
+
+enum PinchAction: String, GestureAction {
+
+ case none
+ case aspectFill
+
+ var displayTitle: String {
+ switch self {
+ case .none:
+ return L10n.none
+ case .aspectFill:
+ return "Aspect Fill"
+ }
+ }
+}
+
+enum SwipeAction: String, GestureAction {
+
+ case none
+ case jump
+
+ var displayTitle: String {
+ switch self {
+ case .none:
+ return L10n.none
+ case .jump:
+ return "Jump"
+ }
+ }
+}
diff --git a/Shared/Objects/HTTPScheme.swift b/Shared/Objects/HTTPScheme.swift
index 5d717222..478fb23f 100644
--- a/Shared/Objects/HTTPScheme.swift
+++ b/Shared/Objects/HTTPScheme.swift
@@ -3,13 +3,18 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
-enum HTTPScheme: String, Defaults.Serializable, CaseIterable {
+enum HTTPScheme: String, CaseIterable, Displayable, Defaults.Serializable {
+
case http
case https
+
+ var displayTitle: String {
+ rawValue
+ }
}
diff --git a/Shared/Objects/ItemFilters.swift b/Shared/Objects/ItemFilters.swift
index 01380bda..40751982 100644
--- a/Shared/Objects/ItemFilters.swift
+++ b/Shared/Objects/ItemFilters.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -32,7 +32,7 @@ struct ItemFilters: Codable, Defaults.Serializable, Hashable {
// Type-erased object for use with WritableKeyPath
struct Filter: Codable, Defaults.Serializable, Displayable, Hashable, Identifiable {
- var displayName: String
+ var displayTitle: String
var id: String?
var filterName: String
}
diff --git a/Shared/Objects/ItemViewType.swift b/Shared/Objects/ItemViewType.swift
index 41225289..a2d495f5 100644
--- a/Shared/Objects/ItemViewType.swift
+++ b/Shared/Objects/ItemViewType.swift
@@ -3,18 +3,19 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
-enum ItemViewType: String, CaseIterable, Defaults.Serializable {
+enum ItemViewType: String, CaseIterable, Displayable, Defaults.Serializable {
+
case compactPoster
case compactLogo
case cinematic
- var localizedName: String {
+ var displayTitle: String {
switch self {
case .compactPoster:
return L10n.compactPoster
diff --git a/Shared/Objects/LibraryParent.swift b/Shared/Objects/LibraryParent.swift
index 0eee8498..a18b4948 100644
--- a/Shared/Objects/LibraryParent.swift
+++ b/Shared/Objects/LibraryParent.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Objects/LibraryViewType.swift b/Shared/Objects/LibraryViewType.swift
index e811d9ae..e7b817b7 100644
--- a/Shared/Objects/LibraryViewType.swift
+++ b/Shared/Objects/LibraryViewType.swift
@@ -3,18 +3,19 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
-enum LibraryViewType: String, CaseIterable, Defaults.Serializable {
+enum LibraryViewType: String, CaseIterable, Displayable, Defaults.Serializable {
+
case grid
case list
// TODO: localize after organization
- var localizedName: String {
+ var displayTitle: String {
switch self {
case .grid:
return "Grid"
diff --git a/Shared/Objects/MediaLibraryItem.swift b/Shared/Objects/MediaLibraryItem.swift
deleted file mode 100644
index a32964f1..00000000
--- a/Shared/Objects/MediaLibraryItem.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-//
-// Swiftfin is subject to the terms of the Mozilla Public
-// License, v2.0. If a copy of the MPL was not distributed with this
-// file, you can obtain one at https://mozilla.org/MPL/2.0/.
-//
-// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import JellyfinAPI
-import SwiftUI
-
-// TODO: Look at something better that possibly doesn't depend on the viewmodel
-// and accomodates favorites and liveTV better
-struct MediaLibraryItem: Equatable, Poster {
-
- var library: BaseItemDto
- var viewModel: MediaViewModel
- var displayName: String = ""
- var subtitle: String?
- var showTitle: Bool = false
-
- func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource {
- .init()
- }
-
- func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] {
- viewModel.libraryImages[library.id ?? ""] ?? []
- }
-
- static func == (lhs: MediaLibraryItem, rhs: MediaLibraryItem) -> Bool {
- lhs.library == rhs.library &&
- lhs.viewModel.libraryImages[lhs.library.id ?? ""] == rhs.viewModel.libraryImages[rhs.library.id ?? ""]
- }
-
- static func favorites(viewModel: MediaViewModel) -> MediaLibraryItem {
- .init(library: .init(name: L10n.favorites, collectionType: "favorites"), viewModel: viewModel)
- }
-
- static func liveTV(viewModel: MediaViewModel) -> MediaLibraryItem {
- .init(library: .init(name: "LiveTV", collectionType: "liveTV"), viewModel: viewModel)
- }
-}
diff --git a/Shared/Objects/MenuPosterHStackModel.swift b/Shared/Objects/MenuPosterHStackModel.swift
new file mode 100644
index 00000000..e40b83a7
--- /dev/null
+++ b/Shared/Objects/MenuPosterHStackModel.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) 2023 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: [PosterButtonType]] { get set }
+ var menuSectionSort: (Section, Section) -> Bool { get }
+
+ func select(section: Section)
+}
diff --git a/Shared/Objects/OverlayType.swift b/Shared/Objects/OverlayType.swift
index bae92b38..96eeddbb 100644
--- a/Shared/Objects/OverlayType.swift
+++ b/Shared/Objects/OverlayType.swift
@@ -3,13 +3,14 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
enum OverlayType: String, CaseIterable, Defaults.Serializable {
+
case normal
case compact
@@ -22,3 +23,18 @@ enum OverlayType: String, CaseIterable, Defaults.Serializable {
}
}
}
+
+enum PlaybackButtonType: String, CaseIterable, Displayable, Defaults.Serializable {
+
+ case large
+ case compact
+
+ var displayTitle: String {
+ switch self {
+ case .large:
+ return "Large"
+ case .compact:
+ return "Compact"
+ }
+ }
+}
diff --git a/Shared/Objects/PanDirectionGestureRecognizer.swift b/Shared/Objects/PanDirectionGestureRecognizer.swift
index edccabbe..22dfad03 100644
--- a/Shared/Objects/PanDirectionGestureRecognizer.swift
+++ b/Shared/Objects/PanDirectionGestureRecognizer.swift
@@ -3,20 +3,21 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
-import UIKit.UIGestureRecognizerSubclass
-
-enum PanDirection {
- case vertical
- case horizontal
-}
+import UIKit
class PanDirectionGestureRecognizer: UIPanGestureRecognizer {
- let direction: PanDirection
- init(direction: PanDirection, target: AnyObject, action: Selector) {
+ enum Direction {
+ case vertical
+ case horizontal
+ }
+
+ private let direction: Direction
+
+ init(direction: Direction, target: AnyObject, action: Selector) {
self.direction = direction
super.init(target: target, action: action)
}
diff --git a/Shared/Objects/PlaybackSpeed.swift b/Shared/Objects/PlaybackSpeed.swift
index 237c35c1..f9ba1245 100644
--- a/Shared/Objects/PlaybackSpeed.swift
+++ b/Shared/Objects/PlaybackSpeed.swift
@@ -3,12 +3,13 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
-enum PlaybackSpeed: Double, CaseIterable {
+enum PlaybackSpeed: Double, CaseIterable, Displayable {
+
case quarter = 0.25
case half = 0.5
case threeQuarter = 0.75
@@ -38,46 +39,4 @@ enum PlaybackSpeed: Double, CaseIterable {
return "2x"
}
}
-
- var previous: PlaybackSpeed? {
- switch self {
- case .quarter:
- return nil
- case .half:
- return .quarter
- case .threeQuarter:
- return .half
- case .one:
- return .threeQuarter
- case .oneQuarter:
- return .one
- case .oneHalf:
- return .oneQuarter
- case .oneThreeQuarter:
- return .oneHalf
- case .two:
- return .oneThreeQuarter
- }
- }
-
- var next: PlaybackSpeed? {
- switch self {
- case .quarter:
- return .half
- case .half:
- return .threeQuarter
- case .threeQuarter:
- return .one
- case .one:
- return .oneQuarter
- case .oneQuarter:
- return .oneHalf
- case .oneHalf:
- return .oneThreeQuarter
- case .oneThreeQuarter:
- return .two
- case .two:
- return nil
- }
- }
}
diff --git a/Shared/Objects/Poster.swift b/Shared/Objects/Poster.swift
index bbd3deeb..ce9187cf 100644
--- a/Shared/Objects/Poster.swift
+++ b/Shared/Objects/Poster.swift
@@ -3,14 +3,13 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
-import Defaults
import Foundation
-import SwiftUI
protocol Poster: Displayable, Hashable {
+
var subtitle: String? { get }
var showTitle: Bool { get }
@@ -20,7 +19,7 @@ protocol Poster: Displayable, Hashable {
extension Poster {
func hash(into hasher: inout Hasher) {
- hasher.combine(displayName)
+ hasher.combine(displayTitle)
hasher.combine(subtitle)
}
}
diff --git a/Shared/Objects/PosterButtonType.swift b/Shared/Objects/PosterButtonType.swift
new file mode 100644
index 00000000..571e8629
--- /dev/null
+++ b/Shared/Objects/PosterButtonType.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+// TODO: Replace with better mechanism
+
+enum PosterButtonType: Hashable, Identifiable {
+
+ case loading
+ case noResult
+ case item(Item)
+
+ var id: Int {
+ switch self {
+ case .loading, .noResult:
+ return UUID().hashValue
+ case let .item(item):
+ return item.hashValue
+ }
+ }
+
+ var _item: Item? {
+ switch self {
+ case let .item(item):
+ return item
+ default:
+ return nil
+ }
+ }
+}
diff --git a/Shared/Objects/PosterType.swift b/Shared/Objects/PosterType.swift
index e60a7921..ca0da7f8 100644
--- a/Shared/Objects/PosterType.swift
+++ b/Shared/Objects/PosterType.swift
@@ -3,13 +3,14 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
-enum PosterType: String, CaseIterable, Defaults.Serializable {
+enum PosterType: String, CaseIterable, Displayable, Defaults.Serializable {
+
case portrait
case landscape
@@ -23,7 +24,7 @@ enum PosterType: String, CaseIterable, Defaults.Serializable {
}
// TODO: localize
- var localizedName: String {
+ var displayTitle: String {
switch self {
case .portrait:
return "Portrait"
@@ -32,16 +33,15 @@ enum PosterType: String, CaseIterable, Defaults.Serializable {
}
}
+ // 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
- @ScaledMetric(relativeTo: .largeTitle)
static var portrait = 100.0
- @ScaledMetric(relativeTo: .largeTitle)
static var landscape = 200.0
#endif
}
diff --git a/Shared/Objects/RepeatingTimer.swift b/Shared/Objects/RepeatingTimer.swift
index 5b4f6181..eecd8887 100644
--- a/Shared/Objects/RepeatingTimer.swift
+++ b/Shared/Objects/RepeatingTimer.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Objects/RoundedCorner.swift b/Shared/Objects/RoundedCorner.swift
new file mode 100644
index 00000000..d41e9af6
--- /dev/null
+++ b/Shared/Objects/RoundedCorner.swift
@@ -0,0 +1,23 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+struct RoundedCorner: Shape {
+
+ let radius: CGFloat
+ let corners: UIRectCorner
+
+ func path(in rect: CGRect) -> Path {
+ Path(UIBezierPath(
+ roundedRect: rect,
+ byRoundingCorners: corners,
+ cornerRadii: CGSize(width: radius, height: radius)
+ ).cgPath)
+ }
+}
diff --git a/Shared/Objects/SelectorType.swift b/Shared/Objects/SelectorType.swift
index a3aadcf1..b5340385 100644
--- a/Shared/Objects/SelectorType.swift
+++ b/Shared/Objects/SelectorType.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
diff --git a/Shared/Objects/SliderType.swift b/Shared/Objects/SliderType.swift
new file mode 100644
index 00000000..36499fb4
--- /dev/null
+++ b/Shared/Objects/SliderType.swift
@@ -0,0 +1,25 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Foundation
+
+enum SliderType: String, CaseIterable, Displayable, Defaults.Serializable {
+
+ case thumb
+ case capsule
+
+ var displayTitle: String {
+ switch self {
+ case .thumb:
+ return "Thumb"
+ case .capsule:
+ return "Capsule"
+ }
+ }
+}
diff --git a/Shared/Objects/SortBy.swift b/Shared/Objects/SortBy.swift
index eb0c1bbd..f5031a0e 100644
--- a/Shared/Objects/SortBy.swift
+++ b/Shared/Objects/SortBy.swift
@@ -3,20 +3,23 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
-public enum SortBy: String, Codable, CaseIterable {
+// TODO: Move to jellyfin-api-swift
+
+enum SortBy: String, CaseIterable, Displayable {
+
case premiereDate = "PremiereDate"
case name = "SortName"
case dateAdded = "DateCreated"
case random = "Random"
// TODO: Localize
- var localized: String {
+ var displayTitle: String {
switch self {
case .premiereDate:
return "Premiere date"
@@ -30,6 +33,6 @@ public enum SortBy: String, Codable, CaseIterable {
}
var filter: ItemFilters.Filter {
- .init(displayName: localized, filterName: rawValue)
+ .init(displayTitle: displayTitle, filterName: rawValue)
}
}
diff --git a/Shared/Objects/SpecialFeatureType.swift b/Shared/Objects/SpecialFeatureType.swift
new file mode 100644
index 00000000..eed1cc91
--- /dev/null
+++ b/Shared/Objects/SpecialFeatureType.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import JellyfinAPI
+
+// enum SpecialFeatureType: String, CaseIterable, Displayable {
+//
+// case unknown = "Unknown"
+// case clip = "Clip"
+// case trailer = "Trailer"
+// case behindTheScenes = "BehindTheScenes"
+// case deletedScene = "DeletedScene"
+// case interview = "Interview"
+// case scene = "Scene"
+// case sample = "Sample"
+// case themeSong = "ThemeSong"
+// case themeVideo = "ThemeVideo"
+
+extension SpecialFeatureType: Displayable {
+
+ // TODO: localize
+ var displayTitle: String {
+ switch self {
+ case .unknown:
+ return L10n.unknown
+ case .clip:
+ return "Clip"
+ case .trailer:
+ return "Trailer"
+ case .behindTheScenes:
+ return "Behind the Scenes"
+ case .deletedScene:
+ return "Deleted Scene"
+ case .interview:
+ return "Interview"
+ case .scene:
+ return "Scene"
+ case .sample:
+ return "Sample"
+ case .themeSong:
+ return "Theme Song"
+ case .themeVideo:
+ return "Theme Video"
+ }
+ }
+
+ var isVideo: Bool {
+ self != .themeSong
+ }
+}
diff --git a/Shared/Objects/StreamType.swift b/Shared/Objects/StreamType.swift
new file mode 100644
index 00000000..3a123a74
--- /dev/null
+++ b/Shared/Objects/StreamType.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+enum StreamType: Displayable {
+
+ case direct
+ case transcode
+ case hls
+
+ var displayTitle: String {
+ switch self {
+ case .direct:
+ return "Direct"
+ case .transcode:
+ return "Transcode"
+ case .hls:
+ return "HLS"
+ }
+ }
+}
diff --git a/Shared/Objects/SubtitleSize.swift b/Shared/Objects/SubtitleSize.swift
deleted file mode 100644
index ef94d13b..00000000
--- a/Shared/Objects/SubtitleSize.swift
+++ /dev/null
@@ -1,58 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Defaults
-
-enum SubtitleSize: Int32, CaseIterable, Defaults.Serializable {
- case smallest
- case smaller
- case regular
- case larger
- case largest
-}
-
-// MARK: - appearance
-
-extension SubtitleSize {
- var label: String {
- switch self {
- case .smallest:
- return L10n.smallest
- case .smaller:
- return L10n.smaller
- case .regular:
- return L10n.regular
- case .larger:
- return L10n.larger
- case .largest:
- return L10n.largest
- }
- }
-}
-
-// MARK: - sizing for VLC
-
-extension SubtitleSize {
- /// Value to be passed to VLCKit (via hacky internal property, until VLCKit 4)
- ///
- /// note that it doesn't correspond to actual font sizes; a smaller int creates bigger text
- var textRendererFontSize: Int {
- switch self {
- case .smallest:
- return 24
- case .smaller:
- return 20
- case .regular:
- return 16
- case .larger:
- return 12
- case .largest:
- return 8
- }
- }
-}
diff --git a/Shared/Objects/TextPair.swift b/Shared/Objects/TextPair.swift
new file mode 100644
index 00000000..952a5451
--- /dev/null
+++ b/Shared/Objects/TextPair.swift
@@ -0,0 +1,21 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+// TODO: better context naming than for "display" purposes
+
+struct TextPair: Displayable, Identifiable {
+
+ let displayTitle: String
+ let subtitle: String
+
+ var id: String {
+ displayTitle.appending(subtitle)
+ }
+}
diff --git a/Shared/Objects/TimeStampType.swift b/Shared/Objects/TimeStampType.swift
new file mode 100644
index 00000000..e3032c57
--- /dev/null
+++ b/Shared/Objects/TimeStampType.swift
@@ -0,0 +1,25 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Foundation
+
+enum TimestampType: String, CaseIterable, Defaults.Serializable, Displayable {
+
+ case split
+ case compact
+
+ var displayTitle: String {
+ switch self {
+ case .split:
+ return "Split"
+ case .compact:
+ return "Compact"
+ }
+ }
+}
diff --git a/Shared/Objects/TimerProxy.swift b/Shared/Objects/TimerProxy.swift
new file mode 100644
index 00000000..137da3f6
--- /dev/null
+++ b/Shared/Objects/TimerProxy.swift
@@ -0,0 +1,47 @@
+//
+// Swiftfin is subject to the terms of the Mozilla Public
+// License, v2.0. If a copy of the MPL was not distributed with this
+// file, you can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import SwiftUI
+
+// TODO: Rename to something more generic, non-proxy
+
+class TimerProxy: ObservableObject {
+
+ @Published
+ var isActive = false
+
+ private var stopWorkitem: DispatchWorkItem?
+
+ func start(_ interval: Double) {
+ isActive = true
+ restartOverlayDismissTimer(interval: interval)
+ }
+
+ func stop() {
+ isActive = false
+ }
+
+ /// Stops the timer without triggering an active update
+ func pause() {
+ stopWorkitem?.cancel()
+ }
+
+ private func restartOverlayDismissTimer(interval: Double) {
+ stopWorkitem?.cancel()
+
+ isActive = true
+
+ let newWorkItem = DispatchWorkItem {
+ self.stop()
+ }
+
+ stopWorkitem = newWorkItem
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: newWorkItem)
+ }
+}
diff --git a/Shared/Objects/TrackLanguage.swift b/Shared/Objects/TrackLanguage.swift
deleted file mode 100644
index 8d95e63a..00000000
--- a/Shared/Objects/TrackLanguage.swift
+++ /dev/null
@@ -1,16 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Foundation
-
-struct TrackLanguage: Hashable {
- var name: String
- var isoCode: String
-
- static let auto = TrackLanguage(name: "Auto", isoCode: "Auto")
-}
diff --git a/Shared/Objects/TrailingTimestampType.swift b/Shared/Objects/TrailingTimestampType.swift
new file mode 100644
index 00000000..c2781acd
--- /dev/null
+++ b/Shared/Objects/TrailingTimestampType.swift
@@ -0,0 +1,25 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Foundation
+
+enum TrailingTimestampType: String, CaseIterable, Displayable, Defaults.Serializable {
+
+ case timeLeft
+ case totalTime
+
+ var displayTitle: String {
+ switch self {
+ case .timeLeft:
+ return "Time left"
+ case .totalTime:
+ return "Total time"
+ }
+ }
+}
diff --git a/Shared/Objects/Utilities.swift b/Shared/Objects/Utilities.swift
new file mode 100644
index 00000000..f27b75a4
--- /dev/null
+++ b/Shared/Objects/Utilities.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+
+@inlinable
+func clamp(_ x: T, min y: T, max z: T) -> T {
+ min(max(x, y), z)
+}
+
+@inlinable
+func round(_ value: T, toNearest: T) -> T {
+ round(value / toNearest) * toNearest
+}
+
+@inlinable
+func round(_ value: T, toNearest: T) -> T {
+ T(round(Double(value), toNearest: Double(toNearest)))
+}
diff --git a/Shared/Objects/VideoPlayerActionButton.swift b/Shared/Objects/VideoPlayerActionButton.swift
new file mode 100644
index 00000000..b187d2af
--- /dev/null
+++ b/Shared/Objects/VideoPlayerActionButton.swift
@@ -0,0 +1,92 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Foundation
+
+enum VideoPlayerActionButton: String, CaseIterable, Defaults.Serializable, Displayable, Identifiable {
+
+ case advanced
+ case aspectFill
+ case audio
+ case autoPlay
+ case chapters
+ case playbackSpeed
+ case playNextItem
+ case playPreviousItem
+ case subtitles
+
+ var displayTitle: String {
+ switch self {
+ case .advanced:
+ return "Advanced"
+ case .aspectFill:
+ return "Aspect Fill"
+ case .audio:
+ return "Audio"
+ case .autoPlay:
+ return "Auto Play"
+ case .chapters:
+ return "Chapters"
+ case .playbackSpeed:
+ return "Playback Speed"
+ case .playNextItem:
+ return "Play Next Item"
+ case .playPreviousItem:
+ return "Play Previous Item"
+ case .subtitles:
+ return "Subtitles"
+ }
+ }
+
+ var id: String {
+ rawValue
+ }
+
+ var settingsSystemImage: String {
+ switch self {
+ case .advanced:
+ return "gearshape.fill"
+ case .aspectFill:
+ return "arrow.up.left.and.arrow.down.right"
+ case .audio:
+ return "speaker.wave.2"
+ case .autoPlay:
+ return "play.circle.fill"
+ case .chapters:
+ return "list.bullet.circle"
+ case .playbackSpeed:
+ return "speedometer"
+ case .playNextItem:
+ return "chevron.right.circle"
+ case .playPreviousItem:
+ return "chevron.left.circle"
+ case .subtitles:
+ return "captions.bubble"
+ }
+ }
+
+ static var defaultBarActionButtons: [VideoPlayerActionButton] {
+ [
+ .aspectFill,
+ .autoPlay,
+ .playPreviousItem,
+ .playNextItem,
+ ]
+ }
+
+ static var defaultMenuActionButtons: [VideoPlayerActionButton] {
+ [
+ .audio,
+ .subtitles,
+ .playbackSpeed,
+ .chapters,
+ .advanced,
+ ]
+ }
+}
diff --git a/Shared/Objects/VideoPlayerJumpLength.swift b/Shared/Objects/VideoPlayerJumpLength.swift
index 5c337301..b880acd6 100644
--- a/Shared/Objects/VideoPlayerJumpLength.swift
+++ b/Shared/Objects/VideoPlayerJumpLength.swift
@@ -3,24 +3,22 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
import UIKit
-enum VideoPlayerJumpLength: Int32, CaseIterable, Defaults.Serializable {
- case thirty = 30
- case fifteen = 15
- case ten = 10
+enum VideoPlayerJumpLength: Int, CaseIterable, Defaults.Serializable, Displayable {
+
case five = 5
+ case ten = 10
+ case fifteen = 15
+ case thirty = 30
- var label: String {
- L10n.jumpLengthSeconds("\(self.rawValue)")
- }
-
- var shortLabel: String {
- "\(self.rawValue)s"
+ // TODO: formatter for locale?
+ var displayTitle: String {
+ "\(rawValue)s"
}
var forwardImageLabel: String {
diff --git a/Shared/Objects/VideoPlayerType.swift b/Shared/Objects/VideoPlayerType.swift
new file mode 100644
index 00000000..b1be1e7b
--- /dev/null
+++ b/Shared/Objects/VideoPlayerType.swift
@@ -0,0 +1,25 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Foundation
+
+enum VideoPlayerType: String, CaseIterable, Defaults.Serializable, Displayable {
+
+ case native
+ case swiftfin
+
+ var displayTitle: String {
+ switch self {
+ case .native:
+ return "Native"
+ case .swiftfin:
+ return "Swiftfin"
+ }
+ }
+}
diff --git a/Shared/Resources/Model.xcdatamodeld/.xccurrentversion b/Shared/Resources/Model.xcdatamodeld/.xccurrentversion
deleted file mode 100644
index 0c67376e..00000000
--- a/Shared/Resources/Model.xcdatamodeld/.xccurrentversion
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/Shared/Resources/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents b/Shared/Resources/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents
deleted file mode 100644
index 28542225..00000000
--- a/Shared/Resources/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Shared/ServerDiscovery/ServerDiscovery.swift b/Shared/ServerDiscovery/ServerDiscovery.swift
index 7be69649..2ebcbd52 100644
--- a/Shared/ServerDiscovery/ServerDiscovery.swift
+++ b/Shared/ServerDiscovery/ServerDiscovery.swift
@@ -3,33 +3,33 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Factory
import Foundation
import UDPBroadcast
-public class ServerDiscovery {
+class ServerDiscovery {
@Injected(LogManager.service)
private var logger
- public struct ServerLookupResponse: Codable, Hashable, Identifiable {
+ struct ServerLookupResponse: Codable, Hashable, Identifiable {
- public func hash(into hasher: inout Hasher) {
+ func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
private let address: String
- public let id: String
- public let name: String
+ let id: String
+ let name: String
- public var url: URL {
+ var url: URL {
URL(string: self.address)!
}
- public var host: String {
+ var host: String {
let components = URLComponents(string: self.address)
if let host = components?.host {
return host
@@ -37,7 +37,7 @@ public class ServerDiscovery {
return self.address
}
- public var port: Int {
+ var port: Int {
let components = URLComponents(string: self.address)
if let port = components?.port {
return port
@@ -56,12 +56,12 @@ public class ServerDiscovery {
init() {}
- public func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) {
+ func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) {
func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) {
do {
let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data)
- logger.debug("Received JellyfinServer from \"\(response.name)\"", tag: "ServerDiscovery")
+ logger.debug("Received JellyfinServer from \"\(response.name)\"")
completion(response)
} catch {
completion(nil)
@@ -69,15 +69,15 @@ public class ServerDiscovery {
}
func errorHandler(error: UDPBroadcastConnection.ConnectionError) {
- logger.error("Error handling response: \(error.localizedDescription)", tag: "ServerDiscovery")
+ logger.error("Error handling response: \(error.localizedDescription)")
}
do {
self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler)
try self.connection?.sendBroadcast("Who is JellyfinServer?")
- logger.debug("Discovery broadcast sent", tag: "ServerDiscovery")
+ logger.debug("Discovery broadcast sent")
} catch {
- logger.error("Error sending discovery broadcast", tag: "ServerDiscovery")
+ logger.error("Error sending discovery broadcast")
}
}
}
diff --git a/Shared/Services/DownloadManager.swift b/Shared/Services/DownloadManager.swift
new file mode 100644
index 00000000..b6702fa8
--- /dev/null
+++ b/Shared/Services/DownloadManager.swift
@@ -0,0 +1,114 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Factory
+import Files
+import Foundation
+import JellyfinAPI
+
+extension Container {
+
+ static let downloadManager = Factory(scope: .singleton) {
+ let manager = DownloadManager()
+ manager.clearTmp()
+ return manager
+ }
+}
+
+class DownloadManager: ObservableObject {
+
+ @Injected(LogManager.service)
+ private var logger
+
+ @Published
+ private(set) var downloads: [DownloadTask] = []
+
+ fileprivate init() {
+
+ createDownloadDirectory()
+ }
+
+ private func createDownloadDirectory() {
+
+ try? FileManager.default.createDirectory(
+ at: URL.downloads,
+ withIntermediateDirectories: true
+ )
+ }
+
+ func clearTmp() {
+ do {
+ try Folder(path: URL.tmp.path).files.delete()
+
+ logger.trace("Cleared tmp directory")
+ } catch {
+ logger.error("Unable to clear tmp directory: \(error.localizedDescription)")
+ }
+ }
+
+ func download(task: DownloadTask) {
+ guard !downloads.contains(where: { $0.item == task.item }) else { return }
+
+ downloads.append(task)
+
+ task.download()
+ }
+
+ func task(for item: BaseItemDto) -> DownloadTask? {
+ if let currentlyDownloading = downloads.first(where: { $0.item == item }) {
+ return currentlyDownloading
+ } else {
+ var isDir: ObjCBool = true
+ guard let downloadFolder = item.downloadFolder else { return nil }
+ guard FileManager.default.fileExists(atPath: downloadFolder.path, isDirectory: &isDir) else { return nil }
+
+ return parseDownloadItem(with: item.id!)
+ }
+ }
+
+ func cancel(task: DownloadTask) {
+ guard downloads.contains(where: { $0.item == task.item }) else { return }
+
+ task.cancel()
+
+ remove(task: task)
+ }
+
+ func remove(task: DownloadTask) {
+ downloads.removeAll(where: { $0.item == task.item })
+ }
+
+ func downloadedItems() -> [DownloadTask] {
+ do {
+ let downloadContents = try FileManager.default.contentsOfDirectory(atPath: URL.downloads.path)
+ return downloadContents.compactMap(parseDownloadItem(with:))
+ } catch {
+ logger.error("Error retrieving all downloads: \(error.localizedDescription)")
+
+ return []
+ }
+ }
+
+ private func parseDownloadItem(with id: String) -> DownloadTask? {
+
+ let itemMetadataFile = URL.downloads
+ .appendingPathComponent(id)
+ .appendingPathComponent("Metadata")
+ .appendingPathComponent("Item.json")
+
+ guard let itemMetadataData = FileManager.default.contents(atPath: itemMetadataFile.path) else { return nil }
+
+ let jsonDecoder = JSONDecoder()
+
+ guard let offlineItem = try? jsonDecoder.decode(BaseItemDto.self, from: itemMetadataData) else { return nil }
+
+ let task = DownloadTask(item: offlineItem)
+ task.state = .complete
+ return task
+ }
+}
diff --git a/Shared/Services/DownloadTask.swift b/Shared/Services/DownloadTask.swift
new file mode 100644
index 00000000..8979f5fa
--- /dev/null
+++ b/Shared/Services/DownloadTask.swift
@@ -0,0 +1,309 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Factory
+import Files
+import Foundation
+import Get
+import JellyfinAPI
+
+// TODO: Only move items if entire download successful
+// TODO: Better state for which stage of downloading
+
+class DownloadTask: NSObject, ObservableObject {
+
+ enum DownloadError: Error {
+
+ case notEnoughStorage
+
+ var localizedDescription: String {
+ switch self {
+ case .notEnoughStorage:
+ return "Not enough storage"
+ }
+ }
+ }
+
+ enum State {
+
+ case cancelled
+ case complete
+ case downloading(Double)
+ case error(Error)
+ case ready
+ }
+
+ @Injected(LogManager.service)
+ private var logger
+ @Injected(Container.userSession)
+ private var userSession
+
+ @Published
+ var state: State = .ready
+
+ private var downloadTask: Task?
+
+ let item: BaseItemDto
+
+ var imagesFolder: URL? {
+ item.downloadFolder?.appendingPathComponent("Images")
+ }
+
+ var metadataFolder: URL? {
+ item.downloadFolder?.appendingPathComponent("Metadata")
+ }
+
+ init(item: BaseItemDto) {
+ self.item = item
+ }
+
+ func createFolder() throws {
+ guard let downloadFolder = item.downloadFolder else { return }
+ try FileManager.default.createDirectory(at: downloadFolder, withIntermediateDirectories: true)
+ }
+
+ func download() {
+
+ let task = Task {
+
+ deleteRootFolder()
+
+ // TODO: Look at TaskGroup for parallel calls
+ do {
+ try await downloadMedia()
+ } catch {
+ await MainActor.run {
+ self.state = .error(error)
+
+ Container.downloadManager.callAsFunction()
+ .remove(task: self)
+ }
+ return
+ }
+ await downloadBackdropImage()
+ await downloadPrimaryImage()
+
+ saveMetadata()
+
+ await MainActor.run {
+ self.state = .complete
+ }
+ }
+
+ self.downloadTask = task
+ }
+
+ func cancel() {
+ self.downloadTask?.cancel()
+ self.state = .cancelled
+
+ logger.trace("Cancelled download for: \(item.displayTitle)")
+ }
+
+ func deleteRootFolder() {
+ guard let downloadFolder = item.downloadFolder else { return }
+ try? FileManager.default.removeItem(at: downloadFolder)
+ }
+
+ func encodeMetadata() -> Data {
+ try! JSONEncoder().encode(item)
+ }
+
+ private func downloadMedia() async throws {
+
+ guard let downloadFolder = item.downloadFolder else { return }
+
+ let request = Paths.getDownload(itemID: item.id!)
+ let response = try await userSession.client.download(for: request, delegate: self)
+
+ let subtype = response.response.mimeSubtype
+ let mediaExtension = subtype == nil ? "" : ".\(subtype!)"
+
+ do {
+ try FileManager.default.createDirectory(at: downloadFolder, withIntermediateDirectories: true)
+
+ try FileManager.default.moveItem(
+ at: response.value,
+ to: downloadFolder.appendingPathComponent("Media\(mediaExtension)")
+ )
+ } catch {
+ logger.error("Error downloading media for: \(item.displayTitle) with error: \(error.localizedDescription)")
+ }
+ }
+
+ private func downloadBackdropImage() async {
+
+ guard let type = item.type else { return }
+
+ let imageURL: URL
+
+ // TODO: move to BaseItemDto
+ switch type {
+ case .movie, .series:
+ guard let url = item.imageSource(.backdrop, maxWidth: 600).url else { return }
+ imageURL = url
+ case .episode:
+ guard let url = item.imageSource(.primary, maxWidth: 600).url else { return }
+ imageURL = url
+ default:
+ return
+ }
+
+ guard let response = try? await userSession.client.download(
+ for: .init(url: imageURL.absoluteString).withResponse(URL.self),
+ delegate: self
+ ) else { return }
+
+ let filename = getImageFilename(from: response, secondary: "Backdrop")
+ saveImage(from: response, filename: filename)
+ }
+
+ private func downloadPrimaryImage() async {
+
+ guard let type = item.type else { return }
+
+ let imageURL: URL
+
+ switch type {
+ case .movie, .series:
+ guard let url = item.imageSource(.primary, maxWidth: 300).url else { return }
+ imageURL = url
+ default:
+ return
+ }
+
+ guard let response = try? await userSession.client.download(
+ for: .init(url: imageURL.absoluteString).withResponse(URL.self),
+ delegate: self
+ ) else { return }
+
+ let filename = getImageFilename(from: response, secondary: "Primary")
+ saveImage(from: response, filename: filename)
+ }
+
+ private func saveImage(from response: Response?, filename: String) {
+
+ guard let response, let imagesFolder else { return }
+
+ do {
+ try FileManager.default.createDirectory(at: imagesFolder, withIntermediateDirectories: true)
+
+ try FileManager.default.moveItem(
+ at: response.value,
+ to: imagesFolder.appendingPathComponent(filename)
+ )
+ } catch {
+ logger.error("Error saving image: \(error.localizedDescription)")
+ }
+ }
+
+ private func getImageFilename(from response: Response, secondary: String) -> String {
+
+ if let suggestedFilename = response.response.suggestedFilename {
+ return suggestedFilename
+ } else {
+ let imageExtension = response.response.mimeSubtype ?? "png"
+ return "\(secondary).\(imageExtension)"
+ }
+ }
+
+ private func saveMetadata() {
+ guard let metadataFolder else { return }
+
+ let jsonEncoder = JSONEncoder()
+ jsonEncoder.outputFormatting = .prettyPrinted
+
+ let itemJsonData = try! jsonEncoder.encode(item)
+ let itemJson = String(data: itemJsonData, encoding: .utf8)
+ let itemFileURL = metadataFolder.appendingPathComponent("Item.json")
+
+ do {
+ try FileManager.default.createDirectory(at: metadataFolder, withIntermediateDirectories: true)
+
+ try itemJson?.write(to: itemFileURL, atomically: true, encoding: .utf8)
+ } catch {
+ logger.error("Error saving item metadata: \(error.localizedDescription)")
+ }
+ }
+
+ func getImageURL(name: String) -> URL? {
+ do {
+ guard let imagesFolder else { return nil }
+ let images = try FileManager.default.contentsOfDirectory(atPath: imagesFolder.path)
+
+ guard let imageFilename = images.first(where: { $0.starts(with: name) }) else { return nil }
+
+ return imagesFolder.appendingPathComponent(imageFilename)
+ } catch {
+ return nil
+ }
+ }
+
+ func getMediaURL() -> URL? {
+ do {
+ guard let downloadFolder = item.downloadFolder else { return nil }
+ let contents = try FileManager.default.contentsOfDirectory(atPath: downloadFolder.path)
+
+ guard let mediaFilename = contents.first(where: { $0.starts(with: "Media") }) else { return nil }
+
+ return downloadFolder.appendingPathComponent(mediaFilename)
+ } catch {
+ return nil
+ }
+ }
+}
+
+// MARK: URLSessionDownloadDelegate
+
+extension DownloadTask: URLSessionDownloadDelegate {
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didWriteData bytesWritten: Int64,
+ totalBytesWritten: Int64,
+ totalBytesExpectedToWrite: Int64
+ ) {
+ let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
+
+ DispatchQueue.main.async {
+ self.state = .downloading(progress)
+ }
+ }
+
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {}
+
+ func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
+ guard let error else { return }
+
+ DispatchQueue.main.async {
+ self.state = .error(error)
+
+ Container.downloadManager.callAsFunction()
+ .remove(task: self)
+ }
+ }
+
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+ guard let error else { return }
+
+ DispatchQueue.main.async {
+ self.state = .error(error)
+
+ Container.downloadManager.callAsFunction()
+ .remove(task: self)
+ }
+ }
+}
+
+extension DownloadTask: Identifiable {
+
+ var id: String {
+ item.id!
+ }
+}
diff --git a/Shared/Services/LogManager.swift b/Shared/Services/LogManager.swift
new file mode 100644
index 00000000..92bd9131
--- /dev/null
+++ b/Shared/Services/LogManager.swift
@@ -0,0 +1,140 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import CoreStore
+import Factory
+import Foundation
+import Logging
+import Pulse
+
+// TODO: cleanup
+
+class LogManager {
+
+ static let service = Factory(scope: .singleton) {
+ .init(label: "Swiftfin")
+ }
+
+ static let pulseNetworkLogger = Factory(scope: .singleton) {
+ var configuration = NetworkLogger.Configuration()
+ configuration.willHandleEvent = { event -> LoggerStore.Event? in
+ switch event {
+ case let .networkTaskCreated(networkTask):
+ if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil {
+ return nil
+ }
+ case let .networkTaskCompleted(networkTask):
+ if networkTask.originalRequest.url?.absoluteString.range(of: "/Images") != nil {
+ return nil
+ }
+ default: ()
+ }
+
+ return event
+ }
+
+ return NetworkLogger(configuration: configuration)
+ }
+}
+
+struct SwiftfinConsoleLogger: LogHandler {
+
+ var logLevel: Logger.Level = .trace
+ var metadata: Logger.Metadata = [:]
+
+ subscript(metadataKey key: String) -> Logger.Metadata.Value? {
+ get {
+ metadata[key]
+ }
+ set(newValue) {
+ metadata[key] = newValue
+ }
+ }
+
+ func log(
+ level: Logger.Level,
+ message: Logger.Message,
+ metadata: Logger.Metadata?,
+ source: String,
+ file: String,
+ function: String,
+ line: UInt
+ ) {
+ print("[\(level.emoji) \(level.rawValue.capitalized)] \(file.shortFileName)#\(line):\(function) \(message)")
+ }
+}
+
+struct SwiftfinCorestoreLogger: CoreStoreLogger {
+
+ @Injected(LogManager.service)
+ private var logger
+
+ func log(
+ error: CoreStoreError,
+ message: String,
+ fileName: StaticString,
+ lineNumber: Int,
+ functionName: StaticString
+ ) {
+ logger.error(
+ "\(message)",
+ metadata: nil,
+ source: "Corestore",
+ file: fileName.description,
+ function: functionName.description,
+ line: UInt(lineNumber)
+ )
+ }
+
+ func log(
+ level: LogLevel,
+ message: String,
+ fileName: StaticString,
+ lineNumber: Int,
+ functionName: StaticString
+ ) {
+ logger.log(
+ level: level.asSwiftLog,
+ "\(message)",
+ metadata: nil,
+ source: "Corestore",
+ file: fileName.description,
+ function: functionName.description,
+ line: UInt(lineNumber)
+ )
+ }
+
+ func assert(
+ _ condition: @autoclosure () -> Bool,
+ message: @autoclosure () -> String,
+ fileName: StaticString,
+ lineNumber: Int,
+ functionName: StaticString
+ ) {}
+}
+
+extension Logger.Level {
+ var emoji: String {
+ switch self {
+ case .trace:
+ return "🟣"
+ case .debug:
+ return "🔵"
+ case .info:
+ return "🟢"
+ case .notice:
+ return "🟠"
+ case .warning:
+ return "🟡"
+ case .error:
+ return "🔴"
+ case .critical:
+ return "💥"
+ }
+ }
+}
diff --git a/Shared/Services/NewSessionManager.swift b/Shared/Services/NewSessionManager.swift
new file mode 100644
index 00000000..86439e22
--- /dev/null
+++ b/Shared/Services/NewSessionManager.swift
@@ -0,0 +1,126 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import CoreData
+import CoreStore
+import Defaults
+import Factory
+import Foundation
+import JellyfinAPI
+import Pulse
+import UIKit
+
+// TODO: cleanup
+
+final class SwiftfinSession {
+
+ let client: JellyfinClient
+ let server: ServerState
+ let user: UserState
+ let authenticated: Bool
+
+ init(
+ server: ServerState,
+ user: UserState,
+ authenticated: Bool
+ ) {
+ self.server = server
+ self.user = user
+ self.authenticated = authenticated
+
+ let client = JellyfinClient(
+ configuration: .swiftfinConfiguration(url: server.currentURL),
+ sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger.callAsFunction()),
+ accessToken: user.accessToken
+ )
+
+ self.client = client
+ }
+}
+
+final class BasicServerSession {
+
+ let client: JellyfinClient
+ let server: ServerState
+
+ init(server: ServerState) {
+ self.server = server
+
+ let client = JellyfinClient(
+ configuration: .swiftfinConfiguration(url: server.currentURL),
+ sessionDelegate: URLSessionProxyDelegate(logger: LogManager.pulseNetworkLogger.callAsFunction())
+ )
+
+ self.client = client
+ }
+}
+
+extension Container.Scope {
+
+ static var basicServerSessionScope = Shared()
+ static var userSessionScope = Cached()
+}
+
+extension Container {
+
+ static let basicServerSessionScope = ParameterFactory(scope: .basicServerSessionScope) {
+ .init(server: $0)
+ }
+
+ static let userSession = Factory(scope: .userSessionScope) {
+
+ if let lastUserID = Defaults[.lastServerUserID],
+ let user = try? SwiftfinStore.dataStack.fetchOne(
+ From(),
+ [Where("id == %@", lastUserID)]
+ )
+ {
+ guard let server = user.server,
+ let existingServer = SwiftfinStore.dataStack.fetchExisting(server)
+ else {
+ fatalError("No associated server for last user")
+ }
+
+ return .init(
+ server: server.state,
+ user: user.state,
+ authenticated: true
+ )
+
+ } else {
+ return .init(
+ server: .sample,
+ user: .sample,
+ authenticated: false
+ )
+ }
+ }
+}
+
+extension JellyfinClient.Configuration {
+
+ static func swiftfinConfiguration(url: URL) -> Self {
+
+ let client = "Swiftfin \(UIDevice.platform)"
+ let deviceName = UIDevice.current.name
+ .folding(options: .diacriticInsensitive, locale: .current)
+ .unicodeScalars
+ .filter { CharacterSet.urlQueryAllowed.contains($0) }
+ .description
+ let deviceID = "\(UIDevice.platform)_\(UIDevice.vendorUUIDString)"
+ let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.1"
+
+ return .init(
+ url: url,
+ client: client,
+ deviceName: deviceName,
+ deviceID: deviceID,
+ version: version
+ )
+ }
+}
diff --git a/Shared/Services/PlaybackManager.swift b/Shared/Services/PlaybackManager.swift
new file mode 100644
index 00000000..840c1a4a
--- /dev/null
+++ b/Shared/Services/PlaybackManager.swift
@@ -0,0 +1,75 @@
+//
+// 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) 2023 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
new file mode 100644
index 00000000..1914e6df
--- /dev/null
+++ b/Shared/Services/SwiftfinDefaults.swift
@@ -0,0 +1,219 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Defaults
+import Foundation
+import SwiftUI
+import UIKit
+
+// TODO: Organize
+
+extension UserDefaults {
+ static let generalSuite = UserDefaults(suiteName: "swiftfinstore-general-defaults")!
+ static let universalSuite = UserDefaults(suiteName: "swiftfinstore-universal-defaults")!
+}
+
+extension Defaults.Keys {
+
+ // Universal settings
+ static let accentColor: Key = .init("accentColor", default: .jellyfinPurple, suite: .universalSuite)
+ static let appAppearance = Key("appAppearance", default: .system, suite: .universalSuite)
+ static let hapticFeedback: Key = .init("hapticFeedback", default: true, suite: .universalSuite)
+ static let lastServerUserID = Defaults.Key("lastServerUserID", suite: .universalSuite)
+
+ // TODO: Replace with a cache
+ static let libraryFilterStore = Key<[String: ItemFilters]>("libraryFilterStore", default: [:], suite: .generalSuite)
+
+ enum Customization {
+
+ static let itemViewType = Key("itemViewType", default: .compactLogo, suite: .generalSuite)
+
+ static let showPosterLabels = Key("showPosterLabels", default: true, suite: .generalSuite)
+ static let nextUpPosterType = Key("nextUpPosterType", default: .portrait, suite: .generalSuite)
+ static let recentlyAddedPosterType = Key("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
+ static let latestInLibraryPosterType = Key("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
+ 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)
+ static let searchPosterType = Key("searchPosterType", default: .portrait, suite: .generalSuite)
+
+ enum CinematicItemViewType {
+
+ static let usePrimaryImage: Key = .init("cinematicItemViewTypeUsePrimaryImage", default: false, suite: .generalSuite)
+ }
+
+ enum Episodes {
+
+ static let useSeriesLandscapeBackdrop = Key("useSeriesBackdrop", default: true, suite: .generalSuite)
+ }
+
+ enum Indicators {
+
+ static let showFavorited: Key = .init("showFavoritedIndicator", default: true, suite: .generalSuite)
+ static let showProgress: Key = .init("showProgressIndicator", default: true, suite: .generalSuite)
+ static let showUnplayed: Key = .init("showUnplayedIndicator", default: true, suite: .generalSuite)
+ static let showPlayed: Key = .init("showPlayedIndicator", default: true, suite: .generalSuite)
+ }
+
+ 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 VideoPlayer {
+
+ static let autoPlayEnabled: Key = .init("autoPlayEnabled", default: true, suite: .generalSuite)
+ static let barActionButtons: Key<[VideoPlayerActionButton]> = .init(
+ "barActionButtons",
+ default: VideoPlayerActionButton.defaultBarActionButtons,
+ suite: .generalSuite
+ )
+ static let jumpBackwardLength: Key = .init(
+ "jumpBackwardLength",
+ default: .fifteen,
+ suite: .generalSuite
+ )
+ static let jumpForwardLength: Key = .init(
+ "jumpForwardLength",
+ default: .fifteen,
+ suite: .generalSuite
+ )
+ static let menuActionButtons: Key<[VideoPlayerActionButton]> = .init(
+ "menuActionButtons",
+ default: VideoPlayerActionButton.defaultMenuActionButtons,
+ suite: .generalSuite
+ )
+ static let resumeOffset: Key = .init("resumeOffset", default: 0, suite: .generalSuite)
+ static let showJumpButtons: Key = .init("showJumpButtons", default: true, suite: .generalSuite)
+ static let videoPlayerType: Key = .init("videoPlayerType", default: .swiftfin, suite: .generalSuite)
+
+ enum Gesture {
+
+ static let horizontalPanGesture: Key = .init(
+ "videoPlayerHorizontalPanGesture",
+ default: .none,
+ suite: .generalSuite
+ )
+ static let horizontalSwipeGesture: Key = .init(
+ "videoPlayerHorizontalSwipeGesture",
+ default: .none,
+ suite: .generalSuite
+ )
+ static let longPressGesture: Key = .init(
+ "videoPlayerLongPressGesture",
+ default: .gestureLock,
+ suite: .generalSuite
+ )
+ static let multiTapGesture: Key = .init("videoPlayerMultiTapGesture", default: .none, suite: .generalSuite)
+ static let doubleTouchGesture: Key = .init(
+ "videoPlayerDoubleTouchGesture",
+ default: .none,
+ suite: .generalSuite
+ )
+ static let pinchGesture: Key = .init("videoPlayerSwipeGesture", default: .aspectFill, suite: .generalSuite)
+ static let verticalPanGestureLeft: Key = .init(
+ "videoPlayerVerticalPanGestureLeft",
+ default: .none,
+ suite: .generalSuite
+ )
+ static let verticalPanGestureRight: Key = .init(
+ "videoPlayerVerticalPanGestureRight",
+ default: .none,
+ suite: .generalSuite
+ )
+ }
+
+ enum Native {
+
+ static let fMP4Container: Key = .init("fmp4Container", default: false, suite: .generalSuite)
+ }
+
+ enum Overlay {
+
+ static let chapterSlider: Key = .init("chapterSlider", default: true, suite: .generalSuite)
+ static let playbackButtonType: Key = .init(
+ "videoPlayerPlaybackButtonLocation",
+ default: .large,
+ suite: .generalSuite
+ )
+ static let sliderColor: Key = .init("sliderColor", default: Color.white, suite: .generalSuite)
+ static let sliderType: Key = .init("sliderType", default: .capsule, suite: .generalSuite)
+
+ // Timestamp
+ static let trailingTimestampType: Key = .init(
+ "trailingTimestamp",
+ default: .timeLeft,
+ suite: .generalSuite
+ )
+ static let showCurrentTimeWhileScrubbing: Key = .init(
+ "showCurrentTimeWhileScrubbing",
+ default: true,
+ suite: .generalSuite
+ )
+ static let timestampType: Key = .init("timestampType", default: .split, suite: .generalSuite)
+ }
+
+ enum Subtitle {
+
+ static let subtitleColor: Key = .init(
+ "subtitleColor",
+ default: .white,
+ suite: .generalSuite
+ )
+ static let subtitleFontName: Key = .init(
+ "subtitleFontName",
+ default: UIFont.systemFont(ofSize: 14).fontName,
+ suite: .generalSuite
+ )
+ static let subtitleSize: Key = .init("subtitleSize", default: 16, suite: .generalSuite)
+ }
+ }
+
+ // Experimental settings
+ enum Experimental {
+
+ static let downloads: Key = .init("experimentalDownloads", default: false, suite: .generalSuite)
+ static let syncSubtitleStateWithAdjacent = Key(
+ "experimentalSyncSubtitleState",
+ default: false,
+ suite: .generalSuite
+ )
+ 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)
+ }
+
+ // tvos specific
+ static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: .generalSuite)
+ static let confirmClose = Key("confirmClose", default: false, suite: .generalSuite)
+}
+
+// MARK: Debug
+
+#if DEBUG
+
+extension UserDefaults {
+
+ static let debugSuite = UserDefaults(suiteName: "swiftfinstore-debug-defaults")!
+}
+
+extension Defaults.Keys {
+
+ static let sendProgressReports: Key = .init("sendProgressReports", default: false, suite: .debugSuite)
+}
+#endif
diff --git a/Shared/Singleton/SwiftfinNotificationCenter.swift b/Shared/Services/SwiftfinNotifications.swift
similarity index 84%
rename from Shared/Singleton/SwiftfinNotificationCenter.swift
rename to Shared/Services/SwiftfinNotifications.swift
index 33d60024..a504bedd 100644
--- a/Shared/Singleton/SwiftfinNotificationCenter.swift
+++ b/Shared/Services/SwiftfinNotifications.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Factory
@@ -38,12 +38,12 @@ enum Notifications {
static let service = Factory(scope: .singleton) { NotificationCenter() }
final class Key {
- public typealias NotificationKey = Notifications.Key
+ typealias NotificationKey = Notifications.Key
- public let key: String
- public let underlyingNotification: SwiftfinNotification
+ let key: String
+ let underlyingNotification: SwiftfinNotification
- public init(_ key: String) {
+ init(_ key: String) {
self.key = key
self.underlyingNotification = SwiftfinNotification(Notification.Name(key))
}
@@ -62,4 +62,5 @@ extension Notifications.Key {
static let didPurge = NotificationKey("didPurge")
static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI")
static let didSendStopReport = NotificationKey("didSendStopReport")
+ static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh")
}
diff --git a/Shared/SwiftfinStore/SwiftfinStore.swift b/Shared/Services/SwiftfinStore.swift
similarity index 58%
rename from Shared/SwiftfinStore/SwiftfinStore.swift
rename to Shared/Services/SwiftfinStore.swift
index f0519a1f..67b5cec9 100644
--- a/Shared/SwiftfinStore/SwiftfinStore.swift
+++ b/Shared/Services/SwiftfinStore.swift
@@ -3,24 +3,30 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import CoreStore
import Defaults
import Foundation
+typealias ServerModel = SwiftfinStore.Models.StoredServer
+typealias UserModel = SwiftfinStore.Models.StoredUser
+
+typealias ServerState = SwiftfinStore.State.Server
+typealias UserState = SwiftfinStore.State.User
+
enum SwiftfinStore {
// MARK: State
// Safe, copyable representations of their underlying CoreStoredObject
- // Relationships are represented by the related object's IDs or value
+ // Relationships are represented by object IDs
enum State {
struct Server: Hashable, Identifiable {
- let uris: Set
- let currentURI: String
+ let urls: Set
+ let currentURL: URL
let name: String
let id: String
let os: String
@@ -28,16 +34,16 @@ enum SwiftfinStore {
let userIDs: [String]
init(
- uris: Set,
- currentURI: String,
+ urls: Set,
+ currentURL: URL,
name: String,
id: String,
os: String,
version: String,
usersIDs: [String]
) {
- self.uris = uris
- self.currentURI = currentURI
+ self.urls = urls
+ self.currentURL = currentURL
self.name = name
self.id = id
self.os = os
@@ -46,9 +52,11 @@ enum SwiftfinStore {
}
static var sample: Server {
- Server(
- uris: ["https://www.notaurl.com", "http://www.maybeaurl.org"],
- currentURI: "https://www.notaurl.com",
+ .init(
+ urls: [
+ .init(string: "http://localhost:8096")!,
+ ],
+ currentURL: .init(string: "http://localhost:8096")!,
name: "Johnny's Tree",
id: "123abc",
os: "macOS",
@@ -59,24 +67,30 @@ enum SwiftfinStore {
}
struct User: Hashable, Identifiable {
- let username: String
+
+ let accessToken: String
let id: String
let serverID: String
- let accessToken: String
+ let username: String
- fileprivate init(username: String, id: String, serverID: String, accessToken: String) {
- self.username = username
+ fileprivate init(
+ accessToken: String,
+ id: String,
+ serverID: String,
+ username: String
+ ) {
+ self.accessToken = accessToken
self.id = id
self.serverID = serverID
- self.accessToken = accessToken
+ self.username = username
}
- static var sample: User {
- User(
- username: "JohnnyAppleseed",
+ static var sample: Self {
+ .init(
+ accessToken: "open-sesame",
id: "123abc",
serverID: "123abc",
- accessToken: "open-sesame"
+ username: "JohnnyAppleseed"
)
}
}
@@ -88,11 +102,11 @@ enum SwiftfinStore {
final class StoredServer: CoreStoreObject {
- @Field.Coded("uris", coder: FieldCoders.Json.self)
- var uris: Set = []
+ @Field.Coded("urls", coder: FieldCoders.Json.self)
+ var urls: Set = []
- @Field.Stored("currentURI")
- var currentURI: String = ""
+ @Field.Stored("currentURL")
+ var currentURL: URL = .init(string: "/")!
@Field.Stored("name")
var name: String = ""
@@ -109,10 +123,10 @@ enum SwiftfinStore {
@Field.Relationship("users", inverse: \StoredUser.$server)
var users: Set
- var state: State.Server {
- State.Server(
- uris: uris,
- currentURI: currentURI,
+ var state: ServerState {
+ .init(
+ urls: urls,
+ currentURL: currentURL,
name: name,
id: id,
os: os,
@@ -124,6 +138,9 @@ enum SwiftfinStore {
final class StoredUser: CoreStoreObject {
+ @Field.Stored("accessToken")
+ var accessToken: String = ""
+
@Field.Stored("username")
var username: String = ""
@@ -136,29 +153,16 @@ enum SwiftfinStore {
@Field.Relationship("server")
var server: StoredServer?
- @Field.Relationship("accessToken", inverse: \StoredAccessToken.$user)
- var accessToken: StoredAccessToken?
-
- var state: State.User {
+ var state: UserState {
guard let server = server else { fatalError("No server associated with user") }
- guard let accessToken = accessToken else { fatalError("No access token associated with user") }
- return State.User(
- username: username,
+ return .init(
+ accessToken: accessToken,
id: id,
serverID: server.id,
- accessToken: accessToken.value
+ username: username
)
}
}
-
- final class StoredAccessToken: CoreStoreObject {
-
- @Field.Stored("value")
- var value: String = ""
-
- @Field.Relationship("user")
- var user: StoredUser?
- }
}
// MARK: Error
@@ -170,37 +174,30 @@ enum SwiftfinStore {
// MARK: dataStack
- static let dataStack: DataStack = {
- let schema = CoreStoreSchema(
- modelVersion: "V1",
- entities: [
- Entity("Server"),
- Entity("User"),
- Entity("AccessToken"),
+ private static let v1Schema = CoreStoreSchema(
+ modelVersion: "V1",
+ entities: [
+ Entity("Server"),
+ Entity("User"),
+ ],
+ versionLock: [
+ "Server": [
+ 0x4E8_8201_635C_2BB5,
+ 0x7A7_85D8_A65D_177C,
+ 0x3FE6_7B5B_D402_6EEE,
+ 0x8893_16D4_188E_B136,
],
- versionLock: [
- "AccessToken": [
- 0xA8C4_75E8_7449_4BB1,
- 0x7948_6E93_449F_0B3D,
- 0xA7DC_4A00_0354_1EDB,
- 0x9418_3FAE_7580_EF72,
- ],
- "Server": [
- 0x936B_46AC_D8E8_F0E3,
- 0x5989_0D4D_9F3F_885F,
- 0x819C_F7A4_ABF9_8B22,
- 0xE161_25C5_AF88_5A06,
- ],
- "User": [
- 0x845D_E08A_74BC_53ED,
- 0xE95A_406A_29F3_A5D0,
- 0x9EDA_7328_21A1_5EA9,
- 0xB5A_FA53_1E41_CE8A,
- ],
- ]
- )
+ "User": [
+ 0x1001_44F1_4D4D_5A31,
+ 0x828F_7943_7D0B_4C03,
+ 0x3824_5761_B815_D61A,
+ 0x3C1D_BF68_E42B_1DA6,
+ ],
+ ]
+ )
- let _dataStack = DataStack(schema)
+ static let dataStack: DataStack = {
+ let _dataStack = DataStack(v1Schema)
try! _dataStack.addStorageAndWait(SQLiteStore(
fileName: "Swiftfin.sqlite",
localStorageOptions: .recreateStoreOnModelMismatch
diff --git a/Shared/Singleton/LogManager.swift b/Shared/Singleton/LogManager.swift
deleted file mode 100644
index 5dc269c7..00000000
--- a/Shared/Singleton/LogManager.swift
+++ /dev/null
@@ -1,74 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Factory
-import Foundation
-import Puppy
-
-class LogManager {
-
- static let service = Factory(scope: .singleton) {
- Puppy.swiftfinInstance()
- }
-
-// static let log = Puppy()
-}
-
-class LogFormatter: LogFormattable {
- func formatMessage(
- _ level: LogLevel,
- message: String,
- tag: String,
- function: String,
- file: String,
- line: UInt,
- swiftLogInfo: [String: String],
- label: String,
- date: Date,
- threadID: UInt64
- ) -> String {
- let file = shortFileName(file).replacingOccurrences(of: ".swift", with: "")
- return " [\(level.emoji) \(level)] \(file)#\(line):\(function) \(message)"
- }
-}
-
-private extension Puppy {
- static func swiftfinInstance() -> Puppy {
-
- let logger = Puppy()
-
- #if !os(tvOS)
- let logsDirectory = URL.documents.appendingPathComponent("logs", isDirectory: true)
-
- do {
- try FileManager.default.createDirectory(
- atPath: logsDirectory.path,
- withIntermediateDirectories: true,
- attributes: nil
- )
- } catch {
- // logs directory already created
- }
-
- let logFileURL = logsDirectory.appendingPathComponent("swiftfin_log.log")
-
- let fileRotationLogger = try! FileRotationLogger(
- "org.jellyfin.swiftfin.logger.file-rotation",
- fileURL: logFileURL
- )
- fileRotationLogger.format = LogFormatter()
- logger.add(fileRotationLogger, withLevel: .debug)
- #endif
-
- let consoleLogger = ConsoleLogger("org.jellyfin.swiftfin.logger.console")
- consoleLogger.format = LogFormatter()
-
- logger.add(consoleLogger, withLevel: .debug)
- return logger
- }
-}
diff --git a/Shared/Singleton/SessionManager.swift b/Shared/Singleton/SessionManager.swift
deleted file mode 100644
index 4091f64a..00000000
--- a/Shared/Singleton/SessionManager.swift
+++ /dev/null
@@ -1,390 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Combine
-import CoreData
-import CoreStore
-import Defaults
-import Foundation
-import JellyfinAPI
-import UIKit
-
-typealias CurrentLogin = (server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User)
-
-// MARK: NewSessionManager
-
-final class SessionManager {
-
- // MARK: currentLogin
-
- fileprivate(set) var currentLogin: CurrentLogin!
-
- // MARK: main
-
- static let main = SessionManager()
-
- // MARK: init
-
- private init() {
- setAuthHeader(with: "")
-
- if let lastUserID = Defaults[.lastServerUserID],
- let user = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- [Where("id == %@", lastUserID)]
- )
- {
-
- guard let server = user.server,
- let accessToken = user.accessToken else { fatalError("No associated server or access token for last user?") }
- guard let existingServer = SwiftfinStore.dataStack.fetchExisting(server) else { return }
-
- JellyfinAPIAPI.basePath = server.currentURI
- setAuthHeader(with: accessToken.value)
- currentLogin = (server: existingServer.state, user: user.state)
- }
- }
-
- // MARK: fetchServers
-
- func fetchServers() -> [SwiftfinStore.State.Server] {
- let servers = try! SwiftfinStore.dataStack.fetchAll(From())
- return servers.map(\.state)
- }
-
- // MARK: fetchUsers
-
- func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] {
- guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- Where("id == %@", server.id)
- )
- else { fatalError("No stored server associated with given state server?") }
- return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username })
- }
-
- // MARK: connectToServer publisher
-
- // Connects to a server at the given uri, storing if successful
- func connectToServer(with uri: String) -> AnyPublisher {
- var uriComponents = URLComponents(string: uri) ?? URLComponents()
-
- if uriComponents.scheme == nil {
- uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue
- }
-
- var uri = uriComponents.string ?? ""
-
- if uri.last == "/" {
- uri = String(uri.dropLast())
- }
-
- JellyfinAPIAPI.basePath = uri
-
- return SystemAPI.getPublicSystemInfo()
- .tryMap { response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
-
- let transaction = SwiftfinStore.dataStack.beginUnsafe()
- let newServer = transaction.create(Into())
-
- guard let name = response.serverName,
- let id = response.id,
- let os = response.operatingSystem,
- let version = response.version else { throw JellyfinAPIError("Missing server data from network call") }
-
- newServer.uris = [uri]
- newServer.currentURI = uri
- newServer.name = name
- newServer.id = id
- newServer.os = os
- newServer.version = version
- newServer.users = []
-
- // Check for existing server on device
- if let existingServer = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- [Where(
- "id == %@",
- newServer.id
- )]
- ) {
- throw SwiftfinStore.Error.existingServer(existingServer.state)
- }
-
- return (newServer, transaction)
- }
- .handleEvents(receiveOutput: { _, transaction in
- try? transaction.commitAndWait()
- })
- .map { server, _ in
- server.state
- }
- .eraseToAnyPublisher()
- }
-
- // MARK: addURIToServer publisher
-
- func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher {
- Just(server)
- .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
-
- let transaction = SwiftfinStore.dataStack.beginUnsafe()
-
- guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- [Where(
- "id == %@",
- server.id
- )]
- )
- else {
- fatalError("No stored server associated with given state server?")
- }
-
- guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") }
- editServer.uris.insert(uri)
-
- return (editServer, transaction)
- }
- .handleEvents(receiveOutput: { _, transaction in
- try? transaction.commitAndWait()
- })
- .map { server, _ in
- server.state
- }
- .eraseToAnyPublisher()
- }
-
- // MARK: setServerCurrentURI publisher
-
- func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher {
- Just(server)
- .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
-
- let transaction = SwiftfinStore.dataStack.beginUnsafe()
-
- guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- [Where(
- "id == %@",
- server.id
- )]
- )
- else {
- fatalError("No stored server associated with given state server?")
- }
-
- if !existingServer.uris.contains(uri) {
- fatalError("Attempting to set current uri while server doesn't contain it?")
- }
-
- guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") }
- editServer.currentURI = uri
-
- return (editServer, transaction)
- }
- .handleEvents(receiveOutput: { _, transaction in
- try? transaction.commitAndWait()
- })
- .map { server, _ in
- server.state
- }
- .eraseToAnyPublisher()
- }
-
- // MARK: signInUser publisher
-
- // Logs in a user with an associated server, storing if successful
- func signInUser(
- server: SwiftfinStore.State.Server,
- username: String,
- password: String
- ) -> AnyPublisher {
- JellyfinAPIAPI.basePath = server.currentURI
-
- return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password))
- .processAuthenticationRequest(with: self, server: server)
- }
-
- // Logs in a user with an associated server, storing if successful
- func signInUser(server: SwiftfinStore.State.Server, quickConnectSecret: String) -> AnyPublisher {
- JellyfinAPIAPI.basePath = server.currentURI
-
- return UserAPI.authenticateWithQuickConnect(authenticateWithQuickConnectRequest: .init(secret: quickConnectSecret))
- .processAuthenticationRequest(with: self, server: server)
- }
-
- // MARK: signInUser
-
- func signInUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
- JellyfinAPIAPI.basePath = server.currentURI
- Defaults[.lastServerUserID] = user.id
- setAuthHeader(with: user.accessToken)
- currentLogin = (server: server, user: user)
- Notifications[.didSignIn].post()
- }
-
- // MARK: logout
-
- func logout() {
- currentLogin = nil
- JellyfinAPIAPI.basePath = ""
- setAuthHeader(with: "")
- Defaults[.lastServerUserID] = nil
- Notifications[.didSignOut].post()
- }
-
- // MARK: purge
-
- func purge() {
- // Delete all servers
- let servers = fetchServers()
-
- for server in servers {
- delete(server: server)
- }
-
- Notifications[.didPurge].post()
- }
-
- // MARK: delete user
-
- func delete(user: SwiftfinStore.State.User) {
- guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- [Where("id == %@", user.id)]
- )
- else { fatalError("No stored user for state user?") }
- _delete(user: storedUser, transaction: nil)
- }
-
- // MARK: delete server
-
- func delete(server: SwiftfinStore.State.Server) {
- guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- [Where("id == %@", server.id)]
- )
- else { fatalError("No stored server for state server?") }
- _delete(server: storedServer, transaction: nil)
- }
-
- private func _delete(user: SwiftfinStore.Models.StoredUser, transaction: UnsafeDataTransaction?) {
- guard let storedAccessToken = user.accessToken else { fatalError("No access token for stored user?") }
-
- let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction!
- transaction.delete(storedAccessToken)
- transaction.delete(user)
- try? transaction.commitAndWait()
- }
-
- private func _delete(server: SwiftfinStore.Models.StoredServer, transaction: UnsafeDataTransaction?) {
- let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction!
-
- for user in server.users {
- _delete(user: user, transaction: transaction)
- }
-
- transaction.delete(server)
- try? transaction.commitAndWait()
- }
-
- fileprivate func setAuthHeader(with accessToken: String) {
- let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
- var deviceName = UIDevice.current.name
- deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current)
- deviceName = String(deviceName.unicodeScalars.filter { CharacterSet.urlQueryAllowed.contains($0) })
-
- let platform: String
- #if os(tvOS)
- platform = "tvOS"
- #else
- platform = "iOS"
- #endif
-
- var header = "MediaBrowser "
- header.append("Client=\"Jellyfin \(platform)\", ")
- header.append("Device=\"\(deviceName)\", ")
- header.append("DeviceId=\"\(platform)_\(UIDevice.vendorUUIDString)_\(String(Date().timeIntervalSince1970))\", ")
- header.append("Version=\"\(appVersion ?? "0.0.1")\", ")
- header.append("Token=\"\(accessToken)\"")
-
- JellyfinAPIAPI.customHeaders["X-Emby-Authorization"] = header
- }
-}
-
-extension AnyPublisher where Output == AuthenticationResult {
- func processAuthenticationRequest(
- with sessionManager: SessionManager,
- server: SwiftfinStore.State.Server
- ) -> AnyPublisher {
- self
- .tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in
-
- guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") }
-
- let transaction = SwiftfinStore.dataStack.beginUnsafe()
- let newUser = transaction.create(Into())
-
- guard let username = response.user?.name,
- let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
-
- newUser.username = username
- newUser.id = id
- newUser.appleTVID = ""
-
- // Check for existing user on device
- if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- [Where(
- "id == %@",
- newUser.id
- )]
- ) {
- throw SwiftfinStore.Error.existingUser(existingUser.state)
- }
-
- let newAccessToken = transaction.create(Into())
- newAccessToken.value = accessToken
- newUser.accessToken = newAccessToken
-
- guard let userServer = try? SwiftfinStore.dataStack.fetchOne(
- From(),
- [
- Where(
- "id == %@",
- server.id
- ),
- ]
- )
- else { fatalError("No stored server associated with given state server?") }
-
- guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") }
- editUserServer.users.insert(newUser)
-
- return (editUserServer, newUser, transaction)
- }
- .handleEvents(receiveOutput: { server, user, transaction in
- sessionManager.setAuthHeader(with: user.accessToken?.value ?? "")
- try? transaction.commitAndWait()
-
- // Fetch for the right queue
- let currentServer = SwiftfinStore.dataStack.fetchExisting(server)!
- let currentUser = SwiftfinStore.dataStack.fetchExisting(user)!
-
- Defaults[.lastServerUserID] = user.id
-
- sessionManager.currentLogin = (server: currentServer.state, user: currentUser.state)
- Notifications[.didSignIn].post()
- })
- .map { _, user, _ in
- user.state
- }
- .eraseToAnyPublisher()
- }
-}
diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift
index a03ef85a..c759c1f1 100644
--- a/Shared/Strings/Strings.swift
+++ b/Shared/Strings/Strings.swift
@@ -12,6 +12,10 @@ import Foundation
internal enum L10n {
/// About
internal static let about = L10n.tr("Localizable", "about", fallback: "About")
+ /// Accent Color
+ internal static let accentColor = L10n.tr("Localizable", "accentColor", fallback: "Accent Color")
+ /// Some views may need an app restart to update.
+ internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.")
/// Accessibility
internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility")
/// Add URL
@@ -26,6 +30,8 @@ internal enum L10n {
internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media")
/// Appearance
internal static let appearance = L10n.tr("Localizable", "appearance", fallback: "Appearance")
+ /// App Icon
+ internal static let appIcon = L10n.tr("Localizable", "appIcon", fallback: "App Icon")
/// Apply
internal static let apply = L10n.tr("Localizable", "apply", fallback: "Apply")
/// Audio
@@ -40,6 +46,10 @@ internal enum L10n {
internal static let autoPlay = L10n.tr("Localizable", "autoPlay", fallback: "Auto Play")
/// Back
internal static let back = L10n.tr("Localizable", "back", fallback: "Back")
+ /// Blue
+ internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue")
+ /// Bugs and Features
+ internal static let bugsAndFeatures = L10n.tr("Localizable", "bugsAndFeatures", fallback: "Bugs and Features")
/// Cancel
internal static let cancel = L10n.tr("Localizable", "cancel", fallback: "Cancel")
/// Cannot connect to host
@@ -62,6 +72,10 @@ internal enum L10n {
internal static let close = L10n.tr("Localizable", "close", fallback: "Close")
/// Closed Captions
internal static let closedCaptions = L10n.tr("Localizable", "closedCaptions", fallback: "Closed Captions")
+ /// Collections
+ internal static let collections = L10n.tr("Localizable", "collections", fallback: "Collections")
+ /// Color
+ internal static let color = L10n.tr("Localizable", "color", fallback: "Color")
/// Compact
internal static let compact = L10n.tr("Localizable", "compact", fallback: "Compact")
/// Compact Logo
@@ -96,16 +110,22 @@ internal enum L10n {
internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark")
/// Default Scheme
internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme")
+ /// Delivery
+ internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery")
/// DIRECTOR
internal static let director = L10n.tr("Localizable", "director", fallback: "DIRECTOR")
/// Discovered Servers
internal static let discoveredServers = L10n.tr("Localizable", "discoveredServers", fallback: "Discovered Servers")
+ /// Dismiss
+ internal static let dismiss = L10n.tr("Localizable", "dismiss", fallback: "Dismiss")
/// Display order
internal static let displayOrder = L10n.tr("Localizable", "displayOrder", fallback: "Display order")
/// Edit Jump Lengths
internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths")
/// Empty Next Up
internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up")
+ /// Episode Landscape Poster
+ internal static let episodeLandscapePoster = L10n.tr("Localizable", "episodeLandscapePoster", fallback: "Episode Landscape Poster")
/// Episode %1$@
internal static func episodeNumber(_ p1: Any) -> String {
return L10n.tr("Localizable", "episodeNumber", String(describing: p1), fallback: "Episode %1$@")
@@ -120,6 +140,8 @@ internal enum L10n {
internal static let existingUser = L10n.tr("Localizable", "existingUser", fallback: "Existing User")
/// Experimental
internal static let experimental = L10n.tr("Localizable", "experimental", fallback: "Experimental")
+ /// Favorited
+ internal static let favorited = L10n.tr("Localizable", "favorited", fallback: "Favorited")
/// Favorites
internal static let favorites = L10n.tr("Localizable", "favorites", fallback: "Favorites")
/// File
@@ -130,10 +152,20 @@ internal enum L10n {
internal static let filters = L10n.tr("Localizable", "filters", fallback: "Filters")
/// Genres
internal static let genres = L10n.tr("Localizable", "genres", fallback: "Genres")
+ /// Green
+ internal static let green = L10n.tr("Localizable", "green", fallback: "Green")
+ /// Haptic Feedback
+ internal static let hapticFeedback = L10n.tr("Localizable", "hapticFeedback", fallback: "Haptic Feedback")
/// Home
internal static let home = L10n.tr("Localizable", "home", fallback: "Home")
+ /// Indicators
+ internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators")
/// Information
internal static let information = L10n.tr("Localizable", "information", fallback: "Information")
+ /// Inverted Dark
+ internal static let invertedDark = L10n.tr("Localizable", "invertedDark", fallback: "Inverted Dark")
+ /// Inverted Light
+ internal static let invertedLight = L10n.tr("Localizable", "invertedLight", fallback: "Inverted Light")
/// Items
internal static let items = L10n.tr("Localizable", "items", fallback: "Items")
/// Jump Backward
@@ -230,6 +262,8 @@ internal enum L10n {
internal static let oneUser = L10n.tr("Localizable", "oneUser", fallback: "1 user")
/// Operating System
internal static let operatingSystem = L10n.tr("Localizable", "operatingSystem", fallback: "Operating System")
+ /// Orange
+ internal static let orange = L10n.tr("Localizable", "orange", fallback: "Orange")
/// Other
internal static let other = L10n.tr("Localizable", "other", fallback: "Other")
/// Other User
@@ -246,6 +280,8 @@ internal enum L10n {
}
/// Password
internal static let password = L10n.tr("Localizable", "password", fallback: "Password")
+ /// People
+ internal static let people = L10n.tr("Localizable", "people", fallback: "People")
/// Play
internal static let play = L10n.tr("Localizable", "play", fallback: "Play")
/// Play / Pause
@@ -254,6 +290,8 @@ internal enum L10n {
internal static let playbackSettings = L10n.tr("Localizable", "playbackSettings", fallback: "Playback settings")
/// Playback Speed
internal static let playbackSpeed = L10n.tr("Localizable", "playbackSpeed", fallback: "Playback Speed")
+ /// Played
+ internal static let played = L10n.tr("Localizable", "played", fallback: "Played")
/// Player Gestures Lock Gesture Enabled
internal static let playerGesturesLockGestureEnabled = L10n.tr("Localizable", "playerGesturesLockGestureEnabled", fallback: "Player Gestures Lock Gesture Enabled")
/// Play From Beginning
@@ -264,14 +302,20 @@ internal enum L10n {
internal static let playNextItem = L10n.tr("Localizable", "playNextItem", fallback: "Play Next Item")
/// Play Previous Item
internal static let playPreviousItem = L10n.tr("Localizable", "playPreviousItem", fallback: "Play Previous Item")
+ /// Posters
+ internal static let posters = L10n.tr("Localizable", "posters", fallback: "Posters")
/// Present
internal static let present = L10n.tr("Localizable", "present", fallback: "Present")
/// Press Down for Menu
internal static let pressDownForMenu = L10n.tr("Localizable", "pressDownForMenu", fallback: "Press Down for Menu")
/// Previous Item
internal static let previousItem = L10n.tr("Localizable", "previousItem", fallback: "Previous Item")
+ /// Primary
+ internal static let primary = L10n.tr("Localizable", "primary", fallback: "Primary")
/// Programs
internal static let programs = L10n.tr("Localizable", "programs", fallback: "Programs")
+ /// Progress
+ internal static let progress = L10n.tr("Localizable", "progress", fallback: "Progress")
/// Public Users
internal static let publicUsers = L10n.tr("Localizable", "publicUsers", fallback: "Public Users")
/// Quick Connect
@@ -290,12 +334,16 @@ internal enum L10n {
internal static let quickConnectStep3 = L10n.tr("Localizable", "quickConnectStep3", fallback: "3. Enter the following code:")
/// Authorizing Quick Connect successful. Please continue on your other device.
internal static let quickConnectSuccessMessage = L10n.tr("Localizable", "quickConnectSuccessMessage", fallback: "Authorizing Quick Connect successful. Please continue on your other device.")
+ /// Random Image
+ internal static let randomImage = L10n.tr("Localizable", "randomImage", fallback: "Random Image")
/// Rated
internal static let rated = L10n.tr("Localizable", "rated", fallback: "Rated")
/// Recently Added
internal static let recentlyAdded = L10n.tr("Localizable", "recentlyAdded", fallback: "Recently Added")
/// Recommended
internal static let recommended = L10n.tr("Localizable", "recommended", fallback: "Recommended")
+ /// Red
+ internal static let red = L10n.tr("Localizable", "red", fallback: "Red")
/// Refresh
internal static let refresh = L10n.tr("Localizable", "refresh", fallback: "Refresh")
/// Regular
@@ -350,6 +398,8 @@ internal enum L10n {
internal static let selectCastDestination = L10n.tr("Localizable", "selectCastDestination", fallback: "Select Cast Destination")
/// Series
internal static let series = L10n.tr("Localizable", "series", fallback: "Series")
+ /// Series Backdrop
+ internal static let seriesBackdrop = L10n.tr("Localizable", "seriesBackdrop", fallback: "Series Backdrop")
/// Server
internal static let server = L10n.tr("Localizable", "server", fallback: "Server")
/// Server %s is already connected
@@ -402,10 +452,14 @@ internal enum L10n {
internal static let sortBy = L10n.tr("Localizable", "sortBy", fallback: "Sort by")
/// Source Code
internal static let sourceCode = L10n.tr("Localizable", "sourceCode", fallback: "Source Code")
+ /// Special Features
+ internal static let specialFeatures = L10n.tr("Localizable", "specialFeatures", fallback: "Special Features")
/// STUDIO
internal static let studio = L10n.tr("Localizable", "studio", fallback: "STUDIO")
/// Studios
internal static let studios = L10n.tr("Localizable", "studios", fallback: "Studios")
+ /// Subtitle
+ internal static let subtitle = L10n.tr("Localizable", "subtitle", fallback: "Subtitle")
/// Subtitle Font
internal static let subtitleFont = L10n.tr("Localizable", "subtitleFont", fallback: "Subtitle Font")
/// Subtitles
@@ -442,8 +496,14 @@ internal enum L10n {
internal static let unknown = L10n.tr("Localizable", "unknown", fallback: "Unknown")
/// Unknown Error
internal static let unknownError = L10n.tr("Localizable", "unknownError", fallback: "Unknown Error")
+ /// Unplayed
+ internal static let unplayed = L10n.tr("Localizable", "unplayed", fallback: "Unplayed")
/// URL
internal static let url = L10n.tr("Localizable", "url", fallback: "URL")
+ /// Use Primary Image
+ internal static let usePrimaryImage = L10n.tr("Localizable", "usePrimaryImage", fallback: "Use Primary Image")
+ /// Uses the primary image and hides the logo.
+ internal static let usePrimaryImageDescription = L10n.tr("Localizable", "usePrimaryImageDescription", fallback: "Uses the primary image and hides the logo.")
/// User
internal static let user = L10n.tr("Localizable", "user", fallback: "User")
/// User %s is already signed in
@@ -454,12 +514,16 @@ internal enum L10n {
internal static let username = L10n.tr("Localizable", "username", fallback: "Username")
/// Version
internal static let version = L10n.tr("Localizable", "version", fallback: "Version")
+ /// Video
+ internal static let video = L10n.tr("Localizable", "video", fallback: "Video")
/// Video Player
internal static let videoPlayer = L10n.tr("Localizable", "videoPlayer", fallback: "Video Player")
/// Who's watching?
internal static let whosWatching = L10n.tr("Localizable", "WhosWatching", fallback: "Who's watching?")
/// WIP
internal static let wip = L10n.tr("Localizable", "wip", fallback: "WIP")
+ /// Yellow
+ internal static let yellow = L10n.tr("Localizable", "yellow", fallback: "Yellow")
/// Your Favorites
internal static let yourFavorites = L10n.tr("Localizable", "yourFavorites", fallback: "Your Favorites")
}
diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift
deleted file mode 100644
index 92343474..00000000
--- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift
+++ /dev/null
@@ -1,128 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Defaults
-import Foundation
-import UIKit
-
-// TODO: Organize
-
-extension UserDefaults {
- static let generalSuite = UserDefaults(suiteName: "swiftfinstore-general-defaults")!
- static let universalSuite = UserDefaults(suiteName: "swiftfinstore-universal-defaults")!
-}
-
-extension Defaults.Keys {
- // Universal settings
- static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: .universalSuite)
- static let appAppearance = Key("appAppearance", default: .system, suite: .universalSuite)
-
- // General settings
- static let lastServerUserID = Defaults.Key("lastServerUserID", suite: .generalSuite)
- static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: .generalSuite)
- static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: .generalSuite)
- static let libraryFilterStore = Key<[String: ItemFilters]>("libraryFilterStore", default: [:], suite: .generalSuite)
-
- enum Customization {
- static let itemViewType = Key("itemViewType", default: .compactLogo, suite: .generalSuite)
-
- static let showPosterLabels = Key("showPosterLabels", default: true, suite: .generalSuite)
- static let nextUpPosterType = Key("nextUpPosterType", default: .portrait, suite: .generalSuite)
- static let recentlyAddedPosterType = Key("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
- static let latestInLibraryPosterType = Key("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
- static let similarPosterType = Key("similarPosterType", default: .portrait, suite: .generalSuite)
- static let searchPosterType = Key("searchPosterType", default: .portrait, suite: .generalSuite)
-
- enum Episodes {
- static let useSeriesLandscapeBackdrop = Key("useSeriesBackdrop", default: true, suite: .generalSuite)
- }
-
- enum Library {
- static let viewType = Key("Customization.Library.viewType", default: .grid, suite: .generalSuite)
- static let gridPosterType = Key("Customization.Library.gridPosterType", default: .portrait, suite: .generalSuite)
- }
- }
-
- // Video player / overlay settings
- static let overlayType = Key("overlayType", default: .normal, suite: .generalSuite)
- static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: .generalSuite)
- static let systemControlGesturesEnabled = Key(
- "systemControlGesturesEnabled",
- default: true,
- suite: .generalSuite
- )
- static let playerGesturesLockGestureEnabled = Key(
- "playerGesturesLockGestureEnabled",
- default: true,
- suite: .generalSuite
- )
- static let seekSlideGestureEnabled = Key(
- "seekSlideGestureEnabled",
- default: true,
- suite: .generalSuite
- )
- static let videoPlayerJumpForward = Key(
- "videoPlayerJumpForward",
- default: .fifteen,
- suite: .generalSuite
- )
- static let videoPlayerJumpBackward = Key(
- "videoPlayerJumpBackward",
- default: .fifteen,
- suite: .generalSuite
- )
- static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: .generalSuite)
- static let resumeOffset = Key("resumeOffset", default: false, suite: .generalSuite)
- static let subtitleFontName = Key(
- "subtitleFontName",
- default: UIFont.systemFont(ofSize: 14).fontName,
- suite: .generalSuite
- )
- static let subtitleSize = Key("subtitleSize", default: .regular, suite: .generalSuite)
-
- // Should show video player items
- static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: .generalSuite)
- static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: .generalSuite)
- static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: .generalSuite)
-
- // Should show missing seasons and episodes
- static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: .generalSuite)
- static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", default: true, suite: .generalSuite)
-
- // Should show video player items in overlay menu
- static let shouldShowJumpButtonsInOverlayMenu = Key(
- "shouldShowJumpButtonsInMenu",
- default: true,
- suite: .generalSuite
- )
-
- static let shouldShowChaptersInfoInBottomOverlay = Key(
- "shouldShowChaptersInfoInBottomOverlay",
- default: true,
- suite: .generalSuite
- )
-
- // Experimental settings
- enum Experimental {
- static let syncSubtitleStateWithAdjacent = Key(
- "experimental.syncSubtitleState",
- default: false,
- suite: .generalSuite
- )
- static let forceDirectPlay = Key("forceDirectPlay", default: false, suite: .generalSuite)
- static let nativePlayer = Key("nativePlayer", default: false, suite: .generalSuite)
- static let usefmp4Hls = Key("usefmp4Hls", default: false, suite: .generalSuite)
- static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: .generalSuite)
- static let liveTVForceDirectPlay = Key("liveTVForceDirectPlay", default: false, suite: .generalSuite)
- static let liveTVNativePlayer = Key("liveTVNativePlayer", default: false, suite: .generalSuite)
- }
-
- // tvos specific
- static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: .generalSuite)
- static let confirmClose = Key("confirmClose", default: false, suite: .generalSuite)
-}
diff --git a/Shared/ViewModels/BasicAppSettingsViewModel.swift b/Shared/ViewModels/BasicAppSettingsViewModel.swift
deleted file mode 100644
index 22378cd7..00000000
--- a/Shared/ViewModels/BasicAppSettingsViewModel.swift
+++ /dev/null
@@ -1,26 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import SwiftUI
-
-final class BasicAppSettingsViewModel: ViewModel {
-
- let appearances = AppAppearance.allCases
-
- func resetUserSettings() {
- UserDefaults.generalSuite.removeAll()
- }
-
- func resetAppSettings() {
- UserDefaults.universalSuite.removeAll()
- }
-
- func removeAllUsers() {
- SessionManager.main.purge()
- }
-}
diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift
index 1729715d..e5a3b05e 100644
--- a/Shared/ViewModels/ConnectToServerViewModel.swift
+++ b/Shared/ViewModels/ConnectToServerViewModel.swift
@@ -3,113 +3,120 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
-import Combine
+import CoreStore
+import CryptoKit
+import Defaults
import Factory
import Foundation
+import Get
import JellyfinAPI
-import Stinsen
-
-struct AddServerURIPayload: Identifiable {
-
- let server: SwiftfinStore.State.Server
- let uri: String
-
- var id: String {
- server.id.appending(uri)
- }
-}
+import Pulse
+import UIKit
final class ConnectToServerViewModel: ViewModel {
- @RouterObject
- var router: ConnectToServerCoodinator.Router?
@Published
- var discoveredServers: [SwiftfinStore.State.Server] = []
+ private(set) var discoveredServers: [ServerState] = []
+
@Published
- var searching = false
- @Published
- var addServerURIPayload: AddServerURIPayload?
- var backAddServerURIPayload: AddServerURIPayload?
+ private(set) var isSearching = false
private let discovery = ServerDiscovery()
- var alertTitle: String {
- var message: String = ""
- if errorMessage?.code != ErrorMessage.noShowErrorCode {
- message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n")
+ var connectToServerTask: Task?
+
+ func connectToServer(url: String) async throws -> (server: ServerState, url: URL) {
+
+ #if os(iOS)
+ // shhhh
+ // TODO: remove
+ if let data = url.data(using: .utf8) {
+ var sha = SHA256()
+ sha.update(data: data)
+ let digest = sha.finalize()
+ let urlHash = digest.compactMap { String(format: "%02x", $0) }.joined()
+ if urlHash == "7499aced43869b27f505701e4edc737f0cc346add1240d4ba86fbfa251e0fc35" {
+ Defaults[.Experimental.downloads] = true
+
+ await UIDevice.feedback(.success)
+ }
}
- message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)")
- return message
- }
+ #endif
- func connectToServer(uri: String, redirectCount: Int = 0) {
-
- let uri = uri.trimmingCharacters(in: .whitespacesAndNewlines)
+ let formattedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .objectReplacement)
- logger.debug("Attempting to connect to server at \"\(uri)\"", tag: "connectToServer")
- SessionManager.main.connectToServer(with: uri)
- .trackActivity(loading)
- .sink(receiveCompletion: { completion in
- // This is disgusting. ViewModel Error handling overall needs to be refactored
- switch completion {
- case .finished: ()
- case let .failure(error):
- switch error {
- case is ErrorResponse:
- let errorResponse = error as! ErrorResponse
- switch errorResponse {
- case let .error(_, _, response, _):
- // a url in the response is the result if a redirect
- if let newURL = response?.url {
- if redirectCount > 2 {
- self.handleAPIRequestError(displayMessage: L10n.tooManyRedirects, completion: completion)
- } else {
- self
- .connectToServer(
- uri: newURL.absoluteString
- .removeRegexMatches(pattern: "/web/index.html"),
- redirectCount: redirectCount + 1
- )
- }
- } else {
- self.handleAPIRequestError(completion: completion)
- }
- }
- case is SwiftfinStore.Error:
- let swiftfinError = error as! SwiftfinStore.Error
- switch swiftfinError {
- case let .existingServer(server):
- self.addServerURIPayload = AddServerURIPayload(server: server, uri: uri)
- self.backAddServerURIPayload = AddServerURIPayload(server: server, uri: uri)
- default:
- self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
- }
- default:
- self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
- }
- }
- }, receiveValue: { server in
- self.logger.debug("Connected to server at \"\(uri)\"", tag: "connectToServer")
- self.router?.route(to: \.userSignIn, server)
- })
- .store(in: &cancellables)
+ guard let url = URL(string: formattedURL) else { throw JellyfinAPIError("Invalid URL") }
+
+ let client = JellyfinClient(
+ configuration: .swiftfinConfiguration(url: url),
+ sessionDelegate: URLSessionProxyDelegate()
+ )
+
+ let response = try await client.send(Paths.getPublicSystemInfo)
+
+ guard let name = response.value.serverName,
+ let id = response.value.id,
+ let os = response.value.operatingSystem,
+ let version = response.value.version
+ else {
+ throw JellyfinAPIError("Missing server data from network call")
+ }
+
+ let newServerState = ServerState(
+ urls: [url],
+ currentURL: url,
+ name: name,
+ id: id,
+ os: os,
+ version: version,
+ usersIDs: []
+ )
+
+ return (newServerState, url)
+ }
+
+ func isDuplicate(server: ServerState) -> Bool {
+ if let _ = try? SwiftfinStore.dataStack.fetchOne(
+ From(),
+ [Where(
+ "id == %@",
+ server.id
+ )]
+ ) {
+ return true
+ }
+ return false
+ }
+
+ func save(server: ServerState) throws {
+ try SwiftfinStore.dataStack.perform { transaction in
+ let newServer = transaction.create(Into())
+
+ newServer.urls = server.urls
+ newServer.currentURL = server.currentURL
+ newServer.name = server.name
+ newServer.id = server.id
+ newServer.os = server.os
+ newServer.version = server.version
+ newServer.users = []
+ }
}
func discoverServers() {
+ isSearching = true
discoveredServers.removeAll()
- searching = true
var _discoveredServers: Set = []
discovery.locateServer { server in
if let server = server {
_discoveredServers.insert(.init(
- uris: [],
- currentURI: server.url.absoluteString,
+ urls: [],
+ currentURL: server.url,
name: server.name,
id: server.id,
os: "",
@@ -121,32 +128,23 @@ final class ConnectToServerViewModel: ViewModel {
// Timeout after 3 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
- self.searching = false
+ self.isSearching = false
self.discoveredServers = _discoveredServers.sorted(by: { $0.name < $1.name })
}
}
- func addURIToServer(addServerURIPayload: AddServerURIPayload) {
- SessionManager.main.addURIToServer(server: addServerURIPayload.server, uri: addServerURIPayload.uri)
- .sink { completion in
- self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
- } receiveValue: { server in
- SessionManager.main.setServerCurrentURI(server: server, uri: addServerURIPayload.uri)
- .sink { completion in
- self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
- } receiveValue: { _ in
- self.router?.dismissCoordinator()
- }
- .store(in: &self.cancellables)
- }
- .store(in: &cancellables)
- }
+ func add(url: URL, server: ServerState) {
+ try! SwiftfinStore.dataStack.perform { transaction in
+ let existingServer = try! SwiftfinStore.dataStack.fetchOne(
+ From(),
+ [Where(
+ "id == %@",
+ server.id
+ )]
+ )
- func cancelConnection() {
- for cancellable in cancellables {
- cancellable.cancel()
+ let editServer = transaction.edit(existingServer)!
+ editServer.urls.insert(url)
}
-
- self.isLoading = false
}
}
diff --git a/Shared/ViewModels/DownloadListViewModel.swift b/Shared/ViewModels/DownloadListViewModel.swift
new file mode 100644
index 00000000..a9218632
--- /dev/null
+++ b/Shared/ViewModels/DownloadListViewModel.swift
@@ -0,0 +1,25 @@
+//
+// 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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Factory
+import SwiftUI
+
+class DownloadListViewModel: ViewModel {
+
+ @Injected(Container.downloadManager)
+ private var downloadManager
+
+ @Published
+ var items: [DownloadTask] = []
+
+ override init() {
+ super.init()
+
+ items = downloadManager.downloadedItems()
+ }
+}
diff --git a/Shared/ViewModels/EpisodesRowManager.swift b/Shared/ViewModels/EpisodesRowManager.swift
deleted file mode 100644
index 4c4916b6..00000000
--- a/Shared/ViewModels/EpisodesRowManager.swift
+++ /dev/null
@@ -1,93 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Combine
-import Defaults
-import JellyfinAPI
-import SwiftUI
-
-protocol EpisodesRowManager: ViewModel {
- var item: BaseItemDto { get }
- var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set }
- var selectedSeason: BaseItemDto? { get set }
-
- func getSeasons()
- func getEpisodesForSeason(_ season: BaseItemDto)
- func select(season: BaseItemDto)
- func select(seasonID: String)
-}
-
-extension EpisodesRowManager {
-
- var sortedSeasons: [BaseItemDto] {
- Array(seasonsEpisodes.keys).sorted(by: { $0.indexNumber ?? 0 < $1.indexNumber ?? 0 })
- }
-
- var currentEpisodes: [BaseItemDto]? {
- if let selectedSeason = selectedSeason {
- return seasonsEpisodes[selectedSeason]
- } else {
- guard let firstSeason = seasonsEpisodes.keys.first else { return nil }
- return seasonsEpisodes[firstSeason]
- }
- }
-
- // Also retrieves the current season episodes if available
- func getSeasons() {
- TvShowsAPI.getSeasons(
- seriesId: item.id ?? "",
- userId: SessionManager.main.currentLogin.user.id,
- isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false
- )
- .sink { completion in
- self.handleAPIRequestError(completion: completion)
- } receiveValue: { response in
- let seasons = response.items ?? []
-
- seasons.forEach { season in
- self.seasonsEpisodes[season] = []
- }
-
- self.selectedSeason = seasons.first
- }
- .store(in: &cancellables)
- }
-
- func getEpisodesForSeason(_ season: BaseItemDto) {
- guard let seasonID = season.id else { return }
-
- TvShowsAPI.getEpisodes(
- seriesId: item.id ?? "",
- userId: SessionManager.main.currentLogin.user.id,
- fields: [.overview, .seasonUserData],
- seasonId: seasonID,
- isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false,
- enableUserData: true
- )
- .trackActivity(loading)
- .sink { completion in
- self.handleAPIRequestError(completion: completion)
- } receiveValue: { episodes in
- self.seasonsEpisodes[season] = episodes.items ?? []
- }
- .store(in: &cancellables)
- }
-
- func select(season: BaseItemDto) {
- self.selectedSeason = season
-
- if seasonsEpisodes[season]!.isEmpty {
- getEpisodesForSeason(season)
- }
- }
-
- func select(seasonID: String) {
- guard let selectedSeason = Array(seasonsEpisodes.keys).first(where: { $0.id == seasonID }) else { return }
- select(season: selectedSeason)
- }
-}
diff --git a/Shared/ViewModels/FilterViewModel.swift b/Shared/ViewModels/FilterViewModel.swift
index a32244ff..3195953a 100644
--- a/Shared/ViewModels/FilterViewModel.swift
+++ b/Shared/ViewModels/FilterViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -31,15 +31,17 @@ final class FilterViewModel: ViewModel {
}
private func getQueryFilters() {
- FilterAPI.getQueryFilters(
- userId: SessionManager.main.currentLogin.user.id,
- parentId: parent?.id
- )
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] queryFilters in
- self?.allFilters.genres = queryFilters.genres?.map(\.filter) ?? []
- })
- .store(in: &cancellables)
+ 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) ?? []
+ }
+ }
}
}
diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift
index cce5f92d..a5d81ca1 100644
--- a/Shared/ViewModels/HomeViewModel.swift
+++ b/Shared/ViewModels/HomeViewModel.swift
@@ -3,209 +3,189 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
-import ActivityIndicator
import Combine
+import CoreStore
+import Factory
import Foundation
import JellyfinAPI
+import UIKit
final class HomeViewModel: ViewModel {
@Published
- var resumeItems: [BaseItemDto] = []
+ var errorMessage: String?
@Published
var hasNextUp: Bool = false
@Published
var hasRecentlyAdded: Bool = false
@Published
- var librariesShowRecentlyAddedIDs: [String] = []
- @Published
var libraries: [BaseItemDto] = []
+ @Published
+ var resumeItems: [BaseItemDto] = []
override init() {
super.init()
- refresh()
-
- // Nov. 6, 2021
- // This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing.
- // See ServerDetailViewModel.swift for feature request issue
- Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
- Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
- }
-
- @objc
- private func didSignIn() {
- for cancellable in cancellables {
- cancellable.cancel()
- }
-
- librariesShowRecentlyAddedIDs = []
- libraries = []
- resumeItems = []
refresh()
}
- @objc
- private func didSignOut() {
- for cancellable in cancellables {
- cancellable.cancel()
- }
-
- cancellables.removeAll()
- }
-
@objc
func refresh() {
- logger.debug("Refresh called.")
- refreshLibrariesLatest()
- refreshLatestAddedItems()
- refreshResumeItems()
- refreshNextUpItems()
+ hasNextUp = false
+ hasRecentlyAdded = false
+ libraries = []
+ resumeItems = []
+
+ Task {
+ logger.debug("Refreshing home screen")
+
+ await MainActor.run {
+ isLoading = true
+ }
+
+ refreshHasRecentlyAddedItems()
+ refreshResumeItems()
+ refreshHasNextUp()
+
+ do {
+ try await refreshLibrariesLatest()
+ } catch {
+ await MainActor.run {
+ isLoading = false
+ errorMessage = error.localizedDescription
+ }
+
+ return
+ }
+
+ await MainActor.run {
+ isLoading = false
+ errorMessage = nil
+ }
+ }
}
// MARK: Libraries Latest Items
- private func refreshLibrariesLatest() {
- UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id)
- .trackActivity(loading)
- .sink(receiveCompletion: { completion in
- switch completion {
- case .finished: ()
- case .failure:
- self.libraries = []
- }
+ private func refreshLibrariesLatest() async throws {
+ let userViewsPath = Paths.getUserViews(userID: userSession.user.id)
+ let response = try await userSession.client.send(userViewsPath)
- self.handleAPIRequestError(completion: completion)
- }, receiveValue: { response in
+ guard let allLibraries = response.value.items else {
+ await MainActor.run {
+ libraries = []
+ }
- var newLibraries: [BaseItemDto] = []
+ return
+ }
- response.items!.forEach { item in
- self.logger
- .debug("Retrieved user view: \(item.id!) (\(item.name ?? "nil")) with type \(item.collectionType ?? "nil")")
- if item.collectionType == "movies" || item.collectionType == "tvshows" {
- newLibraries.append(item)
- }
- }
+ let excludedLibraryIDs = await getExcludedLibraries()
- UserAPI.getCurrentUser()
- .trackActivity(self.loading)
- .sink(receiveCompletion: { completion in
- switch completion {
- case .finished: ()
- case .failure:
- self.libraries = []
- self.handleAPIRequestError(completion: completion)
- }
- }, receiveValue: { response in
- let excludeIDs = response.configuration?.latestItemsExcludes != nil ? response.configuration!
- .latestItemsExcludes! : []
+ let newLibraries = allLibraries
+ .filter { $0.collectionType == "movies" || $0.collectionType == "tvshows" }
+ .filter { library in
+ !excludedLibraryIDs.contains(where: { $0 == library.id ?? "" })
+ }
- for excludeID in excludeIDs {
- newLibraries.removeAll { library in
- library.id == excludeID
- }
- }
+ await MainActor.run {
+ libraries = newLibraries
+ }
+ }
- self.libraries = newLibraries
- })
- .store(in: &self.cancellables)
- })
- .store(in: &cancellables)
+ private func getExcludedLibraries() async -> [String] {
+ let currentUserPath = Paths.getCurrentUser
+ let response = try? await userSession.client.send(currentUserPath)
+
+ return response?.value.configuration?.latestItemsExcludes ?? []
}
// MARK: Recently Added Items
- private func refreshLatestAddedItems() {
- UserLibraryAPI.getLatestMedia(
- userId: SessionManager.main.currentLogin.user.id,
- includeItemTypes: [.movie, .series],
- limit: 1
- )
- .sink { completion in
- switch completion {
- case .finished: ()
- case .failure:
- self.hasRecentlyAdded = false
- self.handleAPIRequestError(completion: completion)
+ 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
}
- } receiveValue: { items in
- self.hasRecentlyAdded = items.count > 0
}
- .store(in: &cancellables)
}
// MARK: Resume Items
private func refreshResumeItems() {
- ItemsAPI.getResumeItems(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 20,
- fields: [
- .primaryImageAspectRatio,
- .seriesPrimaryImage,
- .seasonUserData,
- .overview,
- .genres,
- .people,
- .chapters,
- ],
- enableUserData: true
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { completion in
- switch completion {
- case .finished: ()
- case .failure:
- self.resumeItems = []
- self.handleAPIRequestError(completion: completion)
- }
- }, receiveValue: { response in
- self.logger.debug("Retrieved \(String(response.items!.count)) resume items")
+ Task {
+ let resumeParameters = Paths.GetResumeItemsParameters(
+ limit: 20,
+ fields: ItemFields.minimumCases,
+ enableUserData: true,
+ includeItemTypes: [.movie, .episode]
+ )
- self.resumeItems = response.items ?? []
- })
- .store(in: &cancellables)
+ let request = Paths.getResumeItems(userID: userSession.user.id, parameters: resumeParameters)
+ let response = try await userSession.client.send(request)
+
+ guard let items = response.value.items else { return }
+
+ await MainActor.run {
+ resumeItems = items
+ }
+ }
}
- func removeItemFromResume(_ item: BaseItemDto) {
- guard let itemID = item.id, resumeItems.contains(where: { $0.id == itemID }) else { return }
+ func markItemUnplayed(_ item: BaseItemDto) {
+ guard resumeItems.contains(where: { $0.id == item.id! }) else { return }
- PlaystateAPI.markUnplayedItem(
- userId: SessionManager.main.currentLogin.user.id,
- itemId: item.id!
- )
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { _ in
- self.refreshResumeItems()
- self.refreshNextUpItems()
- })
- .store(in: &cancellables)
+ Task {
+ let request = Paths.markUnplayedItem(
+ userID: userSession.user.id,
+ itemID: item.id!
+ )
+ let _ = try await userSession.client.send(request)
+
+ refreshResumeItems()
+ refreshHasNextUp()
+ }
+ }
+
+ 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()
+ }
}
// MARK: Next Up Items
- private func refreshNextUpItems() {
- TvShowsAPI.getNextUp(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 1
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { completion in
- switch completion {
- case .finished: ()
- case .failure:
- self.hasNextUp = false
- self.handleAPIRequestError(completion: completion)
+ 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)
}
- }, receiveValue: { response in
- self.hasNextUp = (response.items ?? []).count > 0
- })
- .store(in: &cancellables)
+ }
}
}
diff --git a/Shared/ViewModels/ItemTypeLibraryViewModel.swift b/Shared/ViewModels/ItemTypeLibraryViewModel.swift
index b1425b0c..d388dfcf 100644
--- a/Shared/ViewModels/ItemTypeLibraryViewModel.swift
+++ b/Shared/ViewModels/ItemTypeLibraryViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -30,9 +30,9 @@ final class ItemTypeLibraryViewModel: PagingLibraryViewModel {
private func requestItems(with filters: ItemFilters, replaceCurrentItems: Bool = false) {
if replaceCurrentItems {
- self.items = []
- self.currentPage = 0
- self.hasNextPage = true
+ items = []
+ currentPage = 0
+ hasNextPage = true
}
let genreIDs = filters.genres.compactMap(\.id)
@@ -40,31 +40,32 @@ final class ItemTypeLibraryViewModel: PagingLibraryViewModel {
let sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending }
let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) }
- ItemsAPI.getItemsByUserId(
- userId: SessionManager.main.currentLogin.user.id,
- startIndex: currentPage * pageItemSize,
- limit: pageItemSize,
- recursive: true,
- sortOrder: sortOrder,
- fields: ItemFields.allCases,
- includeItemTypes: itemTypes,
- filters: itemFilters,
- sortBy: sortBy,
- enableUserData: true,
- genreIds: genreIDs
- )
- .trackActivity(loading)
- .sink { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- } receiveValue: { [weak self] response in
- guard let items = response.items, !items.isEmpty else {
- self?.hasNextPage = false
+ Task {
+ 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
+ )
+ 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
}
- self?.items.append(contentsOf: items)
+ await MainActor.run {
+ self.items.append(contentsOf: items)
+ }
}
- .store(in: &cancellables)
}
override func _requestNextPage() {
diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift
index da0e9423..66e7a188 100644
--- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift
+++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -22,17 +22,18 @@ final class CollectionItemViewModel: ItemViewModel {
}
private func getCollectionItems() {
- ItemsAPI.getItems(
- userId: SessionManager.main.currentLogin.user.id,
- parentId: item.id,
- fields: ItemFields.allCases
- )
- .trackActivity(loading)
- .sink { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- } receiveValue: { [weak self] response in
- self?.collectionItems = response.items ?? []
+ Task {
+ let parameters = Paths.GetItemsParameters(
+ userID: userSession.user.id,
+ parentID: item.id,
+ fields: ItemFields.allCases
+ )
+ let request = Paths.getItems(parameters: parameters)
+ let response = try await userSession.client.send(request)
+
+ await MainActor.run {
+ collectionItems = response.value.items ?? []
+ }
}
- .store(in: &cancellables)
}
}
diff --git a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift
index d45f9636..55870a0b 100644
--- a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift
+++ b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -22,50 +22,24 @@ final class EpisodeItemViewModel: ItemViewModel {
getSeriesItem()
}
- override func updateItem() {
- ItemsAPI.getItems(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 1,
- fields: [
- .primaryImageAspectRatio,
- .seriesPrimaryImage,
- .seasonUserData,
- .overview,
- .genres,
- .people,
- .chapters,
- ],
- enableUserData: true,
- ids: [item.id ?? ""]
- )
- .sink { completion in
- self.handleAPIRequestError(completion: completion)
- } receiveValue: { response in
- if let item = response.items?.first {
- self.item = item
- self.playButtonItem = item
- }
- }
- .store(in: &cancellables)
- }
+ override func updateItem() {}
private func getSeriesItem() {
- guard let seriesID = item.seriesId else { return }
+ guard let seriesID = item.seriesID else { return }
+ Task {
+ let parameters = Paths.GetItemsParameters(
+ userID: userSession.user.id,
+ limit: 1,
+ fields: ItemFields.allCases,
+ enableUserData: true,
+ ids: [seriesID]
+ )
+ let request = Paths.getItems(parameters: parameters)
+ let response = try await userSession.client.send(request)
- ItemsAPI.getItems(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 1,
- fields: ItemFields.allCases,
- enableUserData: true,
- ids: [seriesID]
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- guard let firstItem = response.items?.first else { return }
- self?.seriesItem = firstItem
- })
- .store(in: &cancellables)
+ await MainActor.run {
+ seriesItem = response.value.items?.first
+ }
+ }
}
}
diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift
index 9543920e..aba1973d 100644
--- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift
+++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -15,48 +15,78 @@ import UIKit
class ItemViewModel: ViewModel {
@Published
- var item: BaseItemDto
- @Published
- var playButtonItem: BaseItemDto? {
- didSet {
- if let playButtonItem = playButtonItem {
- refreshItemVideoPlayerViewModel(for: playButtonItem)
+ var item: BaseItemDto {
+ willSet {
+ switch item.type {
+ case .episode, .movie:
+ guard !item.isMissing else { return }
+ playButtonItem = newValue
+ default: ()
}
}
}
@Published
- var similarItems: [BaseItemDto] = []
- @Published
- var isWatched = false
+ var playButtonItem: BaseItemDto? {
+ willSet {
+ if let newValue {
+ selectedMediaSource = newValue.mediaSources?.first
+ }
+ }
+ }
+
@Published
var isFavorited = false
@Published
- var selectedVideoPlayerViewModel: VideoPlayerViewModel?
+ var isPlayed = false
@Published
- var videoPlayerViewModels: [VideoPlayerViewModel] = []
+ var selectedMediaSource: MediaSourceInfo?
+ @Published
+ var similarItems: [BaseItemDto] = []
+ @Published
+ var specialFeatures: [BaseItemDto] = []
init(item: BaseItemDto) {
self.item = item
super.init()
- switch item.type {
- case .episode, .movie:
- if !item.missing && !item.unaired {
- self.playButtonItem = item
- }
- default: ()
- }
+ getFullItem()
isFavorited = item.userData?.isFavorite ?? false
- isWatched = item.userData?.played ?? false
+ isPlayed = item.userData?.isPlayed ?? false
getSimilarItems()
- refreshItemVideoPlayerViewModel(for: item)
+ getSpecialFeatures()
Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:)))
}
+ private func getFullItem() {
+ Task {
+
+ await MainActor.run {
+ isLoading = true
+ }
+
+ let parameters = Paths.GetItemsParameters(
+ userID: userSession.user.id,
+ fields: ItemFields.allCases,
+ enableUserData: true,
+ ids: [item.id!]
+ )
+
+ let request = Paths.getItems(parameters: parameters)
+ let response = try await userSession.client.send(request)
+
+ guard let fullItem = response.value.items?.first else { return }
+
+ await MainActor.run {
+ self.item = fullItem
+ isLoading = false
+ }
+ }
+ }
+
@objc
private func receivedStopReport(_ notification: NSNotification) {
guard let itemID = notification.object as? String else { return }
@@ -70,31 +100,18 @@ class ItemViewModel: ViewModel {
}
}
- func refreshItemVideoPlayerViewModel(for item: BaseItemDto) {
- guard item.type == .episode || item.type == .movie,
- !item.missing else { return }
-
- item.createVideoPlayerViewModel()
- .sink { completion in
- self.handleAPIRequestError(completion: completion)
- } receiveValue: { viewModels in
- self.videoPlayerViewModels = viewModels
- self.selectedVideoPlayerViewModel = viewModels.first
- }
- .store(in: &cancellables)
- }
-
+ // TODO: remove and have views handle
func playButtonText() -> String {
- if item.unaired {
+ if item.isUnaired {
return L10n.unaired
}
- if item.missing {
+ if item.isMissing {
return L10n.missing
}
- if let itemProgressString = item.progress {
+ if let itemProgressString = item.progressLabel {
return itemProgressString
}
@@ -102,67 +119,84 @@ class ItemViewModel: ViewModel {
}
func getSimilarItems() {
- LibraryAPI.getSimilarItems(
- itemId: item.id!,
- userId: SessionManager.main.currentLogin.user.id,
- limit: 20,
- fields: ItemFields.allCases
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.similarItems = response.items ?? []
- })
- .store(in: &cancellables)
+ Task {
+ let parameters = Paths.GetSimilarItemsParameters(
+ userID: userSession.user.id,
+ limit: 20,
+ fields: ItemFields.minimumCases
+ )
+ let request = Paths.getSimilarItems(
+ itemID: item.id!,
+ parameters: parameters
+ )
+ let response = try await userSession.client.send(request)
+
+ await MainActor.run {
+ similarItems = response.value.items ?? []
+ }
+ }
+ }
+
+ func getSpecialFeatures() {
+ Task {
+ let request = Paths.getSpecialFeatures(
+ userID: userSession.user.id,
+ itemID: item.id!
+ )
+ let response = try await userSession.client.send(request)
+
+ await MainActor.run {
+ specialFeatures = response.value.filter { $0.extraType?.isVideo ?? false }
+ }
+ }
}
func toggleWatchState() {
- let current = isWatched
- isWatched.toggle()
- let request: AnyPublisher
+// let current = isPlayed
+// isPlayed.toggle()
+// let request: AnyPublisher
- if current {
- request = PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
- } else {
- request = PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
- }
+// if current {
+// request = PlaystateAPI.markUnplayedItem(userId: "123abc", itemId: item.id!)
+// } else {
+// request = PlaystateAPI.markPlayedItem(userId: "123abc", itemId: item.id!)
+// }
- request
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- switch completion {
- case .failure:
- self?.isWatched = !current
- case .finished: ()
- }
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { _ in })
- .store(in: &cancellables)
+// request
+// .trackActivity(loading)
+// .sink(receiveCompletion: { [weak self] completion in
+// switch completion {
+// case .failure:
+// self?.isPlayed = !current
+// case .finished: ()
+// }
+// self?.handleAPIRequestError(completion: completion)
+// }, receiveValue: { _ in })
+// .store(in: &cancellables)
}
func toggleFavoriteState() {
- let current = isFavorited
- isFavorited.toggle()
- let request: AnyPublisher
+// let current = isFavorited
+// isFavorited.toggle()
+// let request: AnyPublisher
- if current {
- request = UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
- } else {
- request = UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
- }
+// if current {
+// request = UserLibraryAPI.unmarkFavoriteItem(userId: "123abc", itemId: item.id!)
+// } else {
+// request = UserLibraryAPI.markFavoriteItem(userId: "123abc", itemId: item.id!)
+// }
- request
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- switch completion {
- case .failure:
- self?.isFavorited = !current
- case .finished: ()
- }
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { _ in })
- .store(in: &cancellables)
+// request
+// .trackActivity(loading)
+// .sink(receiveCompletion: { [weak self] completion in
+// switch completion {
+// case .failure:
+// self?.isFavorited = !current
+// case .finished: ()
+// }
+// self?.handleAPIRequestError(completion: completion)
+// }, receiveValue: { _ in })
+// .store(in: &cancellables)
}
// Overridden by subclasses
diff --git a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift b/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift
index 652b4f54..54a763b2 100644
--- a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift
+++ b/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -12,30 +12,5 @@ import JellyfinAPI
final class MovieItemViewModel: ItemViewModel {
- override func updateItem() {
- ItemsAPI.getItems(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 1,
- fields: [
- .primaryImageAspectRatio,
- .seriesPrimaryImage,
- .seasonUserData,
- .overview,
- .genres,
- .people,
- .chapters,
- ],
- enableUserData: true,
- ids: [item.id ?? ""]
- )
- .sink { completion in
- self.handleAPIRequestError(completion: completion)
- } receiveValue: { response in
- if let item = response.items?.first {
- self.item = item
- self.playButtonItem = item
- }
- }
- .store(in: &cancellables)
- }
+ override func updateItem() {}
}
diff --git a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift
deleted file mode 100644
index 65293cba..00000000
--- a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift
+++ /dev/null
@@ -1,109 +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) 2022 Jellyfin & Jellyfin Contributors
-//
-
-import Combine
-import Foundation
-import JellyfinAPI
-import Stinsen
-
-final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
-
- @RouterObject
- private var itemRouter: ItemCoordinator.Router?
- @Published
- var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
- @Published
- var selectedSeason: BaseItemDto?
-
- override init(item: BaseItemDto) {
- super.init(item: item)
-
- selectedSeason = item
-// getSeasons()
- requestEpisodes()
- }
-
- override func playButtonText() -> String {
-
- if item.unaired {
- return L10n.unaired
- }
-
- guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.episodeLocator else { return L10n.play }
- return episodeLocator
- }
-
- private func requestEpisodes() {
- logger
- .debug("Getting episodes in season \(item.id!) (\(item.name!)) of show \(item.seriesId!) (\(item.seriesName!))")
- TvShowsAPI.getEpisodes(
- seriesId: item.seriesId ?? "",
- userId: SessionManager.main.currentLogin.user.id,
- fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
- seasonId: item.id ?? ""
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- guard let self = self else { return }
- self.seasonsEpisodes[self.item] = response.items ?? []
-
- self.setNextUpInSeason()
- })
- .store(in: &cancellables)
- }
-
- private func setNextUpInSeason() {
-
- TvShowsAPI.getNextUp(
- userId: SessionManager.main.currentLogin.user.id,
- fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
- seriesId: item.seriesId ?? "",
- enableUserData: true
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- guard let self = self else { return }
-
- // Find the nextup item that belongs to current season.
- if let nextUpItem = (response.items ?? []).first(where: { episode in
- !episode.unaired && !episode.missing && episode.seasonId ?? "" == self.item.id!
- }) {
- self.playButtonItem = nextUpItem
- self.logger.debug("Nextup in season \(self.item.id!) (\(self.item.name!)): \(nextUpItem.id!)")
- }
-
- // if self.playButtonItem == nil && !self.episodes.isEmpty {
- // // Fallback to the old mechanism:
- // // Sets the play button item to the "Next up" in the season based upon
- // // the watched status of episodes in the season.
- // // Default to the first episode of the season if all have been watched.
- // var firstUnwatchedSearch: BaseItemDto?
-//
- // for episode in self.episodes {
- // guard let played = episode.userData?.played else { continue }
- // if !played {
- // firstUnwatchedSearch = episode
- // break
- // }
- // }
-//
- // if let firstUnwatched = firstUnwatchedSearch {
- // self.playButtonItem = firstUnwatched
- // } else {
- // guard let firstEpisode = self.episodes.first else { return }
- // self.playButtonItem = firstEpisode
- // }
- // }
- })
- .store(in: &cancellables)
- }
-}
diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift
index 7f53dcde..99c2800c 100644
--- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift
+++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift
@@ -3,22 +3,27 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
import Defaults
+import Factory
import Foundation
import JellyfinAPI
-final class SeriesItemViewModel: ItemViewModel, EpisodesRowManager {
+final class SeriesItemViewModel: ItemViewModel, MenuPosterHStackModel {
@Published
- var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
+ var menuSelection: BaseItemDto?
@Published
- var selectedSeason: BaseItemDto?
+ var menuSections: [BaseItemDto: [PosterButtonType]]
+ var menuSectionSort: (BaseItemDto, BaseItemDto) -> Bool
override init(item: BaseItemDto) {
+ self.menuSections = [:]
+ self.menuSectionSort = { i, j in i.indexNumber ?? -1 < j.indexNumber ?? -1 }
+
super.init(item: item)
getSeasons()
@@ -34,11 +39,11 @@ final class SeriesItemViewModel: ItemViewModel, EpisodesRowManager {
override func playButtonText() -> String {
- if item.unaired {
+ if item.isUnaired {
return L10n.unaired
}
- if item.missing {
+ if item.isMissing {
return L10n.missing
}
@@ -49,90 +54,126 @@ final class SeriesItemViewModel: ItemViewModel, EpisodesRowManager {
}
private func getNextUp() {
- logger.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
- TvShowsAPI.getNextUp(
- userId: SessionManager.main.currentLogin.user.id,
- seriesId: self.item.id!,
- enableUserData: true
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing {
- self?.playButtonItem = nextUpItem
+ Task {
+ let parameters = Paths.GetNextUpParameters(
+ userID: userSession.user.id,
+ fields: ItemFields.minimumCases,
+ seriesID: item.id,
+ enableUserData: true
+ )
+ let request = Paths.getNextUp(parameters: parameters)
+ let response = try await userSession.client.send(request)
- if let seasonID = nextUpItem.seasonId {
- self?.select(seasonID: seasonID)
- }
- }
- })
- .store(in: &cancellables)
- }
-
- private func getResumeItem() {
- ItemsAPI.getResumeItems(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 1,
- parentId: item.id
- )
- .trackActivity(loading)
- .sink { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- } receiveValue: { [weak self] response in
- if let firstItem = response.items?.first {
- self?.playButtonItem = firstItem
-
- if let seasonID = firstItem.seasonId {
- self?.select(seasonID: seasonID)
+ if let item = response.value.items?.first, !item.isMissing {
+ await MainActor.run {
+ self.playButtonItem = item
+ }
+ }
+ }
+ }
+
+ private func getResumeItem() {
+ Task {
+ let parameters = Paths.GetResumeItemsParameters(
+ limit: 1,
+ parentID: item.id,
+ fields: ItemFields.minimumCases
+ )
+ let request = Paths.getResumeItems(userID: userSession.user.id, parameters: parameters)
+ let response = try await userSession.client.send(request)
+
+ if let item = response.value.items?.first {
+ await MainActor.run {
+ self.playButtonItem = item
}
}
}
- .store(in: &cancellables)
}
private func getFirstAvailableItem() {
- ItemsAPI.getItemsByUserId(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 2,
- recursive: true,
- sortOrder: [.ascending],
- parentId: item.id,
- includeItemTypes: [.episode]
- )
- .trackActivity(loading)
- .sink { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- } receiveValue: { [weak self] response in
- if let firstItem = response.items?.first {
- if self?.playButtonItem == nil {
- // If other calls finish after this, it will be overwritten
- self?.playButtonItem = firstItem
+ Task {
+ let parameters = Paths.GetItemsParameters(
+ userID: userSession.user.id,
+ limit: 1,
+ isRecursive: true,
+ sortOrder: [.ascending],
+ parentID: item.id,
+ fields: ItemFields.minimumCases,
+ includeItemTypes: [.episode]
+ )
+ let request = Paths.getItems(parameters: parameters)
+ let response = try await userSession.client.send(request)
- if let seasonID = firstItem.seasonId {
- self?.select(seasonID: seasonID)
+ if let item = response.value.items?.first {
+ if self.playButtonItem == nil {
+ await MainActor.run {
+ self.playButtonItem = item
}
}
}
}
- .store(in: &cancellables)
}
- func getRunYears() -> String {
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "yyyy"
+ func select(section: BaseItemDto) {
+ self.menuSelection = section
- var startYear: String?
- var endYear: String?
-
- if item.premiereDate != nil {
- startYear = dateFormatter.string(from: item.premiereDate!)
+ if let existingItems = menuSections[section] {
+ if existingItems.allSatisfy({ $0 == .loading }) {
+ getEpisodesForSeason(section)
+ } else if existingItems.allSatisfy({ $0 == .noResult }) {
+ menuSections[section] = PosterButtonType.loading.random(in: 3 ..< 8)
+ getEpisodesForSeason(section)
+ }
+ } else {
+ getEpisodesForSeason(section)
}
+ }
- if item.endDate != nil {
- endYear = dateFormatter.string(from: item.endDate!)
+ private func getSeasons() {
+ Task {
+ let parameters = Paths.GetSeasonsParameters(
+ userID: userSession.user.id,
+ isMissing: Defaults[.Customization.shouldShowMissingSeasons] ? nil : false
+ )
+ let request = Paths.getSeasons(seriesID: item.id!, parameters: parameters)
+ let response = try await userSession.client.send(request)
+
+ guard let seasons = response.value.items else { return }
+
+ await MainActor.run {
+ seasons.forEach { season in
+ self.menuSections[season] = PosterButtonType.loading.random(in: 3 ..< 8)
+ }
+ }
+
+ if let firstSeason = seasons.first {
+ self.getEpisodesForSeason(firstSeason)
+ await MainActor.run {
+ self.menuSelection = firstSeason
+ }
+ }
}
+ }
- return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)"
+ private func getEpisodesForSeason(_ season: BaseItemDto) {
+ Task {
+ let parameters = Paths.GetEpisodesParameters(
+ userID: userSession.user.id,
+ fields: ItemFields.minimumCases,
+ seasonID: season.id!,
+ isMissing: Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false,
+ enableUserData: true
+ )
+ let request = Paths.getEpisodes(seriesID: item.id!, parameters: parameters)
+ let response = try await userSession.client.send(request)
+
+ await MainActor.run {
+ if let items = response.value.items {
+ self.menuSections[season] = items.map { .item($0) }
+ } else {
+ self.menuSections[season] = [.noResult]
+ }
+ }
+ }
}
}
diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift
index 68d6e584..5fbab98f 100644
--- a/Shared/ViewModels/LibraryViewModel.swift
+++ b/Shared/ViewModels/LibraryViewModel.swift
@@ -3,11 +3,12 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
import Defaults
+import Factory
import JellyfinAPI
import SwiftUI
import UIKit
@@ -102,49 +103,42 @@ final class LibraryViewModel: PagingLibraryViewModel {
let sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending }
let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) }
- ItemsAPI.getItemsByUserId(
- userId: SessionManager.main.currentLogin.user.id,
- excludeItemIds: excludedIDs,
- startIndex: currentPage * pageItemSize,
- limit: pageItemSize,
- recursive: recursive,
- sortOrder: sortOrder,
- parentId: libraryID,
- fields: ItemFields.allCases,
- includeItemTypes: includeItemTypes,
- filters: itemFilters,
- sortBy: sortBy,
- enableUserData: true,
- personIds: personIDs,
- studioIds: studioIDs,
- genreIds: genreIDs,
- enableImages: true
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- guard !(response.items?.isEmpty ?? false) else {
- self?.hasNextPage = false
+ Task {
+ await MainActor.run {
+ self.isLoading = true
+ }
+
+ let parameters = Paths.GetItemsParameters(
+ userID: userSession.user.id,
+ excludeItemIDs: excludedIDs,
+ startIndex: currentPage * pageItemSize,
+ limit: pageItemSize,
+ isRecursive: recursive,
+ sortOrder: sortOrder,
+ parentID: libraryID,
+ fields: ItemFields.allCases,
+ includeItemTypes: includeItemTypes,
+ filters: itemFilters,
+ sortBy: sortBy,
+ enableUserData: true,
+ personIDs: personIDs,
+ studioIDs: studioIDs,
+ genreIDs: genreIDs,
+ enableImages: 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
}
- let items: [BaseItemDto]
-
- // There is a bug either with the request construction or the server when using
- // "Random" sort which causes duplicate items to be sent even though we send the
- // excluded ids. This causes shorter item additions when using "Random" over
- // consecutive calls. Investigation needs to be done to find the root of the problem.
- // Only filter for "Random" as an optimization.
- if filters.sortBy.first == SortBy.random.filter {
- items = response.items?.filter { !(self?.items.contains($0) ?? true) } ?? []
- } else {
- items = response.items ?? []
+ await MainActor.run {
+ self.isLoading = false
+ self.items.append(contentsOf: items)
}
-
- self?.items.append(contentsOf: items)
- })
- .store(in: &cancellables)
+ }
}
override func _requestNextPage() {
diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift
index 709944e9..1c3cc1c5 100644
--- a/Shared/ViewModels/LiveTVChannelsViewModel.swift
+++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift
@@ -3,20 +3,12 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Factory
import Foundation
import JellyfinAPI
-import SwiftUICollection
-
-typealias LiveTVChannelRow = CollectionRow
-
-struct LiveTVChannelRowCell: Hashable {
- let id = UUID()
- let item: LiveTVChannelProgram
-}
struct LiveTVChannelProgram: Hashable {
let id = UUID()
@@ -28,20 +20,23 @@ struct LiveTVChannelProgram: Hashable {
final class LiveTVChannelsViewModel: ViewModel {
@Published
- var channels = [BaseItemDto]()
+ var channels: [BaseItemDto] = []
@Published
- var channelPrograms = [LiveTVChannelProgram]() {
- didSet {
- rows = []
- let rowChannels = channelPrograms.chunked(into: 4)
- for (index, rowChans) in rowChannels.enumerated() {
- rows.append(LiveTVChannelRow(section: index, items: rowChans.map { LiveTVChannelRowCell(item: $0) }))
- }
- }
- }
+ var channelPrograms: [LiveTVChannelProgram] = []
- @Published
- var rows = [LiveTVChannelRow]()
+// @Published
+// var channelPrograms = [LiveTVChannelProgram]() {
+// didSet {
+// rows = []
+// let rowChannels = channelPrograms.chunked(into: 4)
+// for (index, rowChans) in rowChannels.enumerated() {
+// rows.append(LiveTVChannelRow(section: index, items: rowChans.map { LiveTVChannelRowCell(item: $0) }))
+// }
+// }
+// }
+
+// @Published
+// var rows = [LiveTVChannelRow]()
private var programs = [BaseItemDto]()
private var channelProgramsList = [BaseItemDto: [BaseItemDto]]()
@@ -65,43 +60,41 @@ final class LiveTVChannelsViewModel: ViewModel {
}
private func getGuideInfo() {
- LiveTvAPI.getGuideInfo()
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] _ in
- self?.logger.debug("Received Guide Info")
- guard let self = self else { return }
+ Task {
+ let request = Paths.getGuideInfo
+ guard let _ = try? await userSession.client.send(request) else { return }
+
+ await MainActor.run {
self.getChannels()
- })
- .store(in: &cancellables)
+ }
+ }
}
func getChannels() {
- LiveTvAPI.getLiveTvChannels(
- userId: SessionManager.main.currentLogin.user.id,
- startIndex: 0,
- limit: 1000,
- enableImageTypes: [.primary],
- enableUserData: false,
- enableFavoriteSorting: true
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(response.items?.count ?? 0) Channels")
- guard let self = self else { return }
- self.channels = response.items ?? []
- self.getPrograms()
- })
- .store(in: &cancellables)
+ Task {
+ let parameters = Paths.GetLiveTvChannelsParameters(
+ userID: userSession.user.id,
+ startIndex: 0,
+ limit: 100,
+ enableImageTypes: [.primary],
+ fields: ItemFields.minimumCases,
+ enableUserData: false,
+ enableFavoriteSorting: true
+ )
+
+ let request = Paths.getLiveTvChannels(parameters: parameters)
+ guard let response = try? await userSession.client.send(request) else { return }
+
+ await MainActor.run {
+ self.channels = response.value.items ?? []
+ self.getPrograms()
+ }
+ }
}
private func getPrograms() {
- // http://192.168.1.50:8096/LiveTv/Programs
guard !channels.isEmpty else {
- logger.debug("Cannot get programs, channels list empty. ")
+ logger.debug("Cannot get programs, channels list empty.")
return
}
let channelIds = channels.compactMap(\.id)
@@ -109,30 +102,28 @@ final class LiveTVChannelsViewModel: ViewModel {
let minEndDate = Date.now.addComponentsToDate(hours: -1)
let maxStartDate = minEndDate.addComponentsToDate(hours: 6)
- let getProgramsRequest = GetProgramsRequest(
- channelIds: channelIds,
- userId: SessionManager.main.currentLogin.user.id,
- maxStartDate: maxStartDate,
- minEndDate: minEndDate,
- sortBy: ["StartDate"],
- enableImages: true,
- enableTotalRecordCount: false,
- imageTypeLimit: 1,
- enableImageTypes: [.primary],
- enableUserData: false
- )
+ Task {
+ let parameters = Paths.GetLiveTvProgramsParameters(
+ channelIDs: channelIds,
+ userID: userSession.user.id,
+ maxStartDate: maxStartDate,
+ minEndDate: minEndDate,
+ sortBy: ["StartDate"]
+ )
- LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(response.items?.count ?? 0) Programs")
- guard let self = self else { return }
- self.programs = response.items ?? []
- self.channelPrograms = self.processChannelPrograms()
- })
- .store(in: &cancellables)
+ let request = Paths.getLiveTvPrograms(parameters: parameters)
+
+ do {
+ let response = try await userSession.client.send(request)
+
+ await MainActor.run {
+ self.programs = response.value.items ?? []
+ self.channelPrograms = self.processChannelPrograms()
+ }
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
}
private func processChannelPrograms() -> [LiveTVChannelProgram] {
@@ -140,7 +131,7 @@ final class LiveTVChannelsViewModel: ViewModel {
let now = Date()
for channel in self.channels {
let prgs = self.programs.filter { item in
- item.channelId == channel.id
+ item.channelID == channel.id
}
DispatchQueue.main.async {
self.channelProgramsList[channel] = prgs
@@ -195,18 +186,6 @@ final class LiveTVChannelsViewModel: ViewModel {
func stopScheduleCheckTimer() {
timer?.invalidate()
}
-
- func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) {
- item.createLiveTVVideoPlayerViewModel()
- .sink { completion in
- self.handleAPIRequestError(completion: completion)
- } receiveValue: { videoPlayerViewModels in
- if let viewModel = videoPlayerViewModels.first {
- completion(viewModel)
- }
- }
- .store(in: &self.cancellables)
- }
}
extension Array {
diff --git a/Shared/ViewModels/LiveTVProgramsViewModel.swift b/Shared/ViewModels/LiveTVProgramsViewModel.swift
index 8aa3d9ba..77c239d6 100644
--- a/Shared/ViewModels/LiveTVProgramsViewModel.swift
+++ b/Shared/ViewModels/LiveTVProgramsViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -24,12 +24,12 @@ final class LiveTVProgramsViewModel: ViewModel {
@Published
var newsItems = [BaseItemDto]()
- private var channels = [String: BaseItemDto]()
+ var channels = [String: BaseItemDto]()
override init() {
super.init()
- getChannels()
+// getChannels()
}
func findChannel(id: String) -> BaseItemDto? {
@@ -37,190 +37,178 @@ final class LiveTVProgramsViewModel: ViewModel {
}
private func getChannels() {
- LiveTvAPI.getLiveTvChannels(
- userId: SessionManager.main.currentLogin.user.id,
- startIndex: 0,
- limit: 1000,
- enableImageTypes: [.primary],
- enableUserData: false,
- enableFavoriteSorting: true
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(response.items?.count ?? 0) Channels")
- guard let self = self else { return }
- if let chans = response.items {
- for chan in chans {
- if let chanId = chan.id {
- self.channels[chanId] = chan
- }
- }
- self.getRecommendedPrograms()
- self.getSeries()
- self.getMovies()
- self.getSports()
- self.getKids()
- self.getNews()
+ Task {
+ let parameters = Paths.GetLiveTvChannelsParameters(
+ userID: userSession.user.id,
+ startIndex: 0,
+ limit: 1000,
+ enableImageTypes: [.primary],
+ enableUserData: false,
+ enableFavoriteSorting: true
+ )
+ let request = Paths.getLiveTvChannels(parameters: parameters)
+ let response = try await userSession.client.send(request)
+
+ guard let channels = response.value.items else { return }
+
+ for channel in channels {
+ guard let channelID = channel.id else { continue }
+ self.channels[channelID] = channel
}
- })
- .store(in: &cancellables)
+
+ getRecommendedPrograms()
+ getSeries()
+ getMovies()
+ getSports()
+ getKids()
+ getNews()
+ }
}
private func getRecommendedPrograms() {
- LiveTvAPI.getRecommendedPrograms(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 9,
- isAiring: true,
- imageTypeLimit: 1,
- enableImageTypes: [.primary, .thumb],
- fields: [.channelInfo, .primaryImageAspectRatio],
- enableTotalRecordCount: false
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(String(response.items?.count ?? 0)) Recommended Programs")
- guard let self = self else { return }
- self.recommendedItems = response.items ?? []
- })
- .store(in: &cancellables)
+ Task {
+ let parameters = Paths.GetRecommendedProgramsParameters(
+ userID: userSession.user.id,
+ limit: 9,
+ isAiring: true,
+ imageTypeLimit: 1,
+ enableImageTypes: [.primary, .thumb],
+ fields: [.channelInfo, .primaryImageAspectRatio],
+ enableTotalRecordCount: false
+ )
+ let request = Paths.getRecommendedPrograms(parameters: parameters)
+ let response = try await userSession.client.send(request)
+
+ guard let items = response.value.items else { return }
+
+ await MainActor.run {
+ self.recommendedItems = items
+ }
+ }
}
private func getSeries() {
- let getProgramsRequest = GetProgramsRequest(
- userId: SessionManager.main.currentLogin.user.id,
- hasAired: false,
- isMovie: false,
- isSeries: true,
- isNews: false,
- isKids: false,
- isSports: false,
- limit: 9,
- enableTotalRecordCount: false,
- enableImageTypes: [.primary, .thumb],
- fields: [.channelInfo, .primaryImageAspectRatio]
- )
+ Task {
+ let request = Paths.getPrograms(.init(
+ enableImageTypes: [.primary, .thumb],
+ enableTotalRecordCount: false,
+ fields: [.channelInfo, .primaryImageAspectRatio],
+ hasAired: false,
+ isKids: false,
+ isMovie: false,
+ isNews: false,
+ isSeries: true,
+ isSports: false,
+ limit: 9,
+ userID: userSession.user.id
+ ))
+ let response = try await userSession.client.send(request)
- LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(String(response.items?.count ?? 0)) Series Items")
- guard let self = self else { return }
- self.seriesItems = response.items ?? []
- })
- .store(in: &cancellables)
+ guard let items = response.value.items else { return }
+
+ await MainActor.run {
+ self.seriesItems = items
+ }
+ }
}
private func getMovies() {
- let getProgramsRequest = GetProgramsRequest(
- userId: SessionManager.main.currentLogin.user.id,
- hasAired: false,
- isMovie: true,
- isSeries: false,
- isNews: false,
- isKids: false,
- isSports: false,
- limit: 9,
- enableTotalRecordCount: false,
- enableImageTypes: [.primary, .thumb],
- fields: [.channelInfo, .primaryImageAspectRatio]
- )
+ Task {
+ let request = Paths.getPrograms(.init(
+ enableImageTypes: [.primary, .thumb],
+ enableTotalRecordCount: false,
+ fields: [.channelInfo, .primaryImageAspectRatio],
+ hasAired: false,
+ isKids: false,
+ isMovie: true,
+ isNews: false,
+ isSeries: false,
+ isSports: false,
+ limit: 9,
+ userID: userSession.user.id
+ ))
+ let response = try await userSession.client.send(request)
- LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(String(response.items?.count ?? 0)) Movie Items")
- guard let self = self else { return }
- self.movieItems = response.items ?? []
- })
- .store(in: &cancellables)
+ guard let items = response.value.items else { return }
+
+ await MainActor.run {
+ self.movieItems = items
+ }
+ }
}
private func getSports() {
- let getProgramsRequest = GetProgramsRequest(
- userId: SessionManager.main.currentLogin.user.id,
- hasAired: false,
- isSports: true,
- limit: 9,
- enableTotalRecordCount: false,
- enableImageTypes: [.primary, .thumb],
- fields: [.channelInfo, .primaryImageAspectRatio]
- )
+ Task {
+ let request = Paths.getPrograms(.init(
+ enableImageTypes: [.primary, .thumb],
+ enableTotalRecordCount: false,
+ fields: [.channelInfo, .primaryImageAspectRatio],
+ hasAired: false,
+ isKids: false,
+ isMovie: false,
+ isNews: false,
+ isSeries: false,
+ isSports: true,
+ limit: 9,
+ userID: userSession.user.id
+ ))
+ let response = try await userSession.client.send(request)
- LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(String(response.items?.count ?? 0)) Sports Items")
- guard let self = self else { return }
- self.sportsItems = response.items ?? []
- })
- .store(in: &cancellables)
+ guard let items = response.value.items else { return }
+
+ await MainActor.run {
+ self.sportsItems = items
+ }
+ }
}
private func getKids() {
- let getProgramsRequest = GetProgramsRequest(
- userId: SessionManager.main.currentLogin.user.id,
- hasAired: false,
- isKids: true,
- limit: 9,
- enableTotalRecordCount: false,
- enableImageTypes: [.primary, .thumb],
- fields: [.channelInfo, .primaryImageAspectRatio]
- )
+ Task {
+ let request = Paths.getPrograms(.init(
+ enableImageTypes: [.primary, .thumb],
+ enableTotalRecordCount: false,
+ fields: [.channelInfo, .primaryImageAspectRatio],
+ hasAired: false,
+ isKids: true,
+ isMovie: false,
+ isNews: false,
+ isSeries: false,
+ isSports: false,
+ limit: 9,
+ userID: userSession.user.id
+ ))
+ let response = try await userSession.client.send(request)
- LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(String(response.items?.count ?? 0)) Kids Items")
- guard let self = self else { return }
- self.kidsItems = response.items ?? []
- })
- .store(in: &cancellables)
+ guard let items = response.value.items else { return }
+
+ await MainActor.run {
+ self.kidsItems = items
+ }
+ }
}
private func getNews() {
- let getProgramsRequest = GetProgramsRequest(
- userId: SessionManager.main.currentLogin.user.id,
- hasAired: false,
- isNews: true,
- limit: 9,
- enableTotalRecordCount: false,
- enableImageTypes: [.primary, .thumb],
- fields: [.channelInfo, .primaryImageAspectRatio]
- )
+ Task {
+ let request = Paths.getPrograms(.init(
+ enableImageTypes: [.primary, .thumb],
+ enableTotalRecordCount: false,
+ fields: [.channelInfo, .primaryImageAspectRatio],
+ hasAired: false,
+ isKids: false,
+ isMovie: false,
+ isNews: true,
+ isSeries: false,
+ isSports: false,
+ limit: 9,
+ userID: userSession.user.id
+ ))
+ let response = try await userSession.client.send(request)
- LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.logger.debug("Received \(String(response.items?.count ?? 0)) News Items")
- guard let self = self else { return }
- self.newsItems = response.items ?? []
- })
- .store(in: &cancellables)
- }
+ guard let items = response.value.items else { return }
- func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) {
- item.createLiveTVVideoPlayerViewModel()
- .sink { completion in
- self.handleAPIRequestError(completion: completion)
- } receiveValue: { videoPlayerViewModels in
- if let viewModel = videoPlayerViewModels.first {
- completion(viewModel)
- }
+ await MainActor.run {
+ self.seriesItems = items
}
- .store(in: &self.cancellables)
+ }
}
}
diff --git a/Shared/ViewModels/MediaItemViewModel.swift b/Shared/ViewModels/MediaItemViewModel.swift
new file mode 100644
index 00000000..f5c1ee96
--- /dev/null
+++ b/Shared/ViewModels/MediaItemViewModel.swift
@@ -0,0 +1,70 @@
+//
+// 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) 2023 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 bbb391e9..26e10f3f 100644
--- a/Shared/ViewModels/MediaViewModel.swift
+++ b/Shared/ViewModels/MediaViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
@@ -13,17 +13,22 @@ import JellyfinAPI
final class MediaViewModel: ViewModel {
@Published
- private var libraries: [MediaLibraryItem] = []
- @Published
- var libraryImages: [String: [ImageSource]] = [:]
+ private var libraries: [BaseItemDto] = []
- @Default(.Experimental.liveTVAlphaEnabled)
- private var liveTVEnabled
-
- var libraryItems: [MediaLibraryItem] {
- [.init(library: .init(name: L10n.favorites, collectionType: "favorites"), viewModel: self)]
- .appending(.init(library: .init(name: "LiveTV", collectionType: "liveTV"), viewModel: self), if: liveTVEnabled)
- .appending(libraries)
+ var libraryItems: [MediaItemViewModel] {
+ libraries.map { .init(item: $0) }
+ .prepending(
+ .init(item: .init(collectionType: "liveTV", name: "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: "Downloads")),
+ if: Defaults[.Experimental.downloads]
+ )
}
private static let supportedCollectionTypes: [String] = ["boxsets", "folders", "movies", "tvshows", "unknown"]
@@ -32,43 +37,19 @@ final class MediaViewModel: ViewModel {
super.init()
requestLibraries()
- getRandomItemImageSource(with: [.isFavorite], id: nil, key: "favorites")
}
func requestLibraries() {
- UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id)
- .trackActivity(loading)
- .sink(receiveCompletion: { completion in
- self.handleAPIRequestError(completion: completion)
- }, receiveValue: { response in
- guard let items = response.items else { return }
- let filteredLibraries = items.filter { Self.supportedCollectionTypes.contains($0.collectionType ?? "unknown") }
- filteredLibraries.forEach {
- self.getRandomItemImageSource(with: nil, id: $0.id, key: $0.id ?? "")
- }
+ Task {
+ let request = Paths.getUserViews(userID: userSession.user.id)
+ let response = try await userSession.client.send(request)
- self.libraries = filteredLibraries.map { .init(library: $0, viewModel: self) }
- })
- .store(in: &cancellables)
- }
+ guard let items = response.value.items else { return }
+ let supportedLibraries = items.filter { Self.supportedCollectionTypes.contains($0.collectionType ?? "unknown") }
- private func getRandomItemImageSource(with filters: [ItemFilter]?, id: String?, key: String) {
- ItemsAPI.getItemsByUserId(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 3,
- recursive: true,
- parentId: id,
- includeItemTypes: [.movie, .series],
- filters: filters,
- sortBy: ["Random"]
- )
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- guard let items = response.items else { return }
- let imageSources = items.map { $0.imageSource(.backdrop, maxWidth: 500) }
- self?.libraryImages[key] = imageSources
- })
- .store(in: &cancellables)
+ await MainActor.run {
+ libraries = supportedLibraries
+ }
+ }
}
}
diff --git a/Shared/ViewModels/NextUpLibraryViewModel.swift b/Shared/ViewModels/NextUpLibraryViewModel.swift
index 5aea62e9..1e15fdc6 100644
--- a/Shared/ViewModels/NextUpLibraryViewModel.swift
+++ b/Shared/ViewModels/NextUpLibraryViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -19,33 +19,45 @@ final class NextUpLibraryViewModel: PagingLibraryViewModel {
}
override func _requestNextPage() {
+ Task {
- TvShowsAPI.getNextUp(
- userId: SessionManager.main.currentLogin.user.id,
- startIndex: currentPage * pageItemSize,
- limit: pageItemSize,
- fields: [
- .primaryImageAspectRatio,
- .seriesPrimaryImage,
- .seasonUserData,
- .overview,
- .genres,
- .people,
- .chapters,
- ],
- enableUserData: true
- )
- .trackActivity(loading)
- .sink { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- } receiveValue: { [weak self] response in
- guard let items = response.items, !items.isEmpty else {
- self?.hasNextPage = false
+ 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
}
- self?.items.append(contentsOf: items)
+ 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()
+ }
}
- .store(in: &cancellables)
}
}
diff --git a/Shared/ViewModels/PagingLibraryViewModel.swift b/Shared/ViewModels/PagingLibraryViewModel.swift
index 5989bda3..042c93a7 100644
--- a/Shared/ViewModels/PagingLibraryViewModel.swift
+++ b/Shared/ViewModels/PagingLibraryViewModel.swift
@@ -3,12 +3,13 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import JellyfinAPI
+import OrderedCollections
import UIKit
class PagingLibraryViewModel: ViewModel {
@@ -17,7 +18,7 @@ class PagingLibraryViewModel: ViewModel {
private var libraryGridPosterType
@Published
- var items: [BaseItemDto] = []
+ var items: OrderedSet = []
var currentPage = 0
var hasNextPage = true
@@ -27,6 +28,15 @@ class PagingLibraryViewModel: ViewModel {
return UIScreen.main.maxChildren(width: libraryGridPosterType.width, height: height)
}
+ func refresh() {
+ currentPage = 0
+ hasNextPage = true
+
+ items = []
+
+ requestNextPage()
+ }
+
func requestNextPage() {
guard hasNextPage else { return }
currentPage += 1
diff --git a/Shared/ViewModels/QuickConnectSettingsViewModel.swift b/Shared/ViewModels/QuickConnectSettingsViewModel.swift
index 2f0a82d5..4becc2f5 100644
--- a/Shared/ViewModels/QuickConnectSettingsViewModel.swift
+++ b/Shared/ViewModels/QuickConnectSettingsViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -11,36 +11,15 @@ import JellyfinAPI
final class QuickConnectSettingsViewModel: ViewModel {
- @Published
- var quickConnectCode = ""
- @Published
- var showSuccessMessage = false
+ func authorize(code: String) async throws {
+ let request = Paths.authorize(code: code)
+ let response = try await userSession.client.send(request)
- var alertTitle: String {
- var message: String = ""
- if errorMessage?.code != ErrorMessage.noShowErrorCode {
- message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n")
+ let decoder = JSONDecoder()
+ let isAuthorized = (try? decoder.decode(Bool.self, from: response.value)) ?? false
+
+ if !isAuthorized {
+ throw JellyfinAPIError("Authorization unsuccessful")
}
- message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)")
- return message
- }
-
- func sendQuickConnect() {
- QuickConnectAPI.authorize(code: self.quickConnectCode)
- .trackActivity(loading)
- .sink(receiveCompletion: { completion in
- self.handleAPIRequestError(displayMessage: L10n.quickConnectInvalidError, completion: completion)
- switch completion {
- case .failure:
- self.logger.debug("Invalid Quick Connect code entered")
- default:
- break
- }
- }, receiveValue: { _ in
- // receiving a successful HTTP response indicates a valid code
- self.logger.debug("Valid Quick connect code entered")
- self.showSuccessMessage = true
- })
- .store(in: &cancellables)
}
}
diff --git a/Shared/ViewModels/RecentlyAddedViewModel.swift b/Shared/ViewModels/RecentlyAddedViewModel.swift
index ebbdf80d..99c2af96 100644
--- a/Shared/ViewModels/RecentlyAddedViewModel.swift
+++ b/Shared/ViewModels/RecentlyAddedViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -19,28 +19,29 @@ final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel {
}
override func _requestNextPage() {
- ItemsAPI.getItemsByUserId(
- userId: SessionManager.main.currentLogin.user.id,
- startIndex: currentPage * pageItemSize,
- limit: pageItemSize,
- recursive: true,
- sortOrder: [.descending],
- fields: ItemFields.allCases,
- includeItemTypes: [.movie, .series],
- sortBy: [SortBy.dateAdded.rawValue],
- enableUserData: true
- )
- .trackActivity(loading)
- .sink { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- } receiveValue: { [weak self] response in
- guard let items = response.items, !items.isEmpty else {
- self?.hasNextPage = false
+ 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
}
- self?.items.append(contentsOf: items)
+ await MainActor.run {
+ self.items.append(contentsOf: items)
+ }
}
- .store(in: &cancellables)
}
}
diff --git a/Shared/ViewModels/SearchViewModel.swift b/Shared/ViewModels/SearchViewModel.swift
index ed957017..9f0ff32c 100644
--- a/Shared/ViewModels/SearchViewModel.swift
+++ b/Shared/ViewModels/SearchViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Combine
@@ -45,10 +45,19 @@ final class SearchViewModel: ViewModel {
getSuggestions()
searchTextSubject
- .handleEvents(receiveOutput: { _ in self.cancelPreviousSearch() })
- .filter { !$0.isEmpty }
- .debounce(for: 0.25, scheduler: DispatchQueue.main)
+ .debounce(for: 0.5, scheduler: DispatchQueue.main)
.sink { newSearch in
+
+ if newSearch.isEmpty {
+ self.movies = []
+ self.collections = []
+ self.series = []
+ self.episodes = []
+ self.people = []
+
+ return
+ }
+
self._search(with: newSearch, filters: self.filterViewModel.currentFilters)
}
.store(in: &cancellables)
@@ -60,10 +69,6 @@ final class SearchViewModel: ViewModel {
.store(in: &cancellables)
}
- private func cancelPreviousSearch() {
- searchCancellables.forEach { $0.cancel() }
- }
-
func search(with query: String) {
searchTextSubject.send(query)
}
@@ -83,31 +88,32 @@ final class SearchViewModel: ViewModel {
keyPath: ReferenceWritableKeyPath
) {
let genreIDs = filters.genres.compactMap(\.id)
- let sortBy: [String] = filters.sortBy.map(\.filterName)
+ 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) }
- ItemsAPI.getItemsByUserId(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 20,
- recursive: true,
- searchTerm: query,
- sortOrder: sortOrder,
- fields: ItemFields.allCases,
- includeItemTypes: [itemType],
- filters: itemFilters,
- sortBy: sortBy,
- enableUserData: true,
- genreIds: genreIDs,
- enableImages: true
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?[keyPath: keyPath] = response.items ?? []
- })
- .store(in: &searchCancellables)
+ 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 ?? []
+ }
+ }
}
private func getPeople(for query: String?, with filters: ItemFilters) {
@@ -116,35 +122,38 @@ final class SearchViewModel: ViewModel {
return
}
- PersonsAPI.getPersons(
- limit: 20,
- searchTerm: query
- )
- .trackActivity(loading)
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.people = response.items ?? []
- })
- .store(in: &searchCancellables)
+ Task {
+ let parameters = Paths.GetPersonsParameters(
+ limit: 20,
+ searchTerm: query
+ )
+ let request = Paths.getPersons(parameters: parameters)
+ let response = try await userSession.client.send(request)
+
+ await MainActor.run {
+ people = response.value.items ?? []
+ }
+ }
}
private func getSuggestions() {
- ItemsAPI.getItemsByUserId(
- userId: SessionManager.main.currentLogin.user.id,
- limit: 10,
- recursive: true,
- includeItemTypes: [.movie, .series],
- sortBy: ["IsFavoriteOrLiked", "Random"],
- imageTypeLimit: 0,
- enableTotalRecordCount: false,
- enableImages: false
- )
- .sink(receiveCompletion: { [weak self] completion in
- self?.handleAPIRequestError(completion: completion)
- }, receiveValue: { [weak self] response in
- self?.suggestions = response.items ?? []
- })
- .store(in: &cancellables)
+ 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 ?? []
+ }
+ }
}
}
diff --git a/Shared/ViewModels/ServerDetailViewModel.swift b/Shared/ViewModels/ServerDetailViewModel.swift
index 05910aea..8c6af535 100644
--- a/Shared/ViewModels/ServerDetailViewModel.swift
+++ b/Shared/ViewModels/ServerDetailViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -12,21 +12,22 @@ import JellyfinAPI
class ServerDetailViewModel: ViewModel {
@Published
- var server: SwiftfinStore.State.Server
+ var server: ServerState
- init(server: SwiftfinStore.State.Server) {
+ init(server: ServerState) {
self.server = server
}
func setServerCurrentURI(uri: String) {
- SessionManager.main.setServerCurrentURI(server: server, uri: uri)
- .sink { c in
- print(c)
- } receiveValue: { newServerState in
- self.server = newServerState
- Notifications[.didChangeServerCurrentURI].post(object: newServerState)
- }
- .store(in: &cancellables)
+// SessionManager.main.setServerCurrentURI(server: server, uri: uri)
+// .sink { c in
+// print(c)
+// } receiveValue: { newServerState in
+// self.server = newServerState
+//
+// Notifications[.didChangeServerCurrentURI].post(object: newServerState)
+// }
+// .store(in: &cancellables)
}
}
diff --git a/Shared/ViewModels/ServerListViewModel.swift b/Shared/ViewModels/ServerListViewModel.swift
index 34be22b8..b94a562b 100644
--- a/Shared/ViewModels/ServerListViewModel.swift
+++ b/Shared/ViewModels/ServerListViewModel.swift
@@ -3,18 +3,20 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import CoreStore
import Foundation
import SwiftUI
-class ServerListViewModel: ObservableObject {
+final class ServerListViewModel: ViewModel {
@Published
var servers: [SwiftfinStore.State.Server] = []
- init() {
+ override init() {
+ super.init()
// Oct. 15, 2021
// This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing.
@@ -24,7 +26,8 @@ class ServerListViewModel: ObservableObject {
}
func fetchServers() {
- self.servers = SessionManager.main.fetchServers()
+ let servers = try! SwiftfinStore.dataStack.fetchAll(From())
+ self.servers = servers.map(\.state)
}
func userTextFor(server: SwiftfinStore.State.Server) -> String {
@@ -36,7 +39,18 @@ class ServerListViewModel: ObservableObject {
}
func remove(server: SwiftfinStore.State.Server) {
- SessionManager.main.delete(server: server)
+
+ guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
+ From(),
+ [Where("id == %@", server.id)]
+ )
+ else { fatalError("No stored server for state server?") }
+
+ try! SwiftfinStore.dataStack.perform { transaction in
+ transaction.delete(storedServer.users)
+ transaction.delete(storedServer)
+ }
+
fetchServers()
}
@@ -44,4 +58,25 @@ class ServerListViewModel: ObservableObject {
private func didPurge() {
fetchServers()
}
+
+ func purge() {
+ try? SwiftfinStore.dataStack.perform { transaction in
+ let users = try! transaction.fetchAll(From())
+
+ transaction.delete(users)
+
+ let servers = try! transaction.fetchAll(From())
+
+ for server in servers {
+ transaction.delete(server.users)
+ }
+
+ transaction.delete(servers)
+ }
+
+ fetchServers()
+
+ UserDefaults.generalSuite.removeAll()
+ UserDefaults.universalSuite.removeAll()
+ }
}
diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift
index 25d0ae19..ad95bb7c 100644
--- a/Shared/ViewModels/SettingsViewModel.swift
+++ b/Shared/ViewModels/SettingsViewModel.swift
@@ -3,48 +3,96 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import CoreStore
import Defaults
+import Factory
+import Files
import Foundation
-import JellyfinAPI
-import Stinsen
-import SwiftUI
+import UIKit
final class SettingsViewModel: ViewModel {
- var bitrates: [Bitrates] = []
- var langs: [TrackLanguage] = []
+ @Published
+ var currentAppIcon: any AppIcon = PrimaryAppIcon.primary
- let server: SwiftfinStore.State.Server
- let user: SwiftfinStore.State.User
+ override init() {
- init(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
-
- self.server = server
- self.user = user
- super.init()
-
- // Bitrates
- let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")!
-
- do {
- let jsonData = try Data(contentsOf: url, options: .mappedIfSafe)
- do {
- self.bitrates = try JSONDecoder().decode([Bitrates].self, from: jsonData)
- } catch {
- logger.error("Error converting processed JSON into Swift compatible schema.")
- }
- } catch {
- logger.error("Error processing JSON file `bitrates.json`")
+ guard let iconName = UIApplication.shared.alternateIconName else {
+ currentAppIcon = PrimaryAppIcon.primary
+ super.init()
+ return
}
- // Track languages
- self.langs = Locale.isoLanguageCodes.compactMap {
- guard let name = Locale.current.localizedString(forLanguageCode: $0) else { return nil }
- return TrackLanguage(name: name, isoCode: $0)
- }.sorted(by: { $0.name < $1.name })
- self.langs.insert(.auto, at: 0)
+ if let appicon = PrimaryAppIcon.createCase(iconName: iconName) {
+ currentAppIcon = appicon
+ super.init()
+ return
+ }
+
+ if let appicon = DarkAppIcon.createCase(iconName: iconName) {
+ currentAppIcon = appicon
+ super.init()
+ return
+ }
+
+ if let appicon = InvertedDarkAppIcon.createCase(iconName: iconName) {
+ currentAppIcon = appicon
+ super.init()
+ return
+ }
+
+ if let appicon = InvertedLightAppIcon.createCase(iconName: iconName) {
+ currentAppIcon = appicon
+ super.init()
+ return
+ }
+
+ if let appicon = LightAppIcon.createCase(iconName: iconName) {
+ currentAppIcon = appicon
+ super.init()
+ return
+ }
+
+ super.init()
+ }
+
+ func select(icon: any AppIcon) {
+ let previousAppIcon = currentAppIcon
+ currentAppIcon = icon
+
+ Task { @MainActor in
+
+ do {
+ if case PrimaryAppIcon.primary = icon {
+ try await UIApplication.shared.setAlternateIconName(nil)
+ } else {
+ try await UIApplication.shared.setAlternateIconName(icon.iconName)
+ }
+ } catch {
+ logger.error("Unable to update app icon to \(icon.iconName): \(error.localizedDescription)")
+ currentAppIcon = previousAppIcon
+ }
+ }
+ }
+
+ func signOut() {
+ Defaults[.lastServerUserID] = nil
+ Container.userSession.reset()
+ Notifications[.didSignOut].post()
+ }
+
+ func resetUserSettings() {
+ UserDefaults.generalSuite.removeAll()
+ }
+
+ func removeAllServers() {
+ guard let allServers = try? SwiftfinStore.dataStack.fetchAll(From()) else { return }
+
+ try? SwiftfinStore.dataStack.perform { transaction in
+ transaction.delete(allServers)
+ }
}
}
diff --git a/Shared/ViewModels/SpecialFeaturesViewModel.swift b/Shared/ViewModels/SpecialFeaturesViewModel.swift
new file mode 100644
index 00000000..aea798cd
--- /dev/null
+++ b/Shared/ViewModels/SpecialFeaturesViewModel.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) 2023 Jellyfin & Jellyfin Contributors
+//
+
+import Foundation
+import JellyfinAPI
+
+class SpecialFeaturesViewModel: ViewModel, MenuPosterHStackModel {
+
+ @Published
+ var menuSelection: SpecialFeatureType?
+ @Published
+ var menuSections: [SpecialFeatureType: [PosterButtonType]]
+ var menuSectionSort: (SpecialFeatureType, SpecialFeatureType) -> Bool
+
+ init(sections: [SpecialFeatureType: [PosterButtonType]]) {
+ 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/StaticLibraryViewModel.swift b/Shared/ViewModels/StaticLibraryViewModel.swift
index 2d4b17c3..6f142814 100644
--- a/Shared/ViewModels/StaticLibraryViewModel.swift
+++ b/Shared/ViewModels/StaticLibraryViewModel.swift
@@ -3,7 +3,7 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import Foundation
@@ -14,6 +14,6 @@ class StaticLibraryViewModel: PagingLibraryViewModel {
init(items: [BaseItemDto]) {
super.init()
- self.items = items
+ self.items.elements = items
}
}
diff --git a/Shared/ViewModels/UserListViewModel.swift b/Shared/ViewModels/UserListViewModel.swift
index fe10f75f..6b4f19b2 100644
--- a/Shared/ViewModels/UserListViewModel.swift
+++ b/Shared/ViewModels/UserListViewModel.swift
@@ -3,46 +3,62 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
+import CoreStore
+import Defaults
+import Factory
import Foundation
import JellyfinAPI
+import Pulse
import SwiftUI
class UserListViewModel: ViewModel {
@Published
- var users: [SwiftfinStore.State.User] = []
+ private(set) var users: [UserState] = []
- var server: SwiftfinStore.State.Server
+ let client: JellyfinClient
+ let server: ServerState
- init(server: SwiftfinStore.State.Server) {
+ init(server: ServerState) {
+ self.client = JellyfinClient(
+ configuration: .swiftfinConfiguration(url: server.currentURL),
+ sessionDelegate: URLSessionProxyDelegate()
+ )
self.server = server
-
super.init()
- JellyfinAPIAPI.basePath = server.currentURI
- Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeCurrentLoginURI(_:)))
+// Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeCurrentLoginURI(_:)))
}
@objc
func didChangeCurrentLoginURI(_ notification: Notification) {
- guard let newServerState = notification.object as? SwiftfinStore.State.Server else { fatalError("Need to have new state server") }
- self.server = newServerState
+// guard let newServerState = notification.object as? SwiftfinStore.State.Server else { fatalError("Need to have new state server") }
+// self.server = newServerState
}
func fetchUsers() {
- self.users = SessionManager.main.fetchUsers(for: server)
+
+ guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
+ From(),
+ Where("id == %@", server.id)
+ )
+ else { fatalError("No stored server associated with given state server?") }
+
+ users = storedServer.users
+ .map(\.state)
+ .sorted(using: \.username)
}
- func signIn(user: SwiftfinStore.State.User) {
- self.isLoading = true
- SessionManager.main.signInUser(server: server, user: user)
+ func signIn(user: UserState) {
+ Defaults[.lastServerUserID] = user.id
+ Container.userSession.reset()
+ Notifications[.didSignIn].post()
}
func remove(user: SwiftfinStore.State.User) {
- SessionManager.main.delete(user: user)
fetchUsers()
}
}
diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift
index 684b857b..2f47ff19 100644
--- a/Shared/ViewModels/UserSignInViewModel.swift
+++ b/Shared/ViewModels/UserSignInViewModel.swift
@@ -3,145 +3,193 @@
// 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) 2022 Jellyfin & Jellyfin Contributors
+// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
import CoreStore
+import Defaults
+import Factory
import Foundation
import JellyfinAPI
-import Stinsen
+import Pulse
final class UserSignInViewModel: ViewModel {
- @RouterObject
- private var router: UserSignInCoordinator.Router?
-
- @Published
- var publicUsers: [UserDto] = []
- @Published
- var quickConnectCode: String?
- @Published
- var quickConnectEnabled = false
+ @Published
+ private(set) var publicUsers: [UserDto] = []
+ @Published
+ private(set) var quickConnectCode: String?
+ @Published
+ private(set) var quickConnectEnabled = false
+ let client: JellyfinClient
let server: SwiftfinStore.State.Server
+
+ private var quickConnectTask: Task?
private var quickConnectTimer: RepeatingTimer?
private var quickConnectSecret: String?
- init(server: SwiftfinStore.State.Server) {
+ init(server: ServerState) {
+ self.client = JellyfinClient(
+ configuration: .swiftfinConfiguration(url: server.currentURL),
+ sessionDelegate: URLSessionProxyDelegate()
+ )
self.server = server
super.init()
-
- JellyfinAPIAPI.basePath = server.currentURI
- checkQuickConnect()
- getPublicUsers()
}
- var alertTitle: String {
- var message: String = ""
- if errorMessage?.code != ErrorMessage.noShowErrorCode {
- message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n")
- }
- message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)")
- return message
- }
-
- func signIn(username: String, password: String) {
- logger.debug("Attempting to login to server at \"\(server.currentURI)\"", tag: "login")
+ func signIn(username: String, password: String) async throws {
let username = username.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .objectReplacement)
let password = password.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .objectReplacement)
- SessionManager.main.signInUser(server: server, username: username, password: password)
- .trackActivity(loading)
- .sink { completion in
- self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
- } receiveValue: { _ in
- }
- .store(in: &cancellables)
- }
+ let response = try await client.signIn(username: username, password: password)
- func cancelSignIn() {
- for cancellable in cancellables {
- cancellable.cancel()
+ let user: UserState
+
+ do {
+ user = try await createLocalUser(response: response)
+ } catch {
+ if case let SwiftfinStore.Error.existingUser(existingUser) = error {
+ user = existingUser
+ } else {
+ throw error
+ }
}
- self.isLoading = false
+ Defaults[.lastServerUserID] = user.id
+ Container.userSession.reset()
+ Notifications[.didSignIn].post()
}
- func getPublicUsers() {
- UserAPI.getPublicUsers()
- .trackActivity(loading)
- .sink(receiveCompletion: { completion in
- self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
- }, receiveValue: { response in
- self.publicUsers = response
- })
- .store(in: &cancellables)
+ func getPublicUsers() async throws {
+ let publicUsersPath = Paths.getPublicUsers
+ let response = try await client.send(publicUsersPath)
+
+ await MainActor.run {
+ publicUsers = response.value
+ }
}
- func checkQuickConnect() {
- QuickConnectAPI.getEnabled()
- .sink(receiveCompletion: { completion in
- self.handleAPIRequestError(completion: completion)
- }, receiveValue: { enabled in
- self.quickConnectEnabled = enabled
- })
- .store(in: &cancellables)
+ func checkQuickConnect() async throws {
+ let quickConnectEnabledPath = Paths.getEnabled
+ let response = try await client.send(quickConnectEnabledPath)
+ let decoder = JSONDecoder()
+ let isEnabled = try? decoder.decode(Bool.self, from: response.value)
+
+ await MainActor.run {
+ quickConnectEnabled = isEnabled ?? false
+ }
}
- func startQuickConnect(_ onSuccess: @escaping () -> Void) {
- QuickConnectAPI.initiate()
- .sink(receiveCompletion: { completion in
- self.handleAPIRequestError(completion: completion)
- }, receiveValue: { response in
+ func startQuickConnect() -> AsyncStream {
+ Task {
- self.quickConnectSecret = response.secret
- self.quickConnectCode = response.code
- self.logger.debug("QuickConnect code: \(response.code ?? .emptyDash)")
+ let initiatePath = Paths.initiate
+ let response = try? await client.send(initiatePath)
- self.quickConnectTimer = RepeatingTimer(interval: 5) {
- self.checkAuthStatus(onSuccess)
- }
+ guard let response else { return }
- self.quickConnectTimer?.start()
- })
- .store(in: &cancellables)
+ await MainActor.run {
+ quickConnectSecret = response.value.secret
+ quickConnectCode = response.value.code
+ }
+ }
+
+ return .init { continuation in
+
+ checkAuthStatus(continuation: continuation)
+ }
}
- @objc
- private func checkAuthStatus(_ onSuccess: @escaping () -> Void) {
- guard let quickConnectSecret = quickConnectSecret else { return }
+ private func checkAuthStatus(continuation: AsyncStream.Continuation) {
- QuickConnectAPI.connect(secret: quickConnectSecret)
- .sink(receiveCompletion: { _ in
- // Prefer not to handle error handling like normal as
- // this is a repeated call
- }, receiveValue: { value in
- guard let authenticated = value.authenticated, authenticated else {
- self.logger.debug("QuickConnect not authenticated yet")
- return
- }
+ let task = Task {
+ guard let quickConnectSecret else { return }
+ let connectPath = Paths.connect(secret: quickConnectSecret)
+ let response = try? await client.send(connectPath)
- self.stopQuickConnectAuthCheck()
- onSuccess()
+ if let responseValue = response?.value, responseValue.isAuthenticated ?? false {
+ continuation.yield(responseValue)
+ return
+ }
- SessionManager.main.signInUser(server: self.server, quickConnectSecret: quickConnectSecret)
- .trackActivity(self.loading)
- .sink { completion in
- self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
- } receiveValue: { _ in
- }
- .store(in: &self.cancellables)
- })
- .store(in: &cancellables)
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
+
+ checkAuthStatus(continuation: continuation)
+ }
+
+ self.quickConnectTask = task
}
func stopQuickConnectAuthCheck() {
- DispatchQueue.main.async {
- self.quickConnectTimer?.stop()
- self.quickConnectTimer = nil
+ self.quickConnectTask?.cancel()
+ }
+
+ func signIn(quickConnectSecret: String) async throws {
+ let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret))
+ let response = try await client.send(quickConnectPath)
+
+ let user: UserState
+
+ do {
+ user = try await createLocalUser(response: response.value)
+ } catch {
+ if case let SwiftfinStore.Error.existingUser(existingUser) = error {
+ user = existingUser
+ } else {
+ throw error
+ }
}
+
+ Defaults[.lastServerUserID] = user.id
+ Container.userSession.reset()
+ Notifications[.didSignIn].post()
+ }
+
+ @MainActor
+ private func createLocalUser(response: AuthenticationResult) async throws -> UserState {
+ guard let accessToken = response.accessToken,
+ let username = response.user?.name,
+ let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
+
+ if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
+ From(),
+ [Where(
+ "id == %@",
+ id
+ )]
+ ) {
+ throw SwiftfinStore.Error.existingUser(existingUser.state)
+ }
+
+ guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
+ From